mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
commit
7df2e9cf11
152
.github/workflows/build-branch.yml
vendored
152
.github/workflows/build-branch.yml
vendored
@ -2,11 +2,6 @@ name: Branch Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
branch_name:
|
|
||||||
description: "Branch Name"
|
|
||||||
required: true
|
|
||||||
default: "preview"
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
@ -16,49 +11,71 @@ on:
|
|||||||
types: [released, prereleased]
|
types: [released, prereleased]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
TARGET_BRANCH: ${{ inputs.branch_name || github.ref_name || github.event.release.target_commitish }}
|
TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
branch_build_setup:
|
branch_build_setup:
|
||||||
name: Build-Push Web/Space/API/Proxy Docker Image
|
name: Build-Push Web/Space/API/Proxy Docker Image
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
|
||||||
- name: Check out the repo
|
|
||||||
uses: actions/checkout@v3.3.0
|
|
||||||
outputs:
|
outputs:
|
||||||
gh_branch_name: ${{ env.TARGET_BRANCH }}
|
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
|
||||||
|
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
|
||||||
|
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
|
||||||
|
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
|
||||||
|
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: set_env_variables
|
||||||
|
name: Set Environment Variables
|
||||||
|
run: |
|
||||||
|
if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||||
|
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
|
||||||
|
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
|
||||||
|
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
|
||||||
|
echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT
|
||||||
|
echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT
|
||||||
|
echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT
|
||||||
|
echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
branch_build_push_frontend:
|
branch_build_push_frontend:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||||
|
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||||
|
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||||
|
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||||
|
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||||
|
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||||
steps:
|
steps:
|
||||||
- name: Set Frontend Docker Tag
|
- name: Set Frontend Docker Tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
|
||||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
|
||||||
else
|
else
|
||||||
TAG=${{ env.FRONTEND_TAG }}
|
TAG=${{ env.FRONTEND_TAG }}
|
||||||
fi
|
fi
|
||||||
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV
|
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV
|
||||||
- name: Docker Setup QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3.0.0
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3.0.0
|
|
||||||
with:
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3.0.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver: ${{ env.BUILDX_DRIVER }}
|
||||||
|
version: ${{ env.BUILDX_VERSION }}
|
||||||
|
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||||
|
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4.1.1
|
||||||
|
|
||||||
@ -67,7 +84,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./web/Dockerfile.web
|
file: ./web/Dockerfile.web
|
||||||
platforms: linux/amd64
|
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||||
tags: ${{ env.FRONTEND_TAG }}
|
tags: ${{ env.FRONTEND_TAG }}
|
||||||
push: true
|
push: true
|
||||||
env:
|
env:
|
||||||
@ -80,33 +97,36 @@ jobs:
|
|||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||||
|
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||||
|
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||||
|
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||||
|
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||||
|
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||||
steps:
|
steps:
|
||||||
- name: Set Space Docker Tag
|
- name: Set Space Docker Tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
|
||||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
|
||||||
else
|
else
|
||||||
TAG=${{ env.SPACE_TAG }}
|
TAG=${{ env.SPACE_TAG }}
|
||||||
fi
|
fi
|
||||||
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
|
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Docker Setup QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3.0.0
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3.0.0
|
|
||||||
with:
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3.0.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver: ${{ env.BUILDX_DRIVER }}
|
||||||
|
version: ${{ env.BUILDX_VERSION }}
|
||||||
|
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||||
|
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4.1.1
|
||||||
|
|
||||||
@ -115,7 +135,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./space/Dockerfile.space
|
file: ./space/Dockerfile.space
|
||||||
platforms: linux/amd64
|
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||||
tags: ${{ env.SPACE_TAG }}
|
tags: ${{ env.SPACE_TAG }}
|
||||||
push: true
|
push: true
|
||||||
env:
|
env:
|
||||||
@ -128,33 +148,36 @@ jobs:
|
|||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||||
|
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||||
|
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||||
|
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||||
|
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||||
|
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||||
steps:
|
steps:
|
||||||
- name: Set Backend Docker Tag
|
- name: Set Backend Docker Tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
|
||||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
|
||||||
else
|
else
|
||||||
TAG=${{ env.BACKEND_TAG }}
|
TAG=${{ env.BACKEND_TAG }}
|
||||||
fi
|
fi
|
||||||
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV
|
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Docker Setup QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3.0.0
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3.0.0
|
|
||||||
with:
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3.0.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver: ${{ env.BUILDX_DRIVER }}
|
||||||
|
version: ${{ env.BUILDX_VERSION }}
|
||||||
|
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||||
|
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4.1.1
|
||||||
|
|
||||||
@ -163,7 +186,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: ./apiserver
|
context: ./apiserver
|
||||||
file: ./apiserver/Dockerfile.api
|
file: ./apiserver/Dockerfile.api
|
||||||
platforms: linux/amd64
|
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ env.BACKEND_TAG }}
|
tags: ${{ env.BACKEND_TAG }}
|
||||||
env:
|
env:
|
||||||
@ -171,38 +194,42 @@ jobs:
|
|||||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
|
||||||
branch_build_push_proxy:
|
branch_build_push_proxy:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||||
|
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||||
|
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||||
|
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||||
|
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||||
|
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||||
steps:
|
steps:
|
||||||
- name: Set Proxy Docker Tag
|
- name: Set Proxy Docker Tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
|
||||||
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
|
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
|
||||||
else
|
else
|
||||||
TAG=${{ env.PROXY_TAG }}
|
TAG=${{ env.PROXY_TAG }}
|
||||||
fi
|
fi
|
||||||
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV
|
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Docker Setup QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3.0.0
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3.0.0
|
|
||||||
with:
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3.0.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver: ${{ env.BUILDX_DRIVER }}
|
||||||
|
version: ${{ env.BUILDX_VERSION }}
|
||||||
|
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||||
|
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4.1.1
|
||||||
|
|
||||||
@ -211,10 +238,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: ./nginx
|
context: ./nginx
|
||||||
file: ./nginx/Dockerfile
|
file: ./nginx/Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||||
tags: ${{ env.PROXY_TAG }}
|
tags: ${{ env.PROXY_TAG }}
|
||||||
push: true
|
push: true
|
||||||
env:
|
env:
|
||||||
DOCKER_BUILDKIT: 1
|
DOCKER_BUILDKIT: 1
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"name": "plane-api",
|
"name": "plane-api",
|
||||||
"version": "0.15.0"
|
"version": "0.15.1"
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import zoneinfo
|
import zoneinfo
|
||||||
import json
|
import json
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -51,6 +53,11 @@ class WebhookMixin:
|
|||||||
and self.request.method in ["POST", "PATCH", "DELETE"]
|
and self.request.method in ["POST", "PATCH", "DELETE"]
|
||||||
and response.status_code in [200, 201, 204]
|
and response.status_code in [200, 201, 204]
|
||||||
):
|
):
|
||||||
|
url = request.build_absolute_uri()
|
||||||
|
parsed_url = urlparse(url)
|
||||||
|
# Extract the scheme and netloc
|
||||||
|
scheme = parsed_url.scheme
|
||||||
|
netloc = parsed_url.netloc
|
||||||
# Push the object to delay
|
# Push the object to delay
|
||||||
send_webhook.delay(
|
send_webhook.delay(
|
||||||
event=self.webhook_event,
|
event=self.webhook_event,
|
||||||
@ -59,6 +66,7 @@ class WebhookMixin:
|
|||||||
action=self.request.method,
|
action=self.request.method,
|
||||||
slug=self.workspace_slug,
|
slug=self.workspace_slug,
|
||||||
bulk=self.bulk,
|
bulk=self.bulk,
|
||||||
|
current_site=f"{scheme}://{netloc}",
|
||||||
)
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
@ -243,6 +243,29 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
):
|
):
|
||||||
serializer = CycleSerializer(data=request.data)
|
serializer = CycleSerializer(data=request.data)
|
||||||
if serializer.is_valid():
|
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(
|
serializer.save(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
owned_by=request.user,
|
owned_by=request.user,
|
||||||
@ -289,6 +312,23 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
|
|
||||||
serializer = CycleSerializer(cycle, data=request.data, partial=True)
|
serializer = CycleSerializer(cycle, data=request.data, partial=True)
|
||||||
if serializer.is_valid():
|
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()
|
serializer.save()
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
@ -220,6 +220,30 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if serializer.is_valid():
|
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()
|
serializer.save()
|
||||||
|
|
||||||
# Track the issue
|
# Track the issue
|
||||||
@ -256,6 +280,26 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
partial=True,
|
partial=True,
|
||||||
)
|
)
|
||||||
if serializer.is_valid():
|
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()
|
serializer.save()
|
||||||
issue_activity.delay(
|
issue_activity.delay(
|
||||||
type="issue.activity.updated",
|
type="issue.activity.updated",
|
||||||
@ -263,6 +307,8 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
actor_id=str(request.user.id),
|
actor_id=str(request.user.id),
|
||||||
issue_id=str(pk),
|
issue_id=str(pk),
|
||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
|
external_id__isnull=False,
|
||||||
|
external_source__isnull=False,
|
||||||
current_instance=current_instance,
|
current_instance=current_instance,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
)
|
)
|
||||||
@ -318,6 +364,30 @@ class LabelAPIEndpoint(BaseAPIView):
|
|||||||
try:
|
try:
|
||||||
serializer = LabelSerializer(data=request.data)
|
serializer = LabelSerializer(data=request.data)
|
||||||
if serializer.is_valid():
|
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)
|
serializer.save(project_id=project_id)
|
||||||
return Response(
|
return Response(
|
||||||
serializer.data, status=status.HTTP_201_CREATED
|
serializer.data, status=status.HTTP_201_CREATED
|
||||||
@ -326,11 +396,17 @@ class LabelAPIEndpoint(BaseAPIView):
|
|||||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
|
label = Label.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
name=request.data.get("name"),
|
||||||
|
).first()
|
||||||
return Response(
|
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):
|
def get(self, request, slug, project_id, pk=None):
|
||||||
@ -357,6 +433,25 @@ class LabelAPIEndpoint(BaseAPIView):
|
|||||||
label = self.get_queryset().get(pk=pk)
|
label = self.get_queryset().get(pk=pk)
|
||||||
serializer = LabelSerializer(label, data=request.data, partial=True)
|
serializer = LabelSerializer(label, data=request.data, partial=True)
|
||||||
if serializer.is_valid():
|
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()
|
serializer.save()
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
@ -132,6 +132,29 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if serializer.is_valid():
|
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()
|
serializer.save()
|
||||||
module = Module.objects.get(pk=serializer.data["id"])
|
module = Module.objects.get(pk=serializer.data["id"])
|
||||||
serializer = ModuleSerializer(module)
|
serializer = ModuleSerializer(module)
|
||||||
@ -149,8 +172,25 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
partial=True,
|
partial=True,
|
||||||
)
|
)
|
||||||
if serializer.is_valid():
|
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()
|
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)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def get(self, request, slug, project_id, pk=None):
|
def get(self, request, slug, project_id, pk=None):
|
||||||
|
@ -38,6 +38,30 @@ class StateAPIEndpoint(BaseAPIView):
|
|||||||
data=request.data, context={"project_id": project_id}
|
data=request.data, context={"project_id": project_id}
|
||||||
)
|
)
|
||||||
if serializer.is_valid():
|
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)
|
serializer.save(project_id=project_id)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
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)
|
serializer = StateSerializer(state, data=request.data, partial=True)
|
||||||
if serializer.is_valid():
|
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()
|
serializer.save()
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
@ -68,6 +68,7 @@ from .issue import (
|
|||||||
IssueRelationSerializer,
|
IssueRelationSerializer,
|
||||||
RelatedIssueSerializer,
|
RelatedIssueSerializer,
|
||||||
IssuePublicSerializer,
|
IssuePublicSerializer,
|
||||||
|
IssueDetailSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .module import (
|
from .module import (
|
||||||
|
@ -586,7 +586,6 @@ class IssueSerializer(DynamicBaseSerializer):
|
|||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
"state_id",
|
"state_id",
|
||||||
"description_html",
|
|
||||||
"sort_order",
|
"sort_order",
|
||||||
"completed_at",
|
"completed_at",
|
||||||
"estimate_point",
|
"estimate_point",
|
||||||
@ -618,6 +617,13 @@ class IssueSerializer(DynamicBaseSerializer):
|
|||||||
return [module for module in obj.issue_module.values_list("module_id", flat=True)]
|
return [module for module in obj.issue_module.values_list("module_id", flat=True)]
|
||||||
|
|
||||||
|
|
||||||
|
class IssueDetailSerializer(IssueSerializer):
|
||||||
|
description_html = serializers.CharField()
|
||||||
|
|
||||||
|
class Meta(IssueSerializer.Meta):
|
||||||
|
fields = IssueSerializer.Meta.fields + ['description_html']
|
||||||
|
|
||||||
|
|
||||||
class IssueLiteSerializer(DynamicBaseSerializer):
|
class IssueLiteSerializer(DynamicBaseSerializer):
|
||||||
workspace_detail = WorkspaceLiteSerializer(
|
workspace_detail = WorkspaceLiteSerializer(
|
||||||
read_only=True, source="workspace"
|
read_only=True, source="workspace"
|
||||||
|
@ -401,8 +401,8 @@ class EmailCheckEndpoint(BaseAPIView):
|
|||||||
email=email,
|
email=email,
|
||||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||||
ip=request.META.get("REMOTE_ADDR"),
|
ip=request.META.get("REMOTE_ADDR"),
|
||||||
event_name="SIGN_IN",
|
event_name="Sign up",
|
||||||
medium="MAGIC_LINK",
|
medium="Magic link",
|
||||||
first_time=True,
|
first_time=True,
|
||||||
)
|
)
|
||||||
key, token, current_attempt = generate_magic_token(email=email)
|
key, token, current_attempt = generate_magic_token(email=email)
|
||||||
@ -438,8 +438,8 @@ class EmailCheckEndpoint(BaseAPIView):
|
|||||||
email=email,
|
email=email,
|
||||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||||
ip=request.META.get("REMOTE_ADDR"),
|
ip=request.META.get("REMOTE_ADDR"),
|
||||||
event_name="SIGN_IN",
|
event_name="Sign in",
|
||||||
medium="MAGIC_LINK",
|
medium="Magic link",
|
||||||
first_time=False,
|
first_time=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -468,8 +468,8 @@ class EmailCheckEndpoint(BaseAPIView):
|
|||||||
email=email,
|
email=email,
|
||||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||||
ip=request.META.get("REMOTE_ADDR"),
|
ip=request.META.get("REMOTE_ADDR"),
|
||||||
event_name="SIGN_IN",
|
event_name="Sign in",
|
||||||
medium="EMAIL",
|
medium="Email",
|
||||||
first_time=False,
|
first_time=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -274,8 +274,8 @@ class SignInEndpoint(BaseAPIView):
|
|||||||
email=email,
|
email=email,
|
||||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||||
ip=request.META.get("REMOTE_ADDR"),
|
ip=request.META.get("REMOTE_ADDR"),
|
||||||
event_name="SIGN_IN",
|
event_name="Sign in",
|
||||||
medium="EMAIL",
|
medium="Email",
|
||||||
first_time=False,
|
first_time=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -349,8 +349,8 @@ class MagicSignInEndpoint(BaseAPIView):
|
|||||||
email=email,
|
email=email,
|
||||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||||
ip=request.META.get("REMOTE_ADDR"),
|
ip=request.META.get("REMOTE_ADDR"),
|
||||||
event_name="SIGN_IN",
|
event_name="Sign in",
|
||||||
medium="MAGIC_LINK",
|
medium="Magic link",
|
||||||
first_time=False,
|
first_time=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -64,6 +64,7 @@ class WebhookMixin:
|
|||||||
action=self.request.method,
|
action=self.request.method,
|
||||||
slug=self.workspace_slug,
|
slug=self.workspace_slug,
|
||||||
bulk=self.bulk,
|
bulk=self.bulk,
|
||||||
|
current_site=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
@ -20,6 +20,7 @@ from django.core import serializers
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.gzip import gzip_page
|
from django.views.decorators.gzip import gzip_page
|
||||||
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -242,13 +243,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
.values("display_name", "assignee_id", "avatar")
|
.values("display_name", "assignee_id", "avatar")
|
||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Count(
|
total_issues=Count(
|
||||||
"assignee_id",
|
"id",
|
||||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"assignee_id",
|
"id",
|
||||||
filter=Q(
|
filter=Q(
|
||||||
completed_at__isnull=False,
|
completed_at__isnull=False,
|
||||||
archived_at__isnull=True,
|
archived_at__isnull=True,
|
||||||
@ -258,7 +259,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
pending_issues=Count(
|
pending_issues=Count(
|
||||||
"assignee_id",
|
"id",
|
||||||
filter=Q(
|
filter=Q(
|
||||||
completed_at__isnull=True,
|
completed_at__isnull=True,
|
||||||
archived_at__isnull=True,
|
archived_at__isnull=True,
|
||||||
@ -281,13 +282,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
.values("label_name", "color", "label_id")
|
.values("label_name", "color", "label_id")
|
||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Count(
|
total_issues=Count(
|
||||||
"label_id",
|
"id",
|
||||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"label_id",
|
"id",
|
||||||
filter=Q(
|
filter=Q(
|
||||||
completed_at__isnull=False,
|
completed_at__isnull=False,
|
||||||
archived_at__isnull=True,
|
archived_at__isnull=True,
|
||||||
@ -297,7 +298,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
pending_issues=Count(
|
pending_issues=Count(
|
||||||
"label_id",
|
"id",
|
||||||
filter=Q(
|
filter=Q(
|
||||||
completed_at__isnull=True,
|
completed_at__isnull=True,
|
||||||
archived_at__isnull=True,
|
archived_at__isnull=True,
|
||||||
@ -312,6 +313,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
"labels": label_distribution,
|
"labels": label_distribution,
|
||||||
"completion_chart": {},
|
"completion_chart": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
if data[0]["start_date"] and data[0]["end_date"]:
|
if data[0]["start_date"] and data[0]["end_date"]:
|
||||||
data[0]["distribution"][
|
data[0]["distribution"][
|
||||||
"completion_chart"
|
"completion_chart"
|
||||||
@ -419,13 +421,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Count(
|
total_issues=Count(
|
||||||
"assignee_id",
|
"id",
|
||||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"assignee_id",
|
"id",
|
||||||
filter=Q(
|
filter=Q(
|
||||||
completed_at__isnull=False,
|
completed_at__isnull=False,
|
||||||
archived_at__isnull=True,
|
archived_at__isnull=True,
|
||||||
@ -435,7 +437,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
pending_issues=Count(
|
pending_issues=Count(
|
||||||
"assignee_id",
|
"id",
|
||||||
filter=Q(
|
filter=Q(
|
||||||
completed_at__isnull=True,
|
completed_at__isnull=True,
|
||||||
archived_at__isnull=True,
|
archived_at__isnull=True,
|
||||||
@ -459,13 +461,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
.values("label_name", "color", "label_id")
|
.values("label_name", "color", "label_id")
|
||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Count(
|
total_issues=Count(
|
||||||
"label_id",
|
"id",
|
||||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"label_id",
|
"id",
|
||||||
filter=Q(
|
filter=Q(
|
||||||
completed_at__isnull=False,
|
completed_at__isnull=False,
|
||||||
archived_at__isnull=True,
|
archived_at__isnull=True,
|
||||||
@ -475,7 +477,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
pending_issues=Count(
|
pending_issues=Count(
|
||||||
"label_id",
|
"id",
|
||||||
filter=Q(
|
filter=Q(
|
||||||
completed_at__isnull=True,
|
completed_at__isnull=True,
|
||||||
archived_at__isnull=True,
|
archived_at__isnull=True,
|
||||||
@ -840,9 +842,229 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
new_cycle = Cycle.objects.get(
|
new_cycle = Cycle.objects.filter(
|
||||||
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
|
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
old_cycle = (
|
||||||
|
Cycle.objects.filter(
|
||||||
|
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||||
)
|
)
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"issue_cycle",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="completed",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="cancelled",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="started",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="unstarted",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="backlog",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
total_estimates=Sum("issue_cycle__issue__estimate_point")
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
"issue_cycle__issue__estimate_point",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="completed",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_estimates=Sum(
|
||||||
|
"issue_cycle__issue__estimate_point",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="started",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pass the new_cycle queryset to burndown_plot
|
||||||
|
completion_chart = burndown_plot(
|
||||||
|
queryset=old_cycle.first(),
|
||||||
|
slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
cycle_id=cycle_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assignee_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=cycle_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(display_name=F("assignees__display_name"))
|
||||||
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
|
.values("display_name", "assignee_id", "avatar")
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("display_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
label_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=cycle_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(label_name=F("labels__name"))
|
||||||
|
.annotate(color=F("labels__color"))
|
||||||
|
.annotate(label_id=F("labels__id"))
|
||||||
|
.values("label_name", "color", "label_id")
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_issues=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("label_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
assignee_distribution_data = [
|
||||||
|
{
|
||||||
|
"display_name": item["display_name"],
|
||||||
|
"assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None,
|
||||||
|
"avatar": item["avatar"],
|
||||||
|
"total_issues": item["total_issues"],
|
||||||
|
"completed_issues": item["completed_issues"],
|
||||||
|
"pending_issues": item["pending_issues"],
|
||||||
|
}
|
||||||
|
for item in assignee_distribution
|
||||||
|
]
|
||||||
|
|
||||||
|
label_distribution_data = [
|
||||||
|
{
|
||||||
|
"label_name": item["label_name"],
|
||||||
|
"color": item["color"],
|
||||||
|
"label_id": str(item["label_id"]) if item["label_id"] else None,
|
||||||
|
"total_issues": item["total_issues"],
|
||||||
|
"completed_issues": item["completed_issues"],
|
||||||
|
"pending_issues": item["pending_issues"],
|
||||||
|
}
|
||||||
|
for item in label_distribution
|
||||||
|
]
|
||||||
|
|
||||||
|
current_cycle = Cycle.objects.filter(
|
||||||
|
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
current_cycle.progress_snapshot = {
|
||||||
|
"total_issues": old_cycle.first().total_issues,
|
||||||
|
"completed_issues": old_cycle.first().completed_issues,
|
||||||
|
"cancelled_issues": old_cycle.first().cancelled_issues,
|
||||||
|
"started_issues": old_cycle.first().started_issues,
|
||||||
|
"unstarted_issues": old_cycle.first().unstarted_issues,
|
||||||
|
"backlog_issues": old_cycle.first().backlog_issues,
|
||||||
|
"total_estimates": old_cycle.first().total_estimates,
|
||||||
|
"completed_estimates": old_cycle.first().completed_estimates,
|
||||||
|
"started_estimates": old_cycle.first().started_estimates,
|
||||||
|
"distribution":{
|
||||||
|
"labels": label_distribution_data,
|
||||||
|
"assignees": assignee_distribution_data,
|
||||||
|
"completion_chart": completion_chart,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
current_cycle.save(update_fields=["progress_snapshot"])
|
||||||
|
|
||||||
if (
|
if (
|
||||||
new_cycle.end_date is not None
|
new_cycle.end_date is not None
|
||||||
|
@ -145,6 +145,23 @@ def dashboard_assigned_issues(self, request, slug):
|
|||||||
)
|
)
|
||||||
).order_by("priority_order")
|
).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":
|
if issue_type == "completed":
|
||||||
completed_issues_count = assigned_issues.filter(
|
completed_issues_count = assigned_issues.filter(
|
||||||
state__group__in=["completed"]
|
state__group__in=["completed"]
|
||||||
@ -257,6 +274,23 @@ def dashboard_created_issues(self, request, slug):
|
|||||||
)
|
)
|
||||||
).order_by("priority_order")
|
).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":
|
if issue_type == "completed":
|
||||||
completed_issues_count = created_issues.filter(
|
completed_issues_count = created_issues.filter(
|
||||||
state__group__in=["completed"]
|
state__group__in=["completed"]
|
||||||
|
@ -50,6 +50,7 @@ from plane.app.serializers import (
|
|||||||
CommentReactionSerializer,
|
CommentReactionSerializer,
|
||||||
IssueRelationSerializer,
|
IssueRelationSerializer,
|
||||||
RelatedIssueSerializer,
|
RelatedIssueSerializer,
|
||||||
|
IssueDetailSerializer,
|
||||||
)
|
)
|
||||||
from plane.app.permissions import (
|
from plane.app.permissions import (
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
@ -267,7 +268,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
def retrieve(self, request, slug, project_id, pk=None):
|
def retrieve(self, request, slug, project_id, pk=None):
|
||||||
issue = self.get_queryset().filter(pk=pk).first()
|
issue = self.get_queryset().filter(pk=pk).first()
|
||||||
return Response(
|
return Response(
|
||||||
IssueSerializer(
|
IssueDetailSerializer(
|
||||||
issue, fields=self.fields, expand=self.expand
|
issue, fields=self.fields, expand=self.expand
|
||||||
).data,
|
).data,
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
@ -1668,15 +1669,9 @@ class IssueDraftViewSet(BaseViewSet):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return (
|
return (
|
||||||
Issue.objects.annotate(
|
Issue.objects.filter(
|
||||||
sub_issues_count=Issue.issue_objects.filter(
|
project_id=self.kwargs.get("project_id")
|
||||||
parent=OuterRef("id")
|
|
||||||
)
|
)
|
||||||
.order_by()
|
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
|
||||||
.values("count")
|
|
||||||
)
|
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(is_draft=True)
|
.filter(is_draft=True)
|
||||||
.select_related("workspace", "project", "state", "parent")
|
.select_related("workspace", "project", "state", "parent")
|
||||||
@ -1710,7 +1705,7 @@ class IssueDraftViewSet(BaseViewSet):
|
|||||||
.annotate(count=Func(F("id"), function="Count"))
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
.values("count")
|
.values("count")
|
||||||
)
|
)
|
||||||
)
|
).distinct()
|
||||||
|
|
||||||
@method_decorator(gzip_page)
|
@method_decorator(gzip_page)
|
||||||
def list(self, request, slug, project_id):
|
def list(self, request, slug, project_id):
|
||||||
@ -1832,7 +1827,10 @@ class IssueDraftViewSet(BaseViewSet):
|
|||||||
notification=True,
|
notification=True,
|
||||||
origin=request.META.get("HTTP_ORIGIN"),
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
issue = (
|
||||||
|
self.get_queryset().filter(pk=serializer.data["id"]).first()
|
||||||
|
)
|
||||||
|
return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def partial_update(self, request, slug, project_id, pk):
|
def partial_update(self, request, slug, project_id, pk):
|
||||||
@ -1868,10 +1866,13 @@ class IssueDraftViewSet(BaseViewSet):
|
|||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, pk=None):
|
def retrieve(self, request, slug, project_id, pk=None):
|
||||||
issue = Issue.objects.get(
|
issue = self.get_queryset().filter(pk=pk).first()
|
||||||
workspace__slug=slug, project_id=project_id, pk=pk, is_draft=True
|
return Response(
|
||||||
|
IssueSerializer(
|
||||||
|
issue, fields=self.fields, expand=self.expand
|
||||||
|
).data,
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
def destroy(self, request, slug, project_id, pk=None):
|
def destroy(self, request, slug, project_id, pk=None):
|
||||||
issue = Issue.objects.get(
|
issue = Issue.objects.get(
|
||||||
|
@ -197,7 +197,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Count(
|
total_issues=Count(
|
||||||
"assignee_id",
|
"id",
|
||||||
filter=Q(
|
filter=Q(
|
||||||
archived_at__isnull=True,
|
archived_at__isnull=True,
|
||||||
is_draft=False,
|
is_draft=False,
|
||||||
@ -206,7 +206,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"assignee_id",
|
"id",
|
||||||
filter=Q(
|
filter=Q(
|
||||||
completed_at__isnull=False,
|
completed_at__isnull=False,
|
||||||
archived_at__isnull=True,
|
archived_at__isnull=True,
|
||||||
@ -216,7 +216,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
pending_issues=Count(
|
pending_issues=Count(
|
||||||
"assignee_id",
|
"id",
|
||||||
filter=Q(
|
filter=Q(
|
||||||
completed_at__isnull=True,
|
completed_at__isnull=True,
|
||||||
archived_at__isnull=True,
|
archived_at__isnull=True,
|
||||||
@ -239,7 +239,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
.values("label_name", "color", "label_id")
|
.values("label_name", "color", "label_id")
|
||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Count(
|
total_issues=Count(
|
||||||
"label_id",
|
"id",
|
||||||
filter=Q(
|
filter=Q(
|
||||||
archived_at__isnull=True,
|
archived_at__isnull=True,
|
||||||
is_draft=False,
|
is_draft=False,
|
||||||
@ -248,7 +248,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
"label_id",
|
"id",
|
||||||
filter=Q(
|
filter=Q(
|
||||||
completed_at__isnull=False,
|
completed_at__isnull=False,
|
||||||
archived_at__isnull=True,
|
archived_at__isnull=True,
|
||||||
@ -258,7 +258,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
pending_issues=Count(
|
pending_issues=Count(
|
||||||
"label_id",
|
"id",
|
||||||
filter=Q(
|
filter=Q(
|
||||||
completed_at__isnull=True,
|
completed_at__isnull=True,
|
||||||
archived_at__isnull=True,
|
archived_at__isnull=True,
|
||||||
@ -334,7 +334,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return (
|
return (
|
||||||
Issue.objects.filter(
|
Issue.issue_objects.filter(
|
||||||
project_id=self.kwargs.get("project_id"),
|
project_id=self.kwargs.get("project_id"),
|
||||||
workspace__slug=self.kwargs.get("slug"),
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
issue_module__module_id=self.kwargs.get("module_id")
|
issue_module__module_id=self.kwargs.get("module_id")
|
||||||
|
@ -296,7 +296,7 @@ class OauthEndpoint(BaseAPIView):
|
|||||||
email=email,
|
email=email,
|
||||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||||
ip=request.META.get("REMOTE_ADDR"),
|
ip=request.META.get("REMOTE_ADDR"),
|
||||||
event_name="SIGN_IN",
|
event_name="Sign in",
|
||||||
medium=medium.upper(),
|
medium=medium.upper(),
|
||||||
first_time=False,
|
first_time=False,
|
||||||
)
|
)
|
||||||
@ -427,7 +427,7 @@ class OauthEndpoint(BaseAPIView):
|
|||||||
email=email,
|
email=email,
|
||||||
user_agent=request.META.get("HTTP_USER_AGENT"),
|
user_agent=request.META.get("HTTP_USER_AGENT"),
|
||||||
ip=request.META.get("REMOTE_ADDR"),
|
ip=request.META.get("REMOTE_ADDR"),
|
||||||
event_name="SIGN_IN",
|
event_name="Sign up",
|
||||||
medium=medium.upper(),
|
medium=medium.upper(),
|
||||||
first_time=True,
|
first_time=True,
|
||||||
)
|
)
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import json
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
@ -9,7 +10,6 @@ from django.utils import timezone
|
|||||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.utils.html import strip_tags
|
from django.utils.html import strip_tags
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.db.models import EmailNotificationLog, User, Issue
|
from plane.db.models import EmailNotificationLog, User, Issue
|
||||||
@ -40,7 +40,7 @@ def stack_email_notification():
|
|||||||
processed_notifications = []
|
processed_notifications = []
|
||||||
# Loop through all the issues to create the emails
|
# Loop through all the issues to create the emails
|
||||||
for receiver_id in receivers:
|
for receiver_id in receivers:
|
||||||
# Notifcation triggered for the receiver
|
# Notification triggered for the receiver
|
||||||
receiver_notifications = [
|
receiver_notifications = [
|
||||||
notification
|
notification
|
||||||
for notification in email_notifications
|
for notification in email_notifications
|
||||||
@ -124,11 +124,29 @@ def create_payload(notification_data):
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
def process_mention(mention_component):
|
||||||
|
soup = BeautifulSoup(mention_component, 'html.parser')
|
||||||
|
mentions = soup.find_all('mention-component')
|
||||||
|
for mention in mentions:
|
||||||
|
user_id = mention['id']
|
||||||
|
user = User.objects.get(pk=user_id)
|
||||||
|
user_name = user.display_name
|
||||||
|
highlighted_name = f"@{user_name}"
|
||||||
|
mention.replace_with(highlighted_name)
|
||||||
|
return str(soup)
|
||||||
|
|
||||||
|
def process_html_content(content):
|
||||||
|
processed_content_list = []
|
||||||
|
for html_content in content:
|
||||||
|
processed_content = process_mention(html_content)
|
||||||
|
processed_content_list.append(processed_content)
|
||||||
|
return processed_content_list
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def send_email_notification(
|
def send_email_notification(
|
||||||
issue_id, notification_data, receiver_id, email_notification_ids
|
issue_id, notification_data, receiver_id, email_notification_ids
|
||||||
):
|
):
|
||||||
|
try:
|
||||||
ri = redis_instance()
|
ri = redis_instance()
|
||||||
base_api = (ri.get(str(issue_id)).decode())
|
base_api = (ri.get(str(issue_id)).decode())
|
||||||
data = create_payload(notification_data=notification_data)
|
data = create_payload(notification_data=notification_data)
|
||||||
@ -153,6 +171,7 @@ def send_email_notification(
|
|||||||
actor = User.objects.get(pk=actor_id)
|
actor = User.objects.get(pk=actor_id)
|
||||||
total_changes = total_changes + len(changes)
|
total_changes = total_changes + len(changes)
|
||||||
comment = changes.pop("comment", False)
|
comment = changes.pop("comment", False)
|
||||||
|
mention = changes.pop("mention", False)
|
||||||
actors_involved.append(actor_id)
|
actors_involved.append(actor_id)
|
||||||
if comment:
|
if comment:
|
||||||
comments.append(
|
comments.append(
|
||||||
@ -165,6 +184,19 @@ def send_email_notification(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
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")
|
activity_time = changes.pop("activity_time")
|
||||||
# Parse the input string into a datetime object
|
# Parse the input string into a datetime object
|
||||||
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
|
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
|
||||||
@ -240,3 +272,5 @@ def send_email_notification(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
return
|
return
|
||||||
|
except Issue.DoesNotExist:
|
||||||
|
return
|
||||||
|
@ -353,13 +353,18 @@ def track_assignees(
|
|||||||
issue_activities,
|
issue_activities,
|
||||||
epoch,
|
epoch,
|
||||||
):
|
):
|
||||||
requested_assignees = set(
|
requested_assignees = (
|
||||||
[str(asg) for asg in requested_data.get("assignee_ids", [])]
|
set([str(asg) for asg in requested_data.get("assignee_ids", [])])
|
||||||
|
if requested_data is not None
|
||||||
|
else set()
|
||||||
)
|
)
|
||||||
current_assignees = set(
|
current_assignees = (
|
||||||
[str(asg) for asg in current_instance.get("assignee_ids", [])]
|
set([str(asg) for asg in current_instance.get("assignee_ids", [])])
|
||||||
|
if current_instance is not None
|
||||||
|
else set()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
added_assignees = requested_assignees - current_assignees
|
added_assignees = requested_assignees - current_assignees
|
||||||
dropped_assginees = current_assignees - requested_assignees
|
dropped_assginees = current_assignees - requested_assignees
|
||||||
|
|
||||||
@ -547,6 +552,20 @@ def create_issue_activity(
|
|||||||
epoch=epoch,
|
epoch=epoch,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
requested_data = (
|
||||||
|
json.loads(requested_data) if requested_data is not None else None
|
||||||
|
)
|
||||||
|
if requested_data.get("assignee_ids") is not None:
|
||||||
|
track_assignees(
|
||||||
|
requested_data,
|
||||||
|
current_instance,
|
||||||
|
issue_id,
|
||||||
|
project_id,
|
||||||
|
workspace_id,
|
||||||
|
actor_id,
|
||||||
|
issue_activities,
|
||||||
|
epoch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def update_issue_activity(
|
def update_issue_activity(
|
||||||
|
@ -515,7 +515,7 @@ def notifications(
|
|||||||
bulk_email_logs.append(
|
bulk_email_logs.append(
|
||||||
EmailNotificationLog(
|
EmailNotificationLog(
|
||||||
triggered_by_id=actor_id,
|
triggered_by_id=actor_id,
|
||||||
receiver_id=subscriber,
|
receiver_id=mention_id,
|
||||||
entity_identifier=issue_id,
|
entity_identifier=issue_id,
|
||||||
entity_name="issue",
|
entity_name="issue",
|
||||||
data={
|
data={
|
||||||
@ -552,6 +552,7 @@ def notifications(
|
|||||||
"old_value": str(
|
"old_value": str(
|
||||||
issue_activity.get("old_value")
|
issue_activity.get("old_value")
|
||||||
),
|
),
|
||||||
|
"activity_time": issue_activity.get("created_at"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -639,6 +640,7 @@ def notifications(
|
|||||||
"old_value": str(
|
"old_value": str(
|
||||||
last_activity.old_value
|
last_activity.old_value
|
||||||
),
|
),
|
||||||
|
"activity_time": issue_activity.get("created_at"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -695,6 +697,7 @@ def notifications(
|
|||||||
"old_value"
|
"old_value"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
"activity_time": issue_activity.get("created_at"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -7,6 +7,9 @@ import hmac
|
|||||||
# Django imports
|
# Django imports
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
@ -22,10 +25,10 @@ from plane.db.models import (
|
|||||||
ModuleIssue,
|
ModuleIssue,
|
||||||
CycleIssue,
|
CycleIssue,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
from plane.api.serializers import (
|
from plane.api.serializers import (
|
||||||
ProjectSerializer,
|
ProjectSerializer,
|
||||||
IssueSerializer,
|
|
||||||
CycleSerializer,
|
CycleSerializer,
|
||||||
ModuleSerializer,
|
ModuleSerializer,
|
||||||
CycleIssueSerializer,
|
CycleIssueSerializer,
|
||||||
@ -34,6 +37,9 @@ from plane.api.serializers import (
|
|||||||
IssueExpandSerializer,
|
IssueExpandSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from plane.license.utils.instance_value import get_email_configuration
|
||||||
|
|
||||||
SERIALIZER_MAPPER = {
|
SERIALIZER_MAPPER = {
|
||||||
"project": ProjectSerializer,
|
"project": ProjectSerializer,
|
||||||
"issue": IssueExpandSerializer,
|
"issue": IssueExpandSerializer,
|
||||||
@ -72,7 +78,7 @@ def get_model_data(event, event_id, many=False):
|
|||||||
max_retries=5,
|
max_retries=5,
|
||||||
retry_jitter=True,
|
retry_jitter=True,
|
||||||
)
|
)
|
||||||
def webhook_task(self, webhook, slug, event, event_data, action):
|
def webhook_task(self, webhook, slug, event, event_data, action, current_site):
|
||||||
try:
|
try:
|
||||||
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
|
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
|
||||||
|
|
||||||
@ -151,7 +157,18 @@ def webhook_task(self, webhook, slug, event, event_data, action):
|
|||||||
response_body=str(e),
|
response_body=str(e),
|
||||||
retry_count=str(self.request.retries),
|
retry_count=str(self.request.retries),
|
||||||
)
|
)
|
||||||
|
# Retry logic
|
||||||
|
if self.request.retries >= self.max_retries:
|
||||||
|
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
|
||||||
|
if webhook:
|
||||||
|
# send email for the deactivation of the webhook
|
||||||
|
send_webhook_deactivation_email(
|
||||||
|
webhook_id=webhook.id,
|
||||||
|
receiver_id=webhook.created_by_id,
|
||||||
|
reason=str(e),
|
||||||
|
current_site=current_site,
|
||||||
|
)
|
||||||
|
return
|
||||||
raise requests.RequestException()
|
raise requests.RequestException()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -162,7 +179,7 @@ def webhook_task(self, webhook, slug, event, event_data, action):
|
|||||||
|
|
||||||
|
|
||||||
@shared_task()
|
@shared_task()
|
||||||
def send_webhook(event, payload, kw, action, slug, bulk):
|
def send_webhook(event, payload, kw, action, slug, bulk, current_site):
|
||||||
try:
|
try:
|
||||||
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
|
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
|
||||||
|
|
||||||
@ -216,6 +233,7 @@ def send_webhook(event, payload, kw, action, slug, bulk):
|
|||||||
event=event,
|
event=event,
|
||||||
event_data=data,
|
event_data=data,
|
||||||
action=action,
|
action=action,
|
||||||
|
current_site=current_site,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -223,3 +241,56 @@ def send_webhook(event, payload, kw, action, slug, bulk):
|
|||||||
print(e)
|
print(e)
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reason):
|
||||||
|
# Get email configurations
|
||||||
|
(
|
||||||
|
EMAIL_HOST,
|
||||||
|
EMAIL_HOST_USER,
|
||||||
|
EMAIL_HOST_PASSWORD,
|
||||||
|
EMAIL_PORT,
|
||||||
|
EMAIL_USE_TLS,
|
||||||
|
EMAIL_FROM,
|
||||||
|
) = get_email_configuration()
|
||||||
|
|
||||||
|
receiver = User.objects.get(pk=receiver_id)
|
||||||
|
webhook = Webhook.objects.get(pk=webhook_id)
|
||||||
|
subject="Webhook Deactivated"
|
||||||
|
message=f"Webhook {webhook.url} has been deactivated due to failed requests."
|
||||||
|
|
||||||
|
# Send the mail
|
||||||
|
context = {
|
||||||
|
"email": receiver.email,
|
||||||
|
"message": message,
|
||||||
|
"webhook_url":f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}",
|
||||||
|
}
|
||||||
|
html_content = render_to_string(
|
||||||
|
"emails/notifications/webhook-deactivate.html", context
|
||||||
|
)
|
||||||
|
text_content = strip_tags(html_content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
connection = get_connection(
|
||||||
|
host=EMAIL_HOST,
|
||||||
|
port=int(EMAIL_PORT),
|
||||||
|
username=EMAIL_HOST_USER,
|
||||||
|
password=EMAIL_HOST_PASSWORD,
|
||||||
|
use_tls=EMAIL_USE_TLS == "1",
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_content,
|
||||||
|
from_email=EMAIL_FROM,
|
||||||
|
to=[receiver.email],
|
||||||
|
connection=connection,
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html_content, "text/html")
|
||||||
|
msg.send()
|
||||||
|
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return
|
||||||
|
33
apiserver/plane/db/migrations/0059_auto_20240208_0957.py
Normal file
33
apiserver/plane/db/migrations/0059_auto_20240208_0957.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2024-02-08 09:57
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def widgets_filter_change(apps, schema_editor):
|
||||||
|
Widget = apps.get_model("db", "Widget")
|
||||||
|
widgets_to_update = []
|
||||||
|
|
||||||
|
# Define the filter dictionaries for each widget key
|
||||||
|
filters_mapping = {
|
||||||
|
"assigned_issues": {"duration": "none", "tab": "pending"},
|
||||||
|
"created_issues": {"duration": "none", "tab": "pending"},
|
||||||
|
"issues_by_state_groups": {"duration": "none"},
|
||||||
|
"issues_by_priority": {"duration": "none"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Iterate over widgets and update filters if applicable
|
||||||
|
for widget in Widget.objects.all():
|
||||||
|
if widget.key in filters_mapping:
|
||||||
|
widget.filters = filters_mapping[widget.key]
|
||||||
|
widgets_to_update.append(widget)
|
||||||
|
|
||||||
|
# Bulk update the widgets
|
||||||
|
Widget.objects.bulk_update(widgets_to_update, ["filters"], batch_size=10)
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('db', '0058_alter_moduleissue_issue_and_more'),
|
||||||
|
]
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(widgets_filter_change)
|
||||||
|
]
|
@ -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)
|
sort_order = models.FloatField(default=65535)
|
||||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
progress_snapshot = models.JSONField(default=dict)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Cycle"
|
verbose_name = "Cycle"
|
||||||
|
@ -172,4 +172,9 @@ def create_user_notification(sender, instance, created, **kwargs):
|
|||||||
from plane.db.models import UserNotificationPreference
|
from plane.db.models import UserNotificationPreference
|
||||||
UserNotificationPreference.objects.create(
|
UserNotificationPreference.objects.create(
|
||||||
user=instance,
|
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")
|
redis_url = os.environ.get("REDIS_URL")
|
||||||
broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
||||||
CELERY_BROKER_URL = broker_url
|
CELERY_BROKER_URL = broker_url
|
||||||
CELERY_RESULT_BACKEND = broker_url
|
|
||||||
else:
|
else:
|
||||||
CELERY_BROKER_URL = REDIS_URL
|
CELERY_BROKER_URL = REDIS_URL
|
||||||
CELERY_RESULT_BACKEND = REDIS_URL
|
|
||||||
|
|
||||||
CELERY_IMPORTS = (
|
CELERY_IMPORTS = (
|
||||||
"plane.bgtasks.issue_automation_task",
|
"plane.bgtasks.issue_automation_task",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# base requirements
|
# base requirements
|
||||||
|
|
||||||
Django==4.2.7
|
Django==4.2.10
|
||||||
psycopg==3.1.12
|
psycopg==3.1.12
|
||||||
djangorestframework==3.14.0
|
djangorestframework==3.14.0
|
||||||
redis==4.6.0
|
redis==4.6.0
|
||||||
@ -30,7 +30,7 @@ openpyxl==3.1.2
|
|||||||
beautifulsoup4==4.12.2
|
beautifulsoup4==4.12.2
|
||||||
dj-database-url==2.1.0
|
dj-database-url==2.1.0
|
||||||
posthog==3.0.2
|
posthog==3.0.2
|
||||||
cryptography==41.0.6
|
cryptography==42.0.0
|
||||||
lxml==4.9.3
|
lxml==4.9.3
|
||||||
boto3==1.28.40
|
boto3==1.28.40
|
||||||
|
|
||||||
|
@ -66,7 +66,7 @@
|
|||||||
style="margin-left: 30px; margin-bottom: 20px; margin-top: 20px"
|
style="margin-left: 30px; margin-bottom: 20px; margin-top: 20px"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/plane-logo.webp"
|
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/plane-logo.png"
|
||||||
width="130"
|
width="130"
|
||||||
height="40"
|
height="40"
|
||||||
border="0"
|
border="0"
|
||||||
@ -112,21 +112,29 @@
|
|||||||
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
|
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
|
||||||
{{summary}}
|
{{summary}}
|
||||||
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
|
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
|
||||||
|
{% if data|length > 0 %}
|
||||||
{{ data.0.actor_detail.first_name}}
|
{{ data.0.actor_detail.first_name}}
|
||||||
{{data.0.actor_detail.last_name}}
|
{{data.0.actor_detail.last_name}}
|
||||||
|
{% else %}
|
||||||
|
{{ comments.0.actor_detail.first_name}}
|
||||||
|
{{comments.0.actor_detail.last_name}}
|
||||||
|
{% endif %}
|
||||||
</span>.
|
</span>.
|
||||||
</p>
|
</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
|
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
|
||||||
{{summary}}
|
{{summary}}
|
||||||
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
|
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
|
||||||
|
{% if data|length > 0 %}
|
||||||
{{ data.0.actor_detail.first_name}}
|
{{ data.0.actor_detail.first_name}}
|
||||||
{{data.0.actor_detail.last_name }}
|
{{data.0.actor_detail.last_name}}
|
||||||
|
{% else %}
|
||||||
|
{{ comments.0.actor_detail.first_name}}
|
||||||
|
{{comments.0.actor_detail.last_name}}
|
||||||
|
{% endif %}
|
||||||
</span>and others.
|
</span>and others.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<!-- {% if actors_involved == 1 %}
|
<!-- {% if actors_involved == 1 %}
|
||||||
{% if data|length > 0 and comments|length == 0 %}
|
{% if data|length > 0 and comments|length == 0 %}
|
||||||
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
|
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
|
||||||
@ -272,7 +280,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/due-date.webp"
|
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/due-date.png"
|
||||||
width="12"
|
width="12"
|
||||||
height="12"
|
height="12"
|
||||||
border="0"
|
border="0"
|
||||||
@ -333,7 +341,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/duplicate.webp"
|
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/duplicate.png"
|
||||||
width="12"
|
width="12"
|
||||||
height="12"
|
height="12"
|
||||||
border="0"
|
border="0"
|
||||||
@ -428,7 +436,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td valign="top" style="white-space: nowrap; padding: 0px;">
|
<td valign="top" style="white-space: nowrap; padding: 0px;">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/assignee.webp"
|
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/assignee.png"
|
||||||
width="12"
|
width="12"
|
||||||
height="12"
|
height="12"
|
||||||
border="0"
|
border="0"
|
||||||
@ -524,7 +532,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td valign="top" style="white-space: nowrap; padding: 0px;">
|
<td valign="top" style="white-space: nowrap; padding: 0px;">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/labels.webp"
|
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/labels.png"
|
||||||
width="12"
|
width="12"
|
||||||
height="12"
|
height="12"
|
||||||
border="0"
|
border="0"
|
||||||
@ -621,7 +629,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/state.webp"
|
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/state.png"
|
||||||
width="12"
|
width="12"
|
||||||
height="12"
|
height="12"
|
||||||
border="0"
|
border="0"
|
||||||
@ -639,15 +647,17 @@
|
|||||||
State:
|
State:
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td >
|
{% if update.changes.state.old_value.0 == 'Backlog' or update.changes.state.old_value.0 == 'In Progress' or update.changes.state.old_value.0 == 'Done' or update.changes.state.old_value.0 == 'Cancelled' %}
|
||||||
|
<td>
|
||||||
<img
|
<img
|
||||||
src="{% if update.changes.state.old_value.0 == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.webp{% endif %}{% if update.changes.state.old_value.0 == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.webp{% endif %}{% if update.changes.state.old_value.0 == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.webp{% endif %}{% if update.changes.state.old_value.0 == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.webp{% endif %}"
|
src="{% if update.changes.state.old_value.0 == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.png{% endif %}{% if update.changes.state.old_value.0 == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.png{% endif %}{% if update.changes.state.old_value.0 == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.png{% endif %}{% if update.changes.state.old_value.0 == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.png{% endif %}"
|
||||||
width="12"
|
width="12"
|
||||||
height="12"
|
height="12"
|
||||||
border="0"
|
border="0"
|
||||||
style="display: block; margin-left: 5px;"
|
style="display: block; margin-left: 5px;"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
{% endif %}
|
||||||
<td>
|
<td>
|
||||||
<p
|
<p
|
||||||
style="
|
style="
|
||||||
@ -661,22 +671,24 @@
|
|||||||
</td>
|
</td>
|
||||||
<td style="padding-left: 10px; padding-right: 10px;">
|
<td style="padding-left: 10px; padding-right: 10px;">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.webp"
|
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.png"
|
||||||
width="16"
|
width="16"
|
||||||
height="16"
|
height="16"
|
||||||
border="0"
|
border="0"
|
||||||
style="display: block;"
|
style="display: block;"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td >
|
{% if update.changes.state.new_value|last == 'Backlog' or update.changes.state.new_value|last == 'In Progress' or update.changes.state.new_value|last == 'Done' or update.changes.state.new_value|last == 'Cancelled' %}
|
||||||
|
<td>
|
||||||
<img
|
<img
|
||||||
src="{% if update.changes.state.new_value|last == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.webp{% elif update.changes.state.new_value|last == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.webp{% elif update.changes.state.new_value|last == 'Todo' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/todo.webp{% elif update.changes.state.new_value|last == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.webp{% elif update.changes.state.new_value|last == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.webp{% endif %}"
|
src="{% if update.changes.state.new_value|last == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.png{% elif update.changes.state.new_value|last == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.png{% elif update.changes.state.new_value|last == 'Todo' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/todo.png{% elif update.changes.state.new_value|last == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.png{% elif update.changes.state.new_value|last == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.png{% endif %}"
|
||||||
width="12"
|
width="12"
|
||||||
height="12"
|
height="12"
|
||||||
border="0"
|
border="0"
|
||||||
style="display: block;"
|
style="display: block;"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
{% endif %}
|
||||||
<td>
|
<td>
|
||||||
<p
|
<p
|
||||||
style="
|
style="
|
||||||
@ -699,7 +711,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td valign="top">
|
<td valign="top">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/link.webp"
|
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/link.png"
|
||||||
width="12"
|
width="12"
|
||||||
height="12"
|
height="12"
|
||||||
border="0"
|
border="0"
|
||||||
@ -760,7 +772,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/priority.webp"
|
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/priority.png"
|
||||||
width="12"
|
width="12"
|
||||||
height="12"
|
height="12"
|
||||||
border="0"
|
border="0"
|
||||||
@ -800,7 +812,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td style="padding-left: 10px; padding-right: 10px;">
|
<td style="padding-left: 10px; padding-right: 10px;">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.webp"
|
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.png"
|
||||||
width="16"
|
width="16"
|
||||||
height="16"
|
height="16"
|
||||||
border="0"
|
border="0"
|
||||||
@ -838,7 +850,7 @@
|
|||||||
<tr style="overflow-wrap: break-word;">
|
<tr style="overflow-wrap: break-word;">
|
||||||
<td>
|
<td>
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/blocking.webp"
|
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/blocking.png"
|
||||||
width="12"
|
width="12"
|
||||||
height="12"
|
height="12"
|
||||||
border="0"
|
border="0"
|
||||||
|
1544
apiserver/templates/emails/notifications/webhook-deactivate.html
Normal file
1544
apiserver/templates/emails/notifications/webhook-deactivate.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Check if the user has sudo access
|
||||||
if command -v curl &> /dev/null; then
|
if command -v curl &> /dev/null; then
|
||||||
sudo curl -sSL \
|
sudo curl -sSL \
|
||||||
-o /usr/local/bin/plane-app \
|
-o /usr/local/bin/plane-app \
|
||||||
@ -11,6 +12,6 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
sudo chmod +x /usr/local/bin/plane-app
|
sudo chmod +x /usr/local/bin/plane-app
|
||||||
sudo sed -i 's/export BRANCH=${BRANCH:-master}/export BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app
|
sudo sed -i 's/export DEPLOY_BRANCH=${BRANCH:-master}/export DEPLOY_BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app
|
||||||
|
|
||||||
sudo plane-app --help
|
plane-app --help
|
||||||
|
@ -17,7 +17,7 @@ Project management tool from the future
|
|||||||
|
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
function update_env_files() {
|
function update_env_file() {
|
||||||
config_file=$1
|
config_file=$1
|
||||||
key=$2
|
key=$2
|
||||||
value=$3
|
value=$3
|
||||||
@ -25,14 +25,16 @@ function update_env_files() {
|
|||||||
# Check if the config file exists
|
# Check if the config file exists
|
||||||
if [ ! -f "$config_file" ]; then
|
if [ ! -f "$config_file" ]; then
|
||||||
echo "Config file not found. Creating a new one..." >&2
|
echo "Config file not found. Creating a new one..." >&2
|
||||||
touch "$config_file"
|
sudo touch "$config_file"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if the key already exists in the config file
|
# Check if the key already exists in the config file
|
||||||
if grep -q "^$key=" "$config_file"; then
|
if sudo grep "^$key=" "$config_file"; then
|
||||||
awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file"
|
sudo awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" | sudo tee "$config_file.tmp" > /dev/null
|
||||||
|
sudo mv "$config_file.tmp" "$config_file" &> /dev/null
|
||||||
else
|
else
|
||||||
echo "$key=$value" >> "$config_file"
|
# sudo echo "$key=$value" >> "$config_file"
|
||||||
|
echo -e "$key=$value" | sudo tee -a "$config_file" > /dev/null
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
function read_env_file() {
|
function read_env_file() {
|
||||||
@ -42,12 +44,12 @@ function read_env_file() {
|
|||||||
# Check if the config file exists
|
# Check if the config file exists
|
||||||
if [ ! -f "$config_file" ]; then
|
if [ ! -f "$config_file" ]; then
|
||||||
echo "Config file not found. Creating a new one..." >&2
|
echo "Config file not found. Creating a new one..." >&2
|
||||||
touch "$config_file"
|
sudo touch "$config_file"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if the key already exists in the config file
|
# Check if the key already exists in the config file
|
||||||
if grep -q "^$key=" "$config_file"; then
|
if sudo grep -q "^$key=" "$config_file"; then
|
||||||
value=$(awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file")
|
value=$(sudo awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file")
|
||||||
echo "$value"
|
echo "$value"
|
||||||
else
|
else
|
||||||
echo ""
|
echo ""
|
||||||
@ -55,19 +57,19 @@ function read_env_file() {
|
|||||||
}
|
}
|
||||||
function update_config() {
|
function update_config() {
|
||||||
config_file="$PLANE_INSTALL_DIR/config.env"
|
config_file="$PLANE_INSTALL_DIR/config.env"
|
||||||
update_env_files "$config_file" "$1" "$2"
|
update_env_file $config_file $1 $2
|
||||||
}
|
}
|
||||||
function read_config() {
|
function read_config() {
|
||||||
config_file="$PLANE_INSTALL_DIR/config.env"
|
config_file="$PLANE_INSTALL_DIR/config.env"
|
||||||
read_env_file "$config_file" "$1"
|
read_env_file $config_file $1
|
||||||
}
|
}
|
||||||
function update_env() {
|
function update_env() {
|
||||||
config_file="$PLANE_INSTALL_DIR/.env"
|
config_file="$PLANE_INSTALL_DIR/.env"
|
||||||
update_env_files "$config_file" "$1" "$2"
|
update_env_file $config_file $1 $2
|
||||||
}
|
}
|
||||||
function read_env() {
|
function read_env() {
|
||||||
config_file="$PLANE_INSTALL_DIR/.env"
|
config_file="$PLANE_INSTALL_DIR/.env"
|
||||||
read_env_file "$config_file" "$1"
|
read_env_file $config_file $1
|
||||||
}
|
}
|
||||||
function show_message() {
|
function show_message() {
|
||||||
print_header
|
print_header
|
||||||
@ -87,14 +89,14 @@ function prepare_environment() {
|
|||||||
show_message "Prepare Environment..." >&2
|
show_message "Prepare Environment..." >&2
|
||||||
|
|
||||||
show_message "- Updating OS with required tools ✋" >&2
|
show_message "- Updating OS with required tools ✋" >&2
|
||||||
sudo apt-get update -y &> /dev/null
|
sudo "$PACKAGE_MANAGER" update -y
|
||||||
sudo apt-get upgrade -y &> /dev/null
|
sudo "$PACKAGE_MANAGER" upgrade -y
|
||||||
|
|
||||||
required_tools=("curl" "awk" "wget" "nano" "dialog" "git")
|
local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap")
|
||||||
|
|
||||||
for tool in "${required_tools[@]}"; do
|
for tool in "${required_tools[@]}"; do
|
||||||
if ! command -v $tool &> /dev/null; then
|
if ! command -v $tool &> /dev/null; then
|
||||||
sudo apt install -y $tool &> /dev/null
|
sudo "$PACKAGE_MANAGER" install -y $tool
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
@ -103,11 +105,30 @@ function prepare_environment() {
|
|||||||
# Install Docker if not installed
|
# Install Docker if not installed
|
||||||
if ! command -v docker &> /dev/null; then
|
if ! command -v docker &> /dev/null; then
|
||||||
show_message "- Installing Docker ✋" >&2
|
show_message "- Installing Docker ✋" >&2
|
||||||
sudo curl -o- https://get.docker.com | bash -
|
# curl -o- https://get.docker.com | bash -
|
||||||
|
|
||||||
if [ "$EUID" -ne 0 ]; then
|
if [ "$PACKAGE_MANAGER" == "yum" ]; then
|
||||||
dockerd-rootless-setuptool.sh install &> /dev/null
|
sudo $PACKAGE_MANAGER install -y yum-utils
|
||||||
|
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo &> /dev/null
|
||||||
|
elif [ "$PACKAGE_MANAGER" == "apt-get" ]; then
|
||||||
|
# Add Docker's official GPG key:
|
||||||
|
sudo $PACKAGE_MANAGER update
|
||||||
|
sudo $PACKAGE_MANAGER install ca-certificates curl &> /dev/null
|
||||||
|
sudo install -m 0755 -d /etc/apt/keyrings &> /dev/null
|
||||||
|
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc &> /dev/null
|
||||||
|
sudo chmod a+r /etc/apt/keyrings/docker.asc &> /dev/null
|
||||||
|
|
||||||
|
# Add the repository to Apt sources:
|
||||||
|
echo \
|
||||||
|
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
|
||||||
|
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
|
||||||
|
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||||
|
|
||||||
|
sudo $PACKAGE_MANAGER update
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
sudo $PACKAGE_MANAGER install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
|
||||||
|
|
||||||
show_message "- Docker Installed ✅" "replace_last_line" >&2
|
show_message "- Docker Installed ✅" "replace_last_line" >&2
|
||||||
else
|
else
|
||||||
show_message "- Docker is already installed ✅" >&2
|
show_message "- Docker is already installed ✅" >&2
|
||||||
@ -127,17 +148,17 @@ function prepare_environment() {
|
|||||||
function download_plane() {
|
function download_plane() {
|
||||||
# Download Docker Compose File from github url
|
# Download Docker Compose File from github url
|
||||||
show_message "Downloading Plane Setup Files ✋" >&2
|
show_message "Downloading Plane Setup Files ✋" >&2
|
||||||
curl -H 'Cache-Control: no-cache, no-store' \
|
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
||||||
-s -o $PLANE_INSTALL_DIR/docker-compose.yaml \
|
-s -o $PLANE_INSTALL_DIR/docker-compose.yaml \
|
||||||
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s)
|
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s)
|
||||||
|
|
||||||
curl -H 'Cache-Control: no-cache, no-store' \
|
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
||||||
-s -o $PLANE_INSTALL_DIR/variables-upgrade.env \
|
-s -o $PLANE_INSTALL_DIR/variables-upgrade.env \
|
||||||
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s)
|
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s)
|
||||||
|
|
||||||
# if .env does not exists rename variables-upgrade.env to .env
|
# if .env does not exists rename variables-upgrade.env to .env
|
||||||
if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then
|
if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then
|
||||||
mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env
|
sudo mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env
|
||||||
fi
|
fi
|
||||||
|
|
||||||
show_message "Plane Setup Files Downloaded ✅" "replace_last_line" >&2
|
show_message "Plane Setup Files Downloaded ✅" "replace_last_line" >&2
|
||||||
@ -186,7 +207,7 @@ function build_local_image() {
|
|||||||
PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp
|
PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp
|
||||||
sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null
|
sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null
|
||||||
|
|
||||||
sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $BRANCH --single-branch -q > /dev/null
|
sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $DEPLOY_BRANCH --single-branch -q > /dev/null
|
||||||
|
|
||||||
sudo cp $PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml $PLANE_TEMP_CODE_DIR/build.yml
|
sudo cp $PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml $PLANE_TEMP_CODE_DIR/build.yml
|
||||||
|
|
||||||
@ -199,25 +220,26 @@ function check_for_docker_images() {
|
|||||||
show_message "" >&2
|
show_message "" >&2
|
||||||
# show_message "Building Plane Images" >&2
|
# show_message "Building Plane Images" >&2
|
||||||
|
|
||||||
update_env "DOCKERHUB_USER" "makeplane"
|
|
||||||
update_env "PULL_POLICY" "always"
|
|
||||||
CURR_DIR=$(pwd)
|
CURR_DIR=$(pwd)
|
||||||
|
|
||||||
if [ "$BRANCH" == "master" ]; then
|
if [ "$DEPLOY_BRANCH" == "master" ]; then
|
||||||
update_env "APP_RELEASE" "latest"
|
update_env "APP_RELEASE" "latest"
|
||||||
export APP_RELEASE=latest
|
export APP_RELEASE=latest
|
||||||
else
|
else
|
||||||
update_env "APP_RELEASE" "$BRANCH"
|
update_env "APP_RELEASE" "$DEPLOY_BRANCH"
|
||||||
export APP_RELEASE=$BRANCH
|
export APP_RELEASE=$DEPLOY_BRANCH
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ $CPU_ARCH == "amd64" ] || [ $CPU_ARCH == "x86_64" ]; then
|
if [ $USE_GLOBAL_IMAGES == 1 ]; then
|
||||||
# show_message "Building Plane Images for $CPU_ARCH is not required. Skipping... ✅" "replace_last_line" >&2
|
# show_message "Building Plane Images for $CPU_ARCH is not required. Skipping... ✅" "replace_last_line" >&2
|
||||||
|
export DOCKERHUB_USER=makeplane
|
||||||
|
update_env "DOCKERHUB_USER" "$DOCKERHUB_USER"
|
||||||
|
update_env "PULL_POLICY" "always"
|
||||||
echo "Building Plane Images for $CPU_ARCH is not required. Skipping..."
|
echo "Building Plane Images for $CPU_ARCH is not required. Skipping..."
|
||||||
else
|
else
|
||||||
export DOCKERHUB_USER=myplane
|
export DOCKERHUB_USER=myplane
|
||||||
show_message "Building Plane Images for $CPU_ARCH " >&2
|
show_message "Building Plane Images for $CPU_ARCH " >&2
|
||||||
update_env "DOCKERHUB_USER" "myplane"
|
update_env "DOCKERHUB_USER" "$DOCKERHUB_USER"
|
||||||
update_env "PULL_POLICY" "never"
|
update_env "PULL_POLICY" "never"
|
||||||
|
|
||||||
build_local_image
|
build_local_image
|
||||||
@ -233,7 +255,7 @@ function check_for_docker_images() {
|
|||||||
sudo sed -i "s|- uploads:|- $DATA_DIR/minio:|g" $PLANE_INSTALL_DIR/docker-compose.yaml
|
sudo sed -i "s|- uploads:|- $DATA_DIR/minio:|g" $PLANE_INSTALL_DIR/docker-compose.yaml
|
||||||
|
|
||||||
show_message "Downloading Plane Images for $CPU_ARCH ✋" >&2
|
show_message "Downloading Plane Images for $CPU_ARCH ✋" >&2
|
||||||
docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull
|
sudo docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull
|
||||||
show_message "Plane Images Downloaded ✅" "replace_last_line" >&2
|
show_message "Plane Images Downloaded ✅" "replace_last_line" >&2
|
||||||
}
|
}
|
||||||
function configure_plane() {
|
function configure_plane() {
|
||||||
@ -453,9 +475,11 @@ function install() {
|
|||||||
show_message ""
|
show_message ""
|
||||||
if [ "$(uname)" == "Linux" ]; then
|
if [ "$(uname)" == "Linux" ]; then
|
||||||
OS="linux"
|
OS="linux"
|
||||||
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
|
OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release)
|
||||||
# check the OS
|
OS_NAME=$(echo "$OS_NAME" | tr -d '"')
|
||||||
if [ "$OS_NAME" == "ubuntu" ]; then
|
print_header
|
||||||
|
if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
|
||||||
|
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
|
||||||
OS_SUPPORTED=true
|
OS_SUPPORTED=true
|
||||||
show_message "******** Installing Plane ********"
|
show_message "******** Installing Plane ********"
|
||||||
show_message ""
|
show_message ""
|
||||||
@ -488,7 +512,8 @@ function install() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
else
|
else
|
||||||
PROGRESS_MSG="❌❌❌ Unsupported OS Detected ❌❌❌"
|
OS_SUPPORTED=false
|
||||||
|
PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
|
||||||
show_message ""
|
show_message ""
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@ -499,12 +524,17 @@ function install() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
function upgrade() {
|
function upgrade() {
|
||||||
|
print_header
|
||||||
if [ "$(uname)" == "Linux" ]; then
|
if [ "$(uname)" == "Linux" ]; then
|
||||||
OS="linux"
|
OS="linux"
|
||||||
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
|
OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release)
|
||||||
# check the OS
|
OS_NAME=$(echo "$OS_NAME" | tr -d '"')
|
||||||
if [ "$OS_NAME" == "ubuntu" ]; then
|
if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
|
||||||
|
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
|
||||||
|
|
||||||
OS_SUPPORTED=true
|
OS_SUPPORTED=true
|
||||||
|
show_message "******** Upgrading Plane ********"
|
||||||
|
show_message ""
|
||||||
|
|
||||||
prepare_environment
|
prepare_environment
|
||||||
|
|
||||||
@ -528,53 +558,49 @@ function upgrade() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
PROGRESS_MSG="Unsupported OS Detected"
|
PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
|
||||||
show_message ""
|
show_message ""
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
PROGRESS_MSG="Unsupported OS Detected : $(uname)"
|
PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌"
|
||||||
show_message ""
|
show_message ""
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
function uninstall() {
|
function uninstall() {
|
||||||
|
print_header
|
||||||
if [ "$(uname)" == "Linux" ]; then
|
if [ "$(uname)" == "Linux" ]; then
|
||||||
OS="linux"
|
OS="linux"
|
||||||
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
|
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
|
||||||
# check the OS
|
OS_NAME=$(echo "$OS_NAME" | tr -d '"')
|
||||||
if [ "$OS_NAME" == "ubuntu" ]; then
|
if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
|
||||||
|
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
|
||||||
|
|
||||||
OS_SUPPORTED=true
|
OS_SUPPORTED=true
|
||||||
show_message "******** Uninstalling Plane ********"
|
show_message "******** Uninstalling Plane ********"
|
||||||
show_message ""
|
show_message ""
|
||||||
|
|
||||||
stop_server
|
stop_server
|
||||||
# CHECK IF PLANE SERVICE EXISTS
|
|
||||||
# if [ -f "/etc/systemd/system/plane.service" ]; then
|
|
||||||
# sudo systemctl stop plane.service &> /dev/null
|
|
||||||
# sudo systemctl disable plane.service &> /dev/null
|
|
||||||
# sudo rm /etc/systemd/system/plane.service &> /dev/null
|
|
||||||
# sudo systemctl daemon-reload &> /dev/null
|
|
||||||
# fi
|
|
||||||
# show_message "- Plane Service removed ✅"
|
|
||||||
|
|
||||||
if ! [ -x "$(command -v docker)" ]; then
|
if ! [ -x "$(command -v docker)" ]; then
|
||||||
echo "DOCKER_NOT_INSTALLED" &> /dev/null
|
echo "DOCKER_NOT_INSTALLED" &> /dev/null
|
||||||
else
|
else
|
||||||
# Ask of user input to confirm uninstall docker ?
|
# Ask of user input to confirm uninstall docker ?
|
||||||
CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3)
|
CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --defaultno --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3)
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
show_message "- Uninstalling Docker ✋"
|
show_message "- Uninstalling Docker ✋"
|
||||||
sudo apt-get purge -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null
|
sudo docker images -q | xargs -r sudo docker rmi -f &> /dev/null
|
||||||
sudo apt-get autoremove -y --purge docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null
|
sudo "$PACKAGE_MANAGER" remove -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null
|
||||||
|
sudo "$PACKAGE_MANAGER" autoremove -y docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null
|
||||||
show_message "- Docker Uninstalled ✅" "replace_last_line" >&2
|
show_message "- Docker Uninstalled ✅" "replace_last_line" >&2
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rm $PLANE_INSTALL_DIR/.env &> /dev/null
|
sudo rm $PLANE_INSTALL_DIR/.env &> /dev/null
|
||||||
rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
|
sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
|
||||||
rm $PLANE_INSTALL_DIR/config.env &> /dev/null
|
sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null
|
||||||
rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
|
sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
|
||||||
|
|
||||||
# rm -rf $PLANE_INSTALL_DIR &> /dev/null
|
# rm -rf $PLANE_INSTALL_DIR &> /dev/null
|
||||||
show_message "- Configuration Cleaned ✅"
|
show_message "- Configuration Cleaned ✅"
|
||||||
@ -593,12 +619,12 @@ function uninstall() {
|
|||||||
show_message ""
|
show_message ""
|
||||||
show_message ""
|
show_message ""
|
||||||
else
|
else
|
||||||
PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌"
|
PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
|
||||||
show_message ""
|
show_message ""
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌"
|
PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌"
|
||||||
show_message ""
|
show_message ""
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@ -608,15 +634,15 @@ function start_server() {
|
|||||||
env_file="$PLANE_INSTALL_DIR/.env"
|
env_file="$PLANE_INSTALL_DIR/.env"
|
||||||
# check if both the files exits
|
# check if both the files exits
|
||||||
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
|
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
|
||||||
show_message "Starting Plane Server ✋"
|
show_message "Starting Plane Server ($APP_RELEASE) ✋"
|
||||||
docker compose -f $docker_compose_file --env-file=$env_file up -d
|
sudo docker compose -f $docker_compose_file --env-file=$env_file up -d
|
||||||
|
|
||||||
# Wait for containers to be running
|
# Wait for containers to be running
|
||||||
echo "Waiting for containers to start..."
|
echo "Waiting for containers to start..."
|
||||||
while ! docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do
|
while ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
show_message "Plane Server Started ✅" "replace_last_line" >&2
|
show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2
|
||||||
else
|
else
|
||||||
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
||||||
fi
|
fi
|
||||||
@ -626,11 +652,11 @@ function stop_server() {
|
|||||||
env_file="$PLANE_INSTALL_DIR/.env"
|
env_file="$PLANE_INSTALL_DIR/.env"
|
||||||
# check if both the files exits
|
# check if both the files exits
|
||||||
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
|
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
|
||||||
show_message "Stopping Plane Server ✋"
|
show_message "Stopping Plane Server ($APP_RELEASE) ✋"
|
||||||
docker compose -f $docker_compose_file --env-file=$env_file down
|
sudo docker compose -f $docker_compose_file --env-file=$env_file down
|
||||||
show_message "Plane Server Stopped ✅" "replace_last_line" >&2
|
show_message "Plane Server Stopped ($APP_RELEASE) ✅" "replace_last_line" >&2
|
||||||
else
|
else
|
||||||
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
show_message "Plane Server not installed [Skipping] ✅" "replace_last_line" >&2
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
function restart_server() {
|
function restart_server() {
|
||||||
@ -638,9 +664,9 @@ function restart_server() {
|
|||||||
env_file="$PLANE_INSTALL_DIR/.env"
|
env_file="$PLANE_INSTALL_DIR/.env"
|
||||||
# check if both the files exits
|
# check if both the files exits
|
||||||
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
|
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
|
||||||
show_message "Restarting Plane Server ✋"
|
show_message "Restarting Plane Server ($APP_RELEASE) ✋"
|
||||||
docker compose -f $docker_compose_file --env-file=$env_file restart
|
sudo docker compose -f $docker_compose_file --env-file=$env_file restart
|
||||||
show_message "Plane Server Restarted ✅" "replace_last_line" >&2
|
show_message "Plane Server Restarted ($APP_RELEASE) ✅" "replace_last_line" >&2
|
||||||
else
|
else
|
||||||
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
||||||
fi
|
fi
|
||||||
@ -666,28 +692,45 @@ function show_help() {
|
|||||||
}
|
}
|
||||||
function update_installer() {
|
function update_installer() {
|
||||||
show_message "Updating Plane Installer ✋" >&2
|
show_message "Updating Plane Installer ✋" >&2
|
||||||
curl -H 'Cache-Control: no-cache, no-store' \
|
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
||||||
-s -o /usr/local/bin/plane-app \
|
-s -o /usr/local/bin/plane-app \
|
||||||
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/1-click/install.sh?token=$(date +%s)
|
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s)
|
||||||
|
|
||||||
chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null
|
sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null
|
||||||
show_message "Plane Installer Updated ✅" "replace_last_line" >&2
|
show_message "Plane Installer Updated ✅" "replace_last_line" >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
export BRANCH=${BRANCH:-master}
|
export DEPLOY_BRANCH=${BRANCH:-master}
|
||||||
export APP_RELEASE=$BRANCH
|
export APP_RELEASE=$DEPLOY_BRANCH
|
||||||
export DOCKERHUB_USER=makeplane
|
export DOCKERHUB_USER=makeplane
|
||||||
export PULL_POLICY=always
|
export PULL_POLICY=always
|
||||||
|
|
||||||
|
if [ "$DEPLOY_BRANCH" == "master" ]; then
|
||||||
|
export APP_RELEASE=latest
|
||||||
|
fi
|
||||||
|
|
||||||
PLANE_INSTALL_DIR=/opt/plane
|
PLANE_INSTALL_DIR=/opt/plane
|
||||||
DATA_DIR=$PLANE_INSTALL_DIR/data
|
DATA_DIR=$PLANE_INSTALL_DIR/data
|
||||||
LOG_DIR=$PLANE_INSTALL_DIR/log
|
LOG_DIR=$PLANE_INSTALL_DIR/log
|
||||||
OS_SUPPORTED=false
|
OS_SUPPORTED=false
|
||||||
CPU_ARCH=$(uname -m)
|
CPU_ARCH=$(uname -m)
|
||||||
PROGRESS_MSG=""
|
PROGRESS_MSG=""
|
||||||
USE_GLOBAL_IMAGES=1
|
USE_GLOBAL_IMAGES=0
|
||||||
|
PACKAGE_MANAGER=""
|
||||||
|
|
||||||
mkdir -p $PLANE_INSTALL_DIR/{data,log}
|
if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then
|
||||||
|
USE_GLOBAL_IMAGES=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo mkdir -p $PLANE_INSTALL_DIR/{data,log}
|
||||||
|
|
||||||
|
if command -v apt-get &> /dev/null; then
|
||||||
|
PACKAGE_MANAGER="apt-get"
|
||||||
|
elif command -v yum &> /dev/null; then
|
||||||
|
PACKAGE_MANAGER="yum"
|
||||||
|
elif command -v apk &> /dev/null; then
|
||||||
|
PACKAGE_MANAGER="apk"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$1" == "start" ]; then
|
if [ "$1" == "start" ]; then
|
||||||
start_server
|
start_server
|
||||||
@ -704,7 +747,7 @@ elif [ "$1" == "--upgrade" ] || [ "$1" == "-up" ]; then
|
|||||||
upgrade
|
upgrade
|
||||||
elif [ "$1" == "--uninstall" ] || [ "$1" == "-un" ]; then
|
elif [ "$1" == "--uninstall" ] || [ "$1" == "-un" ]; then
|
||||||
uninstall
|
uninstall
|
||||||
elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ] ; then
|
elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ]; then
|
||||||
update_installer
|
update_installer
|
||||||
elif [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
|
elif [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
|
||||||
show_help
|
show_help
|
||||||
|
@ -38,10 +38,6 @@ x-app-env : &app-env
|
|||||||
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
|
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
|
||||||
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
|
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
|
||||||
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
|
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
|
||||||
# OPENAI SETTINGS - Deprecated can be configured through admin panel
|
|
||||||
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
|
|
||||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-""}
|
|
||||||
- GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"}
|
|
||||||
# LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel
|
# LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel
|
||||||
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
|
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
|
||||||
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
|
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
|
||||||
|
@ -20,8 +20,8 @@ function buildLocalImage() {
|
|||||||
DO_BUILD="2"
|
DO_BUILD="2"
|
||||||
else
|
else
|
||||||
printf "\n" >&2
|
printf "\n" >&2
|
||||||
printf "${YELLOW}You are on ${ARCH} cpu architecture. ${NC}\n" >&2
|
printf "${YELLOW}You are on ${CPU_ARCH} cpu architecture. ${NC}\n" >&2
|
||||||
printf "${YELLOW}Since the prebuilt ${ARCH} compatible docker images are not available for, we will be running the docker build on this system. ${NC} \n" >&2
|
printf "${YELLOW}Since the prebuilt ${CPU_ARCH} compatible docker images are not available for, we will be running the docker build on this system. ${NC} \n" >&2
|
||||||
printf "${YELLOW}This might take ${YELLOW}5-30 min based on your system's hardware configuration. \n ${NC} \n" >&2
|
printf "${YELLOW}This might take ${YELLOW}5-30 min based on your system's hardware configuration. \n ${NC} \n" >&2
|
||||||
printf "\n" >&2
|
printf "\n" >&2
|
||||||
printf "${GREEN}Select an option to proceed: ${NC}\n" >&2
|
printf "${GREEN}Select an option to proceed: ${NC}\n" >&2
|
||||||
@ -49,7 +49,7 @@ function buildLocalImage() {
|
|||||||
cd $PLANE_TEMP_CODE_DIR
|
cd $PLANE_TEMP_CODE_DIR
|
||||||
if [ "$BRANCH" == "master" ];
|
if [ "$BRANCH" == "master" ];
|
||||||
then
|
then
|
||||||
APP_RELEASE=latest
|
export APP_RELEASE=latest
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker compose -f build.yml build --no-cache >&2
|
docker compose -f build.yml build --no-cache >&2
|
||||||
@ -149,7 +149,7 @@ function upgrade() {
|
|||||||
function askForAction() {
|
function askForAction() {
|
||||||
echo
|
echo
|
||||||
echo "Select a Action you want to perform:"
|
echo "Select a Action you want to perform:"
|
||||||
echo " 1) Install (${ARCH})"
|
echo " 1) Install (${CPU_ARCH})"
|
||||||
echo " 2) Start"
|
echo " 2) Start"
|
||||||
echo " 3) Stop"
|
echo " 3) Stop"
|
||||||
echo " 4) Restart"
|
echo " 4) Restart"
|
||||||
@ -193,8 +193,8 @@ function askForAction() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# CPU ARCHITECHTURE BASED SETTINGS
|
# CPU ARCHITECHTURE BASED SETTINGS
|
||||||
ARCH=$(uname -m)
|
CPU_ARCH=$(uname -m)
|
||||||
if [ $ARCH == "amd64" ] || [ $ARCH == "x86_64" ];
|
if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]];
|
||||||
then
|
then
|
||||||
USE_GLOBAL_IMAGES=1
|
USE_GLOBAL_IMAGES=1
|
||||||
DOCKERHUB_USER=makeplane
|
DOCKERHUB_USER=makeplane
|
||||||
@ -205,6 +205,11 @@ else
|
|||||||
PULL_POLICY=never
|
PULL_POLICY=never
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$BRANCH" == "master" ];
|
||||||
|
then
|
||||||
|
export APP_RELEASE=latest
|
||||||
|
fi
|
||||||
|
|
||||||
# REMOVE SPECIAL CHARACTERS FROM BRANCH NAME
|
# REMOVE SPECIAL CHARACTERS FROM BRANCH NAME
|
||||||
if [ "$BRANCH" != "master" ];
|
if [ "$BRANCH" != "master" ];
|
||||||
then
|
then
|
||||||
|
@ -8,13 +8,13 @@ NGINX_PORT=80
|
|||||||
WEB_URL=http://localhost
|
WEB_URL=http://localhost
|
||||||
DEBUG=0
|
DEBUG=0
|
||||||
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
|
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
|
||||||
SENTRY_DSN=""
|
SENTRY_DSN=
|
||||||
SENTRY_ENVIRONMENT="production"
|
SENTRY_ENVIRONMENT=production
|
||||||
GOOGLE_CLIENT_ID=""
|
GOOGLE_CLIENT_ID=
|
||||||
GITHUB_CLIENT_ID=""
|
GITHUB_CLIENT_ID=
|
||||||
GITHUB_CLIENT_SECRET=""
|
GITHUB_CLIENT_SECRET=
|
||||||
DOCKERIZED=1 # deprecated
|
DOCKERIZED=1 # deprecated
|
||||||
CORS_ALLOWED_ORIGINS="http://localhost"
|
CORS_ALLOWED_ORIGINS=http://localhost
|
||||||
|
|
||||||
#DB SETTINGS
|
#DB SETTINGS
|
||||||
PGHOST=plane-db
|
PGHOST=plane-db
|
||||||
@ -31,19 +31,14 @@ REDIS_PORT=6379
|
|||||||
REDIS_URL=redis://${REDIS_HOST}:6379/
|
REDIS_URL=redis://${REDIS_HOST}:6379/
|
||||||
|
|
||||||
# EMAIL SETTINGS
|
# EMAIL SETTINGS
|
||||||
EMAIL_HOST=""
|
EMAIL_HOST=
|
||||||
EMAIL_HOST_USER=""
|
EMAIL_HOST_USER=
|
||||||
EMAIL_HOST_PASSWORD=""
|
EMAIL_HOST_PASSWORD=
|
||||||
EMAIL_PORT=587
|
EMAIL_PORT=587
|
||||||
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
|
EMAIL_FROM=Team Plane <team@mailer.plane.so>
|
||||||
EMAIL_USE_TLS=1
|
EMAIL_USE_TLS=1
|
||||||
EMAIL_USE_SSL=0
|
EMAIL_USE_SSL=0
|
||||||
|
|
||||||
# OPENAI SETTINGS
|
|
||||||
OPENAI_API_BASE=https://api.openai.com/v1 # deprecated
|
|
||||||
OPENAI_API_KEY="sk-" # deprecated
|
|
||||||
GPT_ENGINE="gpt-3.5-turbo" # deprecated
|
|
||||||
|
|
||||||
# LOGIN/SIGNUP SETTINGS
|
# LOGIN/SIGNUP SETTINGS
|
||||||
ENABLE_SIGNUP=1
|
ENABLE_SIGNUP=1
|
||||||
ENABLE_EMAIL_PASSWORD=1
|
ENABLE_EMAIL_PASSWORD=1
|
||||||
@ -52,13 +47,13 @@ SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
|
|||||||
|
|
||||||
# DATA STORE SETTINGS
|
# DATA STORE SETTINGS
|
||||||
USE_MINIO=1
|
USE_MINIO=1
|
||||||
AWS_REGION=""
|
AWS_REGION=
|
||||||
AWS_ACCESS_KEY_ID="access-key"
|
AWS_ACCESS_KEY_ID=access-key
|
||||||
AWS_SECRET_ACCESS_KEY="secret-key"
|
AWS_SECRET_ACCESS_KEY=secret-key
|
||||||
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
|
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
|
||||||
AWS_S3_BUCKET_NAME=uploads
|
AWS_S3_BUCKET_NAME=uploads
|
||||||
MINIO_ROOT_USER="access-key"
|
MINIO_ROOT_USER=access-key
|
||||||
MINIO_ROOT_PASSWORD="secret-key"
|
MINIO_ROOT_PASSWORD=secret-key
|
||||||
BUCKET_NAME=uploads
|
BUCKET_NAME=uploads
|
||||||
FILE_SIZE_LIMIT=5242880
|
FILE_SIZE_LIMIT=5242880
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"repository": "https://github.com/makeplane/plane.git",
|
"repository": "https://github.com/makeplane/plane.git",
|
||||||
"version": "0.15.0",
|
"version": "0.15.1",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/editor-core",
|
"name": "@plane/editor-core",
|
||||||
"version": "0.15.0",
|
"version": "0.15.1",
|
||||||
"description": "Core Editor that powers Plane",
|
"description": "Core Editor that powers Plane",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/document-editor",
|
"name": "@plane/document-editor",
|
||||||
"version": "0.15.0",
|
"version": "0.15.1",
|
||||||
"description": "Package that powers Plane's Pages Editor",
|
"description": "Package that powers Plane's Pages Editor",
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
|
@ -6,10 +6,16 @@ import { scrollSummary } from "src/utils/editor-summary-utils";
|
|||||||
interface ContentBrowserProps {
|
interface ContentBrowserProps {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
markings: IMarking[];
|
markings: IMarking[];
|
||||||
|
setSidePeekVisible?: (sidePeekState: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContentBrowser = (props: ContentBrowserProps) => {
|
export const ContentBrowser = (props: ContentBrowserProps) => {
|
||||||
const { editor, markings } = props;
|
const { editor, markings, setSidePeekVisible } = props;
|
||||||
|
|
||||||
|
const handleOnClick = (marking: IMarking) => {
|
||||||
|
scrollSummary(editor, marking);
|
||||||
|
if (setSidePeekVisible) setSidePeekVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
@ -18,11 +24,11 @@ export const ContentBrowser = (props: ContentBrowserProps) => {
|
|||||||
{markings.length !== 0 ? (
|
{markings.length !== 0 ? (
|
||||||
markings.map((marking) =>
|
markings.map((marking) =>
|
||||||
marking.level === 1 ? (
|
marking.level === 1 ? (
|
||||||
<HeadingComp onClick={() => scrollSummary(editor, marking)} heading={marking.text} />
|
<HeadingComp onClick={() => handleOnClick(marking)} heading={marking.text} />
|
||||||
) : marking.level === 2 ? (
|
) : marking.level === 2 ? (
|
||||||
<SubheadingComp onClick={() => scrollSummary(editor, marking)} subHeading={marking.text} />
|
<SubheadingComp onClick={() => handleOnClick(marking)} subHeading={marking.text} />
|
||||||
) : (
|
) : (
|
||||||
<HeadingThreeComp heading={marking.text} onClick={() => scrollSummary(editor, marking)} />
|
<HeadingThreeComp heading={marking.text} onClick={() => handleOnClick(marking)} />
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
@ -42,8 +42,8 @@ export const EditorHeader = (props: IEditorHeader) => {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center border-b border-custom-border-200 px-5 py-2">
|
<div className="flex items-center border-b border-custom-border-200 md:px-5 px-3 py-2">
|
||||||
<div className="w-56 flex-shrink-0 lg:w-72">
|
<div className="md:w-56 flex-shrink-0 lg:w-72 w-fit">
|
||||||
<SummaryPopover
|
<SummaryPopover
|
||||||
editor={editor}
|
editor={editor}
|
||||||
markings={markings}
|
markings={markings}
|
||||||
@ -52,7 +52,7 @@ export const EditorHeader = (props: IEditorHeader) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0 hidden md:flex">
|
||||||
{!readonly && uploadFile && (
|
{!readonly && uploadFile && (
|
||||||
<FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />
|
<FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />
|
||||||
)}
|
)}
|
||||||
|
@ -152,7 +152,7 @@ export const PageRenderer = (props: IPageRenderer) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full pb-64 pl-7 pt-5 page-renderer">
|
<div className="w-full pb-64 md:pl-7 pl-3 pt-5 page-renderer">
|
||||||
{!readonly ? (
|
{!readonly ? (
|
||||||
<input
|
<input
|
||||||
onChange={(e) => handlePageTitleChange(e.target.value)}
|
onChange={(e) => handlePageTitleChange(e.target.value)}
|
||||||
|
@ -33,13 +33,25 @@ export const SummaryPopover: React.FC<Props> = (props) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={setReferenceElement}
|
ref={setReferenceElement}
|
||||||
className={`grid h-7 w-7 place-items-center rounded ${
|
className={`grid h-7 w-7 place-items-center rounded ${sidePeekVisible ? "bg-custom-primary-100/20 text-custom-primary-100" : "text-custom-text-300"
|
||||||
sidePeekVisible ? "bg-custom-primary-100/20 text-custom-primary-100" : "text-custom-text-300"
|
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setSidePeekVisible(!sidePeekVisible)}
|
onClick={() => setSidePeekVisible(!sidePeekVisible)}
|
||||||
>
|
>
|
||||||
<List className="h-4 w-4" />
|
<List className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<div className="md:hidden block">
|
||||||
|
{sidePeekVisible && (
|
||||||
|
<div
|
||||||
|
className="z-10 max-h-80 w-64 overflow-y-auto rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg"
|
||||||
|
ref={setPopperElement}
|
||||||
|
style={summaryPopoverStyles.popper}
|
||||||
|
{...summaryPopoverAttributes.popper}
|
||||||
|
>
|
||||||
|
<ContentBrowser setSidePeekVisible={setSidePeekVisible} editor={editor} markings={markings} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block">
|
||||||
{!sidePeekVisible && (
|
{!sidePeekVisible && (
|
||||||
<div
|
<div
|
||||||
className="z-10 hidden max-h-80 w-64 overflow-y-auto rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg group-hover/summary-popover:block"
|
className="z-10 hidden max-h-80 w-64 overflow-y-auto rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg group-hover/summary-popover:block"
|
||||||
@ -51,5 +63,6 @@ export const SummaryPopover: React.FC<Props> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -10,6 +10,7 @@ import { DocumentDetails } from "src/types/editor-types";
|
|||||||
import { PageRenderer } from "src/ui/components/page-renderer";
|
import { PageRenderer } from "src/ui/components/page-renderer";
|
||||||
import { getMenuOptions } from "src/utils/menu-options";
|
import { getMenuOptions } from "src/utils/menu-options";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { FixedMenu } from "src";
|
||||||
|
|
||||||
interface IDocumentEditor {
|
interface IDocumentEditor {
|
||||||
// document info
|
// document info
|
||||||
@ -149,11 +150,14 @@ const DocumentEditor = ({
|
|||||||
documentDetails={documentDetails}
|
documentDetails={documentDetails}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex-shrink-0 md:hidden border-b border-custom-border-200 pl-3 py-2">
|
||||||
|
{uploadFile && <FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />}
|
||||||
|
</div>
|
||||||
<div className="flex h-full w-full overflow-y-auto frame-renderer">
|
<div className="flex h-full w-full overflow-y-auto frame-renderer">
|
||||||
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-72">
|
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-72 hidden md:block">
|
||||||
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
|
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
|
<div className="h-full w-full md:w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
|
||||||
<PageRenderer
|
<PageRenderer
|
||||||
onActionCompleteHandler={onActionCompleteHandler}
|
onActionCompleteHandler={onActionCompleteHandler}
|
||||||
hideDragHandle={hideDragHandleOnMouseLeave}
|
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||||
|
@ -77,7 +77,7 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center divide-x divide-custom-border-200">
|
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
|
||||||
<div className="flex items-center gap-0.5 pr-2">
|
<div className="flex items-center gap-0.5 pr-2">
|
||||||
{basicMarkItems.map((item) => (
|
{basicMarkItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/editor-extensions",
|
"name": "@plane/editor-extensions",
|
||||||
"version": "0.15.0",
|
"version": "0.15.1",
|
||||||
"description": "Package that powers Plane's Editor with extensions",
|
"description": "Package that powers Plane's Editor with extensions",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/lite-text-editor",
|
"name": "@plane/lite-text-editor",
|
||||||
"version": "0.15.0",
|
"version": "0.15.1",
|
||||||
"description": "Package that powers Plane's Comment Editor",
|
"description": "Package that powers Plane's Comment Editor",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/rich-text-editor",
|
"name": "@plane/rich-text-editor",
|
||||||
"version": "0.15.0",
|
"version": "0.15.1",
|
||||||
"description": "Rich Text Editor that powers Plane",
|
"description": "Rich Text Editor that powers Plane",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "eslint-config-custom",
|
"name": "eslint-config-custom",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.15.0",
|
"version": "0.15.1",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tailwind-config-custom",
|
"name": "tailwind-config-custom",
|
||||||
"version": "0.15.0",
|
"version": "0.15.1",
|
||||||
"description": "common tailwind configuration across monorepo",
|
"description": "common tailwind configuration across monorepo",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tsconfig",
|
"name": "tsconfig",
|
||||||
"version": "0.15.0",
|
"version": "0.15.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"files": [
|
"files": [
|
||||||
"base.json",
|
"base.json",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/types",
|
"name": "@plane/types",
|
||||||
"version": "0.15.0",
|
"version": "0.15.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./src/index.d.ts"
|
"main": "./src/index.d.ts"
|
||||||
}
|
}
|
||||||
|
18
packages/types/src/cycles.d.ts
vendored
18
packages/types/src/cycles.d.ts
vendored
@ -31,6 +31,7 @@ export interface ICycle {
|
|||||||
issue: string;
|
issue: string;
|
||||||
name: string;
|
name: string;
|
||||||
owned_by: string;
|
owned_by: string;
|
||||||
|
progress_snapshot: TProgressSnapshot;
|
||||||
project: string;
|
project: string;
|
||||||
project_detail: IProjectLite;
|
project_detail: IProjectLite;
|
||||||
status: TCycleGroups;
|
status: TCycleGroups;
|
||||||
@ -49,6 +50,23 @@ export interface ICycle {
|
|||||||
workspace_detail: IWorkspaceLite;
|
workspace_detail: IWorkspaceLite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TProgressSnapshot = {
|
||||||
|
backlog_issues: number;
|
||||||
|
cancelled_issues: number;
|
||||||
|
completed_estimates: number | null;
|
||||||
|
completed_issues: number;
|
||||||
|
distribution?: {
|
||||||
|
assignees: TAssigneesDistribution[];
|
||||||
|
completion_chart: TCompletionChartDistribution;
|
||||||
|
labels: TLabelsDistribution[];
|
||||||
|
};
|
||||||
|
started_estimates: number | null;
|
||||||
|
started_issues: number;
|
||||||
|
total_estimates: number | null;
|
||||||
|
total_issues: number;
|
||||||
|
unstarted_issues: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type TAssigneesDistribution = {
|
export type TAssigneesDistribution = {
|
||||||
assignee_id: string | null;
|
assignee_id: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
|
11
packages/types/src/dashboard.d.ts
vendored
11
packages/types/src/dashboard.d.ts
vendored
@ -13,9 +13,10 @@ export type TWidgetKeys =
|
|||||||
| "recent_projects"
|
| "recent_projects"
|
||||||
| "recent_collaborators";
|
| "recent_collaborators";
|
||||||
|
|
||||||
export type TIssuesListTypes = "upcoming" | "overdue" | "completed";
|
export type TIssuesListTypes = "pending" | "upcoming" | "overdue" | "completed";
|
||||||
|
|
||||||
export type TDurationFilterOptions =
|
export type TDurationFilterOptions =
|
||||||
|
| "none"
|
||||||
| "today"
|
| "today"
|
||||||
| "this_week"
|
| "this_week"
|
||||||
| "this_month"
|
| "this_month"
|
||||||
@ -23,21 +24,21 @@ export type TDurationFilterOptions =
|
|||||||
|
|
||||||
// widget filters
|
// widget filters
|
||||||
export type TAssignedIssuesWidgetFilters = {
|
export type TAssignedIssuesWidgetFilters = {
|
||||||
target_date?: TDurationFilterOptions;
|
duration?: TDurationFilterOptions;
|
||||||
tab?: TIssuesListTypes;
|
tab?: TIssuesListTypes;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TCreatedIssuesWidgetFilters = {
|
export type TCreatedIssuesWidgetFilters = {
|
||||||
target_date?: TDurationFilterOptions;
|
duration?: TDurationFilterOptions;
|
||||||
tab?: TIssuesListTypes;
|
tab?: TIssuesListTypes;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TIssuesByStateGroupsWidgetFilters = {
|
export type TIssuesByStateGroupsWidgetFilters = {
|
||||||
target_date?: TDurationFilterOptions;
|
duration?: TDurationFilterOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TIssuesByPriorityWidgetFilters = {
|
export type TIssuesByPriorityWidgetFilters = {
|
||||||
target_date?: TDurationFilterOptions;
|
duration?: TDurationFilterOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TWidgetFiltersFormData =
|
export type TWidgetFiltersFormData =
|
||||||
|
9
packages/types/src/issues.d.ts
vendored
9
packages/types/src/issues.d.ts
vendored
@ -221,3 +221,12 @@ export interface IGroupByColumn {
|
|||||||
export interface IIssueMap {
|
export interface IIssueMap {
|
||||||
[key: string]: TIssue;
|
[key: string]: TIssue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IIssueListRow {
|
||||||
|
id: string;
|
||||||
|
groupId: string;
|
||||||
|
type: "HEADER" | "NO_ISSUES" | "QUICK_ADD" | "ISSUE";
|
||||||
|
name?: string;
|
||||||
|
icon?: ReactElement | undefined;
|
||||||
|
payload?: Partial<TIssue>;
|
||||||
|
}
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"name": "@plane/ui",
|
"name": "@plane/ui",
|
||||||
"description": "UI components shared across multiple apps internally",
|
"description": "UI components shared across multiple apps internally",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.15.0",
|
"version": "0.15.1",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
@ -1,66 +1,71 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
// icons
|
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
// components
|
|
||||||
import { Tooltip } from "../tooltip";
|
|
||||||
|
|
||||||
type BreadcrumbsProps = {
|
type BreadcrumbsProps = {
|
||||||
children: any;
|
children: React.ReactNode;
|
||||||
|
onBack?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Breadcrumbs = ({ children }: BreadcrumbsProps) => (
|
const Breadcrumbs = ({ children, onBack }: BreadcrumbsProps) => {
|
||||||
<div className="flex items-center space-x-2">
|
const [isSmallScreen, setIsSmallScreen] = React.useState(false);
|
||||||
{React.Children.map(children, (child, index) => (
|
|
||||||
<div key={index} className="flex flex-wrap items-center gap-2.5">
|
React.useEffect(() => {
|
||||||
{child}
|
const handleResize = () => {
|
||||||
{index !== React.Children.count(children) - 1 && (
|
setIsSmallScreen(window.innerWidth <= 640); // Adjust this value as per your requirement
|
||||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
handleResize(); // Call it initially to set the correct state
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const childrenArray = React.Children.toArray(children);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
|
{!isSmallScreen && (
|
||||||
|
<>
|
||||||
|
{childrenArray.map((child, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{index > 0 && !isSmallScreen && (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<ChevronRight
|
||||||
|
className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className={`flex items-center gap-2.5 ${isSmallScreen && index > 0 ? 'hidden sm:flex' : 'flex'}`}>
|
||||||
|
{child}
|
||||||
</div>
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSmallScreen && childrenArray.length > 1 && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{onBack && <span onClick={onBack} className="text-custom-text-200">...</span>}
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="flex items-center gap-2.5">{childrenArray[childrenArray.length - 1]}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isSmallScreen && childrenArray.length === 1 && childrenArray}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
type?: "text" | "component";
|
type?: "text" | "component";
|
||||||
component?: React.ReactNode;
|
component?: React.ReactNode;
|
||||||
label?: string;
|
link?: JSX.Element;
|
||||||
icon?: React.ReactNode;
|
|
||||||
link?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const BreadcrumbItem: React.FC<Props> = (props) => {
|
const BreadcrumbItem: React.FC<Props> = (props) => {
|
||||||
const { type = "text", component, label, icon, link } = props;
|
const { type = "text", component, link } = props;
|
||||||
return (
|
return <>{type !== "text" ? <div className="flex items-center space-x-2">{component}</div> : link}</>;
|
||||||
<>
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Breadcrumbs.BreadcrumbItem = BreadcrumbItem;
|
Breadcrumbs.BreadcrumbItem = BreadcrumbItem;
|
||||||
|
@ -15,6 +15,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
const {
|
const {
|
||||||
buttonClassName = "",
|
buttonClassName = "",
|
||||||
customButtonClassName = "",
|
customButtonClassName = "",
|
||||||
|
customButtonTabIndex = 0,
|
||||||
placement,
|
placement,
|
||||||
children,
|
children,
|
||||||
className = "",
|
className = "",
|
||||||
@ -29,6 +30,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
verticalEllipsis = false,
|
verticalEllipsis = false,
|
||||||
portalElement,
|
portalElement,
|
||||||
menuButtonOnClick,
|
menuButtonOnClick,
|
||||||
|
onMenuClose,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
closeOnSelect,
|
closeOnSelect,
|
||||||
} = props;
|
} = props;
|
||||||
@ -47,18 +49,28 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
if (referenceElement) referenceElement.focus();
|
if (referenceElement) referenceElement.focus();
|
||||||
};
|
};
|
||||||
const closeDropdown = () => setIsOpen(false);
|
const closeDropdown = () => {
|
||||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
isOpen && onMenuClose && onMenuClose();
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectActiveItem = () => {
|
||||||
|
const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector(
|
||||||
|
`[data-headlessui-state="active"] button`
|
||||||
|
);
|
||||||
|
activeItem?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen, selectActiveItem);
|
||||||
|
|
||||||
|
const handleOnClick = () => {
|
||||||
|
if (closeOnSelect) closeDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
useOutsideClickDetector(dropdownRef, closeDropdown);
|
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||||
|
|
||||||
let menuItems = (
|
let menuItems = (
|
||||||
<Menu.Items
|
<Menu.Items className="fixed z-10" static>
|
||||||
className="fixed z-10"
|
|
||||||
onClick={() => {
|
|
||||||
if (closeOnSelect) closeDropdown();
|
|
||||||
}}
|
|
||||||
static
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap",
|
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap",
|
||||||
@ -89,7 +101,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
className={cn("relative w-min text-left", className)}
|
className={cn("relative w-min text-left", className)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
onClick={handleOnClick}
|
||||||
>
|
>
|
||||||
{({ open }) => (
|
{({ open }) => (
|
||||||
<>
|
<>
|
||||||
@ -98,11 +111,13 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
<button
|
<button
|
||||||
ref={setReferenceElement}
|
ref={setReferenceElement}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
openDropdown();
|
openDropdown();
|
||||||
if (menuButtonOnClick) menuButtonOnClick();
|
if (menuButtonOnClick) menuButtonOnClick();
|
||||||
}}
|
}}
|
||||||
className={customButtonClassName}
|
className={customButtonClassName}
|
||||||
|
tabIndex={customButtonTabIndex}
|
||||||
>
|
>
|
||||||
{customButton}
|
{customButton}
|
||||||
</button>
|
</button>
|
||||||
@ -114,7 +129,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
<button
|
<button
|
||||||
ref={setReferenceElement}
|
ref={setReferenceElement}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
openDropdown();
|
openDropdown();
|
||||||
if (menuButtonOnClick) menuButtonOnClick();
|
if (menuButtonOnClick) menuButtonOnClick();
|
||||||
}}
|
}}
|
||||||
@ -122,6 +138,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
className={`relative grid place-items-center rounded p-1 text-custom-text-200 outline-none hover:text-custom-text-100 ${
|
className={`relative grid place-items-center rounded p-1 text-custom-text-200 outline-none hover:text-custom-text-100 ${
|
||||||
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
|
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
|
||||||
} ${buttonClassName}`}
|
} ${buttonClassName}`}
|
||||||
|
tabIndex={customButtonTabIndex}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className={`h-3.5 w-3.5 ${verticalEllipsis ? "rotate-90" : ""}`} />
|
<MoreHorizontal className={`h-3.5 w-3.5 ${verticalEllipsis ? "rotate-90" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
@ -138,10 +155,12 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
? "cursor-not-allowed text-custom-text-200"
|
? "cursor-not-allowed text-custom-text-200"
|
||||||
: "cursor-pointer hover:bg-custom-background-80"
|
: "cursor-pointer hover:bg-custom-background-80"
|
||||||
} ${buttonClassName}`}
|
} ${buttonClassName}`}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
openDropdown();
|
openDropdown();
|
||||||
if (menuButtonOnClick) menuButtonOnClick();
|
if (menuButtonOnClick) menuButtonOnClick();
|
||||||
}}
|
}}
|
||||||
|
tabIndex={customButtonTabIndex}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{!noChevron && <ChevronDown className="h-3.5 w-3.5" />}
|
{!noChevron && <ChevronDown className="h-3.5 w-3.5" />}
|
||||||
@ -159,6 +178,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
|
|
||||||
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
||||||
const { children, onClick, className = "" } = props;
|
const { children, onClick, className = "" } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu.Item as="div">
|
<Menu.Item as="div">
|
||||||
{({ active, close }) => (
|
{({ active, close }) => (
|
||||||
|
@ -3,6 +3,7 @@ import { Placement } from "@blueprintjs/popover2";
|
|||||||
|
|
||||||
export interface IDropdownProps {
|
export interface IDropdownProps {
|
||||||
customButtonClassName?: string;
|
customButtonClassName?: string;
|
||||||
|
customButtonTabIndex?: number;
|
||||||
buttonClassName?: string;
|
buttonClassName?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
customButton?: JSX.Element;
|
customButton?: JSX.Element;
|
||||||
@ -23,6 +24,7 @@ export interface ICustomMenuDropdownProps extends IDropdownProps {
|
|||||||
noBorder?: boolean;
|
noBorder?: boolean;
|
||||||
verticalEllipsis?: boolean;
|
verticalEllipsis?: boolean;
|
||||||
menuButtonOnClick?: (...args: any) => void;
|
menuButtonOnClick?: (...args: any) => void;
|
||||||
|
onMenuClose?: () => void;
|
||||||
closeOnSelect?: boolean;
|
closeOnSelect?: boolean;
|
||||||
portalElement?: Element | null;
|
portalElement?: Element | null;
|
||||||
}
|
}
|
||||||
|
67
packages/ui/src/form-fields/checkbox.tsx
Normal file
67
packages/ui/src/form-fields/checkbox.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
intermediate?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>((props, ref) => {
|
||||||
|
const { id, name, checked, intermediate = false, disabled, className = "", ...rest } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative w-full flex gap-2 ${className}`}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
name={name}
|
||||||
|
checked={checked}
|
||||||
|
className={`
|
||||||
|
appearance-none shrink-0 w-4 h-4 border rounded-[3px] focus:outline-1 focus:outline-offset-4 focus:outline-custom-primary-50
|
||||||
|
${
|
||||||
|
disabled
|
||||||
|
? "border-custom-border-200 bg-custom-background-80 cursor-not-allowed"
|
||||||
|
: `cursor-pointer ${
|
||||||
|
checked || intermediate
|
||||||
|
? "border-custom-primary-40 bg-custom-primary-100 hover:bg-custom-primary-200"
|
||||||
|
: "border-custom-border-300 hover:border-custom-border-400 bg-white"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
disabled={disabled}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
className={`absolute w-4 h-4 p-0.5 pointer-events-none outline-none ${
|
||||||
|
disabled ? "stroke-custom-text-400 opacity-40" : "stroke-white"
|
||||||
|
} ${checked ? "block" : "hidden"}`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
className={`absolute w-4 h-4 p-0.5 pointer-events-none outline-none ${
|
||||||
|
disabled ? "stroke-custom-text-400 opacity-40" : "stroke-white"
|
||||||
|
} ${intermediate && !checked ? "block" : "hidden"}`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 8 8"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M5.75 4H2.25" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Checkbox.displayName = "form-checkbox-field";
|
||||||
|
|
||||||
|
export { Checkbox };
|
@ -1,3 +1,4 @@
|
|||||||
export * from "./input";
|
export * from "./input";
|
||||||
export * from "./textarea";
|
export * from "./textarea";
|
||||||
export * from "./input-color-picker";
|
export * from "./input-color-picker";
|
||||||
|
export * from "./checkbox";
|
||||||
|
@ -1,16 +1,23 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
type TUseDropdownKeyDown = {
|
type TUseDropdownKeyDown = {
|
||||||
(onOpen: () => void, onClose: () => void, isOpen: boolean): (event: React.KeyboardEvent<HTMLElement>) => void;
|
(
|
||||||
|
onOpen: () => void,
|
||||||
|
onClose: () => void,
|
||||||
|
isOpen: boolean,
|
||||||
|
selectActiveItem?: () => void
|
||||||
|
): (event: React.KeyboardEvent<HTMLElement>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen) => {
|
export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen, selectActiveItem?) => {
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.stopPropagation();
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
|
event.stopPropagation();
|
||||||
onOpen();
|
onOpen();
|
||||||
|
} else {
|
||||||
|
selectActiveItem && selectActiveItem();
|
||||||
}
|
}
|
||||||
} else if (event.key === "Escape" && isOpen) {
|
} else if (event.key === "Escape" && isOpen) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space",
|
"name": "space",
|
||||||
"version": "0.15.0",
|
"version": "0.15.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo run develop",
|
"dev": "turbo run develop",
|
||||||
|
@ -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="px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||||
<div className="">
|
<div className="">
|
||||||
<div className="flex items-start gap-x-4">
|
<div className="flex items-start gap-x-4">
|
||||||
<div className="grid place-items-center rounded-full bg-red-500/20 p-4">
|
<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-6 w-6 text-red-600" aria-hidden="true" />
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title as="h3" className="my-4 text-2xl font-medium leading-6 text-custom-text-100">
|
<Dialog.Title as="h3" className="my-4 text-2xl font-medium leading-6 text-custom-text-100">
|
||||||
|
@ -4,12 +4,14 @@ import { Controller, useForm } from "react-hook-form";
|
|||||||
import { AuthService } from "services/auth.service";
|
import { AuthService } from "services/auth.service";
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
|
import { useEventTracker } from "hooks/store";
|
||||||
// ui
|
// ui
|
||||||
import { Button, Input } from "@plane/ui";
|
import { Button, Input } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { checkEmailValidity } from "helpers/string.helper";
|
import { checkEmailValidity } from "helpers/string.helper";
|
||||||
// icons
|
// icons
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED } from "constants/event-tracker";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
email: string;
|
email: string;
|
||||||
@ -34,6 +36,8 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
|||||||
// states
|
// states
|
||||||
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
|
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
// store hooks
|
||||||
|
const { captureEvent } = useEventTracker();
|
||||||
// toast alert
|
// toast alert
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
// form info
|
// form info
|
||||||
@ -63,21 +67,34 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
|||||||
title: "Success!",
|
title: "Success!",
|
||||||
message: "Password created successfully.",
|
message: "Password created successfully.",
|
||||||
});
|
});
|
||||||
|
captureEvent(PASSWORD_CREATE_SELECTED, {
|
||||||
|
state: "SUCCESS",
|
||||||
|
first_time: false,
|
||||||
|
});
|
||||||
await handleSignInRedirection();
|
await handleSignInRedirection();
|
||||||
})
|
})
|
||||||
.catch((err) =>
|
.catch((err) => {
|
||||||
|
captureEvent(PASSWORD_CREATE_SELECTED, {
|
||||||
|
state: "FAILED",
|
||||||
|
first_time: false,
|
||||||
|
});
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "error",
|
type: "error",
|
||||||
title: "Error!",
|
title: "Error!",
|
||||||
message: err?.error ?? "Something went wrong. Please try again.",
|
message: err?.error ?? "Something went wrong. Please try again.",
|
||||||
})
|
});
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoToWorkspace = async () => {
|
const handleGoToWorkspace = async () => {
|
||||||
setIsGoingToWorkspace(true);
|
setIsGoingToWorkspace(true);
|
||||||
|
await handleSignInRedirection().finally(() => {
|
||||||
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false));
|
captureEvent(PASSWORD_CREATE_SKIPPED, {
|
||||||
|
state: "SUCCESS",
|
||||||
|
first_time: false,
|
||||||
|
});
|
||||||
|
setIsGoingToWorkspace(false);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -7,7 +7,7 @@ import { Eye, EyeOff, XCircle } from "lucide-react";
|
|||||||
import { AuthService } from "services/auth.service";
|
import { AuthService } from "services/auth.service";
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
import { useApplication } from "hooks/store";
|
import { useApplication, useEventTracker } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
import { ESignInSteps, ForgotPasswordPopover } from "components/account";
|
import { ESignInSteps, ForgotPasswordPopover } from "components/account";
|
||||||
// ui
|
// ui
|
||||||
@ -16,6 +16,8 @@ import { Button, Input } from "@plane/ui";
|
|||||||
import { checkEmailValidity } from "helpers/string.helper";
|
import { checkEmailValidity } from "helpers/string.helper";
|
||||||
// types
|
// types
|
||||||
import { IPasswordSignInData } from "@plane/types";
|
import { IPasswordSignInData } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { FORGOT_PASSWORD, SIGN_IN_WITH_PASSWORD } from "constants/event-tracker";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
email: string;
|
email: string;
|
||||||
@ -46,6 +48,7 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
|
|||||||
const {
|
const {
|
||||||
config: { envConfig },
|
config: { envConfig },
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
|
const { captureEvent } = useEventTracker();
|
||||||
// derived values
|
// derived values
|
||||||
const isSmtpConfigured = envConfig?.is_smtp_configured;
|
const isSmtpConfigured = envConfig?.is_smtp_configured;
|
||||||
// form info
|
// form info
|
||||||
@ -72,7 +75,13 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
await authService
|
await authService
|
||||||
.passwordSignIn(payload)
|
.passwordSignIn(payload)
|
||||||
.then(async () => await onSubmit())
|
.then(async () => {
|
||||||
|
captureEvent(SIGN_IN_WITH_PASSWORD, {
|
||||||
|
state: "SUCCESS",
|
||||||
|
first_time: false,
|
||||||
|
});
|
||||||
|
await onSubmit();
|
||||||
|
})
|
||||||
.catch((err) =>
|
.catch((err) =>
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "error",
|
type: "error",
|
||||||
@ -182,9 +191,10 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="w-full text-right mt-2 pb-3">
|
<div className="mt-2 w-full pb-3 text-right">
|
||||||
{isSmtpConfigured ? (
|
{isSmtpConfigured ? (
|
||||||
<Link
|
<Link
|
||||||
|
onClick={() => captureEvent(FORGOT_PASSWORD)}
|
||||||
href={`/accounts/forgot-password?email=${email}`}
|
href={`/accounts/forgot-password?email=${email}`}
|
||||||
className="text-xs font-medium text-custom-primary-100"
|
className="text-xs font-medium text-custom-primary-100"
|
||||||
>
|
>
|
||||||
|
@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication } from "hooks/store";
|
import { useApplication, useEventTracker } from "hooks/store";
|
||||||
import useSignInRedirection from "hooks/use-sign-in-redirection";
|
import useSignInRedirection from "hooks/use-sign-in-redirection";
|
||||||
// components
|
// components
|
||||||
import { LatestFeatureBlock } from "components/common";
|
import { LatestFeatureBlock } from "components/common";
|
||||||
@ -13,6 +13,8 @@ import {
|
|||||||
OAuthOptions,
|
OAuthOptions,
|
||||||
SignInOptionalSetPasswordForm,
|
SignInOptionalSetPasswordForm,
|
||||||
} from "components/account";
|
} from "components/account";
|
||||||
|
// constants
|
||||||
|
import { NAVIGATE_TO_SIGNUP } from "constants/event-tracker";
|
||||||
|
|
||||||
export enum ESignInSteps {
|
export enum ESignInSteps {
|
||||||
EMAIL = "EMAIL",
|
EMAIL = "EMAIL",
|
||||||
@ -32,6 +34,7 @@ export const SignInRoot = observer(() => {
|
|||||||
const {
|
const {
|
||||||
config: { envConfig },
|
config: { envConfig },
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
|
const { captureEvent } = useEventTracker();
|
||||||
// derived values
|
// derived values
|
||||||
const isSmtpConfigured = envConfig?.is_smtp_configured;
|
const isSmtpConfigured = envConfig?.is_smtp_configured;
|
||||||
|
|
||||||
@ -110,7 +113,11 @@ export const SignInRoot = observer(() => {
|
|||||||
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_in" />
|
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_in" />
|
||||||
<p className="text-xs text-onboarding-text-300 text-center mt-6">
|
<p className="text-xs text-onboarding-text-300 text-center mt-6">
|
||||||
Don{"'"}t have an account?{" "}
|
Don{"'"}t have an account?{" "}
|
||||||
<Link href="/accounts/sign-up" className="text-custom-primary-100 font-medium underline">
|
<Link
|
||||||
|
href="/accounts/sign-up"
|
||||||
|
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
|
||||||
|
className="text-custom-primary-100 font-medium underline"
|
||||||
|
>
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
@ -7,12 +7,15 @@ import { UserService } from "services/user.service";
|
|||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
import useTimer from "hooks/use-timer";
|
import useTimer from "hooks/use-timer";
|
||||||
|
import { useEventTracker } from "hooks/store";
|
||||||
// ui
|
// ui
|
||||||
import { Button, Input } from "@plane/ui";
|
import { Button, Input } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { checkEmailValidity } from "helpers/string.helper";
|
import { checkEmailValidity } from "helpers/string.helper";
|
||||||
// types
|
// types
|
||||||
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
|
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { CODE_VERIFIED } from "constants/event-tracker";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
email: string;
|
email: string;
|
||||||
@ -41,6 +44,8 @@ export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
|
|||||||
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
|
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
|
||||||
// toast alert
|
// toast alert
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
// store hooks
|
||||||
|
const { captureEvent } = useEventTracker();
|
||||||
// timer
|
// timer
|
||||||
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30);
|
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30);
|
||||||
// form info
|
// form info
|
||||||
@ -69,17 +74,22 @@ export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
|
|||||||
await authService
|
await authService
|
||||||
.magicSignIn(payload)
|
.magicSignIn(payload)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
|
captureEvent(CODE_VERIFIED, {
|
||||||
|
state: "SUCCESS",
|
||||||
|
});
|
||||||
const currentUser = await userService.currentUser();
|
const currentUser = await userService.currentUser();
|
||||||
|
|
||||||
await onSubmit(currentUser.is_password_autoset);
|
await onSubmit(currentUser.is_password_autoset);
|
||||||
})
|
})
|
||||||
.catch((err) =>
|
.catch((err) => {
|
||||||
|
captureEvent(CODE_VERIFIED, {
|
||||||
|
state: "FAILED",
|
||||||
|
});
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "error",
|
type: "error",
|
||||||
title: "Error!",
|
title: "Error!",
|
||||||
message: err?.error ?? "Something went wrong. Please try again.",
|
message: err?.error ?? "Something went wrong. Please try again.",
|
||||||
})
|
});
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
|
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
|
||||||
|
@ -4,12 +4,14 @@ import { Controller, useForm } from "react-hook-form";
|
|||||||
import { AuthService } from "services/auth.service";
|
import { AuthService } from "services/auth.service";
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
|
import { useEventTracker } from "hooks/store";
|
||||||
// ui
|
// ui
|
||||||
import { Button, Input } from "@plane/ui";
|
import { Button, Input } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { checkEmailValidity } from "helpers/string.helper";
|
import { checkEmailValidity } from "helpers/string.helper";
|
||||||
// constants
|
// constants
|
||||||
import { ESignUpSteps } from "components/account";
|
import { ESignUpSteps } from "components/account";
|
||||||
|
import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED, SETUP_PASSWORD } from "constants/event-tracker";
|
||||||
// icons
|
// icons
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
|
||||||
@ -37,6 +39,8 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
|||||||
// states
|
// states
|
||||||
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
|
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
// store hooks
|
||||||
|
const { captureEvent } = useEventTracker();
|
||||||
// toast alert
|
// toast alert
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
// form info
|
// form info
|
||||||
@ -66,21 +70,34 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
|
|||||||
title: "Success!",
|
title: "Success!",
|
||||||
message: "Password created successfully.",
|
message: "Password created successfully.",
|
||||||
});
|
});
|
||||||
|
captureEvent(SETUP_PASSWORD, {
|
||||||
|
state: "SUCCESS",
|
||||||
|
first_time: true,
|
||||||
|
});
|
||||||
await handleSignInRedirection();
|
await handleSignInRedirection();
|
||||||
})
|
})
|
||||||
.catch((err) =>
|
.catch((err) => {
|
||||||
|
captureEvent(SETUP_PASSWORD, {
|
||||||
|
state: "FAILED",
|
||||||
|
first_time: true,
|
||||||
|
});
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "error",
|
type: "error",
|
||||||
title: "Error!",
|
title: "Error!",
|
||||||
message: err?.error ?? "Something went wrong. Please try again.",
|
message: err?.error ?? "Something went wrong. Please try again.",
|
||||||
})
|
});
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoToWorkspace = async () => {
|
const handleGoToWorkspace = async () => {
|
||||||
setIsGoingToWorkspace(true);
|
setIsGoingToWorkspace(true);
|
||||||
|
await handleSignInRedirection().finally(() => {
|
||||||
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false));
|
captureEvent(PASSWORD_CREATE_SKIPPED, {
|
||||||
|
state: "SUCCESS",
|
||||||
|
first_time: true,
|
||||||
|
});
|
||||||
|
setIsGoingToWorkspace(false);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication } from "hooks/store";
|
import { useApplication, useEventTracker } from "hooks/store";
|
||||||
import useSignInRedirection from "hooks/use-sign-in-redirection";
|
import useSignInRedirection from "hooks/use-sign-in-redirection";
|
||||||
// components
|
// components
|
||||||
import {
|
import {
|
||||||
@ -12,6 +12,8 @@ import {
|
|||||||
SignUpUniqueCodeForm,
|
SignUpUniqueCodeForm,
|
||||||
} from "components/account";
|
} from "components/account";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
// constants
|
||||||
|
import { NAVIGATE_TO_SIGNIN } from "constants/event-tracker";
|
||||||
|
|
||||||
export enum ESignUpSteps {
|
export enum ESignUpSteps {
|
||||||
EMAIL = "EMAIL",
|
EMAIL = "EMAIL",
|
||||||
@ -32,6 +34,7 @@ export const SignUpRoot = observer(() => {
|
|||||||
const {
|
const {
|
||||||
config: { envConfig },
|
config: { envConfig },
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
|
const { captureEvent } = useEventTracker();
|
||||||
|
|
||||||
// step 1 submit handler- email verification
|
// step 1 submit handler- email verification
|
||||||
const handleEmailVerification = () => setSignInStep(ESignUpSteps.UNIQUE_CODE);
|
const handleEmailVerification = () => setSignInStep(ESignUpSteps.UNIQUE_CODE);
|
||||||
@ -86,7 +89,11 @@ export const SignUpRoot = observer(() => {
|
|||||||
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_up" />
|
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_up" />
|
||||||
<p className="text-xs text-onboarding-text-300 text-center mt-6">
|
<p className="text-xs text-onboarding-text-300 text-center mt-6">
|
||||||
Already using Plane?{" "}
|
Already using Plane?{" "}
|
||||||
<Link href="/" className="text-custom-primary-100 font-medium underline">
|
<Link
|
||||||
|
href="/"
|
||||||
|
onClick={() => captureEvent(NAVIGATE_TO_SIGNIN, {})}
|
||||||
|
className="text-custom-primary-100 font-medium underline"
|
||||||
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
@ -8,12 +8,15 @@ import { UserService } from "services/user.service";
|
|||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
import useTimer from "hooks/use-timer";
|
import useTimer from "hooks/use-timer";
|
||||||
|
import { useEventTracker } from "hooks/store";
|
||||||
// ui
|
// ui
|
||||||
import { Button, Input } from "@plane/ui";
|
import { Button, Input } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { checkEmailValidity } from "helpers/string.helper";
|
import { checkEmailValidity } from "helpers/string.helper";
|
||||||
// types
|
// types
|
||||||
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
|
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { CODE_VERIFIED } from "constants/event-tracker";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
email: string;
|
email: string;
|
||||||
@ -39,6 +42,8 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
|
|||||||
const { email, handleEmailClear, onSubmit } = props;
|
const { email, handleEmailClear, onSubmit } = props;
|
||||||
// states
|
// states
|
||||||
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
|
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
|
||||||
|
// store hooks
|
||||||
|
const { captureEvent } = useEventTracker();
|
||||||
// toast alert
|
// toast alert
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
// timer
|
// timer
|
||||||
@ -69,17 +74,22 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
|
|||||||
await authService
|
await authService
|
||||||
.magicSignIn(payload)
|
.magicSignIn(payload)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
|
captureEvent(CODE_VERIFIED, {
|
||||||
|
state: "SUCCESS",
|
||||||
|
});
|
||||||
const currentUser = await userService.currentUser();
|
const currentUser = await userService.currentUser();
|
||||||
|
|
||||||
await onSubmit(currentUser.is_password_autoset);
|
await onSubmit(currentUser.is_password_autoset);
|
||||||
})
|
})
|
||||||
.catch((err) =>
|
.catch((err) => {
|
||||||
|
captureEvent(CODE_VERIFIED, {
|
||||||
|
state: "FAILED",
|
||||||
|
});
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "error",
|
type: "error",
|
||||||
title: "Error!",
|
title: "Error!",
|
||||||
message: err?.error ?? "Something went wrong. Please try again.",
|
message: err?.error ?? "Something went wrong. Please try again.",
|
||||||
})
|
});
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
|
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
|
||||||
@ -96,7 +106,6 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
|
|||||||
title: "Success!",
|
title: "Success!",
|
||||||
message: "A new unique code has been sent to your email.",
|
message: "A new unique code has been sent to your email.",
|
||||||
});
|
});
|
||||||
|
|
||||||
reset({
|
reset({
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
token: "",
|
token: "",
|
||||||
|
@ -10,6 +10,8 @@ import { CustomAnalyticsSelectBar, CustomAnalyticsMainContent, CustomAnalyticsSi
|
|||||||
import { IAnalyticsParams } from "@plane/types";
|
import { IAnalyticsParams } from "@plane/types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { ANALYTICS } from "constants/fetch-keys";
|
import { ANALYTICS } from "constants/fetch-keys";
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
import { useApplication } from "hooks/store";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
additionalParams?: Partial<IAnalyticsParams>;
|
additionalParams?: Partial<IAnalyticsParams>;
|
||||||
@ -46,11 +48,13 @@ export const CustomAnalytics: React.FC<Props> = observer((props) => {
|
|||||||
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
|
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { theme: themeStore } = useApplication();
|
||||||
|
|
||||||
const isProjectLevel = projectId ? true : false;
|
const isProjectLevel = projectId ? true : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col-reverse overflow-hidden ${fullScreen ? "md:grid md:h-full md:grid-cols-4" : ""}`}>
|
<div className={cn("relative w-full h-full flex overflow-hidden", isProjectLevel ? "flex-col-reverse" : "")}>
|
||||||
<div className="col-span-3 flex h-full flex-col overflow-hidden">
|
<div className="w-full flex h-full flex-col overflow-hidden">
|
||||||
<CustomAnalyticsSelectBar
|
<CustomAnalyticsSelectBar
|
||||||
control={control}
|
control={control}
|
||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
@ -61,16 +65,22 @@ export const CustomAnalytics: React.FC<Props> = observer((props) => {
|
|||||||
<CustomAnalyticsMainContent
|
<CustomAnalyticsMainContent
|
||||||
analytics={analytics}
|
analytics={analytics}
|
||||||
error={analyticsError}
|
error={analyticsError}
|
||||||
fullScreen={fullScreen}
|
|
||||||
params={params}
|
params={params}
|
||||||
|
fullScreen={fullScreen}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CustomAnalyticsSidebar
|
|
||||||
analytics={analytics}
|
<div
|
||||||
params={params}
|
className={cn(
|
||||||
fullScreen={fullScreen}
|
"border-l border-custom-border-200 transition-all",
|
||||||
isProjectLevel={isProjectLevel}
|
!isProjectLevel
|
||||||
/>
|
? "absolute right-0 top-0 bottom-0 md:relative flex-shrink-0 h-full max-w-[250px] sm:max-w-full"
|
||||||
|
: ""
|
||||||
|
)}
|
||||||
|
style={themeStore.workspaceAnalyticsSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}}
|
||||||
|
>
|
||||||
|
<CustomAnalyticsSidebar analytics={analytics} params={params} isProjectLevel={isProjectLevel} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -22,8 +22,7 @@ export const CustomAnalyticsSelectBar: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`grid items-center gap-4 px-5 py-2.5 ${isProjectLevel ? "grid-cols-3" : "grid-cols-2"} ${
|
className={`grid items-center gap-4 px-5 py-2.5 ${isProjectLevel ? "grid-cols-1 sm:grid-cols-3" : "grid-cols-2"} ${fullScreen ? "md:py-5 lg:grid-cols-4" : ""
|
||||||
fullScreen ? "md:py-5 lg:grid-cols-4" : ""
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{!isProjectLevel && (
|
{!isProjectLevel && (
|
||||||
|
@ -17,9 +17,9 @@ export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = observer((pro
|
|||||||
const { getProjectById } = useProject();
|
const { getProjectById } = useProject();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hidden h-full overflow-hidden md:flex md:flex-col">
|
<div className="relative flex flex-col gap-4 h-full">
|
||||||
<h4 className="font-medium">Selected Projects</h4>
|
<h4 className="font-medium">Selected Projects</h4>
|
||||||
<div className="mt-4 h-full space-y-6 overflow-y-auto">
|
<div className="relative space-y-6 overflow-hidden overflow-y-auto">
|
||||||
{projectIds.map((projectId) => {
|
{projectIds.map((projectId) => {
|
||||||
const project = getProjectById(projectId);
|
const project = getProjectById(projectId);
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
|
|||||||
<>
|
<>
|
||||||
{projectId ? (
|
{projectId ? (
|
||||||
cycleDetails ? (
|
cycleDetails ? (
|
||||||
<div className="hidden h-full overflow-y-auto md:block">
|
<div className="h-full overflow-y-auto">
|
||||||
<h4 className="break-words font-medium">Analytics for {cycleDetails.name}</h4>
|
<h4 className="break-words font-medium">Analytics for {cycleDetails.name}</h4>
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
@ -52,7 +52,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : moduleDetails ? (
|
) : moduleDetails ? (
|
||||||
<div className="hidden h-full overflow-y-auto md:block">
|
<div className="h-full overflow-y-auto">
|
||||||
<h4 className="break-words font-medium">Analytics for {moduleDetails.name}</h4>
|
<h4 className="break-words font-medium">Analytics for {moduleDetails.name}</h4>
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
@ -78,7 +78,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="hidden h-full overflow-y-auto md:flex md:flex-col">
|
<div className="h-full overflow-y-auto">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{projectDetails?.emoji ? (
|
{projectDetails?.emoji ? (
|
||||||
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">{renderEmoji(projectDetails.emoji)}</div>
|
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">{renderEmoji(projectDetails.emoji)}</div>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { mutate } from "swr";
|
import { mutate } from "swr";
|
||||||
@ -19,18 +19,18 @@ import { renderFormattedDate } from "helpers/date-time.helper";
|
|||||||
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types";
|
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { ANALYTICS } from "constants/fetch-keys";
|
import { ANALYTICS } from "constants/fetch-keys";
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
analytics: IAnalyticsResponse | undefined;
|
analytics: IAnalyticsResponse | undefined;
|
||||||
params: IAnalyticsParams;
|
params: IAnalyticsParams;
|
||||||
fullScreen: boolean;
|
|
||||||
isProjectLevel: boolean;
|
isProjectLevel: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const analyticsService = new AnalyticsService();
|
const analyticsService = new AnalyticsService();
|
||||||
|
|
||||||
export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||||
const { analytics, params, fullScreen, isProjectLevel = false } = props;
|
const { analytics, params, isProjectLevel = false } = props;
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||||
@ -138,18 +138,14 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds;
|
const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds;
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn("relative h-full flex w-full gap-2 justify-between items-start px-5 py-4 bg-custom-sidebar-background-100", !isProjectLevel ? "flex-col" : "")}
|
||||||
className={`flex items-center justify-between space-y-2 px-5 py-2.5 ${
|
|
||||||
fullScreen
|
|
||||||
? "overflow-hidden border-l border-custom-border-200 md:h-full md:flex-col md:items-start md:space-y-4 md:border-l md:border-custom-border-200 md:py-5"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
|
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
|
||||||
<LayersIcon height={14} width={14} />
|
<LayersIcon height={14} width={14} />
|
||||||
{analytics ? analytics.total : "..."} Issues
|
{analytics ? analytics.total : "..."} <div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div>
|
||||||
</div>
|
</div>
|
||||||
{isProjectLevel && (
|
{isProjectLevel && (
|
||||||
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
|
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
|
||||||
@ -164,30 +160,30 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full w-full overflow-hidden">
|
|
||||||
{fullScreen ? (
|
<div className={cn("h-full w-full overflow-hidden", isProjectLevel ? "hidden" : "block")}>
|
||||||
<>
|
<>
|
||||||
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
|
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
|
||||||
<CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} />
|
<CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} />
|
||||||
)}
|
)}
|
||||||
<CustomAnalyticsSidebarHeader />
|
<CustomAnalyticsSidebarHeader />
|
||||||
</>
|
</>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 justify-self-end">
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="neutral-primary"
|
variant="neutral-primary"
|
||||||
prependIcon={<RefreshCw className="h-3.5 w-3.5" />}
|
prependIcon={<RefreshCw className="h-3 md:h-3.5 w-3 md:w-3.5" />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!workspaceSlug) return;
|
if (!workspaceSlug) return;
|
||||||
|
|
||||||
mutate(ANALYTICS(workspaceSlug.toString(), params));
|
mutate(ANALYTICS(workspaceSlug.toString(), params));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Refresh
|
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Refresh</div>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" prependIcon={<Download className="h-3.5 w-3.5" />} onClick={exportAnalytics}>
|
<Button variant="primary" prependIcon={<Download className="h-3.5 w-3.5" />} onClick={exportAnalytics}>
|
||||||
Export as CSV
|
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Export as CSV</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -20,16 +20,15 @@ export const ProjectAnalyticsModalMainContent: React.FC<Props> = observer((props
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Group as={React.Fragment}>
|
<Tab.Group as={React.Fragment}>
|
||||||
<Tab.List as="div" className="space-x-2 border-b border-custom-border-200 p-5 pt-0">
|
<Tab.List as="div" className="flex space-x-2 border-b border-custom-border-200 px-0 md:px-5 py-0 md:py-3">
|
||||||
{ANALYTICS_TABS.map((tab) => (
|
{ANALYTICS_TABS.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
className={({ selected }) =>
|
className={({ selected }) =>
|
||||||
`rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${
|
`rounded-0 w-full md:w-max md:rounded-3xl border-b md:border border-custom-border-200 focus:outline-none px-0 md:px-4 py-2 text-xs hover:bg-custom-background-80 ${selected ? "border-custom-primary-100 text-custom-primary-100 md:bg-custom-background-80 md:text-custom-text-200 md:border-custom-border-200" : "border-transparent"
|
||||||
selected ? "bg-custom-background-80" : ""
|
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
onClick={() => {}}
|
onClick={() => { }}
|
||||||
>
|
>
|
||||||
{tab.title}
|
{tab.title}
|
||||||
</Tab>
|
</Tab>
|
||||||
|
@ -38,13 +38,11 @@ export const ProjectAnalyticsModal: React.FC<Props> = observer((props) => {
|
|||||||
>
|
>
|
||||||
<Dialog.Panel className="fixed inset-0 z-20 h-full w-full overflow-y-auto">
|
<Dialog.Panel className="fixed inset-0 z-20 h-full w-full overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
className={`fixed right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${
|
className={`fixed right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${fullScreen ? "w-full p-2" : "w-full sm:w-full md:w-1/2"
|
||||||
fullScreen ? "w-full p-2" : "w-1/2"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${
|
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${fullScreen ? "rounded-lg border" : "border-l"
|
||||||
fullScreen ? "rounded-lg border" : "border-l"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ProjectAnalyticsModalHeader
|
<ProjectAnalyticsModalHeader
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Command } from "cmdk";
|
import { Command } from "cmdk";
|
||||||
import { ContrastIcon, FileText } from "lucide-react";
|
import { ContrastIcon, FileText } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication } from "hooks/store";
|
import { useApplication, useEventTracker } from "hooks/store";
|
||||||
// ui
|
// ui
|
||||||
import { DiceIcon, PhotoFilterIcon } from "@plane/ui";
|
import { DiceIcon, PhotoFilterIcon } from "@plane/ui";
|
||||||
|
|
||||||
@ -14,8 +14,8 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
commandPalette: { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal },
|
commandPalette: { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal },
|
||||||
eventTracker: { setTrackElement },
|
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
|
const { setTrackElement } = useEventTracker();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -23,7 +23,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
|
|||||||
<Command.Item
|
<Command.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
closePalette();
|
closePalette();
|
||||||
setTrackElement("COMMAND_PALETTE");
|
setTrackElement("Command palette");
|
||||||
toggleCreateCycleModal(true);
|
toggleCreateCycleModal(true);
|
||||||
}}
|
}}
|
||||||
className="focus:outline-none"
|
className="focus:outline-none"
|
||||||
@ -39,6 +39,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
|
|||||||
<Command.Item
|
<Command.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
closePalette();
|
closePalette();
|
||||||
|
setTrackElement("Command palette");
|
||||||
toggleCreateModuleModal(true);
|
toggleCreateModuleModal(true);
|
||||||
}}
|
}}
|
||||||
className="focus:outline-none"
|
className="focus:outline-none"
|
||||||
@ -54,6 +55,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
|
|||||||
<Command.Item
|
<Command.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
closePalette();
|
closePalette();
|
||||||
|
setTrackElement("Command palette");
|
||||||
toggleCreateViewModal(true);
|
toggleCreateViewModal(true);
|
||||||
}}
|
}}
|
||||||
className="focus:outline-none"
|
className="focus:outline-none"
|
||||||
@ -69,6 +71,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
|
|||||||
<Command.Item
|
<Command.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
closePalette();
|
closePalette();
|
||||||
|
setTrackElement("Command palette");
|
||||||
toggleCreatePageModal(true);
|
toggleCreatePageModal(true);
|
||||||
}}
|
}}
|
||||||
className="focus:outline-none"
|
className="focus:outline-none"
|
||||||
|
@ -6,7 +6,7 @@ import { Dialog, Transition } from "@headlessui/react";
|
|||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { FolderPlus, Search, Settings } from "lucide-react";
|
import { FolderPlus, Search, Settings } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useProject } from "hooks/store";
|
import { useApplication, useEventTracker, useProject } from "hooks/store";
|
||||||
// services
|
// services
|
||||||
import { WorkspaceService } from "services/workspace.service";
|
import { WorkspaceService } from "services/workspace.service";
|
||||||
import { IssueService } from "services/issue";
|
import { IssueService } from "services/issue";
|
||||||
@ -64,8 +64,8 @@ export const CommandModal: React.FC = observer(() => {
|
|||||||
toggleCreateIssueModal,
|
toggleCreateIssueModal,
|
||||||
toggleCreateProjectModal,
|
toggleCreateProjectModal,
|
||||||
},
|
},
|
||||||
eventTracker: { setTrackElement },
|
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
|
const { setTrackElement } = useEventTracker();
|
||||||
|
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -278,7 +278,7 @@ export const CommandModal: React.FC = observer(() => {
|
|||||||
<Command.Item
|
<Command.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
closePalette();
|
closePalette();
|
||||||
setTrackElement("COMMAND_PALETTE");
|
setTrackElement("Command Palette");
|
||||||
toggleCreateIssueModal(true);
|
toggleCreateIssueModal(true);
|
||||||
}}
|
}}
|
||||||
className="focus:bg-custom-background-80"
|
className="focus:bg-custom-background-80"
|
||||||
@ -296,7 +296,7 @@ export const CommandModal: React.FC = observer(() => {
|
|||||||
<Command.Item
|
<Command.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
closePalette();
|
closePalette();
|
||||||
setTrackElement("COMMAND_PALETTE");
|
setTrackElement("Command palette");
|
||||||
toggleCreateProjectModal(true);
|
toggleCreateProjectModal(true);
|
||||||
}}
|
}}
|
||||||
className="focus:outline-none"
|
className="focus:outline-none"
|
||||||
|
@ -3,7 +3,7 @@ import { useRouter } from "next/router";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useIssues, useUser } from "hooks/store";
|
import { useApplication, useEventTracker, useIssues, useUser } from "hooks/store";
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// components
|
// components
|
||||||
import { CommandModal, ShortcutsModal } from "components/command-palette";
|
import { CommandModal, ShortcutsModal } from "components/command-palette";
|
||||||
@ -32,8 +32,8 @@ export const CommandPalette: FC = observer(() => {
|
|||||||
const {
|
const {
|
||||||
commandPalette,
|
commandPalette,
|
||||||
theme: { toggleSidebar },
|
theme: { toggleSidebar },
|
||||||
eventTracker: { setTrackElement },
|
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
|
const { setTrackElement } = useEventTracker();
|
||||||
const { currentUser } = useUser();
|
const { currentUser } = useUser();
|
||||||
const {
|
const {
|
||||||
issues: { removeIssue },
|
issues: { removeIssue },
|
||||||
@ -118,11 +118,10 @@ export const CommandPalette: FC = observer(() => {
|
|||||||
toggleSidebar();
|
toggleSidebar();
|
||||||
}
|
}
|
||||||
} else if (!isAnyModalOpen) {
|
} else if (!isAnyModalOpen) {
|
||||||
|
setTrackElement("Shortcut key");
|
||||||
if (keyPressed === "c") {
|
if (keyPressed === "c") {
|
||||||
setTrackElement("SHORTCUT_KEY");
|
|
||||||
toggleCreateIssueModal(true);
|
toggleCreateIssueModal(true);
|
||||||
} else if (keyPressed === "p") {
|
} else if (keyPressed === "p") {
|
||||||
setTrackElement("SHORTCUT_KEY");
|
|
||||||
toggleCreateProjectModal(true);
|
toggleCreateProjectModal(true);
|
||||||
} else if (keyPressed === "h") {
|
} else if (keyPressed === "h") {
|
||||||
toggleShortcutModal(true);
|
toggleShortcutModal(true);
|
||||||
@ -164,6 +163,8 @@ export const CommandPalette: FC = observer(() => {
|
|||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [handleKeyDown]);
|
}, [handleKeyDown]);
|
||||||
|
|
||||||
|
const isDraftIssue = router?.asPath?.includes("draft-issues") || false;
|
||||||
|
|
||||||
if (!currentUser) return null;
|
if (!currentUser) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -218,6 +219,7 @@ export const CommandPalette: FC = observer(() => {
|
|||||||
onClose={() => toggleCreateIssueModal(false)}
|
onClose={() => toggleCreateIssueModal(false)}
|
||||||
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined}
|
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined}
|
||||||
storeType={createIssueStoreType}
|
storeType={createIssueStoreType}
|
||||||
|
isDraft={isDraftIssue}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{workspaceSlug && projectId && issueId && issueDetails && (
|
{workspaceSlug && projectId && issueId && issueDetails && (
|
||||||
|
@ -47,7 +47,7 @@ export const ShortcutsModal: FC<Props> = (props) => {
|
|||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<Dialog.Panel className="h-full w-full">
|
<Dialog.Panel className="h-full w-full">
|
||||||
<div className="grid h-full w-full place-items-center p-5">
|
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
|
||||||
<div className="flex h-[61vh] w-full flex-col space-y-4 overflow-hidden rounded-lg bg-custom-background-100 p-5 shadow-custom-shadow-md transition-all sm:w-[28rem]">
|
<div className="flex h-[61vh] w-full flex-col space-y-4 overflow-hidden rounded-lg bg-custom-background-100 p-5 shadow-custom-shadow-md transition-all sm:w-[28rem]">
|
||||||
<Dialog.Title as="h3" className="flex justify-between">
|
<Dialog.Title as="h3" className="flex justify-between">
|
||||||
<span className="text-lg font-medium">Keyboard shortcuts</span>
|
<span className="text-lg font-medium">Keyboard shortcuts</span>
|
||||||
|
36
web/components/common/breadcrumb-link.tsx
Normal file
36
web/components/common/breadcrumb-link.tsx
Normal file
@ -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 "./product-updates-modal";
|
||||||
export * from "./empty-state";
|
export * from "./empty-state";
|
||||||
export * from "./latest-feature-block";
|
export * from "./latest-feature-block";
|
||||||
|
export * from "./breadcrumb-link";
|
||||||
|
82
web/components/core/render-if-visible-HOC.tsx
Normal file
82
web/components/core/render-if-visible-HOC.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
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) => {
|
||||||
|
//DO no remove comments for future
|
||||||
|
// if (typeof window !== undefined && window.requestIdleCallback) {
|
||||||
|
// window.requestIdleCallback(() => setShouldVisible(entries[0].isIntersecting), {
|
||||||
|
// timeout: 300,
|
||||||
|
// });
|
||||||
|
// } else {
|
||||||
|
// setShouldVisible(entries[0].isIntersecting);
|
||||||
|
// }
|
||||||
|
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",
|
id: "pending",
|
||||||
color: "#3F76FF",
|
color: "#3F76FF",
|
||||||
data: chartData.map((item, index) => ({
|
data:
|
||||||
|
chartData.length > 0
|
||||||
|
? chartData.map((item, index) => ({
|
||||||
index,
|
index,
|
||||||
x: item.currentDate,
|
x: item.currentDate,
|
||||||
y: item.pending,
|
y: item.pending,
|
||||||
color: "#3F76FF",
|
color: "#3F76FF",
|
||||||
})),
|
}))
|
||||||
|
: [],
|
||||||
enableArea: true,
|
enableArea: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -121,7 +124,9 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
|
|||||||
enableArea
|
enableArea
|
||||||
colors={(datum) => datum.color ?? "#3F76FF"}
|
colors={(datum) => datum.color ?? "#3F76FF"}
|
||||||
customYAxisTickValues={[0, totalIssues]}
|
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"
|
enableSlices="x"
|
||||||
sliceTooltip={(datum) => (
|
sliceTooltip={(datum) => (
|
||||||
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||||
|
@ -3,11 +3,11 @@ import { Menu } from "lucide-react";
|
|||||||
import { useApplication } from "hooks/store";
|
import { useApplication } from "hooks/store";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
|
||||||
export const SidebarHamburgerToggle: FC = observer (() => {
|
export const SidebarHamburgerToggle: FC = observer(() => {
|
||||||
const { theme: themStore } = useApplication();
|
const { theme: themStore } = useApplication();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
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"
|
className="w-7 h-7 flex-shrink-0 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()}
|
onClick={() => themStore.toggleSidebar()}
|
||||||
>
|
>
|
||||||
<Menu size={14} className="text-custom-text-200 group-hover:text-custom-text-100 transition-all" />
|
<Menu size={14} className="text-custom-text-200 group-hover:text-custom-text-100 transition-all" />
|
||||||
|
@ -125,7 +125,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
</Tab>
|
</Tab>
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
|
<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.length > 0 ? (
|
||||||
distribution.assignees.map((assignee, index) => {
|
distribution.assignees.map((assignee, index) => {
|
||||||
if (assignee.assignee_id)
|
if (assignee.assignee_id)
|
||||||
|
@ -150,7 +150,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
|||||||
color: group.color,
|
color: group.color,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const daysLeft = findHowManyDaysLeft(activeCycle.end_date ?? new Date());
|
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow">
|
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow">
|
||||||
|
168
web/components/cycles/cycle-mobile-header.tsx
Normal file
168
web/components/cycles/cycle-mobile-header.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import router from "next/router";
|
||||||
|
//components
|
||||||
|
import { CustomMenu } from "@plane/ui";
|
||||||
|
// icons
|
||||||
|
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
|
||||||
|
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||||
|
// hooks
|
||||||
|
import { useIssues, useCycle, useProjectState, useLabel, useMember } from "hooks/store";
|
||||||
|
// constants
|
||||||
|
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "constants/issue";
|
||||||
|
import { ProjectAnalyticsModal } from "components/analytics";
|
||||||
|
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "components/issues";
|
||||||
|
|
||||||
|
export const CycleMobileHeader = () => {
|
||||||
|
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||||
|
const { getCycleById } = useCycle();
|
||||||
|
const layouts = [
|
||||||
|
{ key: "list", title: "List", icon: List },
|
||||||
|
{ key: "kanban", title: "Kanban", icon: Kanban },
|
||||||
|
{ key: "calendar", title: "Calendar", icon: Calendar },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { workspaceSlug, projectId, cycleId } = router.query as {
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
cycleId: string;
|
||||||
|
};
|
||||||
|
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||||
|
// store hooks
|
||||||
|
const {
|
||||||
|
issuesFilter: { issueFilters, updateFilters },
|
||||||
|
} = useIssues(EIssuesStoreType.CYCLE);
|
||||||
|
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||||
|
|
||||||
|
const handleLayoutChange = useCallback(
|
||||||
|
(layout: TIssueLayouts) => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId);
|
||||||
|
},
|
||||||
|
[workspaceSlug, projectId, cycleId, updateFilters]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { projectStates } = useProjectState();
|
||||||
|
const { projectLabels } = useLabel();
|
||||||
|
const {
|
||||||
|
project: { projectMemberIds },
|
||||||
|
} = useMember();
|
||||||
|
|
||||||
|
const handleFiltersUpdate = useCallback(
|
||||||
|
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((val) => {
|
||||||
|
if (!newValues.includes(val)) newValues.push(val);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||||
|
else newValues.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, cycleId);
|
||||||
|
},
|
||||||
|
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDisplayFilters = useCallback(
|
||||||
|
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, cycleId);
|
||||||
|
},
|
||||||
|
[workspaceSlug, projectId, cycleId, updateFilters]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDisplayProperties = useCallback(
|
||||||
|
(property: Partial<IIssueDisplayProperties>) => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, cycleId);
|
||||||
|
},
|
||||||
|
[workspaceSlug, projectId, cycleId, updateFilters]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProjectAnalyticsModal
|
||||||
|
isOpen={analyticsModal}
|
||||||
|
onClose={() => setAnalyticsModal(false)}
|
||||||
|
cycleDetails={cycleDetails ?? undefined}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-evenly py-2 border-b border-custom-border-200">
|
||||||
|
<CustomMenu
|
||||||
|
maxHeight={"md"}
|
||||||
|
className="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||||
|
placement="bottom-start"
|
||||||
|
customButton={<span className="flex flex-grow justify-center text-custom-text-200 text-sm">Layout</span>}
|
||||||
|
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||||
|
closeOnSelect
|
||||||
|
>
|
||||||
|
{layouts.map((layout, index) => (
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleLayoutChange(ISSUE_LAYOUTS[index].key);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<layout.icon className="w-3 h-3" />
|
||||||
|
<div className="text-custom-text-300">{layout.title}</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
))}
|
||||||
|
</CustomMenu>
|
||||||
|
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
|
||||||
|
<FiltersDropdown
|
||||||
|
title="Filters"
|
||||||
|
placement="bottom-end"
|
||||||
|
menuButton={
|
||||||
|
<span className="flex items-center text-custom-text-200 text-sm">
|
||||||
|
Filters
|
||||||
|
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FilterSelection
|
||||||
|
filters={issueFilters?.filters ?? {}}
|
||||||
|
handleFiltersUpdate={handleFiltersUpdate}
|
||||||
|
layoutDisplayFiltersOptions={
|
||||||
|
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||||
|
}
|
||||||
|
labels={projectLabels}
|
||||||
|
memberIds={projectMemberIds ?? undefined}
|
||||||
|
states={projectStates}
|
||||||
|
/>
|
||||||
|
</FiltersDropdown>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
|
||||||
|
<FiltersDropdown
|
||||||
|
title="Display"
|
||||||
|
placement="bottom-end"
|
||||||
|
menuButton={
|
||||||
|
<span className="flex items-center text-custom-text-200 text-sm">
|
||||||
|
Display
|
||||||
|
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DisplayFiltersSelection
|
||||||
|
layoutDisplayFiltersOptions={
|
||||||
|
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||||
|
}
|
||||||
|
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||||
|
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||||
|
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||||
|
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||||
|
/>
|
||||||
|
</FiltersDropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
onClick={() => setAnalyticsModal(true)}
|
||||||
|
className="flex flex-grow justify-center text-custom-text-200 text-sm border-l border-custom-border-200"
|
||||||
|
>
|
||||||
|
Analytics
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -38,7 +38,7 @@ export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspa
|
|||||||
{peekCycle && (
|
{peekCycle && (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300"
|
className="flex h-full w-full max-w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300 fixed md:relative right-0 z-[9]"
|
||||||
style={{
|
style={{
|
||||||
boxShadow:
|
boxShadow:
|
||||||
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
|
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
|
||||||
|
@ -2,7 +2,7 @@ import { FC, MouseEvent, useState } from "react";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useCycle, useUser } from "hooks/store";
|
import { useEventTracker, useCycle, useUser } from "hooks/store";
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// components
|
// components
|
||||||
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
|
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
|
||||||
@ -16,6 +16,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
|
|||||||
// constants
|
// constants
|
||||||
import { CYCLE_STATUS } from "constants/cycle";
|
import { CYCLE_STATUS } from "constants/cycle";
|
||||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||||
|
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
|
||||||
//.types
|
//.types
|
||||||
import { TCycleGroups } from "@plane/types";
|
import { TCycleGroups } from "@plane/types";
|
||||||
|
|
||||||
@ -33,9 +34,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
|||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// store
|
// store
|
||||||
const {
|
const { setTrackElement, captureEvent } = useEventTracker();
|
||||||
eventTracker: { setTrackElement },
|
|
||||||
} = useApplication();
|
|
||||||
const {
|
const {
|
||||||
membership: { currentProjectRole },
|
membership: { currentProjectRole },
|
||||||
} = useUser();
|
} = useUser();
|
||||||
@ -92,7 +91,15 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
|
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
|
||||||
|
.then(() => {
|
||||||
|
captureEvent(CYCLE_FAVORITED, {
|
||||||
|
cycle_id: cycleId,
|
||||||
|
element: "Grid layout",
|
||||||
|
state: "SUCCESS",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "error",
|
type: "error",
|
||||||
title: "Error!",
|
title: "Error!",
|
||||||
@ -105,7 +112,15 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
|
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
|
||||||
|
.then(() => {
|
||||||
|
captureEvent(CYCLE_UNFAVORITED, {
|
||||||
|
cycle_id: cycleId,
|
||||||
|
element: "Grid layout",
|
||||||
|
state: "SUCCESS",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "error",
|
type: "error",
|
||||||
title: "Error!",
|
title: "Error!",
|
||||||
@ -117,14 +132,15 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
|||||||
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
setTrackElement("Cycles page grid layout");
|
||||||
setUpdateModal(true);
|
setUpdateModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
setTrackElement("Cycles page grid layout");
|
||||||
setDeleteModal(true);
|
setDeleteModal(true);
|
||||||
setTrackElement("CYCLE_PAGE_BOARD_LAYOUT");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -159,7 +175,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
||||||
<div className="flex h-44 w-full min-w-[250px] flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
|
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-3 truncate">
|
<div className="flex items-center gap-3 truncate">
|
||||||
<span className="flex-shrink-0">
|
<span className="flex-shrink-0">
|
||||||
@ -237,7 +253,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-custom-text-400">No due date</span>
|
<span className="text-xs text-custom-text-400">No due date</span>
|
||||||
)}
|
)}
|
||||||
<div className="z-10 flex items-center gap-1.5">
|
<div className="z-[5] flex items-center gap-1.5">
|
||||||
{isEditingAllowed &&
|
{isEditingAllowed &&
|
||||||
(cycleDetails.is_favorite ? (
|
(cycleDetails.is_favorite ? (
|
||||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||||
|
@ -2,7 +2,7 @@ import { FC, MouseEvent, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useCycle, useUser } from "hooks/store";
|
import { useEventTracker, useCycle, useUser } from "hooks/store";
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// components
|
// components
|
||||||
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
|
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
|
||||||
@ -18,6 +18,7 @@ import { CYCLE_STATUS } from "constants/cycle";
|
|||||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||||
// types
|
// types
|
||||||
import { TCycleGroups } from "@plane/types";
|
import { TCycleGroups } from "@plane/types";
|
||||||
|
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
|
||||||
|
|
||||||
type TCyclesListItem = {
|
type TCyclesListItem = {
|
||||||
cycleId: string;
|
cycleId: string;
|
||||||
@ -37,9 +38,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
|||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// store hooks
|
// store hooks
|
||||||
const {
|
const { setTrackElement, captureEvent } = useEventTracker();
|
||||||
eventTracker: { setTrackElement },
|
|
||||||
} = useApplication();
|
|
||||||
const {
|
const {
|
||||||
membership: { currentProjectRole },
|
membership: { currentProjectRole },
|
||||||
} = useUser();
|
} = useUser();
|
||||||
@ -65,7 +64,15 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
|
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
|
||||||
|
.then(() => {
|
||||||
|
captureEvent(CYCLE_FAVORITED, {
|
||||||
|
cycle_id: cycleId,
|
||||||
|
element: "List layout",
|
||||||
|
state: "SUCCESS",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "error",
|
type: "error",
|
||||||
title: "Error!",
|
title: "Error!",
|
||||||
@ -78,7 +85,15 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
|
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
|
||||||
|
.then(() => {
|
||||||
|
captureEvent(CYCLE_UNFAVORITED, {
|
||||||
|
cycle_id: cycleId,
|
||||||
|
element: "List layout",
|
||||||
|
state: "SUCCESS",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "error",
|
type: "error",
|
||||||
title: "Error!",
|
title: "Error!",
|
||||||
@ -90,14 +105,15 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
|||||||
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
setTrackElement("Cycles page list layout");
|
||||||
setUpdateModal(true);
|
setUpdateModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
setTrackElement("Cycles page list layout");
|
||||||
setDeleteModal(true);
|
setDeleteModal(true);
|
||||||
setTrackElement("CYCLE_PAGE_LIST_LAYOUT");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
|
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 currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
|
||||||
|
|
||||||
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date ?? new Date());
|
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -160,10 +176,10 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
/>
|
/>
|
||||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
||||||
<div className="group flex h-16 w-full items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90">
|
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 md:flex-row">
|
||||||
<div className="flex w-full items-center gap-3 truncate">
|
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
||||||
<div className="flex items-center gap-4 truncate">
|
<div className="relative flex w-full items-center gap-3 overflow-hidden">
|
||||||
<span className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<CircularProgressIndicator size={38} percentage={progress}>
|
<CircularProgressIndicator size={38} percentage={progress}>
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
progress === 100 ? (
|
progress === 100 ? (
|
||||||
@ -177,27 +193,25 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
|||||||
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
|
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
|
||||||
)}
|
)}
|
||||||
</CircularProgressIndicator>
|
</CircularProgressIndicator>
|
||||||
</span>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="relative flex items-center gap-2.5 overflow-hidden">
|
||||||
<span className="flex-shrink-0">
|
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />
|
|
||||||
</span>
|
|
||||||
<Tooltip tooltipContent={cycleDetails.name} position="top">
|
<Tooltip tooltipContent={cycleDetails.name} position="top">
|
||||||
<span className="truncate text-base font-medium">{cycleDetails.name}</span>
|
<span className="line-clamp-1 inline-block overflow-hidden truncate text-base font-medium">
|
||||||
|
{cycleDetails.name}
|
||||||
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<button onClick={openCycleOverview} className="z-10 hidden flex-shrink-0 group-hover:flex">
|
<button onClick={openCycleOverview} className="flex-shrink-0 z-[5] invisible group-hover:visible">
|
||||||
<Info className="h-4 w-4 text-custom-text-400" />
|
<Info className="h-4 w-4 text-custom-text-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{currentCycle && (
|
||||||
<span
|
<div
|
||||||
className="flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs"
|
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
|
||||||
style={{
|
style={{
|
||||||
color: currentCycle.color,
|
color: currentCycle.color,
|
||||||
backgroundColor: `${currentCycle.color}20`,
|
backgroundColor: `${currentCycle.color}20`,
|
||||||
@ -206,18 +220,17 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
|||||||
{currentCycle.value === "current"
|
{currentCycle.value === "current"
|
||||||
? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
|
? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
|
||||||
: `${currentCycle.label}`}
|
: `${currentCycle.label}`}
|
||||||
</span>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
{renderDate && (
|
<div className="relative flex flex-shrink-0 items-center gap-3">
|
||||||
<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`}>
|
<Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}>
|
||||||
<div className="flex w-16 cursor-default items-center justify-center gap-1">
|
<div className="flex w-10 cursor-default items-center justify-center">
|
||||||
{cycleDetails.assignees.length > 0 ? (
|
{cycleDetails.assignees.length > 0 ? (
|
||||||
<AvatarGroup showTooltip={false}>
|
<AvatarGroup showTooltip={false}>
|
||||||
{cycleDetails.assignees.map((assignee) => (
|
{cycleDetails.assignees.map((assignee) => (
|
||||||
@ -231,8 +244,10 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{isEditingAllowed &&
|
|
||||||
(cycleDetails.is_favorite ? (
|
{isEditingAllowed && (
|
||||||
|
<>
|
||||||
|
{cycleDetails.is_favorite ? (
|
||||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||||
</button>
|
</button>
|
||||||
@ -240,7 +255,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
|||||||
<button type="button" onClick={handleAddToFavorites}>
|
<button type="button" onClick={handleAddToFavorites}>
|
||||||
<Star className="h-3.5 w-3.5 text-custom-text-200" />
|
<Star className="h-3.5 w-3.5 text-custom-text-200" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
<CustomMenu ellipsis>
|
<CustomMenu ellipsis>
|
||||||
{!isCompleted && isEditingAllowed && (
|
{!isCompleted && isEditingAllowed && (
|
||||||
@ -266,6 +281,9 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -4,12 +4,14 @@ import { Dialog, Transition } from "@headlessui/react";
|
|||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useCycle } from "hooks/store";
|
import { useEventTracker, useCycle } from "hooks/store";
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// components
|
// components
|
||||||
import { Button } from "@plane/ui";
|
import { Button } from "@plane/ui";
|
||||||
// types
|
// types
|
||||||
import { ICycle } from "@plane/types";
|
import { ICycle } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { CYCLE_DELETED } from "constants/event-tracker";
|
||||||
|
|
||||||
interface ICycleDelete {
|
interface ICycleDelete {
|
||||||
cycle: ICycle;
|
cycle: ICycle;
|
||||||
@ -27,9 +29,7 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { cycleId, peekCycle } = router.query;
|
const { cycleId, peekCycle } = router.query;
|
||||||
// store hooks
|
// store hooks
|
||||||
const {
|
const { captureCycleEvent } = useEventTracker();
|
||||||
eventTracker: { postHogEventTracker },
|
|
||||||
} = useApplication();
|
|
||||||
const { deleteCycle } = useCycle();
|
const { deleteCycle } = useCycle();
|
||||||
// toast alert
|
// toast alert
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
@ -46,13 +46,15 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
|
|||||||
title: "Success!",
|
title: "Success!",
|
||||||
message: "Cycle deleted successfully.",
|
message: "Cycle deleted successfully.",
|
||||||
});
|
});
|
||||||
postHogEventTracker("CYCLE_DELETE", {
|
captureCycleEvent({
|
||||||
state: "SUCCESS",
|
eventName: CYCLE_DELETED,
|
||||||
|
payload: { ...cycle, state: "SUCCESS" },
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
postHogEventTracker("CYCLE_DELETE", {
|
captureCycleEvent({
|
||||||
state: "FAILED",
|
eventName: CYCLE_DELETED,
|
||||||
|
payload: { ...cycle, state: "FAILED" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
|||||||
import { ICycle } from "@plane/types";
|
import { ICycle } from "@plane/types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
|
handleFormSubmit: (values: Partial<ICycle>, dirtyFields: any) => Promise<void>;
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
status: boolean;
|
status: boolean;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -29,7 +29,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
|||||||
const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data } = props;
|
const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data } = props;
|
||||||
// form data
|
// form data
|
||||||
const {
|
const {
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting, dirtyFields },
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
watch,
|
watch,
|
||||||
@ -61,7 +61,7 @@ export const CycleForm: React.FC<Props> = (props) => {
|
|||||||
maxDate?.setDate(maxDate.getDate() - 1);
|
maxDate?.setDate(maxDate.getDate() - 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
<form onSubmit={handleSubmit((formData)=>handleFormSubmit(formData,dirtyFields))}>
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex items-center gap-x-3">
|
<div className="flex items-center gap-x-3">
|
||||||
{!status && (
|
{!status && (
|
||||||
|
@ -1,16 +1,30 @@
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
// hooks
|
||||||
|
import { useApplication, useCycle } from "hooks/store";
|
||||||
// ui
|
// ui
|
||||||
import { Tooltip, ContrastIcon } from "@plane/ui";
|
import { Tooltip, ContrastIcon } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { renderFormattedDate } from "helpers/date-time.helper";
|
import { renderFormattedDate } from "helpers/date-time.helper";
|
||||||
// types
|
|
||||||
import { ICycle } from "@plane/types";
|
|
||||||
|
|
||||||
export const CycleGanttBlock = ({ data }: { data: ICycle }) => {
|
type Props = {
|
||||||
|
cycleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CycleGanttBlock: React.FC<Props> = observer((props) => {
|
||||||
|
const { cycleId } = props;
|
||||||
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
// store hooks
|
||||||
|
const {
|
||||||
|
router: { workspaceSlug },
|
||||||
|
} = useApplication();
|
||||||
|
const { getCycleById } = useCycle();
|
||||||
|
// derived values
|
||||||
|
const cycleDetails = getCycleById(cycleId);
|
||||||
|
|
||||||
|
const cycleStatus = cycleDetails?.status.toLocaleLowerCase();
|
||||||
|
|
||||||
const cycleStatus = data.status.toLocaleLowerCase();
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex h-full w-full items-center rounded"
|
className="relative flex h-full w-full items-center rounded"
|
||||||
@ -26,36 +40,45 @@ export const CycleGanttBlock = ({ data }: { data: ICycle }) => {
|
|||||||
? "rgb(var(--color-text-200))"
|
? "rgb(var(--color-text-200))"
|
||||||
: "",
|
: "",
|
||||||
}}
|
}}
|
||||||
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/cycles/${data?.id}`)}
|
onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)}
|
||||||
>
|
>
|
||||||
<div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" />
|
<div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
tooltipContent={
|
tooltipContent={
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h5>{data?.name}</h5>
|
<h5>{cycleDetails?.name}</h5>
|
||||||
<div>
|
<div>
|
||||||
{renderFormattedDate(data?.start_date ?? "")} to {renderFormattedDate(data?.end_date ?? "")}
|
{renderFormattedDate(cycleDetails?.start_date ?? "")} to{" "}
|
||||||
|
{renderFormattedDate(cycleDetails?.end_date ?? "")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
position="top-left"
|
position="top-left"
|
||||||
>
|
>
|
||||||
<div className="relative w-full truncate px-2.5 py-1 text-sm text-custom-text-100">{data?.name}</div>
|
<div className="relative w-full truncate px-2.5 py-1 text-sm text-custom-text-100">{cycleDetails?.name}</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export const CycleGanttSidebarBlock = ({ data }: { data: ICycle }) => {
|
export const CycleGanttSidebarBlock: React.FC<Props> = observer((props) => {
|
||||||
|
const { cycleId } = props;
|
||||||
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
// store hooks
|
||||||
|
const {
|
||||||
|
router: { workspaceSlug },
|
||||||
|
} = useApplication();
|
||||||
|
const { getCycleById } = useCycle();
|
||||||
|
// derived values
|
||||||
|
const cycleDetails = getCycleById(cycleId);
|
||||||
|
|
||||||
const cycleStatus = data.status.toLocaleLowerCase();
|
const cycleStatus = cycleDetails?.status.toLocaleLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex h-full w-full items-center gap-2"
|
className="relative flex h-full w-full items-center gap-2"
|
||||||
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/cycles/${data?.id}`)}
|
onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)}
|
||||||
>
|
>
|
||||||
<ContrastIcon
|
<ContrastIcon
|
||||||
className="h-5 w-5 flex-shrink-0"
|
className="h-5 w-5 flex-shrink-0"
|
||||||
@ -71,7 +94,7 @@ export const CycleGanttSidebarBlock = ({ data }: { data: ICycle }) => {
|
|||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<h6 className="flex-grow truncate text-sm font-medium">{data?.name}</h6>
|
<h6 className="flex-grow truncate text-sm font-medium">{cycleDetails?.name}</h6>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -1,11 +1,8 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { KeyedMutator } from "swr";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useCycle, useUser } from "hooks/store";
|
import { useCycle, useUser } from "hooks/store";
|
||||||
// services
|
|
||||||
import { CycleService } from "services/cycle.service";
|
|
||||||
// components
|
// components
|
||||||
import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart";
|
import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart";
|
||||||
import { CycleGanttBlock } from "components/cycles";
|
import { CycleGanttBlock } from "components/cycles";
|
||||||
@ -17,14 +14,10 @@ import { EUserProjectRoles } from "constants/project";
|
|||||||
type Props = {
|
type Props = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
cycleIds: string[];
|
cycleIds: string[];
|
||||||
mutateCycles?: KeyedMutator<ICycle[]>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// services
|
|
||||||
const cycleService = new CycleService();
|
|
||||||
|
|
||||||
export const CyclesListGanttChartView: FC<Props> = observer((props) => {
|
export const CyclesListGanttChartView: FC<Props> = observer((props) => {
|
||||||
const { cycleIds, mutateCycles } = props;
|
const { cycleIds } = props;
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
const { workspaceSlug } = router.query;
|
||||||
@ -32,38 +25,15 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
|
|||||||
const {
|
const {
|
||||||
membership: { currentProjectRole },
|
membership: { currentProjectRole },
|
||||||
} = useUser();
|
} = useUser();
|
||||||
const { getCycleById } = useCycle();
|
const { getCycleById, updateCycleDetails } = useCycle();
|
||||||
|
|
||||||
const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => {
|
const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => {
|
||||||
if (!workspaceSlug) return;
|
if (!workspaceSlug || !cycle) return;
|
||||||
mutateCycles &&
|
|
||||||
mutateCycles((prevData: any) => {
|
|
||||||
if (!prevData) return prevData;
|
|
||||||
|
|
||||||
const newList = prevData.map((p: any) => ({
|
const payload: any = { ...data };
|
||||||
...p,
|
if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder;
|
||||||
...(p.id === cycle.id
|
|
||||||
? {
|
|
||||||
start_date: payload.start_date ? payload.start_date : p.start_date,
|
|
||||||
target_date: payload.target_date ? payload.target_date : p.end_date,
|
|
||||||
sort_order: payload.sort_order ? payload.sort_order.newSortOrder : p.sort_order,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (payload.sort_order) {
|
await updateCycleDetails(workspaceSlug.toString(), cycle.project, cycle.id, payload);
|
||||||
const removedElement = newList.splice(payload.sort_order.sourceIndex, 1)[0];
|
|
||||||
newList.splice(payload.sort_order.destinationIndex, 0, removedElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
return newList;
|
|
||||||
}, false);
|
|
||||||
|
|
||||||
const newPayload: any = { ...payload };
|
|
||||||
|
|
||||||
if (newPayload.sort_order && payload.sort_order) newPayload.sort_order = payload.sort_order.newSortOrder;
|
|
||||||
|
|
||||||
cycleService.patchCycle(workspaceSlug.toString(), cycle.project, cycle.id, newPayload);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const blockFormat = (blocks: (ICycle | null)[]) => {
|
const blockFormat = (blocks: (ICycle | null)[]) => {
|
||||||
@ -93,7 +63,7 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
|
|||||||
blocks={cycleIds ? blockFormat(cycleIds.map((c) => getCycleById(c))) : null}
|
blocks={cycleIds ? blockFormat(cycleIds.map((c) => getCycleById(c))) : null}
|
||||||
blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)}
|
blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)}
|
||||||
sidebarToRender={(props) => <CycleGanttSidebar {...props} />}
|
sidebarToRender={(props) => <CycleGanttSidebar {...props} />}
|
||||||
blockToRender={(data: ICycle) => <CycleGanttBlock data={data} />}
|
blockToRender={(data: ICycle) => <CycleGanttBlock cycleId={data.id} />}
|
||||||
enableBlockLeftResize={false}
|
enableBlockLeftResize={false}
|
||||||
enableBlockRightResize={false}
|
enableBlockRightResize={false}
|
||||||
enableBlockMove={false}
|
enableBlockMove={false}
|
||||||
|
@ -3,13 +3,15 @@ import { Dialog, Transition } from "@headlessui/react";
|
|||||||
// services
|
// services
|
||||||
import { CycleService } from "services/cycle.service";
|
import { CycleService } from "services/cycle.service";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useCycle, useProject } from "hooks/store";
|
import { useEventTracker, useCycle, useProject } from "hooks/store";
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
import useLocalStorage from "hooks/use-local-storage";
|
import useLocalStorage from "hooks/use-local-storage";
|
||||||
// components
|
// components
|
||||||
import { CycleForm } from "components/cycles";
|
import { CycleForm } from "components/cycles";
|
||||||
// types
|
// types
|
||||||
import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types";
|
import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { CYCLE_CREATED, CYCLE_UPDATED } from "constants/event-tracker";
|
||||||
|
|
||||||
type CycleModalProps = {
|
type CycleModalProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -27,9 +29,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
|||||||
// states
|
// states
|
||||||
const [activeProject, setActiveProject] = useState<string | null>(null);
|
const [activeProject, setActiveProject] = useState<string | null>(null);
|
||||||
// store hooks
|
// store hooks
|
||||||
const {
|
const { captureCycleEvent } = useEventTracker();
|
||||||
eventTracker: { postHogEventTracker },
|
|
||||||
} = useApplication();
|
|
||||||
const { workspaceProjectIds } = useProject();
|
const { workspaceProjectIds } = useProject();
|
||||||
const { createCycle, updateCycleDetails } = useCycle();
|
const { createCycle, updateCycleDetails } = useCycle();
|
||||||
// toast alert
|
// toast alert
|
||||||
@ -48,9 +48,9 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
|||||||
title: "Success!",
|
title: "Success!",
|
||||||
message: "Cycle created successfully.",
|
message: "Cycle created successfully.",
|
||||||
});
|
});
|
||||||
postHogEventTracker("CYCLE_CREATE", {
|
captureCycleEvent({
|
||||||
...res,
|
eventName: CYCLE_CREATED,
|
||||||
state: "SUCCESS",
|
payload: { ...res, state: "SUCCESS" },
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@ -59,18 +59,24 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
|||||||
title: "Error!",
|
title: "Error!",
|
||||||
message: err.detail ?? "Error in creating cycle. Please try again.",
|
message: err.detail ?? "Error in creating cycle. Please try again.",
|
||||||
});
|
});
|
||||||
postHogEventTracker("CYCLE_CREATE", {
|
captureCycleEvent({
|
||||||
state: "FAILED",
|
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;
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
const selectedProjectId = payload.project ?? projectId.toString();
|
const selectedProjectId = payload.project ?? projectId.toString();
|
||||||
await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload)
|
await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload)
|
||||||
.then(() => {
|
.then((res) => {
|
||||||
|
const changed_properties = Object.keys(dirtyFields);
|
||||||
|
captureCycleEvent({
|
||||||
|
eventName: CYCLE_UPDATED,
|
||||||
|
payload: { ...res, changed_properties: changed_properties, state: "SUCCESS" },
|
||||||
|
});
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "success",
|
type: "success",
|
||||||
title: "Success!",
|
title: "Success!",
|
||||||
@ -78,6 +84,10 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
captureCycleEvent({
|
||||||
|
eventName: CYCLE_UPDATED,
|
||||||
|
payload: { ...payload, state: "FAILED" },
|
||||||
|
});
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "error",
|
type: "error",
|
||||||
title: "Error!",
|
title: "Error!",
|
||||||
@ -96,7 +106,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
|||||||
return status;
|
return status;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFormSubmit = async (formData: Partial<ICycle>) => {
|
const handleFormSubmit = async (formData: Partial<ICycle>, dirtyFields: any) => {
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
const payload: Partial<ICycle> = {
|
const payload: Partial<ICycle> = {
|
||||||
@ -120,7 +130,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isDateValid) {
|
if (isDateValid) {
|
||||||
if (data) await handleUpdateCycle(data.id, payload);
|
if (data) await handleUpdateCycle(data.id, payload, dirtyFields);
|
||||||
else {
|
else {
|
||||||
await handleCreateCycle(payload).then(() => {
|
await handleCreateCycle(payload).then(() => {
|
||||||
setCycleTab("all");
|
setCycleTab("all");
|
||||||
|
@ -3,10 +3,11 @@ import { useRouter } from "next/router";
|
|||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Disclosure, Popover, Transition } from "@headlessui/react";
|
import { Disclosure, Popover, Transition } from "@headlessui/react";
|
||||||
|
import isEmpty from "lodash/isEmpty";
|
||||||
// services
|
// services
|
||||||
import { CycleService } from "services/cycle.service";
|
import { CycleService } from "services/cycle.service";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useCycle, useMember, useUser } from "hooks/store";
|
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// components
|
// components
|
||||||
import { SidebarProgressStats } from "components/core";
|
import { SidebarProgressStats } from "components/core";
|
||||||
@ -38,6 +39,7 @@ import {
|
|||||||
import { ICycle } from "@plane/types";
|
import { ICycle } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||||
|
import { CYCLE_UPDATED } from "constants/event-tracker";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { CYCLE_STATUS } from "constants/cycle";
|
import { CYCLE_STATUS } from "constants/cycle";
|
||||||
|
|
||||||
@ -66,9 +68,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId, peekCycle } = router.query;
|
const { workspaceSlug, projectId, peekCycle } = router.query;
|
||||||
// store hooks
|
// store hooks
|
||||||
const {
|
const { setTrackElement, captureCycleEvent } = useEventTracker();
|
||||||
eventTracker: { setTrackElement },
|
|
||||||
} = useApplication();
|
|
||||||
const {
|
const {
|
||||||
membership: { currentProjectRole },
|
membership: { currentProjectRole },
|
||||||
} = useUser();
|
} = useUser();
|
||||||
@ -84,10 +84,32 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
defaultValues,
|
defaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const submitChanges = (data: Partial<ICycle>) => {
|
const submitChanges = (data: Partial<ICycle>, changedProperty: string) => {
|
||||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||||
|
|
||||||
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data);
|
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data)
|
||||||
|
.then((res) => {
|
||||||
|
captureCycleEvent({
|
||||||
|
eventName: CYCLE_UPDATED,
|
||||||
|
payload: {
|
||||||
|
...res,
|
||||||
|
changed_properties: [changedProperty],
|
||||||
|
element: "Right side-peek",
|
||||||
|
state: "SUCCESS",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
.catch(() => {
|
||||||
|
captureCycleEvent({
|
||||||
|
eventName: CYCLE_UPDATED,
|
||||||
|
payload: {
|
||||||
|
...data,
|
||||||
|
element: "Right side-peek",
|
||||||
|
state: "FAILED",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyText = () => {
|
const handleCopyText = () => {
|
||||||
@ -147,10 +169,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isDateValidForExistingCycle) {
|
if (isDateValidForExistingCycle) {
|
||||||
submitChanges({
|
submitChanges(
|
||||||
|
{
|
||||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||||
});
|
},
|
||||||
|
"start_date"
|
||||||
|
);
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "success",
|
type: "success",
|
||||||
title: "Success!",
|
title: "Success!",
|
||||||
@ -175,10 +200,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isDateValid) {
|
if (isDateValid) {
|
||||||
submitChanges({
|
submitChanges(
|
||||||
|
{
|
||||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||||
});
|
},
|
||||||
|
"start_date"
|
||||||
|
);
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "success",
|
type: "success",
|
||||||
title: "Success!",
|
title: "Success!",
|
||||||
@ -220,10 +248,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isDateValidForExistingCycle) {
|
if (isDateValidForExistingCycle) {
|
||||||
submitChanges({
|
submitChanges(
|
||||||
|
{
|
||||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||||
});
|
},
|
||||||
|
"end_date"
|
||||||
|
);
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "success",
|
type: "success",
|
||||||
title: "Success!",
|
title: "Success!",
|
||||||
@ -247,10 +278,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isDateValid) {
|
if (isDateValid) {
|
||||||
submitChanges({
|
submitChanges(
|
||||||
|
{
|
||||||
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
|
||||||
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
|
||||||
});
|
},
|
||||||
|
"end_date"
|
||||||
|
);
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "success",
|
type: "success",
|
||||||
title: "Success!",
|
title: "Success!",
|
||||||
@ -295,7 +329,11 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
const isEndValid = new Date(`${cycleDetails?.end_date}`) >= new Date(`${cycleDetails?.start_date}`);
|
const isEndValid = new Date(`${cycleDetails?.end_date}`) >= new Date(`${cycleDetails?.start_date}`);
|
||||||
|
|
||||||
const progressPercentage = cycleDetails
|
const progressPercentage = cycleDetails
|
||||||
? Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100)
|
? isCompleted && cycleDetails?.progress_snapshot
|
||||||
|
? Math.round(
|
||||||
|
(cycleDetails.progress_snapshot.completed_issues / cycleDetails.progress_snapshot.total_issues) * 100
|
||||||
|
)
|
||||||
|
: Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!cycleDetails)
|
if (!cycleDetails)
|
||||||
@ -319,14 +357,16 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
|
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
|
||||||
|
|
||||||
const issueCount =
|
const issueCount =
|
||||||
cycleDetails.total_issues === 0
|
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"
|
? "0 Issue"
|
||||||
: cycleDetails.total_issues === cycleDetails.completed_issues
|
|
||||||
? cycleDetails.total_issues > 1
|
|
||||||
? `${cycleDetails.total_issues}`
|
|
||||||
: `${cycleDetails.total_issues}`
|
|
||||||
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
|
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
|
||||||
|
|
||||||
|
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date);
|
||||||
|
|
||||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -383,8 +423,8 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
backgroundColor: `${currentCycle.color}20`,
|
backgroundColor: `${currentCycle.color}20`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentCycle.value === "current"
|
{currentCycle.value === "current" && daysLeft !== undefined
|
||||||
? `${findHowManyDaysLeft(cycleDetails.end_date ?? new Date())} ${currentCycle.label}`
|
? `${daysLeft} ${currentCycle.label}`
|
||||||
: `${currentCycle.label}`}
|
: `${currentCycle.label}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -575,7 +615,11 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
<Transition show={open}>
|
<Transition show={open}>
|
||||||
<Disclosure.Panel>
|
<Disclosure.Panel>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{isStartValid && isEndValid ? (
|
{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="h-full w-full pt-4">
|
||||||
<div className="flex items-start gap-4 py-2 text-xs">
|
<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 gap-3 text-custom-text-100">
|
||||||
@ -589,20 +633,70 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{cycleDetails && cycleDetails.distribution && (
|
|
||||||
<div className="relative h-40 w-80">
|
<div className="relative h-40 w-80">
|
||||||
<ProgressChart
|
<ProgressChart
|
||||||
distribution={cycleDetails.distribution?.completion_chart ?? {}}
|
distribution={cycleDetails.progress_snapshot.distribution?.completion_chart}
|
||||||
startDate={cycleDetails.start_date ?? ""}
|
startDate={cycleDetails.start_date}
|
||||||
endDate={cycleDetails.end_date ?? ""}
|
endDate={cycleDetails.end_date}
|
||||||
|
totalIssues={cycleDetails.progress_snapshot.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}
|
totalIssues={cycleDetails.total_issues}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</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 && (
|
{cycleDetails.total_issues > 0 && cycleDetails.distribution && (
|
||||||
<div className="h-full w-full border-t border-custom-border-200 pt-5">
|
<div className="h-full w-full border-t border-custom-border-200 pt-5">
|
||||||
<SidebarProgressStats
|
<SidebarProgressStats
|
||||||
@ -619,6 +713,8 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Disclosure.Panel>
|
</Disclosure.Panel>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useUser } from "hooks/store";
|
import { useApplication, useEventTracker, useUser } from "hooks/store";
|
||||||
// ui
|
// ui
|
||||||
import { Button } from "@plane/ui";
|
import { Button } from "@plane/ui";
|
||||||
// assets
|
// assets
|
||||||
@ -14,6 +14,7 @@ export const DashboardProjectEmptyState = observer(() => {
|
|||||||
const {
|
const {
|
||||||
commandPalette: { toggleCreateProjectModal },
|
commandPalette: { toggleCreateProjectModal },
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
|
const { setTrackElement } = useEventTracker();
|
||||||
const {
|
const {
|
||||||
membership: { currentWorkspaceRole },
|
membership: { currentWorkspaceRole },
|
||||||
} = useUser();
|
} = useUser();
|
||||||
@ -31,7 +32,13 @@ export const DashboardProjectEmptyState = observer(() => {
|
|||||||
<Image src={ProjectEmptyStateImage} className="w-full" alt="Project empty state" />
|
<Image src={ProjectEmptyStateImage} className="w-full" alt="Project empty state" />
|
||||||
{canCreateProject && (
|
{canCreateProject && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Button variant="primary" onClick={() => toggleCreateProjectModal(true)}>
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setTrackElement("Project empty state");
|
||||||
|
toggleCreateProjectModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
Build your first project
|
Build your first project
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -13,11 +13,11 @@ import {
|
|||||||
WidgetProps,
|
WidgetProps,
|
||||||
} from "components/dashboard/widgets";
|
} from "components/dashboard/widgets";
|
||||||
// helpers
|
// helpers
|
||||||
import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper";
|
import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashboard.helper";
|
||||||
// types
|
// types
|
||||||
import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types";
|
import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { ISSUES_TABS_LIST } from "constants/dashboard";
|
import { FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard";
|
||||||
|
|
||||||
const WIDGET_KEY = "assigned_issues";
|
const WIDGET_KEY = "assigned_issues";
|
||||||
|
|
||||||
@ -30,6 +30,8 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
// derived values
|
// derived values
|
||||||
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
const widgetStats = getWidgetStats<TAssignedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetStats = getWidgetStats<TAssignedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
|
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? "none";
|
||||||
|
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
|
||||||
|
|
||||||
const handleUpdateFilters = async (filters: Partial<TAssignedIssuesWidgetFilters>) => {
|
const handleUpdateFilters = async (filters: Partial<TAssignedIssuesWidgetFilters>) => {
|
||||||
if (!widgetDetails) return;
|
if (!widgetDetails) return;
|
||||||
@ -41,68 +43,79 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
filters,
|
filters,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const filterDates = getCustomDates(filters.duration ?? selectedDurationFilter);
|
||||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
widget_key: WIDGET_KEY,
|
widget_key: WIDGET_KEY,
|
||||||
issue_type: filters.tab ?? widgetDetails.widget_filters.tab ?? "upcoming",
|
issue_type: filters.tab ?? selectedTab,
|
||||||
target_date: getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "this_week"),
|
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||||
expand: "issue_relation",
|
expand: "issue_relation",
|
||||||
}).finally(() => setFetching(false));
|
}).finally(() => setFetching(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "this_week");
|
const filterDates = getCustomDates(selectedDurationFilter);
|
||||||
|
|
||||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
widget_key: WIDGET_KEY,
|
widget_key: WIDGET_KEY,
|
||||||
issue_type: widgetDetails?.widget_filters.tab ?? "upcoming",
|
issue_type: selectedTab,
|
||||||
target_date: filterDates,
|
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||||
expand: "issue_relation",
|
expand: "issue_relation",
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filterParams = getRedirectionFilters(widgetDetails?.widget_filters.tab ?? "upcoming");
|
const filterParams = getRedirectionFilters(selectedTab);
|
||||||
|
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
||||||
|
|
||||||
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col min-h-96">
|
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col min-h-96">
|
||||||
<div className="flex items-start justify-between gap-2 p-6 pl-7">
|
<div className="flex items-center justify-between gap-2 p-6 pl-7">
|
||||||
<div>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${workspaceSlug}/workspace-views/assigned/${filterParams}`}
|
href={`/${workspaceSlug}/workspace-views/assigned/${filterParams}`}
|
||||||
className="text-lg font-semibold text-custom-text-300 hover:underline"
|
className="text-lg font-semibold text-custom-text-300 hover:underline"
|
||||||
>
|
>
|
||||||
Assigned to you
|
Assigned to you
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-3 text-xs font-medium text-custom-text-300">
|
|
||||||
Filtered by{" "}
|
|
||||||
<span className="border-[0.5px] border-custom-border-300 rounded py-1 px-2 ml-0.5">Due date</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<DurationFilterDropdown
|
<DurationFilterDropdown
|
||||||
value={widgetDetails.widget_filters.target_date ?? "this_week"}
|
value={selectedDurationFilter}
|
||||||
onChange={(val) =>
|
onChange={(val) => {
|
||||||
handleUpdateFilters({
|
if (val === selectedDurationFilter) return;
|
||||||
target_date: val,
|
|
||||||
})
|
// switch to pending tab if target date is changed to none
|
||||||
|
if (val === "none" && selectedTab !== "completed") {
|
||||||
|
handleUpdateFilters({ duration: val, tab: "pending" });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// switch to upcoming tab if target date is changed to other than none
|
||||||
|
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") {
|
||||||
|
handleUpdateFilters({
|
||||||
|
duration: val,
|
||||||
|
tab: "upcoming",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUpdateFilters({ duration: val });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Tab.Group
|
<Tab.Group
|
||||||
as="div"
|
as="div"
|
||||||
defaultIndex={ISSUES_TABS_LIST.findIndex((t) => t.key === widgetDetails.widget_filters.tab ?? "upcoming")}
|
selectedIndex={tabsList.findIndex((tab) => tab.key === selectedTab)}
|
||||||
onChange={(i) => {
|
onChange={(i) => {
|
||||||
const selectedTab = ISSUES_TABS_LIST[i];
|
const selectedTab = tabsList[i];
|
||||||
handleUpdateFilters({ tab: selectedTab.key ?? "upcoming" });
|
handleUpdateFilters({ tab: selectedTab?.key ?? "pending" });
|
||||||
}}
|
}}
|
||||||
className="h-full flex flex-col"
|
className="h-full flex flex-col"
|
||||||
>
|
>
|
||||||
<div className="px-6">
|
<div className="px-6">
|
||||||
<TabsList />
|
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
|
||||||
</div>
|
</div>
|
||||||
<Tab.Panels as="div" className="h-full">
|
<Tab.Panels as="div" className="h-full">
|
||||||
{ISSUES_TABS_LIST.map((tab) => (
|
{tabsList.map((tab) => (
|
||||||
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col">
|
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col">
|
||||||
<WidgetIssuesList
|
<WidgetIssuesList
|
||||||
issues={widgetStats.issues}
|
issues={widgetStats.issues}
|
||||||
|
@ -13,11 +13,11 @@ import {
|
|||||||
WidgetProps,
|
WidgetProps,
|
||||||
} from "components/dashboard/widgets";
|
} from "components/dashboard/widgets";
|
||||||
// helpers
|
// helpers
|
||||||
import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper";
|
import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashboard.helper";
|
||||||
// types
|
// types
|
||||||
import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
|
import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { ISSUES_TABS_LIST } from "constants/dashboard";
|
import { FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard";
|
||||||
|
|
||||||
const WIDGET_KEY = "created_issues";
|
const WIDGET_KEY = "created_issues";
|
||||||
|
|
||||||
@ -30,6 +30,8 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
// derived values
|
// derived values
|
||||||
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
const widgetStats = getWidgetStats<TCreatedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetStats = getWidgetStats<TCreatedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
|
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? "none";
|
||||||
|
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
|
||||||
|
|
||||||
const handleUpdateFilters = async (filters: Partial<TCreatedIssuesWidgetFilters>) => {
|
const handleUpdateFilters = async (filters: Partial<TCreatedIssuesWidgetFilters>) => {
|
||||||
if (!widgetDetails) return;
|
if (!widgetDetails) return;
|
||||||
@ -41,64 +43,76 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
filters,
|
filters,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const filterDates = getCustomDates(filters.duration ?? selectedDurationFilter);
|
||||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
widget_key: WIDGET_KEY,
|
widget_key: WIDGET_KEY,
|
||||||
issue_type: filters.tab ?? widgetDetails.widget_filters.tab ?? "upcoming",
|
issue_type: filters.tab ?? selectedTab,
|
||||||
target_date: getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "this_week"),
|
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||||
}).finally(() => setFetching(false));
|
}).finally(() => setFetching(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const filterDates = getCustomDates(selectedDurationFilter);
|
||||||
|
|
||||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
widget_key: WIDGET_KEY,
|
widget_key: WIDGET_KEY,
|
||||||
issue_type: widgetDetails?.widget_filters.tab ?? "upcoming",
|
issue_type: selectedTab,
|
||||||
target_date: getCustomDates(widgetDetails?.widget_filters.target_date ?? "this_week"),
|
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filterParams = getRedirectionFilters(widgetDetails?.widget_filters.tab ?? "upcoming");
|
const filterParams = getRedirectionFilters(selectedTab);
|
||||||
|
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
||||||
|
|
||||||
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col min-h-96">
|
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col min-h-96">
|
||||||
<div className="flex items-start justify-between gap-2 p-6 pl-7">
|
<div className="flex items-center justify-between gap-2 p-6 pl-7">
|
||||||
<div>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${workspaceSlug}/workspace-views/created/${filterParams}`}
|
href={`/${workspaceSlug}/workspace-views/created/${filterParams}`}
|
||||||
className="text-lg font-semibold text-custom-text-300 hover:underline"
|
className="text-lg font-semibold text-custom-text-300 hover:underline"
|
||||||
>
|
>
|
||||||
Created by you
|
Created by you
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-3 text-xs font-medium text-custom-text-300">
|
|
||||||
Filtered by{" "}
|
|
||||||
<span className="border-[0.5px] border-custom-border-300 rounded py-1 px-2 ml-0.5">Due date</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<DurationFilterDropdown
|
<DurationFilterDropdown
|
||||||
value={widgetDetails.widget_filters.target_date ?? "this_week"}
|
value={selectedDurationFilter}
|
||||||
onChange={(val) =>
|
onChange={(val) => {
|
||||||
handleUpdateFilters({
|
if (val === selectedDurationFilter) return;
|
||||||
target_date: val,
|
|
||||||
})
|
// switch to pending tab if target date is changed to none
|
||||||
|
if (val === "none" && selectedTab !== "completed") {
|
||||||
|
handleUpdateFilters({ duration: val, tab: "pending" });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// switch to upcoming tab if target date is changed to other than none
|
||||||
|
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") {
|
||||||
|
handleUpdateFilters({
|
||||||
|
duration: val,
|
||||||
|
tab: "upcoming",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUpdateFilters({ duration: val });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Tab.Group
|
<Tab.Group
|
||||||
as="div"
|
as="div"
|
||||||
defaultIndex={ISSUES_TABS_LIST.findIndex((t) => t.key === widgetDetails.widget_filters.tab ?? "upcoming")}
|
selectedIndex={tabsList.findIndex((tab) => tab.key === selectedTab)}
|
||||||
onChange={(i) => {
|
onChange={(i) => {
|
||||||
const selectedTab = ISSUES_TABS_LIST[i];
|
const selectedTab = tabsList[i];
|
||||||
handleUpdateFilters({ tab: selectedTab.key ?? "upcoming" });
|
handleUpdateFilters({ tab: selectedTab.key ?? "pending" });
|
||||||
}}
|
}}
|
||||||
className="h-full flex flex-col"
|
className="h-full flex flex-col"
|
||||||
>
|
>
|
||||||
<div className="px-6">
|
<div className="px-6">
|
||||||
<TabsList />
|
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
|
||||||
</div>
|
</div>
|
||||||
<Tab.Panels as="div" className="h-full">
|
<Tab.Panels as="div" className="h-full">
|
||||||
{ISSUES_TABS_LIST.map((tab) => (
|
{tabsList.map((tab) => (
|
||||||
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col">
|
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col">
|
||||||
<WidgetIssuesList
|
<WidgetIssuesList
|
||||||
issues={widgetStats.issues}
|
issues={widgetStats.issues}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user