Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98de106c89 | |||
| 1927fdd437 | |||
| b86c6c906a | |||
| 99975d0ba0 | |||
| 4f72ebded9 | |||
| be5d1eb9f9 | |||
| 8d730e6680 | |||
| 41a3cb708c | |||
| 27037a2177 | |||
| e2affc3fa6 | |||
| 3a14f19c99 | |||
| eb4c3a4db5 | |||
| 2e129682b7 | |||
| 4a145f7a06 | |||
| e69fcd410c | |||
| 55afef204d | |||
| 85a9098960 | |||
| 6949235688 | |||
| f9e187d8b9 | |||
| 9545dc77d6 | |||
| 1fc987c6c9 | |||
| c1c0297b6d | |||
| 1a7b5d7222 | |||
| fb3dd77b66 | |||
| a43dfc097d | |||
| 346c6f5414 | |||
| 729b6ac79e | |||
| 0a35fcfbc0 | |||
| 75b4c6e7d6 | |||
| a1d6c40627 | |||
| 4a2e648f6d | |||
| 76db394ab1 | |||
| 4563b50fad | |||
| 065226f8b2 | |||
| 0a99a1a091 | |||
| 4b2a9c8335 | |||
| 751b15a7a7 | |||
| ac22769220 | |||
| 46ae0f98dc | |||
| 327e35c241 | |||
| 30aaec9097 | |||
| 4041c5bc5b | |||
| 403595a897 | |||
| 0ee93dfd8c | |||
| ee0e3e2e25 | |||
| 0165abab3e | |||
| 7d07afd59c | |||
| efaba43494 | |||
| a8ec2b6914 | |||
| 39eb8c98d1 | |||
| 138d06868b | |||
| 2eab3b41a2 | |||
| 662b497082 | |||
| 67cf1785b8 | |||
| 7d08a57be6 | |||
| b0ad48e35a | |||
| d68669df51 | |||
| 4e600e4e9b | |||
| 4fc4da7982 | |||
| 6f210e1f4b | |||
| f7803dab56 | |||
| 70172f8e3d | |||
| 21bc668a56 | |||
| dc5a5f4a91 | |||
| 2c67aced15 | |||
| 3a4c893368 | |||
| 0d036e6bf5 | |||
| 3ef0570f6a | |||
| c9d2ea36b8 | |||
| f0836ceb10 | |||
| 888665783e | |||
| 817737b2c0 | |||
| 638c1e21c9 | |||
| c67e097fc2 | |||
| 804dd8300d | |||
| c6d6b9a0e9 | |||
| 9debd81a50 | |||
| d53a086206 | |||
| ef8472ce5e | |||
| 4aa34f3eda |
@@ -2,11 +2,6 @@ name: Branch Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch_name:
|
||||
description: "Branch Name"
|
||||
required: true
|
||||
default: "preview"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -16,49 +11,71 @@ on:
|
||||
types: [released, prereleased]
|
||||
|
||||
env:
|
||||
TARGET_BRANCH: ${{ inputs.branch_name || github.ref_name || github.event.release.target_commitish }}
|
||||
TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }}
|
||||
|
||||
jobs:
|
||||
branch_build_setup:
|
||||
name: Build-Push Web/Space/API/Proxy Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3.3.0
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
gh_branch_name: ${{ env.TARGET_BRANCH }}
|
||||
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
|
||||
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
|
||||
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
|
||||
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
|
||||
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
|
||||
|
||||
steps:
|
||||
- id: set_env_variables
|
||||
name: Set Environment Variables
|
||||
run: |
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT
|
||||
echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
|
||||
|
||||
branch_build_push_frontend:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
steps:
|
||||
- name: Set Frontend Docker Tag
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
|
||||
else
|
||||
TAG=${{ env.FRONTEND_TAG }}
|
||||
fi
|
||||
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3.0.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ env.BUILDX_DRIVER }}
|
||||
version: ${{ env.BUILDX_VERSION }}
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
@@ -67,7 +84,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./web/Dockerfile.web
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||
tags: ${{ env.FRONTEND_TAG }}
|
||||
push: true
|
||||
env:
|
||||
@@ -80,33 +97,36 @@ jobs:
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
steps:
|
||||
- name: Set Space Docker Tag
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
|
||||
else
|
||||
TAG=${{ env.SPACE_TAG }}
|
||||
fi
|
||||
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3.0.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ env.BUILDX_DRIVER }}
|
||||
version: ${{ env.BUILDX_VERSION }}
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
@@ -115,7 +135,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./space/Dockerfile.space
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||
tags: ${{ env.SPACE_TAG }}
|
||||
push: true
|
||||
env:
|
||||
@@ -128,33 +148,36 @@ jobs:
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
steps:
|
||||
- name: Set Backend Docker Tag
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
|
||||
else
|
||||
TAG=${{ env.BACKEND_TAG }}
|
||||
fi
|
||||
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3.0.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ env.BUILDX_DRIVER }}
|
||||
version: ${{ env.BUILDX_VERSION }}
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
@@ -163,7 +186,7 @@ jobs:
|
||||
with:
|
||||
context: ./apiserver
|
||||
file: ./apiserver/Dockerfile.api
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||
push: true
|
||||
tags: ${{ env.BACKEND_TAG }}
|
||||
env:
|
||||
@@ -171,38 +194,42 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
|
||||
branch_build_push_proxy:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
steps:
|
||||
- name: Set Proxy Docker Tag
|
||||
run: |
|
||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
|
||||
else
|
||||
TAG=${{ env.PROXY_TAG }}
|
||||
fi
|
||||
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3.0.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ env.BUILDX_DRIVER }}
|
||||
version: ${{ env.BUILDX_VERSION }}
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
@@ -211,10 +238,11 @@ jobs:
|
||||
with:
|
||||
context: ./nginx
|
||||
file: ./nginx/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||
tags: ${{ env.PROXY_TAG }}
|
||||
push: true
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Python imports
|
||||
import zoneinfo
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
@@ -51,6 +53,11 @@ class WebhookMixin:
|
||||
and self.request.method in ["POST", "PATCH", "DELETE"]
|
||||
and response.status_code in [200, 201, 204]
|
||||
):
|
||||
url = request.build_absolute_uri()
|
||||
parsed_url = urlparse(url)
|
||||
# Extract the scheme and netloc
|
||||
scheme = parsed_url.scheme
|
||||
netloc = parsed_url.netloc
|
||||
# Push the object to delay
|
||||
send_webhook.delay(
|
||||
event=self.webhook_event,
|
||||
@@ -59,6 +66,7 @@ class WebhookMixin:
|
||||
action=self.request.method,
|
||||
slug=self.workspace_slug,
|
||||
bulk=self.bulk,
|
||||
current_site=f"{scheme}://{netloc}",
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -243,6 +243,29 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
):
|
||||
serializer = CycleSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and request.data.get("external_source")
|
||||
and Cycle.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"error": "Cycle with the same external id and external source already exists",
|
||||
"id": str(cycle.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save(
|
||||
project_id=project_id,
|
||||
owned_by=request.user,
|
||||
@@ -289,6 +312,23 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
|
||||
serializer = CycleSerializer(cycle, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and (cycle.external_id != request.data.get("external_id"))
|
||||
and Cycle.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source", cycle.external_source),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "Cycle with the same external id and external source already exists",
|
||||
"id": str(cycle.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -220,6 +220,30 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and request.data.get("external_source")
|
||||
and Issue.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
issue = Issue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
external_id=request.data.get("external_id"),
|
||||
external_source=request.data.get("external_source"),
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"error": "Issue with the same external id and external source already exists",
|
||||
"id": str(issue.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
serializer.save()
|
||||
|
||||
# Track the issue
|
||||
@@ -256,6 +280,26 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
partial=True,
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
str(request.data.get("external_id"))
|
||||
and (issue.external_id != str(request.data.get("external_id")))
|
||||
and Issue.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get(
|
||||
"external_source", issue.external_source
|
||||
),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "Issue with the same external id and external source already exists",
|
||||
"id": str(issue.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
serializer.save()
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
@@ -263,6 +307,8 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(pk),
|
||||
project_id=str(project_id),
|
||||
external_id__isnull=False,
|
||||
external_source__isnull=False,
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
@@ -318,6 +364,30 @@ class LabelAPIEndpoint(BaseAPIView):
|
||||
try:
|
||||
serializer = LabelSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and request.data.get("external_source")
|
||||
and Label.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
label = Label.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
external_id=request.data.get("external_id"),
|
||||
external_source=request.data.get("external_source"),
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"error": "Label with the same external id and external source already exists",
|
||||
"id": str(label.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
serializer.save(project_id=project_id)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED
|
||||
@@ -326,11 +396,17 @@ class LabelAPIEndpoint(BaseAPIView):
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except IntegrityError:
|
||||
label = Label.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
name=request.data.get("name"),
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"error": "Label with the same name already exists in the project"
|
||||
"error": "Label with the same name already exists in the project",
|
||||
"id": str(label.id),
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
def get(self, request, slug, project_id, pk=None):
|
||||
@@ -357,6 +433,25 @@ class LabelAPIEndpoint(BaseAPIView):
|
||||
label = self.get_queryset().get(pk=pk)
|
||||
serializer = LabelSerializer(label, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
str(request.data.get("external_id"))
|
||||
and (label.external_id != str(request.data.get("external_id")))
|
||||
and Issue.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get(
|
||||
"external_source", label.external_source
|
||||
),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "Label with the same external id and external source already exists",
|
||||
"id": str(label.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -554,6 +649,31 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
serializer = IssueCommentSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and request.data.get("external_source")
|
||||
and IssueComment.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
issue_id=issue_id,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
issue_comment = IssueComment.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
external_id=request.data.get("external_id"),
|
||||
external_source=request.data.get("external_source"),
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"error": "Issue Comment with the same external id and external source already exists",
|
||||
"id": str(issue_comment.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save(
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
@@ -589,6 +709,26 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
issue_comment, data=request.data, partial=True
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
str(request.data.get("external_id"))
|
||||
and (issue_comment.external_id != str(request.data.get("external_id")))
|
||||
and IssueComment.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
issue_id=issue_id,
|
||||
external_source=request.data.get(
|
||||
"external_source", issue_comment.external_source
|
||||
),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "Issue Comment with the same external id and external source already exists",
|
||||
"id": str(issue_comment.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save()
|
||||
issue_activity.delay(
|
||||
type="comment.activity.updated",
|
||||
|
||||
@@ -132,6 +132,29 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
},
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and request.data.get("external_source")
|
||||
and Module.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
module = Module.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"error": "Module with the same external id and external source already exists",
|
||||
"id": str(module.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save()
|
||||
module = Module.objects.get(pk=serializer.data["id"])
|
||||
serializer = ModuleSerializer(module)
|
||||
@@ -149,8 +172,25 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
partial=True,
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and (module.external_id != request.data.get("external_id"))
|
||||
and Module.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source", module.external_source),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "Module with the same external id and external source already exists",
|
||||
"id": str(module.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def get(self, request, slug, project_id, pk=None):
|
||||
|
||||
@@ -38,6 +38,30 @@ class StateAPIEndpoint(BaseAPIView):
|
||||
data=request.data, context={"project_id": project_id}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
and request.data.get("external_source")
|
||||
and State.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source"),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
state = State.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
external_id=request.data.get("external_id"),
|
||||
external_source=request.data.get("external_source"),
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"error": "State with the same external id and external source already exists",
|
||||
"id": str(state.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
serializer.save(project_id=project_id)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -91,6 +115,23 @@ class StateAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
serializer = StateSerializer(state, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
str(request.data.get("external_id"))
|
||||
and (state.external_id != str(request.data.get("external_id")))
|
||||
and State.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get("external_source", state.external_source),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "State with the same external id and external source already exists",
|
||||
"id": str(state.id),
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -33,7 +33,6 @@ class CycleWriteSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class CycleSerializer(BaseSerializer):
|
||||
owned_by = UserLiteSerializer(read_only=True)
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
total_issues = serializers.IntegerField(read_only=True)
|
||||
cancelled_issues = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -562,7 +562,7 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
state_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
parent_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
module_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
module_ids = serializers.SerializerMethodField()
|
||||
|
||||
# Many to many
|
||||
label_ids = serializers.PrimaryKeyRelatedField(
|
||||
@@ -597,7 +597,7 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"module_id",
|
||||
"module_ids",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
"sub_issues_count",
|
||||
@@ -613,6 +613,10 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_module_ids(self, obj):
|
||||
# Access the prefetched modules and extract module IDs
|
||||
return [module for module in obj.issue_module.values_list("module_id", flat=True)]
|
||||
|
||||
|
||||
class IssueLiteSerializer(DynamicBaseSerializer):
|
||||
workspace_detail = WorkspaceLiteSerializer(
|
||||
|
||||
@@ -35,17 +35,26 @@ urlpatterns = [
|
||||
name="project-modules",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/modules/",
|
||||
ModuleIssueViewSet.as_view(
|
||||
{
|
||||
"post": "create_issue_modules",
|
||||
}
|
||||
),
|
||||
name="issue-module",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/issues/",
|
||||
ModuleIssueViewSet.as_view(
|
||||
{
|
||||
"post": "create_module_issues",
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-module-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:issue_id>/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/issues/<uuid:issue_id>/",
|
||||
ModuleIssueViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
|
||||
@@ -401,8 +401,8 @@ class EmailCheckEndpoint(BaseAPIView):
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
medium="MAGIC_LINK",
|
||||
event_name="Sign up",
|
||||
medium="Magic link",
|
||||
first_time=True,
|
||||
)
|
||||
key, token, current_attempt = generate_magic_token(email=email)
|
||||
@@ -438,8 +438,8 @@ class EmailCheckEndpoint(BaseAPIView):
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
medium="MAGIC_LINK",
|
||||
event_name="Sign in",
|
||||
medium="Magic link",
|
||||
first_time=False,
|
||||
)
|
||||
|
||||
@@ -468,8 +468,8 @@ class EmailCheckEndpoint(BaseAPIView):
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
medium="EMAIL",
|
||||
event_name="Sign in",
|
||||
medium="Email",
|
||||
first_time=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -274,8 +274,8 @@ class SignInEndpoint(BaseAPIView):
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
medium="EMAIL",
|
||||
event_name="Sign in",
|
||||
medium="Email",
|
||||
first_time=False,
|
||||
)
|
||||
|
||||
@@ -349,8 +349,8 @@ class MagicSignInEndpoint(BaseAPIView):
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
medium="MAGIC_LINK",
|
||||
event_name="Sign in",
|
||||
medium="Magic link",
|
||||
first_time=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ class WebhookMixin:
|
||||
action=self.request.method,
|
||||
slug=self.workspace_slug,
|
||||
bulk=self.bulk,
|
||||
current_site=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -20,6 +20,7 @@ from django.core import serializers
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -242,13 +243,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"assignee_id",
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"assignee_id",
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
@@ -258,7 +259,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"assignee_id",
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
@@ -281,13 +282,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"label_id",
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"label_id",
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
@@ -297,7 +298,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"label_id",
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
@@ -312,6 +313,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
"labels": label_distribution,
|
||||
"completion_chart": {},
|
||||
}
|
||||
|
||||
if data[0]["start_date"] and data[0]["end_date"]:
|
||||
data[0]["distribution"][
|
||||
"completion_chart"
|
||||
@@ -419,13 +421,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"assignee_id",
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"assignee_id",
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
@@ -435,7 +437,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"assignee_id",
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
@@ -459,13 +461,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"label_id",
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"label_id",
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
@@ -475,7 +477,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"label_id",
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
@@ -599,16 +601,11 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.filter(project_id=project_id)
|
||||
.filter(workspace__slug=slug)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.order_by(order_by)
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -845,10 +842,230 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
new_cycle = Cycle.objects.get(
|
||||
new_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
|
||||
).first()
|
||||
|
||||
old_cycle = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="cancelled",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="unstarted",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="backlog",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
total_estimates=Sum("issue_cycle__issue__estimate_point")
|
||||
)
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Pass the new_cycle queryset to burndown_plot
|
||||
completion_chart = burndown_plot(
|
||||
queryset=old_cycle.first(),
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
assignee_distribution_data = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None,
|
||||
"avatar": item["avatar"],
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in assignee_distribution
|
||||
]
|
||||
|
||||
label_distribution_data = [
|
||||
{
|
||||
"label_name": item["label_name"],
|
||||
"color": item["color"],
|
||||
"label_id": str(item["label_id"]) if item["label_id"] else None,
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in label_distribution
|
||||
]
|
||||
|
||||
current_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
).first()
|
||||
|
||||
current_cycle.progress_snapshot = {
|
||||
"total_issues": old_cycle.first().total_issues,
|
||||
"completed_issues": old_cycle.first().completed_issues,
|
||||
"cancelled_issues": old_cycle.first().cancelled_issues,
|
||||
"started_issues": old_cycle.first().started_issues,
|
||||
"unstarted_issues": old_cycle.first().unstarted_issues,
|
||||
"backlog_issues": old_cycle.first().backlog_issues,
|
||||
"total_estimates": old_cycle.first().total_estimates,
|
||||
"completed_estimates": old_cycle.first().completed_estimates,
|
||||
"started_estimates": old_cycle.first().started_estimates,
|
||||
"distribution":{
|
||||
"labels": label_distribution_data,
|
||||
"assignees": assignee_distribution_data,
|
||||
"completion_chart": completion_chart,
|
||||
},
|
||||
}
|
||||
current_cycle.save(update_fields=["progress_snapshot"])
|
||||
|
||||
if (
|
||||
new_cycle.end_date is not None
|
||||
and new_cycle.end_date < timezone.now().date()
|
||||
|
||||
@@ -100,7 +100,7 @@ def dashboard_assigned_issues(self, request, slug):
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_relation",
|
||||
@@ -110,7 +110,6 @@ def dashboard_assigned_issues(self, request, slug):
|
||||
)
|
||||
)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -146,6 +145,23 @@ def dashboard_assigned_issues(self, request, slug):
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
if issue_type == "pending":
|
||||
pending_issues_count = assigned_issues.filter(
|
||||
state__group__in=["backlog", "started", "unstarted"]
|
||||
).count()
|
||||
pending_issues = assigned_issues.filter(
|
||||
state__group__in=["backlog", "started", "unstarted"]
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
pending_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": pending_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "completed":
|
||||
completed_issues_count = assigned_issues.filter(
|
||||
state__group__in=["completed"]
|
||||
@@ -221,9 +237,8 @@ def dashboard_created_issues(self, request, slug):
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -259,6 +274,23 @@ def dashboard_created_issues(self, request, slug):
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
if issue_type == "pending":
|
||||
pending_issues_count = created_issues.filter(
|
||||
state__group__in=["backlog", "started", "unstarted"]
|
||||
).count()
|
||||
pending_issues = created_issues.filter(
|
||||
state__group__in=["backlog", "started", "unstarted"]
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
pending_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": pending_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "completed":
|
||||
completed_issues_count = created_issues.filter(
|
||||
state__group__in=["completed"]
|
||||
|
||||
@@ -95,7 +95,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
issue_inbox__inbox_id=self.kwargs.get("inbox_id")
|
||||
)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("labels", "assignees")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_inbox",
|
||||
@@ -105,7 +105,6 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
|
||||
@@ -112,12 +112,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
project_id=self.kwargs.get("project_id")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
@@ -125,7 +121,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -1087,12 +1082,31 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
.filter(archived_at__isnull=False)
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@@ -1120,22 +1134,6 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
@@ -1670,7 +1668,35 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.objects.annotate(
|
||||
Issue.objects.filter(
|
||||
project_id=self.kwargs.get("project_id")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(is_draft=True)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
@@ -1678,22 +1704,7 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(is_draft=True)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
)
|
||||
).distinct()
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug, project_id):
|
||||
@@ -1719,22 +1730,6 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
@@ -1831,7 +1826,10 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
issue = (
|
||||
self.get_queryset().filter(pk=serializer.data["id"]).first()
|
||||
)
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
@@ -1867,10 +1865,13 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk, is_draft=True
|
||||
issue = self.get_queryset().filter(pk=pk).first()
|
||||
return Response(
|
||||
IssueSerializer(
|
||||
issue, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
|
||||
def destroy(self, request, slug, project_id, pk=None):
|
||||
issue = Issue.objects.get(
|
||||
|
||||
+124
-161
@@ -7,6 +7,8 @@ from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q
|
||||
from django.core import serializers
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -195,7 +197,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"assignee_id",
|
||||
"id",
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
@@ -204,7 +206,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"assignee_id",
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
@@ -214,7 +216,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"assignee_id",
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
@@ -237,7 +239,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"label_id",
|
||||
"id",
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
@@ -246,7 +248,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"label_id",
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
@@ -256,7 +258,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"label_id",
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
@@ -296,23 +298,20 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
"issue", flat=True
|
||||
)
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="module.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
{
|
||||
"module_id": str(pk),
|
||||
"module_name": str(module.name),
|
||||
"issues": [str(issue_id) for issue_id in module_issues],
|
||||
}
|
||||
),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(pk),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
_ = [
|
||||
issue_activity.delay(
|
||||
type="module.activity.deleted",
|
||||
requested_data=json.dumps({"module_id": str(pk)}),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue),
|
||||
project_id=project_id,
|
||||
current_instance=json.dumps({"module_name": str(module.name)}),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
for issue in module_issues
|
||||
]
|
||||
module.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -332,62 +331,18 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("issue")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(module_id=self.kwargs.get("module_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("module")
|
||||
.select_related("issue", "issue__state", "issue__project")
|
||||
.prefetch_related("issue__assignees", "issue__labels")
|
||||
.prefetch_related("module__members")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug, project_id, module_id):
|
||||
fields = [
|
||||
field
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
if field
|
||||
]
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.issue_objects.filter(issue_module__module_id=module_id)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
issue_module__module_id=self.kwargs.get("module_id")
|
||||
)
|
||||
.filter(project_id=project_id)
|
||||
.filter(workspace__slug=slug)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.order_by(order_by)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("labels", "assignees")
|
||||
.prefetch_related('issue_module__module')
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -403,105 +358,118 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
is_subscribed=Exists(
|
||||
IssueSubscriber.objects.filter(
|
||||
subscriber=self.request.user, issue_id=OuterRef("id")
|
||||
)
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
).distinct()
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug, project_id, module_id):
|
||||
fields = [
|
||||
field
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
if field
|
||||
]
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
serializer = IssueSerializer(
|
||||
issues, many=True, fields=fields if fields else None
|
||||
issue_queryset, many=True, fields=fields if fields else None
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def create(self, request, slug, project_id, module_id):
|
||||
# create multiple issues inside a module
|
||||
def create_module_issues(self, request, slug, project_id, module_id):
|
||||
issues = request.data.get("issues", [])
|
||||
if not len(issues):
|
||||
return Response(
|
||||
{"error": "Issues are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
module = Module.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=module_id
|
||||
)
|
||||
|
||||
module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues))
|
||||
|
||||
update_module_issue_activity = []
|
||||
records_to_update = []
|
||||
record_to_create = []
|
||||
|
||||
for issue in issues:
|
||||
module_issue = [
|
||||
module_issue
|
||||
for module_issue in module_issues
|
||||
if str(module_issue.issue_id) in issues
|
||||
]
|
||||
|
||||
if len(module_issue):
|
||||
if module_issue[0].module_id != module_id:
|
||||
update_module_issue_activity.append(
|
||||
{
|
||||
"old_module_id": str(module_issue[0].module_id),
|
||||
"new_module_id": str(module_id),
|
||||
"issue_id": str(module_issue[0].issue_id),
|
||||
}
|
||||
)
|
||||
module_issue[0].module_id = module_id
|
||||
records_to_update.append(module_issue[0])
|
||||
else:
|
||||
record_to_create.append(
|
||||
ModuleIssue(
|
||||
module=module,
|
||||
issue_id=issue,
|
||||
project_id=project_id,
|
||||
workspace=module.workspace,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
_ = ModuleIssue.objects.bulk_create(
|
||||
[
|
||||
ModuleIssue(
|
||||
issue_id=str(issue),
|
||||
module_id=module_id,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
|
||||
ModuleIssue.objects.bulk_create(
|
||||
record_to_create,
|
||||
for issue in issues
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
# Bulk Update the activity
|
||||
_ = [
|
||||
issue_activity.delay(
|
||||
type="module.activity.created",
|
||||
requested_data=json.dumps({"module_id": str(module_id)}),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue),
|
||||
project_id=project_id,
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
for issue in issues
|
||||
]
|
||||
issues = (self.get_queryset().filter(pk__in=issues))
|
||||
serializer = IssueSerializer(issues , many=True)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
ModuleIssue.objects.bulk_update(
|
||||
records_to_update,
|
||||
["module"],
|
||||
# create multiple module inside an issue
|
||||
def create_issue_modules(self, request, slug, project_id, issue_id):
|
||||
modules = request.data.get("modules", [])
|
||||
if not len(modules):
|
||||
return Response(
|
||||
{"error": "Modules are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
_ = ModuleIssue.objects.bulk_create(
|
||||
[
|
||||
ModuleIssue(
|
||||
issue_id=issue_id,
|
||||
module_id=module,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for module in modules
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
# Bulk Update the activity
|
||||
_ = [
|
||||
issue_activity.delay(
|
||||
type="module.activity.created",
|
||||
requested_data=json.dumps({"module_id": module}),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
for module in modules
|
||||
]
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
type="module.activity.created",
|
||||
requested_data=json.dumps({"modules_list": issues}),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=None,
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_module_issues": update_module_issue_activity,
|
||||
"created_module_issues": serializers.serialize(
|
||||
"json", record_to_create
|
||||
),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
issue = (self.get_queryset().filter(pk=issue_id).first())
|
||||
serializer = IssueSerializer(issue)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
issues = self.get_queryset().values_list("issue_id", flat=True)
|
||||
|
||||
return Response(
|
||||
IssueSerializer(
|
||||
Issue.objects.filter(pk__in=issues), many=True
|
||||
).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, module_id, issue_id):
|
||||
module_issue = ModuleIssue.objects.get(
|
||||
@@ -512,16 +480,11 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="module.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
{
|
||||
"module_id": str(module_id),
|
||||
"issues": [str(issue_id)],
|
||||
}
|
||||
),
|
||||
requested_data=json.dumps({"module_id": str(module_id)}),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
current_instance=json.dumps({"module_name": module_issue.module.name}),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
|
||||
@@ -296,7 +296,7 @@ class OauthEndpoint(BaseAPIView):
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
event_name="Sign in",
|
||||
medium=medium.upper(),
|
||||
first_time=False,
|
||||
)
|
||||
@@ -427,7 +427,7 @@ class OauthEndpoint(BaseAPIView):
|
||||
email=email,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||
ip=request.META.get("REMOTE_ADDR"),
|
||||
event_name="SIGN_IN",
|
||||
event_name="Sign up",
|
||||
medium=medium.upper(),
|
||||
first_time=True,
|
||||
)
|
||||
|
||||
@@ -228,7 +228,7 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
parent = request.query_params.get("parent", "false")
|
||||
issue_relation = request.query_params.get("issue_relation", "false")
|
||||
cycle = request.query_params.get("cycle", "false")
|
||||
module = request.query_params.get("module", "false")
|
||||
module = request.query_params.get("module", False)
|
||||
sub_issue = request.query_params.get("sub_issue", "false")
|
||||
|
||||
issue_id = request.query_params.get("issue_id", False)
|
||||
@@ -269,8 +269,8 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
if cycle == "true":
|
||||
issues = issues.exclude(issue_cycle__isnull=False)
|
||||
|
||||
if module == "true":
|
||||
issues = issues.exclude(issue_module__isnull=False)
|
||||
if module:
|
||||
issues = issues.exclude(issue_module__module=module)
|
||||
|
||||
return Response(
|
||||
issues.values(
|
||||
|
||||
@@ -87,12 +87,8 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
@@ -127,7 +123,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
.filter(**filters)
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -150,13 +145,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
is_subscribed=Exists(
|
||||
IssueSubscriber.objects.filter(
|
||||
subscriber=self.request.user, issue_id=OuterRef("id")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
|
||||
@@ -1346,9 +1346,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -9,7 +10,6 @@ from django.utils import timezone
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import EmailNotificationLog, User, Issue
|
||||
@@ -40,7 +40,7 @@ def stack_email_notification():
|
||||
processed_notifications = []
|
||||
# Loop through all the issues to create the emails
|
||||
for receiver_id in receivers:
|
||||
# Notifcation triggered for the receiver
|
||||
# Notification triggered for the receiver
|
||||
receiver_notifications = [
|
||||
notification
|
||||
for notification in email_notifications
|
||||
@@ -124,113 +124,153 @@ def create_payload(notification_data):
|
||||
|
||||
return data
|
||||
|
||||
def process_mention(mention_component):
|
||||
soup = BeautifulSoup(mention_component, 'html.parser')
|
||||
mentions = soup.find_all('mention-component')
|
||||
for mention in mentions:
|
||||
user_id = mention['id']
|
||||
user = User.objects.get(pk=user_id)
|
||||
user_name = user.display_name
|
||||
highlighted_name = f"@{user_name}"
|
||||
mention.replace_with(highlighted_name)
|
||||
return str(soup)
|
||||
|
||||
def process_html_content(content):
|
||||
processed_content_list = []
|
||||
for html_content in content:
|
||||
processed_content = process_mention(html_content)
|
||||
processed_content_list.append(processed_content)
|
||||
return processed_content_list
|
||||
|
||||
@shared_task
|
||||
def send_email_notification(
|
||||
issue_id, notification_data, receiver_id, email_notification_ids
|
||||
):
|
||||
ri = redis_instance()
|
||||
base_api = (ri.get(str(issue_id)).decode())
|
||||
data = create_payload(notification_data=notification_data)
|
||||
|
||||
# Get email configurations
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
receiver = User.objects.get(pk=receiver_id)
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
template_data = []
|
||||
total_changes = 0
|
||||
comments = []
|
||||
for actor_id, changes in data.items():
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
total_changes = total_changes + len(changes)
|
||||
comment = changes.pop("comment", False)
|
||||
if comment:
|
||||
comments.append(
|
||||
{
|
||||
"actor_comments": comment,
|
||||
"actor_detail": {
|
||||
"avatar_url": actor.avatar,
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
activity_time = changes.pop("activity_time")
|
||||
# Parse the input string into a datetime object
|
||||
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
|
||||
|
||||
if changes:
|
||||
template_data.append(
|
||||
{
|
||||
"actor_detail": {
|
||||
"avatar_url": actor.avatar,
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
"changes": changes,
|
||||
"issue_details": {
|
||||
"name": issue.name,
|
||||
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
|
||||
},
|
||||
"activity_time": str(formatted_time),
|
||||
}
|
||||
)
|
||||
|
||||
summary = "updates were made to the issue by"
|
||||
|
||||
# Send the mail
|
||||
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
|
||||
context = {
|
||||
"data": template_data,
|
||||
"summary": summary,
|
||||
"issue": {
|
||||
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
|
||||
"name": issue.name,
|
||||
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
||||
},
|
||||
"receiver": {
|
||||
"email": receiver.email,
|
||||
},
|
||||
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
||||
"user_preference": f"{base_api}/profile/preferences/email",
|
||||
"comments": comments,
|
||||
}
|
||||
html_content = render_to_string(
|
||||
"emails/notifications/issue-updates.html", context
|
||||
)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
try:
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
)
|
||||
ri = redis_instance()
|
||||
base_api = (ri.get(str(issue_id)).decode())
|
||||
data = create_payload(notification_data=notification_data)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[receiver.email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
# Get email configurations
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
EmailNotificationLog.objects.filter(
|
||||
pk__in=email_notification_ids
|
||||
).update(sent_at=timezone.now())
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
receiver = User.objects.get(pk=receiver_id)
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
template_data = []
|
||||
total_changes = 0
|
||||
comments = []
|
||||
actors_involved = []
|
||||
for actor_id, changes in data.items():
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
total_changes = total_changes + len(changes)
|
||||
comment = changes.pop("comment", False)
|
||||
mention = changes.pop("mention", False)
|
||||
actors_involved.append(actor_id)
|
||||
if comment:
|
||||
comments.append(
|
||||
{
|
||||
"actor_comments": comment,
|
||||
"actor_detail": {
|
||||
"avatar_url": actor.avatar,
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
if mention:
|
||||
mention["new_value"] = process_html_content(mention.get("new_value"))
|
||||
mention["old_value"] = process_html_content(mention.get("old_value"))
|
||||
comments.append(
|
||||
{
|
||||
"actor_comments": mention,
|
||||
"actor_detail": {
|
||||
"avatar_url": actor.avatar,
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
activity_time = changes.pop("activity_time")
|
||||
# Parse the input string into a datetime object
|
||||
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
|
||||
|
||||
if changes:
|
||||
template_data.append(
|
||||
{
|
||||
"actor_detail": {
|
||||
"avatar_url": actor.avatar,
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
"changes": changes,
|
||||
"issue_details": {
|
||||
"name": issue.name,
|
||||
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
|
||||
},
|
||||
"activity_time": str(formatted_time),
|
||||
}
|
||||
)
|
||||
|
||||
summary = "Updates were made to the issue by"
|
||||
|
||||
# Send the mail
|
||||
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
|
||||
context = {
|
||||
"data": template_data,
|
||||
"summary": summary,
|
||||
"actors_involved": len(set(actors_involved)),
|
||||
"issue": {
|
||||
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
|
||||
"name": issue.name,
|
||||
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
||||
},
|
||||
"receiver": {
|
||||
"email": receiver.email,
|
||||
},
|
||||
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
||||
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
|
||||
"workspace":str(issue.project.workspace.slug),
|
||||
"project": str(issue.project.name),
|
||||
"user_preference": f"{base_api}/profile/preferences/email",
|
||||
"comments": comments,
|
||||
}
|
||||
html_content = render_to_string(
|
||||
"emails/notifications/issue-updates.html", context
|
||||
)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
try:
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[receiver.email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
|
||||
EmailNotificationLog.objects.filter(
|
||||
pk__in=email_notification_ids
|
||||
).update(sent_at=timezone.now())
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return
|
||||
except Issue.DoesNotExist:
|
||||
return
|
||||
|
||||
@@ -30,6 +30,7 @@ from plane.app.serializers import IssueActivitySerializer
|
||||
from plane.bgtasks.notification_task import notifications
|
||||
from plane.settings.redis import redis_instance
|
||||
|
||||
|
||||
# Track Changes in name
|
||||
def track_name(
|
||||
requested_data,
|
||||
@@ -352,13 +353,18 @@ def track_assignees(
|
||||
issue_activities,
|
||||
epoch,
|
||||
):
|
||||
requested_assignees = set(
|
||||
[str(asg) for asg in requested_data.get("assignee_ids", [])]
|
||||
requested_assignees = (
|
||||
set([str(asg) for asg in requested_data.get("assignee_ids", [])])
|
||||
if requested_data is not None
|
||||
else set()
|
||||
)
|
||||
current_assignees = set(
|
||||
[str(asg) for asg in current_instance.get("assignee_ids", [])]
|
||||
current_assignees = (
|
||||
set([str(asg) for asg in current_instance.get("assignee_ids", [])])
|
||||
if current_instance is not None
|
||||
else set()
|
||||
)
|
||||
|
||||
|
||||
added_assignees = requested_assignees - current_assignees
|
||||
dropped_assginees = current_assignees - requested_assignees
|
||||
|
||||
@@ -546,6 +552,20 @@ def create_issue_activity(
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
requested_data = (
|
||||
json.loads(requested_data) if requested_data is not None else None
|
||||
)
|
||||
if requested_data.get("assignee_ids") is not None:
|
||||
track_assignees(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project_id,
|
||||
workspace_id,
|
||||
actor_id,
|
||||
issue_activities,
|
||||
epoch,
|
||||
)
|
||||
|
||||
|
||||
def update_issue_activity(
|
||||
@@ -852,70 +872,26 @@ def create_module_issue_activity(
|
||||
requested_data = (
|
||||
json.loads(requested_data) if requested_data is not None else None
|
||||
)
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
|
||||
# Updated Records:
|
||||
updated_records = current_instance.get("updated_module_issues", [])
|
||||
created_records = json.loads(
|
||||
current_instance.get("created_module_issues", [])
|
||||
)
|
||||
|
||||
for updated_record in updated_records:
|
||||
old_module = Module.objects.filter(
|
||||
pk=updated_record.get("old_module_id", None)
|
||||
).first()
|
||||
new_module = Module.objects.filter(
|
||||
pk=updated_record.get("new_module_id", None)
|
||||
).first()
|
||||
issue = Issue.objects.filter(pk=updated_record.get("issue_id")).first()
|
||||
if issue:
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=updated_record.get("issue_id"),
|
||||
actor_id=actor_id,
|
||||
verb="updated",
|
||||
old_value=old_module.name,
|
||||
new_value=new_module.name,
|
||||
field="modules",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"updated module to ",
|
||||
old_identifier=old_module.id,
|
||||
new_identifier=new_module.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
for created_record in created_records:
|
||||
module = Module.objects.filter(
|
||||
pk=created_record.get("fields").get("module")
|
||||
).first()
|
||||
issue = Issue.objects.filter(
|
||||
pk=created_record.get("fields").get("issue")
|
||||
).first()
|
||||
if issue:
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=created_record.get("fields").get("issue"),
|
||||
actor_id=actor_id,
|
||||
verb="created",
|
||||
old_value="",
|
||||
new_value=module.name,
|
||||
field="modules",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"added module {module.name}",
|
||||
new_identifier=module.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
module = Module.objects.filter(pk=requested_data.get("module_id")).first()
|
||||
issue = Issue.objects.filter(pk=issue_id).first()
|
||||
if issue:
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor_id=actor_id,
|
||||
verb="created",
|
||||
old_value="",
|
||||
new_value=module.name,
|
||||
field="modules",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"added module {module.name}",
|
||||
new_identifier=requested_data.get("module_id"),
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_module_issue_activity(
|
||||
@@ -934,32 +910,26 @@ def delete_module_issue_activity(
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
|
||||
module_id = requested_data.get("module_id", "")
|
||||
module_name = requested_data.get("module_name", "")
|
||||
module = Module.objects.filter(pk=module_id).first()
|
||||
issues = requested_data.get("issues")
|
||||
|
||||
for issue in issues:
|
||||
current_issue = Issue.objects.filter(pk=issue).first()
|
||||
if issue:
|
||||
current_issue.updated_at = timezone.now()
|
||||
current_issue.save(update_fields=["updated_at"])
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue,
|
||||
actor_id=actor_id,
|
||||
verb="deleted",
|
||||
old_value=module.name if module is not None else module_name,
|
||||
new_value="",
|
||||
field="modules",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"removed this issue from {module.name if module is not None else module_name}",
|
||||
old_identifier=module_id if module_id is not None else None,
|
||||
epoch=epoch,
|
||||
)
|
||||
module_name = current_instance.get("module_name")
|
||||
current_issue = Issue.objects.filter(pk=issue_id).first()
|
||||
if current_issue:
|
||||
current_issue.updated_at = timezone.now()
|
||||
current_issue.save(update_fields=["updated_at"])
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor_id=actor_id,
|
||||
verb="deleted",
|
||||
old_value=module_name,
|
||||
new_value="",
|
||||
field="modules",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"removed this issue from {module_name}",
|
||||
old_identifier=requested_data.get("module_id") if requested_data.get("module_id") is not None else None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_link_activity(
|
||||
@@ -1648,7 +1618,6 @@ def issue_activity(
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
|
||||
|
||||
if notification:
|
||||
notifications.delay(
|
||||
|
||||
@@ -515,7 +515,7 @@ def notifications(
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
receiver_id=mention_id,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
@@ -552,6 +552,7 @@ def notifications(
|
||||
"old_value": str(
|
||||
issue_activity.get("old_value")
|
||||
),
|
||||
"activity_time": issue_activity.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -639,6 +640,7 @@ def notifications(
|
||||
"old_value": str(
|
||||
last_activity.old_value
|
||||
),
|
||||
"activity_time": issue_activity.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -695,6 +697,7 @@ def notifications(
|
||||
"old_value"
|
||||
)
|
||||
),
|
||||
"activity_time": issue_activity.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7,6 +7,9 @@ import hmac
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -22,10 +25,10 @@ from plane.db.models import (
|
||||
ModuleIssue,
|
||||
CycleIssue,
|
||||
IssueComment,
|
||||
User,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
ProjectSerializer,
|
||||
IssueSerializer,
|
||||
CycleSerializer,
|
||||
ModuleSerializer,
|
||||
CycleIssueSerializer,
|
||||
@@ -34,6 +37,9 @@ from plane.api.serializers import (
|
||||
IssueExpandSerializer,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
|
||||
SERIALIZER_MAPPER = {
|
||||
"project": ProjectSerializer,
|
||||
"issue": IssueExpandSerializer,
|
||||
@@ -72,7 +78,7 @@ def get_model_data(event, event_id, many=False):
|
||||
max_retries=5,
|
||||
retry_jitter=True,
|
||||
)
|
||||
def webhook_task(self, webhook, slug, event, event_data, action):
|
||||
def webhook_task(self, webhook, slug, event, event_data, action, current_site):
|
||||
try:
|
||||
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
|
||||
|
||||
@@ -151,7 +157,18 @@ def webhook_task(self, webhook, slug, event, event_data, action):
|
||||
response_body=str(e),
|
||||
retry_count=str(self.request.retries),
|
||||
)
|
||||
|
||||
# Retry logic
|
||||
if self.request.retries >= self.max_retries:
|
||||
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
|
||||
if webhook:
|
||||
# send email for the deactivation of the webhook
|
||||
send_webhook_deactivation_email(
|
||||
webhook_id=webhook.id,
|
||||
receiver_id=webhook.created_by_id,
|
||||
reason=str(e),
|
||||
current_site=current_site,
|
||||
)
|
||||
return
|
||||
raise requests.RequestException()
|
||||
|
||||
except Exception as e:
|
||||
@@ -162,7 +179,7 @@ def webhook_task(self, webhook, slug, event, event_data, action):
|
||||
|
||||
|
||||
@shared_task()
|
||||
def send_webhook(event, payload, kw, action, slug, bulk):
|
||||
def send_webhook(event, payload, kw, action, slug, bulk, current_site):
|
||||
try:
|
||||
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
|
||||
|
||||
@@ -216,6 +233,7 @@ def send_webhook(event, payload, kw, action, slug, bulk):
|
||||
event=event,
|
||||
event_data=data,
|
||||
action=action,
|
||||
current_site=current_site,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -223,3 +241,56 @@ def send_webhook(event, payload, kw, action, slug, bulk):
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reason):
|
||||
# Get email configurations
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
receiver = User.objects.get(pk=receiver_id)
|
||||
webhook = Webhook.objects.get(pk=webhook_id)
|
||||
subject="Webhook Deactivated"
|
||||
message=f"Webhook {webhook.url} has been deactivated due to failed requests."
|
||||
|
||||
# Send the mail
|
||||
context = {
|
||||
"email": receiver.email,
|
||||
"message": message,
|
||||
"webhook_url":f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}",
|
||||
}
|
||||
html_content = render_to_string(
|
||||
"emails/notifications/webhook-deactivate.html", context
|
||||
)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
try:
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[receiver.email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.7 on 2024-01-24 18:55
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0057_auto_20240122_0901'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='moduleissue',
|
||||
name='issue',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_module', to='db.issue'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='moduleissue',
|
||||
unique_together={('issue', 'module')},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -68,6 +68,7 @@ class Cycle(ProjectBaseModel):
|
||||
sort_order = models.FloatField(default=65535)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
progress_snapshot = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Cycle"
|
||||
|
||||
@@ -134,11 +134,12 @@ class ModuleIssue(ProjectBaseModel):
|
||||
module = models.ForeignKey(
|
||||
"db.Module", on_delete=models.CASCADE, related_name="issue_module"
|
||||
)
|
||||
issue = models.OneToOneField(
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.CASCADE, related_name="issue_module"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["issue", "module"]
|
||||
verbose_name = "Module Issue"
|
||||
verbose_name_plural = "Module Issues"
|
||||
db_table = "module_issues"
|
||||
|
||||
@@ -172,4 +172,9 @@ def create_user_notification(sender, instance, created, **kwargs):
|
||||
from plane.db.models import UserNotificationPreference
|
||||
UserNotificationPreference.objects.create(
|
||||
user=instance,
|
||||
property_change=False,
|
||||
state_change=False,
|
||||
comment=False,
|
||||
mention=False,
|
||||
issue_completed=False,
|
||||
)
|
||||
|
||||
@@ -282,10 +282,8 @@ if REDIS_SSL:
|
||||
redis_url = os.environ.get("REDIS_URL")
|
||||
broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
||||
CELERY_BROKER_URL = broker_url
|
||||
CELERY_RESULT_BACKEND = broker_url
|
||||
else:
|
||||
CELERY_BROKER_URL = REDIS_URL
|
||||
CELERY_RESULT_BACKEND = REDIS_URL
|
||||
|
||||
CELERY_IMPORTS = (
|
||||
"plane.bgtasks.issue_automation_task",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check if the user has sudo access
|
||||
if command -v curl &> /dev/null; then
|
||||
sudo curl -sSL \
|
||||
-o /usr/local/bin/plane-app \
|
||||
@@ -11,6 +12,6 @@ else
|
||||
fi
|
||||
|
||||
sudo chmod +x /usr/local/bin/plane-app
|
||||
sudo sed -i 's/export BRANCH=${BRANCH:-master}/export BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app
|
||||
sudo sed -i 's/export DEPLOY_BRANCH=${BRANCH:-master}/export DEPLOY_BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app
|
||||
|
||||
sudo plane-app --help
|
||||
plane-app --help
|
||||
|
||||
+123
-80
@@ -17,7 +17,7 @@ Project management tool from the future
|
||||
|
||||
EOF
|
||||
}
|
||||
function update_env_files() {
|
||||
function update_env_file() {
|
||||
config_file=$1
|
||||
key=$2
|
||||
value=$3
|
||||
@@ -25,14 +25,16 @@ function update_env_files() {
|
||||
# Check if the config file exists
|
||||
if [ ! -f "$config_file" ]; then
|
||||
echo "Config file not found. Creating a new one..." >&2
|
||||
touch "$config_file"
|
||||
sudo touch "$config_file"
|
||||
fi
|
||||
|
||||
# Check if the key already exists in the config file
|
||||
if grep -q "^$key=" "$config_file"; then
|
||||
awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file"
|
||||
if sudo grep "^$key=" "$config_file"; then
|
||||
sudo awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" | sudo tee "$config_file.tmp" > /dev/null
|
||||
sudo mv "$config_file.tmp" "$config_file" &> /dev/null
|
||||
else
|
||||
echo "$key=$value" >> "$config_file"
|
||||
# sudo echo "$key=$value" >> "$config_file"
|
||||
echo -e "$key=$value" | sudo tee -a "$config_file" > /dev/null
|
||||
fi
|
||||
}
|
||||
function read_env_file() {
|
||||
@@ -42,12 +44,12 @@ function read_env_file() {
|
||||
# Check if the config file exists
|
||||
if [ ! -f "$config_file" ]; then
|
||||
echo "Config file not found. Creating a new one..." >&2
|
||||
touch "$config_file"
|
||||
sudo touch "$config_file"
|
||||
fi
|
||||
|
||||
# Check if the key already exists in the config file
|
||||
if grep -q "^$key=" "$config_file"; then
|
||||
value=$(awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file")
|
||||
if sudo grep -q "^$key=" "$config_file"; then
|
||||
value=$(sudo awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file")
|
||||
echo "$value"
|
||||
else
|
||||
echo ""
|
||||
@@ -55,19 +57,19 @@ function read_env_file() {
|
||||
}
|
||||
function update_config() {
|
||||
config_file="$PLANE_INSTALL_DIR/config.env"
|
||||
update_env_files "$config_file" "$1" "$2"
|
||||
update_env_file $config_file $1 $2
|
||||
}
|
||||
function read_config() {
|
||||
config_file="$PLANE_INSTALL_DIR/config.env"
|
||||
read_env_file "$config_file" "$1"
|
||||
read_env_file $config_file $1
|
||||
}
|
||||
function update_env() {
|
||||
config_file="$PLANE_INSTALL_DIR/.env"
|
||||
update_env_files "$config_file" "$1" "$2"
|
||||
update_env_file $config_file $1 $2
|
||||
}
|
||||
function read_env() {
|
||||
config_file="$PLANE_INSTALL_DIR/.env"
|
||||
read_env_file "$config_file" "$1"
|
||||
read_env_file $config_file $1
|
||||
}
|
||||
function show_message() {
|
||||
print_header
|
||||
@@ -87,14 +89,14 @@ function prepare_environment() {
|
||||
show_message "Prepare Environment..." >&2
|
||||
|
||||
show_message "- Updating OS with required tools ✋" >&2
|
||||
sudo apt-get update -y &> /dev/null
|
||||
sudo apt-get upgrade -y &> /dev/null
|
||||
sudo "$PACKAGE_MANAGER" update -y
|
||||
sudo "$PACKAGE_MANAGER" upgrade -y
|
||||
|
||||
required_tools=("curl" "awk" "wget" "nano" "dialog" "git")
|
||||
local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap")
|
||||
|
||||
for tool in "${required_tools[@]}"; do
|
||||
if ! command -v $tool &> /dev/null; then
|
||||
sudo apt install -y $tool &> /dev/null
|
||||
sudo "$PACKAGE_MANAGER" install -y $tool
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -103,11 +105,30 @@ function prepare_environment() {
|
||||
# Install Docker if not installed
|
||||
if ! command -v docker &> /dev/null; then
|
||||
show_message "- Installing Docker ✋" >&2
|
||||
sudo curl -o- https://get.docker.com | bash -
|
||||
# curl -o- https://get.docker.com | bash -
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
dockerd-rootless-setuptool.sh install &> /dev/null
|
||||
if [ "$PACKAGE_MANAGER" == "yum" ]; then
|
||||
sudo $PACKAGE_MANAGER install -y yum-utils
|
||||
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo &> /dev/null
|
||||
elif [ "$PACKAGE_MANAGER" == "apt-get" ]; then
|
||||
# Add Docker's official GPG key:
|
||||
sudo $PACKAGE_MANAGER update
|
||||
sudo $PACKAGE_MANAGER install ca-certificates curl &> /dev/null
|
||||
sudo install -m 0755 -d /etc/apt/keyrings &> /dev/null
|
||||
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc &> /dev/null
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc &> /dev/null
|
||||
|
||||
# Add the repository to Apt sources:
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
|
||||
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
sudo $PACKAGE_MANAGER update
|
||||
fi
|
||||
|
||||
sudo $PACKAGE_MANAGER install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
|
||||
|
||||
show_message "- Docker Installed ✅" "replace_last_line" >&2
|
||||
else
|
||||
show_message "- Docker is already installed ✅" >&2
|
||||
@@ -127,17 +148,17 @@ function prepare_environment() {
|
||||
function download_plane() {
|
||||
# Download Docker Compose File from github url
|
||||
show_message "Downloading Plane Setup Files ✋" >&2
|
||||
curl -H 'Cache-Control: no-cache, no-store' \
|
||||
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
||||
-s -o $PLANE_INSTALL_DIR/docker-compose.yaml \
|
||||
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s)
|
||||
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s)
|
||||
|
||||
curl -H 'Cache-Control: no-cache, no-store' \
|
||||
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
||||
-s -o $PLANE_INSTALL_DIR/variables-upgrade.env \
|
||||
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s)
|
||||
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s)
|
||||
|
||||
# if .env does not exists rename variables-upgrade.env to .env
|
||||
if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then
|
||||
mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env
|
||||
sudo mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env
|
||||
fi
|
||||
|
||||
show_message "Plane Setup Files Downloaded ✅" "replace_last_line" >&2
|
||||
@@ -186,7 +207,7 @@ function build_local_image() {
|
||||
PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp
|
||||
sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null
|
||||
|
||||
sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $BRANCH --single-branch -q > /dev/null
|
||||
sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $DEPLOY_BRANCH --single-branch -q > /dev/null
|
||||
|
||||
sudo cp $PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml $PLANE_TEMP_CODE_DIR/build.yml
|
||||
|
||||
@@ -199,25 +220,26 @@ function check_for_docker_images() {
|
||||
show_message "" >&2
|
||||
# show_message "Building Plane Images" >&2
|
||||
|
||||
update_env "DOCKERHUB_USER" "makeplane"
|
||||
update_env "PULL_POLICY" "always"
|
||||
CURR_DIR=$(pwd)
|
||||
|
||||
if [ "$BRANCH" == "master" ]; then
|
||||
if [ "$DEPLOY_BRANCH" == "master" ]; then
|
||||
update_env "APP_RELEASE" "latest"
|
||||
export APP_RELEASE=latest
|
||||
else
|
||||
update_env "APP_RELEASE" "$BRANCH"
|
||||
export APP_RELEASE=$BRANCH
|
||||
update_env "APP_RELEASE" "$DEPLOY_BRANCH"
|
||||
export APP_RELEASE=$DEPLOY_BRANCH
|
||||
fi
|
||||
|
||||
if [ $CPU_ARCH == "amd64" ] || [ $CPU_ARCH == "x86_64" ]; then
|
||||
if [ $USE_GLOBAL_IMAGES == 1 ]; then
|
||||
# show_message "Building Plane Images for $CPU_ARCH is not required. Skipping... ✅" "replace_last_line" >&2
|
||||
export DOCKERHUB_USER=makeplane
|
||||
update_env "DOCKERHUB_USER" "$DOCKERHUB_USER"
|
||||
update_env "PULL_POLICY" "always"
|
||||
echo "Building Plane Images for $CPU_ARCH is not required. Skipping..."
|
||||
else
|
||||
export DOCKERHUB_USER=myplane
|
||||
show_message "Building Plane Images for $CPU_ARCH " >&2
|
||||
update_env "DOCKERHUB_USER" "myplane"
|
||||
update_env "DOCKERHUB_USER" "$DOCKERHUB_USER"
|
||||
update_env "PULL_POLICY" "never"
|
||||
|
||||
build_local_image
|
||||
@@ -233,7 +255,7 @@ function check_for_docker_images() {
|
||||
sudo sed -i "s|- uploads:|- $DATA_DIR/minio:|g" $PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
|
||||
show_message "Downloading Plane Images for $CPU_ARCH ✋" >&2
|
||||
docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull
|
||||
sudo docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull
|
||||
show_message "Plane Images Downloaded ✅" "replace_last_line" >&2
|
||||
}
|
||||
function configure_plane() {
|
||||
@@ -453,9 +475,11 @@ function install() {
|
||||
show_message ""
|
||||
if [ "$(uname)" == "Linux" ]; then
|
||||
OS="linux"
|
||||
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
|
||||
# check the OS
|
||||
if [ "$OS_NAME" == "ubuntu" ]; then
|
||||
OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release)
|
||||
OS_NAME=$(echo "$OS_NAME" | tr -d '"')
|
||||
print_header
|
||||
if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
|
||||
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
|
||||
OS_SUPPORTED=true
|
||||
show_message "******** Installing Plane ********"
|
||||
show_message ""
|
||||
@@ -488,7 +512,8 @@ function install() {
|
||||
fi
|
||||
|
||||
else
|
||||
PROGRESS_MSG="❌❌❌ Unsupported OS Detected ❌❌❌"
|
||||
OS_SUPPORTED=false
|
||||
PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
|
||||
show_message ""
|
||||
exit 1
|
||||
fi
|
||||
@@ -499,12 +524,17 @@ function install() {
|
||||
fi
|
||||
}
|
||||
function upgrade() {
|
||||
print_header
|
||||
if [ "$(uname)" == "Linux" ]; then
|
||||
OS="linux"
|
||||
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
|
||||
# check the OS
|
||||
if [ "$OS_NAME" == "ubuntu" ]; then
|
||||
OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release)
|
||||
OS_NAME=$(echo "$OS_NAME" | tr -d '"')
|
||||
if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
|
||||
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
|
||||
|
||||
OS_SUPPORTED=true
|
||||
show_message "******** Upgrading Plane ********"
|
||||
show_message ""
|
||||
|
||||
prepare_environment
|
||||
|
||||
@@ -528,53 +558,49 @@ function upgrade() {
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
PROGRESS_MSG="Unsupported OS Detected"
|
||||
PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
|
||||
show_message ""
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
PROGRESS_MSG="Unsupported OS Detected : $(uname)"
|
||||
PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌"
|
||||
show_message ""
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
function uninstall() {
|
||||
print_header
|
||||
if [ "$(uname)" == "Linux" ]; then
|
||||
OS="linux"
|
||||
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
|
||||
# check the OS
|
||||
if [ "$OS_NAME" == "ubuntu" ]; then
|
||||
OS_NAME=$(echo "$OS_NAME" | tr -d '"')
|
||||
if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
|
||||
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
|
||||
|
||||
OS_SUPPORTED=true
|
||||
show_message "******** Uninstalling Plane ********"
|
||||
show_message ""
|
||||
|
||||
stop_server
|
||||
# CHECK IF PLANE SERVICE EXISTS
|
||||
# if [ -f "/etc/systemd/system/plane.service" ]; then
|
||||
# sudo systemctl stop plane.service &> /dev/null
|
||||
# sudo systemctl disable plane.service &> /dev/null
|
||||
# sudo rm /etc/systemd/system/plane.service &> /dev/null
|
||||
# sudo systemctl daemon-reload &> /dev/null
|
||||
# fi
|
||||
# show_message "- Plane Service removed ✅"
|
||||
|
||||
if ! [ -x "$(command -v docker)" ]; then
|
||||
echo "DOCKER_NOT_INSTALLED" &> /dev/null
|
||||
else
|
||||
# Ask of user input to confirm uninstall docker ?
|
||||
CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3)
|
||||
CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --defaultno --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3)
|
||||
if [ $? -eq 0 ]; then
|
||||
show_message "- Uninstalling Docker ✋"
|
||||
sudo apt-get purge -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null
|
||||
sudo apt-get autoremove -y --purge docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null
|
||||
sudo docker images -q | xargs -r sudo docker rmi -f &> /dev/null
|
||||
sudo "$PACKAGE_MANAGER" remove -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null
|
||||
sudo "$PACKAGE_MANAGER" autoremove -y docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null
|
||||
show_message "- Docker Uninstalled ✅" "replace_last_line" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
rm $PLANE_INSTALL_DIR/.env &> /dev/null
|
||||
rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
|
||||
rm $PLANE_INSTALL_DIR/config.env &> /dev/null
|
||||
rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
|
||||
sudo rm $PLANE_INSTALL_DIR/.env &> /dev/null
|
||||
sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
|
||||
sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null
|
||||
sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
|
||||
|
||||
# rm -rf $PLANE_INSTALL_DIR &> /dev/null
|
||||
show_message "- Configuration Cleaned ✅"
|
||||
@@ -593,12 +619,12 @@ function uninstall() {
|
||||
show_message ""
|
||||
show_message ""
|
||||
else
|
||||
PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌"
|
||||
PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
|
||||
show_message ""
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌"
|
||||
PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌"
|
||||
show_message ""
|
||||
exit 1
|
||||
fi
|
||||
@@ -608,15 +634,15 @@ function start_server() {
|
||||
env_file="$PLANE_INSTALL_DIR/.env"
|
||||
# check if both the files exits
|
||||
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
|
||||
show_message "Starting Plane Server ✋"
|
||||
docker compose -f $docker_compose_file --env-file=$env_file up -d
|
||||
show_message "Starting Plane Server ($APP_RELEASE) ✋"
|
||||
sudo docker compose -f $docker_compose_file --env-file=$env_file up -d
|
||||
|
||||
# Wait for containers to be running
|
||||
echo "Waiting for containers to start..."
|
||||
while ! docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do
|
||||
while ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do
|
||||
sleep 1
|
||||
done
|
||||
show_message "Plane Server Started ✅" "replace_last_line" >&2
|
||||
show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2
|
||||
else
|
||||
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
||||
fi
|
||||
@@ -626,11 +652,11 @@ function stop_server() {
|
||||
env_file="$PLANE_INSTALL_DIR/.env"
|
||||
# check if both the files exits
|
||||
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
|
||||
show_message "Stopping Plane Server ✋"
|
||||
docker compose -f $docker_compose_file --env-file=$env_file down
|
||||
show_message "Plane Server Stopped ✅" "replace_last_line" >&2
|
||||
show_message "Stopping Plane Server ($APP_RELEASE) ✋"
|
||||
sudo docker compose -f $docker_compose_file --env-file=$env_file down
|
||||
show_message "Plane Server Stopped ($APP_RELEASE) ✅" "replace_last_line" >&2
|
||||
else
|
||||
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
||||
show_message "Plane Server not installed [Skipping] ✅" "replace_last_line" >&2
|
||||
fi
|
||||
}
|
||||
function restart_server() {
|
||||
@@ -638,9 +664,9 @@ function restart_server() {
|
||||
env_file="$PLANE_INSTALL_DIR/.env"
|
||||
# check if both the files exits
|
||||
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
|
||||
show_message "Restarting Plane Server ✋"
|
||||
docker compose -f $docker_compose_file --env-file=$env_file restart
|
||||
show_message "Plane Server Restarted ✅" "replace_last_line" >&2
|
||||
show_message "Restarting Plane Server ($APP_RELEASE) ✋"
|
||||
sudo docker compose -f $docker_compose_file --env-file=$env_file restart
|
||||
show_message "Plane Server Restarted ($APP_RELEASE) ✅" "replace_last_line" >&2
|
||||
else
|
||||
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
||||
fi
|
||||
@@ -666,28 +692,45 @@ function show_help() {
|
||||
}
|
||||
function update_installer() {
|
||||
show_message "Updating Plane Installer ✋" >&2
|
||||
curl -H 'Cache-Control: no-cache, no-store' \
|
||||
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
||||
-s -o /usr/local/bin/plane-app \
|
||||
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/1-click/install.sh?token=$(date +%s)
|
||||
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s)
|
||||
|
||||
chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null
|
||||
sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null
|
||||
show_message "Plane Installer Updated ✅" "replace_last_line" >&2
|
||||
}
|
||||
|
||||
export BRANCH=${BRANCH:-master}
|
||||
export APP_RELEASE=$BRANCH
|
||||
export DEPLOY_BRANCH=${BRANCH:-master}
|
||||
export APP_RELEASE=$DEPLOY_BRANCH
|
||||
export DOCKERHUB_USER=makeplane
|
||||
export PULL_POLICY=always
|
||||
|
||||
if [ "$DEPLOY_BRANCH" == "master" ]; then
|
||||
export APP_RELEASE=latest
|
||||
fi
|
||||
|
||||
PLANE_INSTALL_DIR=/opt/plane
|
||||
DATA_DIR=$PLANE_INSTALL_DIR/data
|
||||
LOG_DIR=$PLANE_INSTALL_DIR/log
|
||||
OS_SUPPORTED=false
|
||||
CPU_ARCH=$(uname -m)
|
||||
PROGRESS_MSG=""
|
||||
USE_GLOBAL_IMAGES=1
|
||||
USE_GLOBAL_IMAGES=0
|
||||
PACKAGE_MANAGER=""
|
||||
|
||||
mkdir -p $PLANE_INSTALL_DIR/{data,log}
|
||||
if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then
|
||||
USE_GLOBAL_IMAGES=1
|
||||
fi
|
||||
|
||||
sudo mkdir -p $PLANE_INSTALL_DIR/{data,log}
|
||||
|
||||
if command -v apt-get &> /dev/null; then
|
||||
PACKAGE_MANAGER="apt-get"
|
||||
elif command -v yum &> /dev/null; then
|
||||
PACKAGE_MANAGER="yum"
|
||||
elif command -v apk &> /dev/null; then
|
||||
PACKAGE_MANAGER="apk"
|
||||
fi
|
||||
|
||||
if [ "$1" == "start" ]; then
|
||||
start_server
|
||||
@@ -704,7 +747,7 @@ elif [ "$1" == "--upgrade" ] || [ "$1" == "-up" ]; then
|
||||
upgrade
|
||||
elif [ "$1" == "--uninstall" ] || [ "$1" == "-un" ]; then
|
||||
uninstall
|
||||
elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ] ; then
|
||||
elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ]; then
|
||||
update_installer
|
||||
elif [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
|
||||
show_help
|
||||
|
||||
@@ -38,10 +38,6 @@ x-app-env : &app-env
|
||||
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
|
||||
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
|
||||
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
|
||||
# OPENAI SETTINGS - Deprecated can be configured through admin panel
|
||||
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-""}
|
||||
- GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"}
|
||||
# LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel
|
||||
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
|
||||
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
|
||||
|
||||
@@ -20,8 +20,8 @@ function buildLocalImage() {
|
||||
DO_BUILD="2"
|
||||
else
|
||||
printf "\n" >&2
|
||||
printf "${YELLOW}You are on ${ARCH} cpu architecture. ${NC}\n" >&2
|
||||
printf "${YELLOW}Since the prebuilt ${ARCH} compatible docker images are not available for, we will be running the docker build on this system. ${NC} \n" >&2
|
||||
printf "${YELLOW}You are on ${CPU_ARCH} cpu architecture. ${NC}\n" >&2
|
||||
printf "${YELLOW}Since the prebuilt ${CPU_ARCH} compatible docker images are not available for, we will be running the docker build on this system. ${NC} \n" >&2
|
||||
printf "${YELLOW}This might take ${YELLOW}5-30 min based on your system's hardware configuration. \n ${NC} \n" >&2
|
||||
printf "\n" >&2
|
||||
printf "${GREEN}Select an option to proceed: ${NC}\n" >&2
|
||||
@@ -49,7 +49,7 @@ function buildLocalImage() {
|
||||
cd $PLANE_TEMP_CODE_DIR
|
||||
if [ "$BRANCH" == "master" ];
|
||||
then
|
||||
APP_RELEASE=latest
|
||||
export APP_RELEASE=latest
|
||||
fi
|
||||
|
||||
docker compose -f build.yml build --no-cache >&2
|
||||
@@ -149,7 +149,7 @@ function upgrade() {
|
||||
function askForAction() {
|
||||
echo
|
||||
echo "Select a Action you want to perform:"
|
||||
echo " 1) Install (${ARCH})"
|
||||
echo " 1) Install (${CPU_ARCH})"
|
||||
echo " 2) Start"
|
||||
echo " 3) Stop"
|
||||
echo " 4) Restart"
|
||||
@@ -193,8 +193,8 @@ function askForAction() {
|
||||
}
|
||||
|
||||
# CPU ARCHITECHTURE BASED SETTINGS
|
||||
ARCH=$(uname -m)
|
||||
if [ $ARCH == "amd64" ] || [ $ARCH == "x86_64" ];
|
||||
CPU_ARCH=$(uname -m)
|
||||
if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]];
|
||||
then
|
||||
USE_GLOBAL_IMAGES=1
|
||||
DOCKERHUB_USER=makeplane
|
||||
@@ -205,6 +205,11 @@ else
|
||||
PULL_POLICY=never
|
||||
fi
|
||||
|
||||
if [ "$BRANCH" == "master" ];
|
||||
then
|
||||
export APP_RELEASE=latest
|
||||
fi
|
||||
|
||||
# REMOVE SPECIAL CHARACTERS FROM BRANCH NAME
|
||||
if [ "$BRANCH" != "master" ];
|
||||
then
|
||||
|
||||
@@ -8,13 +8,13 @@ NGINX_PORT=80
|
||||
WEB_URL=http://localhost
|
||||
DEBUG=0
|
||||
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
|
||||
SENTRY_DSN=""
|
||||
SENTRY_ENVIRONMENT="production"
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENVIRONMENT=production
|
||||
GOOGLE_CLIENT_ID=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
DOCKERIZED=1 # deprecated
|
||||
CORS_ALLOWED_ORIGINS="http://localhost"
|
||||
CORS_ALLOWED_ORIGINS=http://localhost
|
||||
|
||||
#DB SETTINGS
|
||||
PGHOST=plane-db
|
||||
@@ -31,19 +31,14 @@ REDIS_PORT=6379
|
||||
REDIS_URL=redis://${REDIS_HOST}:6379/
|
||||
|
||||
# EMAIL SETTINGS
|
||||
EMAIL_HOST=""
|
||||
EMAIL_HOST_USER=""
|
||||
EMAIL_HOST_PASSWORD=""
|
||||
EMAIL_HOST=
|
||||
EMAIL_HOST_USER=
|
||||
EMAIL_HOST_PASSWORD=
|
||||
EMAIL_PORT=587
|
||||
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
|
||||
EMAIL_FROM=Team Plane <team@mailer.plane.so>
|
||||
EMAIL_USE_TLS=1
|
||||
EMAIL_USE_SSL=0
|
||||
|
||||
# OPENAI SETTINGS
|
||||
OPENAI_API_BASE=https://api.openai.com/v1 # deprecated
|
||||
OPENAI_API_KEY="sk-" # deprecated
|
||||
GPT_ENGINE="gpt-3.5-turbo" # deprecated
|
||||
|
||||
# LOGIN/SIGNUP SETTINGS
|
||||
ENABLE_SIGNUP=1
|
||||
ENABLE_EMAIL_PASSWORD=1
|
||||
@@ -52,13 +47,13 @@ SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
|
||||
|
||||
# DATA STORE SETTINGS
|
||||
USE_MINIO=1
|
||||
AWS_REGION=""
|
||||
AWS_ACCESS_KEY_ID="access-key"
|
||||
AWS_SECRET_ACCESS_KEY="secret-key"
|
||||
AWS_REGION=
|
||||
AWS_ACCESS_KEY_ID=access-key
|
||||
AWS_SECRET_ACCESS_KEY=secret-key
|
||||
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
|
||||
AWS_S3_BUCKET_NAME=uploads
|
||||
MINIO_ROOT_USER="access-key"
|
||||
MINIO_ROOT_PASSWORD="secret-key"
|
||||
MINIO_ROOT_USER=access-key
|
||||
MINIO_ROOT_PASSWORD=secret-key
|
||||
BUCKET_NAME=uploads
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
|
||||
import { InputRule, mergeAttributes, Node, nodeInputRule, wrappingInputRule } from "@tiptap/core";
|
||||
|
||||
/**
|
||||
* Extension based on:
|
||||
* - Tiptap HorizontalRule extension (https://tiptap.dev/api/nodes/horizontal-rule)
|
||||
*/
|
||||
|
||||
export interface HorizontalRuleOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
horizontalRule: {
|
||||
/**
|
||||
* Add a horizontal rule
|
||||
*/
|
||||
setHorizontalRule: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const HorizontalRule = Node.create<HorizontalRuleOptions>({
|
||||
name: "horizontalRule",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
group: "block",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
color: {
|
||||
default: "#dddddd",
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
"data-type": this.name,
|
||||
}),
|
||||
["div", {}],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setHorizontalRule:
|
||||
() =>
|
||||
({ chain }) => {
|
||||
return (
|
||||
chain()
|
||||
.insertContent({ type: this.name })
|
||||
// set cursor after horizontal rule
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
const { $to } = tr.selection;
|
||||
const posAfter = $to.end();
|
||||
|
||||
if ($to.nodeAfter) {
|
||||
tr.setSelection(TextSelection.create(tr.doc, $to.pos));
|
||||
} else {
|
||||
// add node after horizontal rule if it’s the end of the document
|
||||
const node = $to.parent.type.contentMatch.defaultType?.create();
|
||||
|
||||
if (node) {
|
||||
tr.insert(posAfter, node);
|
||||
tr.setSelection(TextSelection.create(tr.doc, posAfter));
|
||||
}
|
||||
}
|
||||
|
||||
tr.scrollIntoView();
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.run()
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
|
||||
handler: ({ state, range, match }) => {
|
||||
state.tr.replaceRangeWith(range.from, range.to, this.type.create());
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -1,26 +1,25 @@
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import TiptapUnderline from "@tiptap/extension-underline";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import TiptapUnderline from "@tiptap/extension-underline";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
|
||||
import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
|
||||
import { Table } from "src/ui/extensions/table/table";
|
||||
import { TableCell } from "src/ui/extensions/table/table-cell/table-cell";
|
||||
import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
|
||||
import { TableRow } from "src/ui/extensions/table/table-row/table-row";
|
||||
import { HorizontalRule } from "src/ui/extensions/horizontal-rule";
|
||||
|
||||
import { ImageExtension } from "src/ui/extensions/image";
|
||||
|
||||
import { isValidHttpUrl } from "src/lib/utils";
|
||||
import { Mentions } from "src/ui/mentions";
|
||||
|
||||
import { CustomKeymap } from "src/ui/extensions/keymap";
|
||||
import { CustomCodeBlockExtension } from "src/ui/extensions/code";
|
||||
import { CustomQuoteExtension } from "src/ui/extensions/quote";
|
||||
import { ListKeymap } from "src/ui/extensions/custom-list-keymap";
|
||||
import { CustomKeymap } from "src/ui/extensions/keymap";
|
||||
import { CustomQuoteExtension } from "src/ui/extensions/quote";
|
||||
|
||||
import { DeleteImage } from "src/types/delete-image";
|
||||
import { IMentionSuggestion } from "src/types/mention-suggestion";
|
||||
@@ -55,7 +54,9 @@ export const CoreEditorExtensions = (
|
||||
},
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
horizontalRule: {
|
||||
HTMLAttributes: { class: "mt-4 mb-4" },
|
||||
},
|
||||
blockquote: false,
|
||||
dropcursor: {
|
||||
color: "rgba(var(--color-text-100))",
|
||||
@@ -104,7 +105,6 @@ export const CoreEditorExtensions = (
|
||||
transformCopiedText: true,
|
||||
transformPastedText: true,
|
||||
}),
|
||||
HorizontalRule,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
|
||||
@@ -10,6 +10,11 @@ export interface CustomMentionOptions extends MentionOptions {
|
||||
}
|
||||
|
||||
export const CustomMention = Mention.extend<CustomMentionOptions>({
|
||||
addStorage(this) {
|
||||
return {
|
||||
mentionsOpen: false,
|
||||
};
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
|
||||
@@ -14,6 +14,7 @@ export const Suggestion = (suggestions: IMentionSuggestion[]) => ({
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
props.editor.storage.mentionsOpen = true;
|
||||
reactRenderer = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
@@ -45,10 +46,18 @@ export const Suggestion = (suggestions: IMentionSuggestion[]) => ({
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return reactRenderer?.ref?.onKeyDown(props);
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
|
||||
if (navigationKeys.includes(props.event.key)) {
|
||||
// @ts-ignore
|
||||
reactRenderer?.ref?.onKeyDown(props);
|
||||
event?.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
|
||||
props.editor.storage.mentionsOpen = false;
|
||||
popup?.[0].destroy();
|
||||
reactRenderer?.destroy();
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
|
||||
import { Table } from "src/ui/extensions/table/table";
|
||||
import { TableCell } from "src/ui/extensions/table/table-cell/table-cell";
|
||||
import { TableRow } from "src/ui/extensions/table/table-row/table-row";
|
||||
import { HorizontalRule } from "src/ui/extensions/horizontal-rule";
|
||||
|
||||
import { ReadOnlyImageExtension } from "src/ui/extensions/image/read-only-image";
|
||||
import { isValidHttpUrl } from "src/lib/utils";
|
||||
@@ -51,7 +50,9 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
},
|
||||
},
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
horizontalRule: {
|
||||
HTMLAttributes: { class: "mt-4 mb-4" },
|
||||
},
|
||||
dropcursor: {
|
||||
color: "rgba(var(--color-text-100))",
|
||||
width: 2,
|
||||
@@ -72,7 +73,6 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
class: "rounded-lg border border-custom-border-300",
|
||||
},
|
||||
}),
|
||||
HorizontalRule,
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
Color,
|
||||
|
||||
@@ -4,13 +4,16 @@ export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
|
||||
Extension.create({
|
||||
name: "enterKey",
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
addKeyboardShortcuts(this) {
|
||||
return {
|
||||
Enter: () => {
|
||||
if (onEnterKeyPress) {
|
||||
onEnterKeyPress();
|
||||
if (!this.editor.storage.mentionsOpen) {
|
||||
if (onEnterKeyPress) {
|
||||
onEnterKeyPress();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
return false;
|
||||
},
|
||||
"Shift-Enter": ({ editor }) =>
|
||||
editor.commands.first(({ commands }) => [
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { EnterKeyExtension } from "src/ui/extensions/enter-key-extension";
|
||||
|
||||
export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [
|
||||
// EnterKeyExtension(onEnterKeyPress),
|
||||
];
|
||||
export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [EnterKeyExtension(onEnterKeyPress)];
|
||||
|
||||
Vendored
+19
-1
@@ -30,7 +30,8 @@ export interface ICycle {
|
||||
is_favorite: boolean;
|
||||
issue: string;
|
||||
name: string;
|
||||
owned_by: IUser;
|
||||
owned_by: string;
|
||||
progress_snapshot: TProgressSnapshot;
|
||||
project: string;
|
||||
project_detail: IProjectLite;
|
||||
status: TCycleGroups;
|
||||
@@ -49,6 +50,23 @@ export interface ICycle {
|
||||
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 = {
|
||||
assignee_id: string | null;
|
||||
avatar: string | null;
|
||||
|
||||
Vendored
+6
-5
@@ -13,9 +13,10 @@ export type TWidgetKeys =
|
||||
| "recent_projects"
|
||||
| "recent_collaborators";
|
||||
|
||||
export type TIssuesListTypes = "upcoming" | "overdue" | "completed";
|
||||
export type TIssuesListTypes = "pending" | "upcoming" | "overdue" | "completed";
|
||||
|
||||
export type TDurationFilterOptions =
|
||||
| "none"
|
||||
| "today"
|
||||
| "this_week"
|
||||
| "this_month"
|
||||
@@ -23,21 +24,21 @@ export type TDurationFilterOptions =
|
||||
|
||||
// widget filters
|
||||
export type TAssignedIssuesWidgetFilters = {
|
||||
target_date?: TDurationFilterOptions;
|
||||
duration?: TDurationFilterOptions;
|
||||
tab?: TIssuesListTypes;
|
||||
};
|
||||
|
||||
export type TCreatedIssuesWidgetFilters = {
|
||||
target_date?: TDurationFilterOptions;
|
||||
duration?: TDurationFilterOptions;
|
||||
tab?: TIssuesListTypes;
|
||||
};
|
||||
|
||||
export type TIssuesByStateGroupsWidgetFilters = {
|
||||
target_date?: TDurationFilterOptions;
|
||||
duration?: TDurationFilterOptions;
|
||||
};
|
||||
|
||||
export type TIssuesByPriorityWidgetFilters = {
|
||||
target_date?: TDurationFilterOptions;
|
||||
duration?: TDurationFilterOptions;
|
||||
};
|
||||
|
||||
export type TWidgetFiltersFormData =
|
||||
|
||||
Vendored
+9
@@ -221,3 +221,12 @@ export interface IGroupByColumn {
|
||||
export interface IIssueMap {
|
||||
[key: string]: TIssue;
|
||||
}
|
||||
|
||||
export interface IIssueListRow {
|
||||
id: string;
|
||||
groupId: string;
|
||||
type: "HEADER" | "NO_ISSUES" | "QUICK_ADD" | "ISSUE";
|
||||
name?: string;
|
||||
icon?: ReactElement | undefined;
|
||||
payload?: Partial<TIssue>;
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -21,7 +21,7 @@ export type TIssue = {
|
||||
project_id: string;
|
||||
parent_id: string | null;
|
||||
cycle_id: string | null;
|
||||
module_id: string | null;
|
||||
module_ids: string[] | null;
|
||||
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
Vendored
+1
-1
@@ -117,7 +117,7 @@ export type TProjectIssuesSearchParams = {
|
||||
parent?: boolean;
|
||||
issue_relation?: boolean;
|
||||
cycle?: boolean;
|
||||
module?: boolean;
|
||||
module?: string[];
|
||||
sub_issue?: boolean;
|
||||
issue_id?: string;
|
||||
workspace_search: boolean;
|
||||
|
||||
Vendored
+1
-3
@@ -64,8 +64,7 @@ export type TIssueParams =
|
||||
| "order_by"
|
||||
| "type"
|
||||
| "sub_issue"
|
||||
| "show_empty_groups"
|
||||
| "start_target_date";
|
||||
| "show_empty_groups";
|
||||
|
||||
export type TCalendarLayouts = "month" | "week";
|
||||
|
||||
@@ -93,7 +92,6 @@ export interface IIssueDisplayFilterOptions {
|
||||
layout?: TIssueLayouts;
|
||||
order_by?: TIssueOrderByOptions;
|
||||
show_empty_groups?: boolean;
|
||||
start_target_date?: boolean;
|
||||
sub_issue?: boolean;
|
||||
type?: TIssueTypeFilters;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import * as React from "react";
|
||||
|
||||
// icons
|
||||
import { ChevronRight } from "lucide-react";
|
||||
// components
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
type BreadcrumbsProps = {
|
||||
children: any;
|
||||
@@ -12,7 +10,7 @@ type BreadcrumbsProps = {
|
||||
const Breadcrumbs = ({ children }: BreadcrumbsProps) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
{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}
|
||||
{index !== React.Children.count(children) - 1 && (
|
||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />
|
||||
@@ -25,42 +23,11 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => (
|
||||
type Props = {
|
||||
type?: "text" | "component";
|
||||
component?: React.ReactNode;
|
||||
label?: string;
|
||||
icon?: React.ReactNode;
|
||||
link?: string;
|
||||
link?: JSX.Element;
|
||||
};
|
||||
const BreadcrumbItem: React.FC<Props> = (props) => {
|
||||
const { type = "text", component, label, icon, link } = props;
|
||||
return (
|
||||
<>
|
||||
{type != "text" ? (
|
||||
<div className="flex items-center space-x-2">{component}</div>
|
||||
) : (
|
||||
<Tooltip tooltipContent={label} position="bottom">
|
||||
<li className="flex items-center space-x-2">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{link ? (
|
||||
<a
|
||||
className="flex items-center gap-1 text-sm font-medium text-custom-text-300 hover:text-custom-text-100"
|
||||
href={link}
|
||||
>
|
||||
{icon && (
|
||||
<div className="flex h-5 w-5 items-center justify-center overflow-hidden !text-[1rem]">{icon}</div>
|
||||
)}
|
||||
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex cursor-default items-center gap-1 text-sm font-medium text-custom-text-100">
|
||||
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden">{icon}</div>}
|
||||
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const { type = "text", component, link } = props;
|
||||
return <>{type != "text" ? <div className="flex items-center space-x-2">{component}</div> : link}</>;
|
||||
};
|
||||
|
||||
Breadcrumbs.BreadcrumbItem = BreadcrumbItem;
|
||||
|
||||
@@ -15,6 +15,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
const {
|
||||
buttonClassName = "",
|
||||
customButtonClassName = "",
|
||||
customButtonTabIndex = 0,
|
||||
placement,
|
||||
children,
|
||||
className = "",
|
||||
@@ -29,6 +30,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
verticalEllipsis = false,
|
||||
portalElement,
|
||||
menuButtonOnClick,
|
||||
onMenuClose,
|
||||
tabIndex,
|
||||
closeOnSelect,
|
||||
} = props;
|
||||
@@ -47,18 +49,28 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
setIsOpen(true);
|
||||
if (referenceElement) referenceElement.focus();
|
||||
};
|
||||
const closeDropdown = () => setIsOpen(false);
|
||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||
const closeDropdown = () => {
|
||||
isOpen && onMenuClose && onMenuClose();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const 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);
|
||||
|
||||
let menuItems = (
|
||||
<Menu.Items
|
||||
className="fixed z-10"
|
||||
onClick={() => {
|
||||
if (closeOnSelect) closeDropdown();
|
||||
}}
|
||||
static
|
||||
>
|
||||
<Menu.Items className="fixed z-10" static>
|
||||
<div
|
||||
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",
|
||||
@@ -89,7 +101,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn("relative w-min text-left", className)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
@@ -98,11 +111,13 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openDropdown();
|
||||
if (menuButtonOnClick) menuButtonOnClick();
|
||||
}}
|
||||
className={customButtonClassName}
|
||||
tabIndex={customButtonTabIndex}
|
||||
>
|
||||
{customButton}
|
||||
</button>
|
||||
@@ -114,7 +129,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openDropdown();
|
||||
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 ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
tabIndex={customButtonTabIndex}
|
||||
>
|
||||
<MoreHorizontal className={`h-3.5 w-3.5 ${verticalEllipsis ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
@@ -138,10 +155,12 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
? "cursor-not-allowed text-custom-text-200"
|
||||
: "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openDropdown();
|
||||
if (menuButtonOnClick) menuButtonOnClick();
|
||||
}}
|
||||
tabIndex={customButtonTabIndex}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && <ChevronDown className="h-3.5 w-3.5" />}
|
||||
@@ -159,6 +178,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
|
||||
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
||||
const { children, onClick, className = "" } = props;
|
||||
|
||||
return (
|
||||
<Menu.Item as="div">
|
||||
{({ active, close }) => (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Placement } from "@blueprintjs/popover2";
|
||||
|
||||
export interface IDropdownProps {
|
||||
customButtonClassName?: string;
|
||||
customButtonTabIndex?: number;
|
||||
buttonClassName?: string;
|
||||
className?: string;
|
||||
customButton?: JSX.Element;
|
||||
@@ -23,6 +24,7 @@ export interface ICustomMenuDropdownProps extends IDropdownProps {
|
||||
noBorder?: boolean;
|
||||
verticalEllipsis?: boolean;
|
||||
menuButtonOnClick?: (...args: any) => void;
|
||||
onMenuClose?: () => void;
|
||||
closeOnSelect?: boolean;
|
||||
portalElement?: Element | null;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./input";
|
||||
export * from "./textarea";
|
||||
export * from "./input-color-picker";
|
||||
export * from "./checkbox";
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
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(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.stopPropagation();
|
||||
if (!isOpen) {
|
||||
event.stopPropagation();
|
||||
onOpen();
|
||||
} else {
|
||||
selectActiveItem && selectActiveItem();
|
||||
}
|
||||
} else if (event.key === "Escape" && isOpen) {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import React from "react";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
type Props = {
|
||||
data: any;
|
||||
noTooltip?: boolean;
|
||||
inPercentage?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
export const LinearProgressIndicator: React.FC<Props> = ({ data, noTooltip = false, inPercentage = false }) => {
|
||||
export const LinearProgressIndicator: React.FC<Props> = ({
|
||||
data,
|
||||
noTooltip = false,
|
||||
inPercentage = false,
|
||||
size = "sm",
|
||||
}) => {
|
||||
const total = data.reduce((acc: any, cur: any) => acc + cur.value, 0);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
let progress = 0;
|
||||
@@ -23,18 +30,24 @@ export const LinearProgressIndicator: React.FC<Props> = ({ data, noTooltip = fal
|
||||
if (noTooltip) return <div style={style} />;
|
||||
else
|
||||
return (
|
||||
<Tooltip key={item.id} tooltipContent={`${item.name} ${Math.round(item.value)}%`}>
|
||||
<div style={style} className="first:rounded-l-full last:rounded-r-full" />
|
||||
<Tooltip key={item.id} tooltipContent={`${item.name} ${Math.round(item.value)}${inPercentage ? "%" : ""}`}>
|
||||
<div style={style} className="first:rounded-l-sm last:rounded-r-sm" />
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-1 w-full items-center justify-between gap-1">
|
||||
<div
|
||||
className={cn("flex w-full items-center justify-between gap-[1px] rounded-sm", {
|
||||
"h-2": size === "sm",
|
||||
"h-3": size === "md",
|
||||
"h-3.5": size === "lg",
|
||||
})}
|
||||
>
|
||||
{total === 0 ? (
|
||||
<div className="flex h-full w-full gap-1 bg-neutral-500">{bars}</div>
|
||||
<div className="flex h-full w-full gap-[1.5px] p-[2px] bg-custom-background-90 rounded-sm">{bars}</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full gap-1">{bars}</div>
|
||||
<div className="flex h-full w-full gap-[1.5px] p-[2px] bg-custom-background-90 rounded-sm">{bars}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -89,8 +89,8 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
|
||||
<div className="px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="">
|
||||
<div className="flex items-start gap-x-4">
|
||||
<div className="grid place-items-center rounded-full bg-red-500/20 p-4">
|
||||
<Trash2 className="h-6 w-6 text-red-600" aria-hidden="true" />
|
||||
<div className="grid place-items-center rounded-full bg-red-500/20 p-2 sm:p-2 md:p-4 lg:p-4 mt-3 sm:mt-3 md:mt-0 lg:mt-0 ">
|
||||
<Trash2 className="h-4 w-4 sm:h-4 sm:w-4 md:h-6 md:w-6 lg:h-6 lg:w-6 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<Dialog.Title as="h3" className="my-4 text-2xl font-medium leading-6 text-custom-text-100">
|
||||
|
||||
@@ -31,7 +31,6 @@ export const GoogleSignInButton: FC<Props> = (props) => {
|
||||
size: "large",
|
||||
logo_alignment: "center",
|
||||
text: type === "sign_in" ? "signin_with" : "signup_with",
|
||||
width: 384,
|
||||
} as GsiButtonConfiguration // customization attributes
|
||||
);
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,10 +4,14 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { AuthService } from "services/auth.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useEventTracker } from "hooks/store";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// helpers
|
||||
import { checkEmailValidity } from "helpers/string.helper";
|
||||
// icons
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
@@ -31,6 +35,9 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
const { email, handleSignInRedirection } = props;
|
||||
// states
|
||||
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
// store hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// form info
|
||||
@@ -60,21 +67,34 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
title: "Success!",
|
||||
message: "Password created successfully.",
|
||||
});
|
||||
captureEvent(PASSWORD_CREATE_SELECTED, {
|
||||
state: "SUCCESS",
|
||||
first_time: false,
|
||||
});
|
||||
await handleSignInRedirection();
|
||||
})
|
||||
.catch((err) =>
|
||||
.catch((err) => {
|
||||
captureEvent(PASSWORD_CREATE_SELECTED, {
|
||||
state: "FAILED",
|
||||
first_time: false,
|
||||
});
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleGoToWorkspace = async () => {
|
||||
setIsGoingToWorkspace(true);
|
||||
|
||||
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false));
|
||||
await handleSignInRedirection().finally(() => {
|
||||
captureEvent(PASSWORD_CREATE_SKIPPED, {
|
||||
state: "SUCCESS",
|
||||
first_time: false,
|
||||
});
|
||||
setIsGoingToWorkspace(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -114,17 +134,30 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
required: "Password is required",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.password)}
|
||||
placeholder="Enter password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
minLength={8}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.password)}
|
||||
placeholder="Enter password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
minLength={8}
|
||||
autoFocus
|
||||
/>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(false)}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<p className="text-onboarding-text-200 text-xs mt-2 pb-3">
|
||||
|
||||
@@ -2,12 +2,12 @@ import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { XCircle } from "lucide-react";
|
||||
import { Eye, EyeOff, XCircle } from "lucide-react";
|
||||
// services
|
||||
import { AuthService } from "services/auth.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useApplication } from "hooks/store";
|
||||
import { useApplication, useEventTracker } from "hooks/store";
|
||||
// components
|
||||
import { ESignInSteps, ForgotPasswordPopover } from "components/account";
|
||||
// ui
|
||||
@@ -16,6 +16,8 @@ import { Button, Input } from "@plane/ui";
|
||||
import { checkEmailValidity } from "helpers/string.helper";
|
||||
// types
|
||||
import { IPasswordSignInData } from "@plane/types";
|
||||
// constants
|
||||
import { FORGOT_PASSWORD, SIGN_IN_WITH_PASSWORD } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
@@ -40,11 +42,13 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
|
||||
const { email, handleStepChange, handleEmailClear, onSubmit } = props;
|
||||
// states
|
||||
const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
const {
|
||||
config: { envConfig },
|
||||
} = useApplication();
|
||||
const { captureEvent } = useEventTracker();
|
||||
// derived values
|
||||
const isSmtpConfigured = envConfig?.is_smtp_configured;
|
||||
// form info
|
||||
@@ -71,7 +75,13 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
|
||||
|
||||
await authService
|
||||
.passwordSignIn(payload)
|
||||
.then(async () => await onSubmit())
|
||||
.then(async () => {
|
||||
captureEvent(SIGN_IN_WITH_PASSWORD, {
|
||||
state: "SUCCESS",
|
||||
first_time: false,
|
||||
});
|
||||
await onSubmit();
|
||||
})
|
||||
.catch((err) =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
@@ -157,20 +167,34 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
|
||||
required: "Password is required",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.password)}
|
||||
placeholder="Enter password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.password)}
|
||||
placeholder="Enter password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
autoFocus
|
||||
/>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(false)}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="w-full text-right mt-2 pb-3">
|
||||
<div className="mt-2 w-full pb-3 text-right">
|
||||
{isSmtpConfigured ? (
|
||||
<Link
|
||||
onClick={() => captureEvent(FORGOT_PASSWORD)}
|
||||
href={`/accounts/forgot-password?email=${email}`}
|
||||
className="text-xs font-medium text-custom-primary-100"
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useApplication } from "hooks/store";
|
||||
import { useApplication, useEventTracker } from "hooks/store";
|
||||
import useSignInRedirection from "hooks/use-sign-in-redirection";
|
||||
// components
|
||||
import { LatestFeatureBlock } from "components/common";
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
OAuthOptions,
|
||||
SignInOptionalSetPasswordForm,
|
||||
} from "components/account";
|
||||
// constants
|
||||
import { NAVIGATE_TO_SIGNUP } from "constants/event-tracker";
|
||||
|
||||
export enum ESignInSteps {
|
||||
EMAIL = "EMAIL",
|
||||
@@ -32,6 +34,7 @@ export const SignInRoot = observer(() => {
|
||||
const {
|
||||
config: { envConfig },
|
||||
} = useApplication();
|
||||
const { captureEvent } = useEventTracker();
|
||||
// derived values
|
||||
const isSmtpConfigured = envConfig?.is_smtp_configured;
|
||||
|
||||
@@ -110,7 +113,11 @@ export const SignInRoot = observer(() => {
|
||||
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_in" />
|
||||
<p className="text-xs text-onboarding-text-300 text-center mt-6">
|
||||
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
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -7,12 +7,15 @@ import { UserService } from "services/user.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useTimer from "hooks/use-timer";
|
||||
import { useEventTracker } from "hooks/store";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// helpers
|
||||
import { checkEmailValidity } from "helpers/string.helper";
|
||||
// types
|
||||
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
|
||||
// constants
|
||||
import { CODE_VERIFIED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
@@ -41,6 +44,8 @@ export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
|
||||
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// store hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
// timer
|
||||
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30);
|
||||
// form info
|
||||
@@ -69,17 +74,22 @@ export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
|
||||
await authService
|
||||
.magicSignIn(payload)
|
||||
.then(async () => {
|
||||
captureEvent(CODE_VERIFIED, {
|
||||
state: "SUCCESS",
|
||||
});
|
||||
const currentUser = await userService.currentUser();
|
||||
|
||||
await onSubmit(currentUser.is_password_autoset);
|
||||
})
|
||||
.catch((err) =>
|
||||
.catch((err) => {
|
||||
captureEvent(CODE_VERIFIED, {
|
||||
state: "FAILED",
|
||||
});
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
|
||||
|
||||
@@ -4,12 +4,16 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { AuthService } from "services/auth.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useEventTracker } from "hooks/store";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// helpers
|
||||
import { checkEmailValidity } from "helpers/string.helper";
|
||||
// constants
|
||||
import { ESignUpSteps } from "components/account";
|
||||
import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED, SETUP_PASSWORD } from "constants/event-tracker";
|
||||
// icons
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
@@ -34,6 +38,9 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
const { email, handleSignInRedirection } = props;
|
||||
// states
|
||||
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
// store hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// form info
|
||||
@@ -63,21 +70,34 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
title: "Success!",
|
||||
message: "Password created successfully.",
|
||||
});
|
||||
captureEvent(SETUP_PASSWORD, {
|
||||
state: "SUCCESS",
|
||||
first_time: true,
|
||||
});
|
||||
await handleSignInRedirection();
|
||||
})
|
||||
.catch((err) =>
|
||||
.catch((err) => {
|
||||
captureEvent(SETUP_PASSWORD, {
|
||||
state: "FAILED",
|
||||
first_time: true,
|
||||
});
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleGoToWorkspace = async () => {
|
||||
setIsGoingToWorkspace(true);
|
||||
|
||||
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false));
|
||||
await handleSignInRedirection().finally(() => {
|
||||
captureEvent(PASSWORD_CREATE_SKIPPED, {
|
||||
state: "SUCCESS",
|
||||
first_time: true,
|
||||
});
|
||||
setIsGoingToWorkspace(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -119,16 +139,29 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
||||
required: "Password is required",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.password)}
|
||||
placeholder="Enter password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
minLength={8}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.password)}
|
||||
placeholder="Enter password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
minLength={8}
|
||||
autoFocus
|
||||
/>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(false)}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<p className="text-onboarding-text-200 text-xs mt-2 pb-3">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { XCircle } from "lucide-react";
|
||||
import { Eye, EyeOff, XCircle } from "lucide-react";
|
||||
// services
|
||||
import { AuthService } from "services/auth.service";
|
||||
// hooks
|
||||
@@ -32,6 +32,8 @@ const authService = new AuthService();
|
||||
|
||||
export const SignUpPasswordForm: React.FC<Props> = observer((props) => {
|
||||
const { onSubmit } = props;
|
||||
// states
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// form info
|
||||
@@ -112,15 +114,28 @@ export const SignUpPasswordForm: React.FC<Props> = observer((props) => {
|
||||
required: "Password is required",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.password)}
|
||||
placeholder="Enter password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.password)}
|
||||
placeholder="Enter password"
|
||||
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
autoFocus
|
||||
/>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(false)}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
|
||||
onClick={() => setShowPassword(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<p className="text-onboarding-text-200 text-xs mt-2 pb-3">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useApplication } from "hooks/store";
|
||||
import { useApplication, useEventTracker } from "hooks/store";
|
||||
import useSignInRedirection from "hooks/use-sign-in-redirection";
|
||||
// components
|
||||
import {
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
SignUpUniqueCodeForm,
|
||||
} from "components/account";
|
||||
import Link from "next/link";
|
||||
// constants
|
||||
import { NAVIGATE_TO_SIGNIN } from "constants/event-tracker";
|
||||
|
||||
export enum ESignUpSteps {
|
||||
EMAIL = "EMAIL",
|
||||
@@ -32,6 +34,7 @@ export const SignUpRoot = observer(() => {
|
||||
const {
|
||||
config: { envConfig },
|
||||
} = useApplication();
|
||||
const { captureEvent } = useEventTracker();
|
||||
|
||||
// step 1 submit handler- email verification
|
||||
const handleEmailVerification = () => setSignInStep(ESignUpSteps.UNIQUE_CODE);
|
||||
@@ -86,7 +89,11 @@ export const SignUpRoot = observer(() => {
|
||||
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_up" />
|
||||
<p className="text-xs text-onboarding-text-300 text-center mt-6">
|
||||
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
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -8,12 +8,15 @@ import { UserService } from "services/user.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useTimer from "hooks/use-timer";
|
||||
import { useEventTracker } from "hooks/store";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// helpers
|
||||
import { checkEmailValidity } from "helpers/string.helper";
|
||||
// types
|
||||
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
|
||||
// constants
|
||||
import { CODE_VERIFIED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
@@ -39,6 +42,8 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
|
||||
const { email, handleEmailClear, onSubmit } = props;
|
||||
// states
|
||||
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
|
||||
// store hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// timer
|
||||
@@ -69,17 +74,22 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
|
||||
await authService
|
||||
.magicSignIn(payload)
|
||||
.then(async () => {
|
||||
captureEvent(CODE_VERIFIED, {
|
||||
state: "SUCCESS",
|
||||
});
|
||||
const currentUser = await userService.currentUser();
|
||||
|
||||
await onSubmit(currentUser.is_password_autoset);
|
||||
})
|
||||
.catch((err) =>
|
||||
.catch((err) => {
|
||||
captureEvent(CODE_VERIFIED, {
|
||||
state: "FAILED",
|
||||
});
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
|
||||
@@ -96,7 +106,6 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
|
||||
title: "Success!",
|
||||
message: "A new unique code has been sent to your email.",
|
||||
});
|
||||
|
||||
reset({
|
||||
email: formData.email,
|
||||
token: "",
|
||||
|
||||
@@ -10,6 +10,8 @@ import { CustomAnalyticsSelectBar, CustomAnalyticsMainContent, CustomAnalyticsSi
|
||||
import { IAnalyticsParams } from "@plane/types";
|
||||
// fetch-keys
|
||||
import { ANALYTICS } from "constants/fetch-keys";
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { useApplication } from "hooks/store";
|
||||
|
||||
type Props = {
|
||||
additionalParams?: Partial<IAnalyticsParams>;
|
||||
@@ -46,11 +48,13 @@ export const CustomAnalytics: React.FC<Props> = observer((props) => {
|
||||
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
|
||||
);
|
||||
|
||||
const { theme: themeStore } = useApplication();
|
||||
|
||||
const isProjectLevel = projectId ? true : false;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col-reverse overflow-hidden ${fullScreen ? "md:grid md:h-full md:grid-cols-4" : ""}`}>
|
||||
<div className="col-span-3 flex h-full flex-col overflow-hidden">
|
||||
<div className={cn("relative w-full h-full flex overflow-hidden", isProjectLevel ? "flex-col-reverse" : "")}>
|
||||
<div className="w-full flex h-full flex-col overflow-hidden">
|
||||
<CustomAnalyticsSelectBar
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
@@ -61,16 +65,22 @@ export const CustomAnalytics: React.FC<Props> = observer((props) => {
|
||||
<CustomAnalyticsMainContent
|
||||
analytics={analytics}
|
||||
error={analyticsError}
|
||||
fullScreen={fullScreen}
|
||||
params={params}
|
||||
fullScreen={fullScreen}
|
||||
/>
|
||||
</div>
|
||||
<CustomAnalyticsSidebar
|
||||
analytics={analytics}
|
||||
params={params}
|
||||
fullScreen={fullScreen}
|
||||
isProjectLevel={isProjectLevel}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"border-l border-custom-border-200 transition-all",
|
||||
!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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,9 +22,8 @@ export const CustomAnalyticsSelectBar: React.FC<Props> = observer((props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid items-center gap-4 px-5 py-2.5 ${isProjectLevel ? "grid-cols-3" : "grid-cols-2"} ${
|
||||
fullScreen ? "md:py-5 lg:grid-cols-4" : ""
|
||||
}`}
|
||||
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" : ""
|
||||
}`}
|
||||
>
|
||||
{!isProjectLevel && (
|
||||
<div>
|
||||
|
||||
@@ -17,9 +17,9 @@ export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = observer((pro
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
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>
|
||||
<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) => {
|
||||
const project = getProjectById(projectId);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useCycle, useModule, useProject } from "hooks/store";
|
||||
import { useCycle, useMember, useModule, useProject } from "hooks/store";
|
||||
// helpers
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
import { renderFormattedDate } from "helpers/date-time.helper";
|
||||
@@ -15,21 +15,23 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
|
||||
const { getProjectById } = useProject();
|
||||
const { getCycleById } = useCycle();
|
||||
const { getModuleById } = useModule();
|
||||
const { getUserDetails } = useMember();
|
||||
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
|
||||
const projectDetails = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{projectId ? (
|
||||
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>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-custom-text-200">Lead</h6>
|
||||
<span>{cycleDetails.owned_by?.display_name}</span>
|
||||
<span>{cycleOwnerDetails?.display_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-custom-text-200">Start Date</h6>
|
||||
@@ -50,7 +52,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
|
||||
</div>
|
||||
</div>
|
||||
) : 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>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
@@ -76,7 +78,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
|
||||
</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">
|
||||
{projectDetails?.emoji ? (
|
||||
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">{renderEmoji(projectDetails.emoji)}</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { mutate } from "swr";
|
||||
@@ -19,18 +19,18 @@ import { renderFormattedDate } from "helpers/date-time.helper";
|
||||
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types";
|
||||
// fetch-keys
|
||||
import { ANALYTICS } from "constants/fetch-keys";
|
||||
import { cn } from "helpers/common.helper";
|
||||
|
||||
type Props = {
|
||||
analytics: IAnalyticsResponse | undefined;
|
||||
params: IAnalyticsParams;
|
||||
fullScreen: boolean;
|
||||
isProjectLevel: boolean;
|
||||
};
|
||||
|
||||
const analyticsService = new AnalyticsService();
|
||||
|
||||
export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||
const { analytics, params, fullScreen, isProjectLevel = false } = props;
|
||||
const { analytics, params, isProjectLevel = false } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
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;
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
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={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" : "")}
|
||||
>
|
||||
<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">
|
||||
<LayersIcon height={14} width={14} />
|
||||
{analytics ? analytics.total : "..."} Issues
|
||||
{analytics ? analytics.total : "..."} <div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div>
|
||||
</div>
|
||||
{isProjectLevel && (
|
||||
<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
|
||||
? cycleDetails?.created_at
|
||||
: moduleId
|
||||
? moduleDetails?.created_at
|
||||
: projectDetails?.created_at) ?? ""
|
||||
? moduleDetails?.created_at
|
||||
: projectDetails?.created_at) ?? ""
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
{fullScreen ? (
|
||||
<>
|
||||
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
|
||||
<CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} />
|
||||
)}
|
||||
<CustomAnalyticsSidebarHeader />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className={cn("h-full w-full overflow-hidden", isProjectLevel ? "hidden" : "block")}>
|
||||
<>
|
||||
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
|
||||
<CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} />
|
||||
)}
|
||||
<CustomAnalyticsSidebarHeader />
|
||||
</>
|
||||
</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
|
||||
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={() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
mutate(ANALYTICS(workspaceSlug.toString(), params));
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Refresh</div>
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,16 +20,15 @@ export const ProjectAnalyticsModalMainContent: React.FC<Props> = observer((props
|
||||
|
||||
return (
|
||||
<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) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
className={({ selected }) =>
|
||||
`rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${
|
||||
selected ? "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"
|
||||
}`
|
||||
}
|
||||
onClick={() => {}}
|
||||
onClick={() => { }}
|
||||
>
|
||||
{tab.title}
|
||||
</Tab>
|
||||
|
||||
@@ -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">
|
||||
<div
|
||||
className={`fixed right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${
|
||||
fullScreen ? "w-full p-2" : "w-1/2"
|
||||
}`}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${
|
||||
fullScreen ? "rounded-lg border" : "border-l"
|
||||
}`}
|
||||
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${fullScreen ? "rounded-lg border" : "border-l"
|
||||
}`}
|
||||
>
|
||||
<ProjectAnalyticsModalHeader
|
||||
fullScreen={fullScreen}
|
||||
|
||||
@@ -65,7 +65,7 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||
);
|
||||
},
|
||||
}}
|
||||
margin={{ top: 20, right: 20 }}
|
||||
margin={{ top: 20 }}
|
||||
theme={{
|
||||
axis: {},
|
||||
}}
|
||||
|
||||
@@ -6,76 +6,54 @@ import emptyGraph from "public/empty-state/empty_graph.svg";
|
||||
import { IDefaultAnalyticsResponse } from "@plane/types";
|
||||
// constants
|
||||
import { MONTHS_LIST } from "constants/calendar";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
defaultAnalytics: IDefaultAnalyticsResponse;
|
||||
};
|
||||
|
||||
export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) => {
|
||||
// states
|
||||
const [width, setWidth] = useState(window.innerWidth);
|
||||
useEffect(() => {
|
||||
// event listener to update the width
|
||||
window.addEventListener("resize", () => {
|
||||
if (width <= 768 && window.innerWidth > 768) {
|
||||
setWidth(window.innerWidth);
|
||||
} else if (width >= 768 && window.innerWidth < 768) {
|
||||
setWidth(window.innerWidth);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="rounded-[10px] border border-custom-border-200 py-3">
|
||||
<h1 className="px-3 text-base font-medium">Issues closed in a year</h1>
|
||||
{defaultAnalytics.issue_completed_month_wise.length > 0 ? (
|
||||
<LineGraph
|
||||
data={[
|
||||
{
|
||||
id: "issues_closed",
|
||||
color: "rgb(var(--color-primary-100))",
|
||||
data: Object.entries(MONTHS_LIST).map(([index, month]) => ({
|
||||
x: month.shortTitle,
|
||||
y:
|
||||
defaultAnalytics.issue_completed_month_wise.find((data) => data.month === parseInt(index, 10))
|
||||
?.count || 0,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
axisBottom={{
|
||||
// if width is less than 768px, rotate the x-axis labels
|
||||
...(width < 768 && {
|
||||
tickRotation: -45,
|
||||
}),
|
||||
}}
|
||||
customYAxisTickValues={defaultAnalytics.issue_completed_month_wise.map((data) => data.count)}
|
||||
height="300px"
|
||||
colors={(datum) => datum.color}
|
||||
curve="monotoneX"
|
||||
margin={{ top: 20, right: 20 }}
|
||||
enableSlices="x"
|
||||
sliceTooltip={(datum) => (
|
||||
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||
{datum.slice.points[0].data.yFormatted}
|
||||
<span className="text-custom-text-200"> issues closed in </span>
|
||||
{datum.slice.points[0].data.xFormatted}
|
||||
</div>
|
||||
)}
|
||||
theme={{
|
||||
background: "rgb(var(--color-background-100))",
|
||||
}}
|
||||
enableArea
|
||||
export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||
<div className="rounded-[10px] border border-custom-border-200 py-3">
|
||||
<h1 className="px-3 text-base font-medium">Issues closed in a year</h1>
|
||||
{defaultAnalytics.issue_completed_month_wise.length > 0 ? (
|
||||
<LineGraph
|
||||
data={[
|
||||
{
|
||||
id: "issues_closed",
|
||||
color: "rgb(var(--color-primary-100))",
|
||||
data: Object.entries(MONTHS_LIST).map(([index, month]) => ({
|
||||
x: month.shortTitle,
|
||||
y:
|
||||
defaultAnalytics.issue_completed_month_wise.find((data) => data.month === parseInt(index, 10))?.count ||
|
||||
0,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
customYAxisTickValues={defaultAnalytics.issue_completed_month_wise.map((data) => data.count)}
|
||||
height="300px"
|
||||
colors={(datum) => datum.color}
|
||||
curve="monotoneX"
|
||||
margin={{ top: 20 }}
|
||||
enableSlices="x"
|
||||
sliceTooltip={(datum) => (
|
||||
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||
{datum.slice.points[0].data.yFormatted}
|
||||
<span className="text-custom-text-200"> issues closed in </span>
|
||||
{datum.slice.points[0].data.xFormatted}
|
||||
</div>
|
||||
)}
|
||||
theme={{
|
||||
background: "rgb(var(--color-background-100))",
|
||||
}}
|
||||
enableArea
|
||||
/>
|
||||
) : (
|
||||
<div className="px-7 py-4">
|
||||
<ProfileEmptyState
|
||||
title="No Data yet"
|
||||
description="Close issues to view analysis of the same in the form of a graph."
|
||||
image={emptyGraph}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-7 py-4">
|
||||
<ProfileEmptyState
|
||||
title="No Data yet"
|
||||
description="Close issues to view analysis of the same in the form of a graph."
|
||||
image={emptyGraph}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command } from "cmdk";
|
||||
import { ContrastIcon, FileText } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication } from "hooks/store";
|
||||
import { useApplication, useEventTracker } from "hooks/store";
|
||||
// ui
|
||||
import { DiceIcon, PhotoFilterIcon } from "@plane/ui";
|
||||
|
||||
@@ -14,8 +14,8 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
|
||||
|
||||
const {
|
||||
commandPalette: { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal },
|
||||
eventTracker: { setTrackElement },
|
||||
} = useApplication();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -23,7 +23,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
setTrackElement("COMMAND_PALETTE");
|
||||
setTrackElement("Command palette");
|
||||
toggleCreateCycleModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
@@ -39,6 +39,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
setTrackElement("Command palette");
|
||||
toggleCreateModuleModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
@@ -54,6 +55,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
setTrackElement("Command palette");
|
||||
toggleCreateViewModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
@@ -69,6 +71,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
setTrackElement("Command palette");
|
||||
toggleCreatePageModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Dialog, Transition } from "@headlessui/react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { FolderPlus, Search, Settings } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useProject } from "hooks/store";
|
||||
import { useApplication, useEventTracker, useProject } from "hooks/store";
|
||||
// services
|
||||
import { WorkspaceService } from "services/workspace.service";
|
||||
import { IssueService } from "services/issue";
|
||||
@@ -64,8 +64,8 @@ export const CommandModal: React.FC = observer(() => {
|
||||
toggleCreateIssueModal,
|
||||
toggleCreateProjectModal,
|
||||
},
|
||||
eventTracker: { setTrackElement },
|
||||
} = useApplication();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
|
||||
// router
|
||||
const router = useRouter();
|
||||
@@ -278,7 +278,7 @@ export const CommandModal: React.FC = observer(() => {
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
setTrackElement("COMMAND_PALETTE");
|
||||
setTrackElement("Command Palette");
|
||||
toggleCreateIssueModal(true);
|
||||
}}
|
||||
className="focus:bg-custom-background-80"
|
||||
@@ -296,7 +296,7 @@ export const CommandModal: React.FC = observer(() => {
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
setTrackElement("COMMAND_PALETTE");
|
||||
setTrackElement("Command palette");
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useApplication, useIssues, useUser } from "hooks/store";
|
||||
import { useApplication, useEventTracker, useIssues, useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { CommandModal, ShortcutsModal } from "components/command-palette";
|
||||
@@ -32,8 +32,8 @@ export const CommandPalette: FC = observer(() => {
|
||||
const {
|
||||
commandPalette,
|
||||
theme: { toggleSidebar },
|
||||
eventTracker: { setTrackElement },
|
||||
} = useApplication();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { currentUser } = useUser();
|
||||
const {
|
||||
issues: { removeIssue },
|
||||
@@ -118,11 +118,10 @@ export const CommandPalette: FC = observer(() => {
|
||||
toggleSidebar();
|
||||
}
|
||||
} else if (!isAnyModalOpen) {
|
||||
setTrackElement("Shortcut key");
|
||||
if (keyPressed === "c") {
|
||||
setTrackElement("SHORTCUT_KEY");
|
||||
toggleCreateIssueModal(true);
|
||||
} else if (keyPressed === "p") {
|
||||
setTrackElement("SHORTCUT_KEY");
|
||||
toggleCreateProjectModal(true);
|
||||
} else if (keyPressed === "h") {
|
||||
toggleShortcutModal(true);
|
||||
@@ -164,6 +163,8 @@ export const CommandPalette: FC = observer(() => {
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const isDraftIssue = router?.asPath?.includes("draft-issues") || false;
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
return (
|
||||
@@ -216,8 +217,9 @@ export const CommandPalette: FC = observer(() => {
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={isCreateIssueModalOpen}
|
||||
onClose={() => toggleCreateIssueModal(false)}
|
||||
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_id: moduleId.toString() } : undefined}
|
||||
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined}
|
||||
storeType={createIssueStoreType}
|
||||
isDraft={isDraftIssue}
|
||||
/>
|
||||
|
||||
{workspaceSlug && projectId && issueId && issueDetails && (
|
||||
|
||||
@@ -47,7 +47,7 @@ export const ShortcutsModal: FC<Props> = (props) => {
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<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]">
|
||||
<Dialog.Title as="h3" className="flex justify-between">
|
||||
<span className="text-lg font-medium">Keyboard shortcuts</span>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
label?: string;
|
||||
href?: string;
|
||||
icon?: React.ReactNode | undefined;
|
||||
};
|
||||
|
||||
export const BreadcrumbLink: React.FC<Props> = (props) => {
|
||||
const { href, label, icon } = props;
|
||||
return (
|
||||
<Tooltip tooltipContent={label} position="bottom">
|
||||
<li className="flex items-center space-x-2" tabIndex={-1}>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{href ? (
|
||||
<Link
|
||||
className="flex items-center gap-1 text-sm font-medium text-custom-text-300 hover:text-custom-text-100"
|
||||
href={href}
|
||||
>
|
||||
{icon && (
|
||||
<div className="flex h-5 w-5 items-center justify-center overflow-hidden !text-[1rem]">{icon}</div>
|
||||
)}
|
||||
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex cursor-default items-center gap-1 text-sm font-medium text-custom-text-100">
|
||||
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden">{icon}</div>}
|
||||
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./product-updates-modal";
|
||||
export * from "./empty-state";
|
||||
export * from "./latest-feature-block";
|
||||
export * from "./breadcrumb-link";
|
||||
|
||||
@@ -40,9 +40,9 @@ export const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
|
||||
}`}`}
|
||||
target={activity.issue === null ? "_self" : "_blank"}
|
||||
rel={activity.issue === null ? "" : "noopener noreferrer"}
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline whitespace-nowrap"
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
{`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}{" "}
|
||||
<span className="whitespace-nowrap">{`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}</span>{" "}
|
||||
<span className="font-normal">{activity.issue_detail?.name}</span>
|
||||
</a>
|
||||
) : (
|
||||
@@ -267,7 +267,7 @@ const activityDetails: {
|
||||
<span className="flex-shrink truncate font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
</span>
|
||||
{showIssue && (
|
||||
<span>
|
||||
<span className="">
|
||||
{" "}
|
||||
to <IssueLink activity={activity} />
|
||||
</span>
|
||||
|
||||
@@ -130,17 +130,29 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
|
||||
onChange(unsplashImages[0].urls.regular);
|
||||
}, [value, onChange, unsplashImages]);
|
||||
|
||||
const openDropdown = () => setIsOpen(true);
|
||||
const closeDropdown = () => setIsOpen(false);
|
||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||
const handleClose = () => {
|
||||
if (isOpen) setIsOpen(false);
|
||||
};
|
||||
|
||||
useOutsideClickDetector(ref, closeDropdown);
|
||||
const toggleDropdown = () => {
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
};
|
||||
|
||||
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
|
||||
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
useOutsideClickDetector(ref, handleClose);
|
||||
|
||||
return (
|
||||
<Popover className="relative z-[2]" ref={ref} tabIndex={tabIndex} onKeyDown={handleKeyDown}>
|
||||
<Popover.Button
|
||||
className="rounded border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
|
||||
onClick={openDropdown}
|
||||
onClick={handleOnClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -157,6 +157,7 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
|
||||
window.removeEventListener("keydown", handleEnterKeyPress);
|
||||
window.removeEventListener("keydown", handleEscapeKeyPress);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, handleSubmit, onClose]);
|
||||
|
||||
const responseActionButton = response !== "" && (
|
||||
|
||||
@@ -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;
|
||||
@@ -86,12 +86,15 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
|
||||
{
|
||||
id: "pending",
|
||||
color: "#3F76FF",
|
||||
data: chartData.map((item, index) => ({
|
||||
index,
|
||||
x: item.currentDate,
|
||||
y: item.pending,
|
||||
color: "#3F76FF",
|
||||
})),
|
||||
data:
|
||||
chartData.length > 0
|
||||
? chartData.map((item, index) => ({
|
||||
index,
|
||||
x: item.currentDate,
|
||||
y: item.pending,
|
||||
color: "#3F76FF",
|
||||
}))
|
||||
: [],
|
||||
enableArea: true,
|
||||
},
|
||||
{
|
||||
@@ -121,7 +124,9 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
|
||||
enableArea
|
||||
colors={(datum) => datum.color ?? "#3F76FF"}
|
||||
customYAxisTickValues={[0, totalIssues]}
|
||||
gridXValues={chartData.map((item, index) => (index % 2 === 0 ? item.currentDate : ""))}
|
||||
gridXValues={
|
||||
chartData.length > 0 ? chartData.map((item, index) => (index % 2 === 0 ? item.currentDate : "")) : undefined
|
||||
}
|
||||
enableSlices="x"
|
||||
sliceTooltip={(datum) => (
|
||||
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { FC } from "react";
|
||||
import { Menu } from "lucide-react";
|
||||
import { useApplication } from "hooks/store";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
export const SidebarHamburgerToggle: FC = observer(() => {
|
||||
const { theme: themStore } = useApplication();
|
||||
return (
|
||||
<div
|
||||
className="w-7 h-7 rounded flex justify-center items-center bg-custom-background-80 transition-all hover:bg-custom-background-90 cursor-pointer group md:hidden"
|
||||
onClick={() => themStore.toggleSidebar()}
|
||||
>
|
||||
<Menu size={14} className="text-custom-text-200 group-hover:text-custom-text-100 transition-all" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -125,7 +125,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
|
||||
<Tab.Panel as="div" className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5">
|
||||
<Tab.Panel as="div" className="flex min-h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5">
|
||||
{distribution?.assignees.length > 0 ? (
|
||||
distribution.assignees.map((assignee, index) => {
|
||||
if (assignee.assignee_id)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
import { useTheme } from "next-themes";
|
||||
// hooks
|
||||
import { useCycle, useIssues, useProject, useUser } from "hooks/store";
|
||||
import { useCycle, useIssues, useMember, useProject, useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { SingleProgressStats } from "components/core";
|
||||
@@ -58,6 +58,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
removeCycleFromFavorites,
|
||||
} = useCycle();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { getUserDetails } = useMember();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@@ -67,6 +68,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
);
|
||||
|
||||
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
|
||||
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by) : undefined;
|
||||
|
||||
const { data: activeCycleIssues } = useSWR(
|
||||
workspaceSlug && projectId && currentProjectActiveCycleId
|
||||
@@ -148,7 +150,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
color: group.color,
|
||||
}));
|
||||
|
||||
const daysLeft = findHowManyDaysLeft(activeCycle.end_date ?? new Date());
|
||||
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
|
||||
|
||||
return (
|
||||
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow">
|
||||
@@ -165,7 +167,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
<h3 className="break-words text-lg font-semibold">{truncateText(activeCycle.name, 70)}</h3>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 capitalize">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="flex gap-1 whitespace-nowrap rounded-sm text-sm px-3 py-0.5 bg-amber-500/10 text-amber-500">
|
||||
{`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`}
|
||||
</span>
|
||||
@@ -203,20 +205,20 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2.5 text-custom-text-200">
|
||||
{activeCycle.owned_by.avatar && activeCycle.owned_by.avatar !== "" ? (
|
||||
{cycleOwnerDetails?.avatar && cycleOwnerDetails?.avatar !== "" ? (
|
||||
<img
|
||||
src={activeCycle.owned_by.avatar}
|
||||
src={cycleOwnerDetails?.avatar}
|
||||
height={16}
|
||||
width={16}
|
||||
className="rounded-full"
|
||||
alt={activeCycle.owned_by.display_name}
|
||||
alt={cycleOwnerDetails?.display_name}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-background-100 capitalize">
|
||||
{activeCycle.owned_by.display_name.charAt(0)}
|
||||
{cycleOwnerDetails?.display_name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-custom-text-200">{activeCycle.owned_by.display_name}</span>
|
||||
<span className="text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
|
||||
</div>
|
||||
|
||||
{activeCycle.assignees.length > 0 && (
|
||||
@@ -255,7 +257,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
<div className="flex h-full w-full flex-col p-4 text-custom-text-200">
|
||||
<div className="flex w-full items-center gap-2 py-1">
|
||||
<span>Progress</span>
|
||||
<LinearProgressIndicator data={progressIndicatorData} inPercentage />
|
||||
<LinearProgressIndicator size="md" data={progressIndicatorData} inPercentage />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col items-center gap-1">
|
||||
{Object.keys(groupedIssues).map((group, index) => (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { FC, MouseEvent, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
// hooks
|
||||
import { useApplication, useCycle, useUser } from "hooks/store";
|
||||
import { useEventTracker, useCycle, useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
|
||||
@@ -16,6 +16,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// constants
|
||||
import { CYCLE_STATUS } from "constants/cycle";
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
|
||||
//.types
|
||||
import { TCycleGroups } from "@plane/types";
|
||||
|
||||
@@ -33,9 +34,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store
|
||||
const {
|
||||
eventTracker: { setTrackElement },
|
||||
} = useApplication();
|
||||
const { setTrackElement, captureEvent } = useEventTracker();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
@@ -92,39 +91,56 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
|
||||
.then(() => {
|
||||
captureEvent(CYCLE_FAVORITED, {
|
||||
cycle_id: cycleId,
|
||||
element: "Grid layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
|
||||
.then(() => {
|
||||
captureEvent(CYCLE_UNFAVORITED, {
|
||||
cycle_id: cycleId,
|
||||
element: "Grid layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTrackElement("Cycles page grid layout");
|
||||
setUpdateModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTrackElement("Cycles page grid layout");
|
||||
setDeleteModal(true);
|
||||
setTrackElement("CYCLE_PAGE_BOARD_LAYOUT");
|
||||
};
|
||||
|
||||
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -138,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 (
|
||||
<div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FC, MouseEvent, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
import { useApplication, useCycle, useUser } from "hooks/store";
|
||||
import { useEventTracker, useCycle, useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
|
||||
@@ -18,6 +18,7 @@ import { CYCLE_STATUS } from "constants/cycle";
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
// types
|
||||
import { TCycleGroups } from "@plane/types";
|
||||
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
|
||||
|
||||
type TCyclesListItem = {
|
||||
cycleId: string;
|
||||
@@ -37,9 +38,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store hooks
|
||||
const {
|
||||
eventTracker: { setTrackElement },
|
||||
} = useApplication();
|
||||
const { setTrackElement, captureEvent } = useEventTracker();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
@@ -65,39 +64,56 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
|
||||
.then(() => {
|
||||
captureEvent(CYCLE_FAVORITED, {
|
||||
cycle_id: cycleId,
|
||||
element: "List layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
|
||||
.then(() => {
|
||||
captureEvent(CYCLE_UNFAVORITED, {
|
||||
cycle_id: cycleId,
|
||||
element: "List layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTrackElement("Cycles page list layout");
|
||||
setUpdateModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTrackElement("Cycles page list layout");
|
||||
setDeleteModal(true);
|
||||
setTrackElement("CYCLE_PAGE_LIST_LAYOUT");
|
||||
};
|
||||
|
||||
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -141,7 +157,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
|
||||
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 (
|
||||
<>
|
||||
@@ -160,10 +176,10 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
projectId={projectId}
|
||||
/>
|
||||
<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="flex w-full items-center gap-3 truncate">
|
||||
<div className="flex items-center gap-4 truncate">
|
||||
<span className="flex-shrink-0">
|
||||
<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="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
||||
<div className="relative flex w-full items-center gap-3 overflow-hidden">
|
||||
<div className="flex-shrink-0">
|
||||
<CircularProgressIndicator size={38} percentage={progress}>
|
||||
{isCompleted ? (
|
||||
progress === 100 ? (
|
||||
@@ -177,95 +193,97 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
|
||||
)}
|
||||
</CircularProgressIndicator>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex-shrink-0">
|
||||
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<div className="relative flex items-center gap-2.5 overflow-hidden">
|
||||
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<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>
|
||||
</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 ">
|
||||
<div className="flex items-center justify-center">
|
||||
{currentCycle && (
|
||||
<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>
|
||||
)}
|
||||
<button onClick={openCycleOverview} className="invisible z-10 flex-shrink-0 group-hover:visible">
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{renderDate && (
|
||||
<span className="flex w-40 items-center justify-center gap-2 text-xs text-custom-text-300">
|
||||
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}>
|
||||
<div className="flex w-16 cursor-default items-center justify-center gap-1">
|
||||
{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>
|
||||
)}
|
||||
{currentCycle && (
|
||||
<div
|
||||
className="relative flex h-6 w-20 flex-shrink-0 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}`}
|
||||
</div>
|
||||
</Tooltip>
|
||||
{isEditingAllowed &&
|
||||
(cycleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleAddToFavorites}>
|
||||
<Star className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
<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 ">
|
||||
<div className="text-xs text-custom-text-300">
|
||||
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
|
||||
</div>
|
||||
|
||||
<CustomMenu ellipsis>
|
||||
{!isCompleted && isEditingAllowed && (
|
||||
<div className="relative flex flex-shrink-0 items-center gap-3">
|
||||
<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}>
|
||||
<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>
|
||||
{cycleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleAddToFavorites}>
|
||||
<Star className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<CustomMenu ellipsis>
|
||||
{!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}>
|
||||
<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>
|
||||
</Link>
|
||||
|
||||
@@ -4,12 +4,14 @@ import { Dialog, Transition } from "@headlessui/react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useCycle } from "hooks/store";
|
||||
import { useEventTracker, useCycle } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { Button } from "@plane/ui";
|
||||
// types
|
||||
import { ICycle } from "@plane/types";
|
||||
// constants
|
||||
import { CYCLE_DELETED } from "constants/event-tracker";
|
||||
|
||||
interface ICycleDelete {
|
||||
cycle: ICycle;
|
||||
@@ -27,9 +29,7 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
|
||||
const router = useRouter();
|
||||
const { cycleId, peekCycle } = router.query;
|
||||
// store hooks
|
||||
const {
|
||||
eventTracker: { postHogEventTracker },
|
||||
} = useApplication();
|
||||
const { captureCycleEvent } = useEventTracker();
|
||||
const { deleteCycle } = useCycle();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
@@ -46,13 +46,15 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
|
||||
title: "Success!",
|
||||
message: "Cycle deleted successfully.",
|
||||
});
|
||||
postHogEventTracker("CYCLE_DELETE", {
|
||||
state: "SUCCESS",
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_DELETED,
|
||||
payload: { ...cycle, state: "SUCCESS" },
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
postHogEventTracker("CYCLE_DELETE", {
|
||||
state: "FAILED",
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_DELETED,
|
||||
payload: { ...cycle, state: "FAILED" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// components
|
||||
import { DateDropdown, ProjectDropdown } from "components/dropdowns";
|
||||
@@ -9,21 +10,30 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
import { ICycle } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
|
||||
handleFormSubmit: (values: Partial<ICycle>, dirtyFields: any) => Promise<void>;
|
||||
handleClose: () => void;
|
||||
status: boolean;
|
||||
projectId: string;
|
||||
setActiveProject: (projectId: string) => void;
|
||||
data?: ICycle | null;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<ICycle> = {
|
||||
name: "",
|
||||
description: "",
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
};
|
||||
|
||||
export const CycleForm: React.FC<Props> = (props) => {
|
||||
const { handleFormSubmit, handleClose, projectId, setActiveProject, data } = props;
|
||||
const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data } = props;
|
||||
// form data
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { errors, isSubmitting, dirtyFields },
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
reset,
|
||||
} = useForm<ICycle>({
|
||||
defaultValues: {
|
||||
project: projectId,
|
||||
@@ -34,6 +44,13 @@ export const CycleForm: React.FC<Props> = (props) => {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...data,
|
||||
});
|
||||
}, [data, reset]);
|
||||
|
||||
const startDate = watch("start_date");
|
||||
const endDate = watch("end_date");
|
||||
|
||||
@@ -44,7 +61,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
||||
maxDate?.setDate(maxDate.getDate() - 1);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<form onSubmit={handleSubmit((formData)=>handleFormSubmit(formData,dirtyFields))}>
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-x-3">
|
||||
{!status && (
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { FC } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { KeyedMutator } from "swr";
|
||||
// hooks
|
||||
import { useCycle, useUser } from "hooks/store";
|
||||
// services
|
||||
import { CycleService } from "services/cycle.service";
|
||||
// components
|
||||
import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart";
|
||||
import { CycleGanttBlock } from "components/cycles";
|
||||
@@ -17,14 +14,10 @@ import { EUserProjectRoles } from "constants/project";
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
cycleIds: string[];
|
||||
mutateCycles?: KeyedMutator<ICycle[]>;
|
||||
};
|
||||
|
||||
// services
|
||||
const cycleService = new CycleService();
|
||||
|
||||
export const CyclesListGanttChartView: FC<Props> = observer((props) => {
|
||||
const { cycleIds, mutateCycles } = props;
|
||||
const { cycleIds } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
@@ -32,38 +25,15 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { getCycleById } = useCycle();
|
||||
const { getCycleById, updateCycleDetails } = useCycle();
|
||||
|
||||
const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => {
|
||||
if (!workspaceSlug) return;
|
||||
mutateCycles &&
|
||||
mutateCycles((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => {
|
||||
if (!workspaceSlug || !cycle) return;
|
||||
|
||||
const newList = prevData.map((p: any) => ({
|
||||
...p,
|
||||
...(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,
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
const payload: any = { ...data };
|
||||
if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder;
|
||||
|
||||
if (payload.sort_order) {
|
||||
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);
|
||||
await updateCycleDetails(workspaceSlug.toString(), cycle.project, cycle.id, payload);
|
||||
};
|
||||
|
||||
const blockFormat = (blocks: (ICycle | null)[]) => {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import { CycleService } from "services/cycle.service";
|
||||
// hooks
|
||||
import { useApplication, useCycle } from "hooks/store";
|
||||
import { useEventTracker, useCycle, useProject } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
// components
|
||||
import { CycleForm } from "components/cycles";
|
||||
// types
|
||||
import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types";
|
||||
// constants
|
||||
import { CYCLE_CREATED, CYCLE_UPDATED } from "constants/event-tracker";
|
||||
|
||||
type CycleModalProps = {
|
||||
isOpen: boolean;
|
||||
@@ -25,11 +27,10 @@ const cycleService = new CycleService();
|
||||
export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
const { isOpen, handleClose, data, workspaceSlug, projectId } = props;
|
||||
// states
|
||||
const [activeProject, setActiveProject] = useState<string>(projectId);
|
||||
const [activeProject, setActiveProject] = useState<string | null>(null);
|
||||
// store hooks
|
||||
const {
|
||||
eventTracker: { postHogEventTracker },
|
||||
} = useApplication();
|
||||
const { captureCycleEvent } = useEventTracker();
|
||||
const { workspaceProjectIds } = useProject();
|
||||
const { createCycle, updateCycleDetails } = useCycle();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
@@ -47,9 +48,9 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
title: "Success!",
|
||||
message: "Cycle created successfully.",
|
||||
});
|
||||
postHogEventTracker("CYCLE_CREATE", {
|
||||
...res,
|
||||
state: "SUCCESS",
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_CREATED,
|
||||
payload: { ...res, state: "SUCCESS" },
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -58,18 +59,24 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
title: "Error!",
|
||||
message: err.detail ?? "Error in creating cycle. Please try again.",
|
||||
});
|
||||
postHogEventTracker("CYCLE_CREATE", {
|
||||
state: "FAILED",
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_CREATED,
|
||||
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;
|
||||
|
||||
const selectedProjectId = payload.project ?? projectId.toString();
|
||||
await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload)
|
||||
.then(() => {
|
||||
.then((res) => {
|
||||
const changed_properties = Object.keys(dirtyFields);
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_UPDATED,
|
||||
payload: { ...res, changed_properties: changed_properties, state: "SUCCESS" },
|
||||
});
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
@@ -77,6 +84,10 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_UPDATED,
|
||||
payload: { ...payload, state: "FAILED" },
|
||||
});
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
@@ -95,7 +106,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
return status;
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: Partial<ICycle>) => {
|
||||
const handleFormSubmit = async (formData: Partial<ICycle>, dirtyFields: any) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const payload: Partial<ICycle> = {
|
||||
@@ -119,7 +130,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
}
|
||||
|
||||
if (isDateValid) {
|
||||
if (data) await handleUpdateCycle(data.id, payload);
|
||||
if (data) await handleUpdateCycle(data.id, payload, dirtyFields);
|
||||
else {
|
||||
await handleCreateCycle(payload).then(() => {
|
||||
setCycleTab("all");
|
||||
@@ -134,6 +145,27 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// if modal is closed, reset active project to null
|
||||
// and return to avoid activeProject being set to some other project
|
||||
if (!isOpen) {
|
||||
setActiveProject(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// if data is present, set active project to the project of the
|
||||
// issue. This has more priority than the project in the url.
|
||||
if (data && data.project) {
|
||||
setActiveProject(data.project);
|
||||
return;
|
||||
}
|
||||
|
||||
// if data is not present, set active project to the project
|
||||
// in the url. This has the least priority.
|
||||
if (workspaceProjectIds && workspaceProjectIds.length > 0 && !activeProject)
|
||||
setActiveProject(projectId ?? workspaceProjectIds?.[0] ?? null);
|
||||
}, [activeProject, data, projectId, workspaceProjectIds, isOpen]);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
@@ -164,7 +196,8 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
<CycleForm
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
handleClose={handleClose}
|
||||
projectId={activeProject}
|
||||
status={data ? true : false}
|
||||
projectId={activeProject ?? ""}
|
||||
setActiveProject={setActiveProject}
|
||||
data={data}
|
||||
/>
|
||||
|
||||
@@ -3,10 +3,11 @@ import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Disclosure, Popover, Transition } from "@headlessui/react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
// services
|
||||
import { CycleService } from "services/cycle.service";
|
||||
// hooks
|
||||
import { useApplication, useCycle, useUser } from "hooks/store";
|
||||
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { SidebarProgressStats } from "components/core";
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
import { ICycle } from "@plane/types";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
import { CYCLE_UPDATED } from "constants/event-tracker";
|
||||
// fetch-keys
|
||||
import { CYCLE_STATUS } from "constants/cycle";
|
||||
|
||||
@@ -66,15 +68,15 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, peekCycle } = router.query;
|
||||
// store hooks
|
||||
const {
|
||||
eventTracker: { setTrackElement },
|
||||
} = useApplication();
|
||||
const { setTrackElement, captureCycleEvent } = useEventTracker();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { getCycleById, updateCycleDetails } = useCycle();
|
||||
const { getUserDetails } = useMember();
|
||||
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@@ -82,10 +84,32 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const submitChanges = (data: Partial<ICycle>) => {
|
||||
const submitChanges = (data: Partial<ICycle>, changedProperty: string) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
|
||||
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data);
|
||||
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data)
|
||||
.then((res) => {
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_UPDATED,
|
||||
payload: {
|
||||
...res,
|
||||
changed_properties: [changedProperty],
|
||||
element: "Right side-peek",
|
||||
state: "SUCCESS",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
.catch((_) => {
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_UPDATED,
|
||||
payload: {
|
||||
...data,
|
||||
element: "Right side-peek",
|
||||
state: "FAILED",
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyText = () => {
|
||||
@@ -145,10 +169,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
|
||||
if (isDateValidForExistingCycle) {
|
||||
submitChanges({
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||
});
|
||||
submitChanges(
|
||||
{
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||
},
|
||||
"start_date"
|
||||
);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
@@ -173,10 +200,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
|
||||
if (isDateValid) {
|
||||
submitChanges({
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||
});
|
||||
submitChanges(
|
||||
{
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||
},
|
||||
"start_date"
|
||||
);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
@@ -218,10 +248,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
|
||||
if (isDateValidForExistingCycle) {
|
||||
submitChanges({
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||
});
|
||||
submitChanges(
|
||||
{
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||
},
|
||||
"end_date"
|
||||
);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
@@ -245,10 +278,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
|
||||
if (isDateValid) {
|
||||
submitChanges({
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||
});
|
||||
submitChanges(
|
||||
{
|
||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||
},
|
||||
"end_date"
|
||||
);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
@@ -293,7 +329,11 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const isEndValid = new Date(`${cycleDetails?.end_date}`) >= new Date(`${cycleDetails?.start_date}`);
|
||||
|
||||
const progressPercentage = cycleDetails
|
||||
? Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100)
|
||||
? isCompleted
|
||||
? Math.round(
|
||||
(cycleDetails.progress_snapshot.completed_issues / cycleDetails.progress_snapshot.total_issues) * 100
|
||||
)
|
||||
: Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100)
|
||||
: null;
|
||||
|
||||
if (!cycleDetails)
|
||||
@@ -317,14 +357,16 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
|
||||
|
||||
const issueCount =
|
||||
cycleDetails.total_issues === 0
|
||||
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.total_issues === cycleDetails.completed_issues
|
||||
? cycleDetails.total_issues > 1
|
||||
? `${cycleDetails.total_issues}`
|
||||
: `${cycleDetails.total_issues}`
|
||||
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
|
||||
|
||||
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date);
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
return (
|
||||
@@ -381,8 +423,8 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
backgroundColor: `${currentCycle.color}20`,
|
||||
}}
|
||||
>
|
||||
{currentCycle.value === "current"
|
||||
? `${findHowManyDaysLeft(cycleDetails.end_date ?? new Date())} ${currentCycle.label}`
|
||||
{currentCycle.value === "current" && daysLeft !== undefined
|
||||
? `${daysLeft} ${currentCycle.label}`
|
||||
: `${currentCycle.label}`}
|
||||
</span>
|
||||
)}
|
||||
@@ -518,8 +560,8 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
<div className="flex w-1/2 items-center rounded-sm">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar name={cycleDetails.owned_by.display_name} src={cycleDetails.owned_by.avatar} />
|
||||
<span className="text-sm text-custom-text-200">{cycleDetails.owned_by.display_name}</span>
|
||||
<Avatar name={cycleOwnerDetails?.display_name} src={cycleOwnerDetails?.avatar} />
|
||||
<span className="text-sm text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -573,47 +615,105 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<Transition show={open}>
|
||||
<Disclosure.Panel>
|
||||
<div className="flex flex-col gap-3">
|
||||
{isStartValid && isEndValid ? (
|
||||
<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>
|
||||
{isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? (
|
||||
<>
|
||||
{cycleDetails.progress_snapshot.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.progress_snapshot.distribution?.completion_chart}
|
||||
startDate={cycleDetails.start_date}
|
||||
endDate={cycleDetails.end_date}
|
||||
totalIssues={cycleDetails.progress_snapshot.total_issues}
|
||||
/>
|
||||
</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 && (
|
||||
<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>
|
||||
{/* stats */}
|
||||
{isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? (
|
||||
<>
|
||||
{cycleDetails.progress_snapshot.total_issues > 0 &&
|
||||
cycleDetails.progress_snapshot.distribution && (
|
||||
<div className="h-full w-full border-t border-custom-border-200 pt-5">
|
||||
<SidebarProgressStats
|
||||
distribution={cycleDetails.progress_snapshot.distribution}
|
||||
groupedIssues={{
|
||||
backlog: cycleDetails.progress_snapshot.backlog_issues,
|
||||
unstarted: cycleDetails.progress_snapshot.unstarted_issues,
|
||||
started: cycleDetails.progress_snapshot.started_issues,
|
||||
completed: cycleDetails.progress_snapshot.completed_issues,
|
||||
cancelled: cycleDetails.progress_snapshot.cancelled_issues,
|
||||
}}
|
||||
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>
|
||||
</Disclosure.Panel>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user