Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e433266fe5 | |||
| 72556d407a | |||
| 51be035a30 | |||
| 59d5452248 | |||
| 37b30fbce9 | |||
| e9121b6798 | |||
| 9e3f7d0873 | |||
| aa2d61cdf7 | |||
| 192482dc30 | |||
| 2fee027f2c | |||
| 5a745314b1 | |||
| a2773e284f | |||
| 7fdfe39bf0 | |||
| 6bc133e3b1 | |||
| 80761d3507 | |||
| 7a5c6e0e96 | |||
| 9c13dbd957 | |||
| 8d9adf4d87 | |||
| 4ccb505f36 | |||
| 884856b021 | |||
| 552c66457a | |||
| 1363ef0b2d | |||
| 898cf98be3 | |||
| cb632408f9 | |||
| b930d98665 | |||
| 69e110f4a8 | |||
| c3c6ef8830 | |||
| 8aca74c68d | |||
| c97b994311 | |||
| 443b93f897 | |||
| 730e556bea | |||
| f77f4b8221 | |||
| 5c4c3f5c04 | |||
| 73c91654eb | |||
| 578bd29f6f | |||
| 2e5e14556d | |||
| ecac45e885 | |||
| 6ec9c64f7c | |||
| f493a03f56 | |||
| 48c9b78397 | |||
| 9c29ad1a28 | |||
| e3ac075ee2 | |||
| 171b664fe7 | |||
| 8c9d328c24 | |||
| b2146098e2 | |||
| 4a06572f73 | |||
| 7f67fe12af | |||
| d34df5c805 | |||
| 01702e9f66 | |||
| b57c389c75 | |||
| 27acd1bec1 | |||
| 539afb0792 | |||
| 6c7ba3bc79 | |||
| c8ab650008 | |||
| 89d019df57 | |||
| 535731141f | |||
| 4b30339a59 | |||
| 899771a678 | |||
| 8997ee2e3e | |||
| 31a2bbc4de | |||
| 6be72149d9 | |||
| 775d8fbd90 | |||
| 58d82313cc | |||
| f32645458c | |||
| cb78ccad1f | |||
| 6c6b7156bb | |||
| 8cc372679c | |||
| 94327b8311 | |||
| 4fb9ab3998 | |||
| 8d4e4a7485 | |||
| edb4280ec1 | |||
| 099cd5955c | |||
| 93cfa13955 | |||
| f231ac0a79 | |||
| 00757a6704 |
@@ -2,6 +2,27 @@ name: Branch Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build-web:
|
||||
required: false
|
||||
description: "Build Web"
|
||||
type: boolean
|
||||
default: false
|
||||
build-space:
|
||||
required: false
|
||||
description: "Build Space"
|
||||
type: boolean
|
||||
default: false
|
||||
build-api:
|
||||
required: false
|
||||
description: "Build API"
|
||||
type: boolean
|
||||
default: false
|
||||
build-proxy:
|
||||
required: false
|
||||
description: "Build Proxy"
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -18,7 +39,7 @@ jobs:
|
||||
name: Build-Push Web/Space/API/Proxy Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
gh_branch_name: ${{ steps.set_env_variables.outputs.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 }}
|
||||
@@ -74,7 +95,7 @@ jobs:
|
||||
- nginx/**
|
||||
|
||||
branch_build_push_frontend:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event.inputs.build-web=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
@@ -126,7 +147,7 @@ jobs:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
branch_build_push_space:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event.inputs.build-space=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
@@ -178,7 +199,7 @@ jobs:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
branch_build_push_backend:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event.inputs.build-api=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
@@ -230,7 +251,7 @@ jobs:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
branch_build_push_proxy:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event.inputs.build-web=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
@@ -280,4 +301,3 @@ jobs:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -4,70 +4,196 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
web-build:
|
||||
required: true
|
||||
required: false
|
||||
description: 'Build Web'
|
||||
type: boolean
|
||||
default: true
|
||||
space-build:
|
||||
required: true
|
||||
required: false
|
||||
description: 'Build Space'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
env:
|
||||
BUILD_WEB: ${{ github.event.inputs.web-build }}
|
||||
BUILD_SPACE: ${{ github.event.inputs.space-build }}
|
||||
|
||||
jobs:
|
||||
setup-feature-build:
|
||||
name: Feature Build Setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
echo "BUILD_WEB=$BUILD_WEB"
|
||||
echo "BUILD_SPACE=$BUILD_SPACE"
|
||||
outputs:
|
||||
web-build: ${{ env.BUILD_WEB}}
|
||||
space-build: ${{env.BUILD_SPACE}}
|
||||
|
||||
feature-build-web:
|
||||
if: ${{ needs.setup-feature-build.outputs.web-build == 'true' }}
|
||||
needs: setup-feature-build
|
||||
name: Feature Build Web
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
|
||||
NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
|
||||
steps:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
- name: Install AWS cli
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3-pip
|
||||
pip3 install awscli
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: plane
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE/plane
|
||||
yarn install
|
||||
- name: Build Web
|
||||
id: build-web
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE/plane
|
||||
yarn build --filter=web
|
||||
cd $GITHUB_WORKSPACE
|
||||
|
||||
TAR_NAME="web.tar.gz"
|
||||
tar -czf $TAR_NAME ./plane
|
||||
|
||||
FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
|
||||
aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
|
||||
|
||||
feature-build-space:
|
||||
if: ${{ needs.setup-feature-build.outputs.space-build == 'true' }}
|
||||
needs: setup-feature-build
|
||||
name: Feature Build Space
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
|
||||
NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1
|
||||
NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
|
||||
outputs:
|
||||
do-build: ${{ needs.setup-feature-build.outputs.space-build }}
|
||||
s3-url: ${{ steps.build-space.outputs.S3_PRESIGNED_URL }}
|
||||
steps:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
- name: Install AWS cli
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3-pip
|
||||
pip3 install awscli
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: plane
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE/plane
|
||||
yarn install
|
||||
- name: Build Space
|
||||
id: build-space
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE/plane
|
||||
yarn build --filter=space
|
||||
cd $GITHUB_WORKSPACE
|
||||
|
||||
TAR_NAME="space.tar.gz"
|
||||
tar -czf $TAR_NAME ./plane
|
||||
|
||||
FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
|
||||
aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
|
||||
|
||||
feature-deploy:
|
||||
if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true') }}
|
||||
needs: [feature-build-web, feature-build-space]
|
||||
name: Feature Deploy
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
KUBE_CONFIG_FILE: ${{ secrets.KUBE_CONFIG }}
|
||||
BUILD_WEB: ${{ (github.event.inputs.web-build == '' && true) || github.event.inputs.web-build }}
|
||||
BUILD_SPACE: ${{ (github.event.inputs.space-build == '' && false) || github.event.inputs.space-build }}
|
||||
|
||||
AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
|
||||
KUBE_CONFIG_FILE: ${{ secrets.FEATURE_PREVIEW_KUBE_CONFIG }}
|
||||
steps:
|
||||
- name: Install AWS cli
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3-pip
|
||||
pip3 install awscli
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@v2
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }}
|
||||
tags: tag:ci
|
||||
|
||||
- name: Kubectl Setup
|
||||
run: |
|
||||
curl -LO "https://dl.k8s.io/release/${{secrets.KUBE_VERSION}}/bin/linux/amd64/kubectl"
|
||||
curl -LO "https://dl.k8s.io/release/${{ vars.FEATURE_PREVIEW_KUBE_VERSION }}/bin/linux/amd64/kubectl"
|
||||
chmod +x kubectl
|
||||
|
||||
mkdir -p ~/.kube
|
||||
echo "$KUBE_CONFIG_FILE" > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: HELM Setup
|
||||
run: |
|
||||
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
|
||||
chmod 700 get_helm.sh
|
||||
./get_helm.sh
|
||||
|
||||
- name: App Deploy
|
||||
run: |
|
||||
helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ secrets.FEATURE_PREVIEW_HELM_CHART_URL }}
|
||||
GIT_BRANCH=${{ github.ref_name }}
|
||||
APP_NAMESPACE=${{ secrets.FEATURE_PREVIEW_NAMESPACE }}
|
||||
WEB_S3_URL=""
|
||||
if [ ${{ env.BUILD_WEB }} == true ]; then
|
||||
WEB_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/web.tar.gz --expires-in 3600)
|
||||
fi
|
||||
|
||||
METADATA=$(helm install feature-preview/${{ secrets.FEATURE_PREVIEW_HELM_CHART_NAME }} \
|
||||
--kube-insecure-skip-tls-verify \
|
||||
--generate-name \
|
||||
--namespace $APP_NAMESPACE \
|
||||
--set shared_config.git_repo=${{github.server_url}}/${{ github.repository }}.git \
|
||||
--set shared_config.git_branch="$GIT_BRANCH" \
|
||||
--set web.enabled=${{ env.BUILD_WEB }} \
|
||||
--set space.enabled=${{ env.BUILD_SPACE }} \
|
||||
--output json \
|
||||
--timeout 1000s)
|
||||
SPACE_S3_URL=""
|
||||
if [ ${{ env.BUILD_SPACE }} == true ]; then
|
||||
SPACE_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/space.tar.gz --expires-in 3600)
|
||||
fi
|
||||
|
||||
APP_NAME=$(echo $METADATA | jq -r '.name')
|
||||
if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ]; then
|
||||
|
||||
INGRESS_HOSTNAME=$(kubectl get ingress -n feature-builds --insecure-skip-tls-verify \
|
||||
-o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \
|
||||
jq -r '.spec.rules[0].host')
|
||||
helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }}
|
||||
|
||||
echo "****************************************"
|
||||
echo "APP NAME ::: $APP_NAME"
|
||||
echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME"
|
||||
echo "****************************************"
|
||||
APP_NAMESPACE="${{ vars.FEATURE_PREVIEW_NAMESPACE }}"
|
||||
DEPLOY_SCRIPT_URL="${{ vars.FEATURE_PREVIEW_DEPLOY_SCRIPT_URL }}"
|
||||
|
||||
METADATA=$(helm --kube-insecure-skip-tls-verify install feature-preview/${{ vars.FEATURE_PREVIEW_HELM_CHART_NAME }} \
|
||||
--generate-name \
|
||||
--namespace $APP_NAMESPACE \
|
||||
--set ingress.primaryDomain=${{vars.FEATURE_PREVIEW_PRIMARY_DOMAIN || 'feature.plane.tools' }} \
|
||||
--set web.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
|
||||
--set web.enabled=${{ env.BUILD_WEB || false }} \
|
||||
--set web.artifact_url=$WEB_S3_URL \
|
||||
--set space.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
|
||||
--set space.enabled=${{ env.BUILD_SPACE || false }} \
|
||||
--set space.artifact_url=$SPACE_S3_URL \
|
||||
--set shared_config.deploy_script_url=$DEPLOY_SCRIPT_URL \
|
||||
--set shared_config.api_base_url=${{vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL}} \
|
||||
--output json \
|
||||
--timeout 1000s)
|
||||
|
||||
APP_NAME=$(echo $METADATA | jq -r '.name')
|
||||
|
||||
INGRESS_HOSTNAME=$(kubectl get ingress -n feature-builds --insecure-skip-tls-verify \
|
||||
-o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \
|
||||
jq -r '.spec.rules[0].host')
|
||||
|
||||
echo "****************************************"
|
||||
echo "APP NAME ::: $APP_NAME"
|
||||
echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME"
|
||||
echo "****************************************"
|
||||
fi
|
||||
|
||||
@@ -51,6 +51,7 @@ staticfiles
|
||||
mediafiles
|
||||
.env
|
||||
.DS_Store
|
||||
logs/
|
||||
|
||||
node_modules/
|
||||
assets/dist/
|
||||
|
||||
@@ -14,10 +14,6 @@ POSTGRES_HOST="plane-db"
|
||||
POSTGRES_DB="plane"
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
|
||||
|
||||
# Oauth variables
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
|
||||
# Redis Settings
|
||||
REDIS_HOST="plane-redis"
|
||||
@@ -34,11 +30,6 @@ AWS_S3_BUCKET_NAME="uploads"
|
||||
# Maximum file upload limit
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
|
||||
# GPT settings
|
||||
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
|
||||
OPENAI_API_KEY="sk-" # deprecated
|
||||
GPT_ENGINE="gpt-3.5-turbo" # deprecated
|
||||
|
||||
# Settings related to Docker
|
||||
DOCKERIZED=1 # deprecated
|
||||
|
||||
@@ -48,19 +39,8 @@ USE_MINIO=1
|
||||
# Nginx Configuration
|
||||
NGINX_PORT=80
|
||||
|
||||
|
||||
# SignUps
|
||||
ENABLE_SIGNUP="1"
|
||||
|
||||
# Enable Email/Password Signup
|
||||
ENABLE_EMAIL_PASSWORD="1"
|
||||
|
||||
# Enable Magic link Login
|
||||
ENABLE_MAGIC_LINK_LOGIN="0"
|
||||
|
||||
# Email redirections and minio domain settings
|
||||
WEB_URL="http://localhost"
|
||||
|
||||
# Gunicorn Workers
|
||||
GUNICORN_WORKERS=2
|
||||
|
||||
|
||||
@@ -48,8 +48,10 @@ USER root
|
||||
RUN apk --no-cache add "bash~=5.2"
|
||||
COPY ./bin ./bin/
|
||||
|
||||
RUN mkdir /code/plane/logs
|
||||
RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
|
||||
RUN chmod -R 777 /code
|
||||
RUN chown -R captain:plane /code
|
||||
|
||||
USER captain
|
||||
|
||||
|
||||
@@ -21,11 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256
|
||||
export MACHINE_SIGNATURE=$SIGNATURE
|
||||
|
||||
# Register instance
|
||||
python manage.py register_instance $MACHINE_SIGNATURE
|
||||
python manage.py register_instance "$MACHINE_SIGNATURE"
|
||||
|
||||
# Load the configuration variable
|
||||
python manage.py configure_instance
|
||||
|
||||
# Create the default bucket
|
||||
python manage.py create_bucket
|
||||
|
||||
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
# Clear Cache before starting to remove stale values
|
||||
python manage.py clear_cache
|
||||
|
||||
exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
|
||||
@@ -21,12 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256
|
||||
export MACHINE_SIGNATURE=$SIGNATURE
|
||||
|
||||
# Register instance
|
||||
python manage.py register_instance $MACHINE_SIGNATURE
|
||||
python manage.py register_instance "$MACHINE_SIGNATURE"
|
||||
# Load the configuration variable
|
||||
python manage.py configure_instance
|
||||
|
||||
# Create the default bucket
|
||||
python manage.py create_bucket
|
||||
|
||||
# Clear Cache before starting to remove stale values
|
||||
python manage.py clear_cache
|
||||
|
||||
python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local
|
||||
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
# Python imports
|
||||
import zoneinfo
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import zoneinfo
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.db import IntegrityError
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.db import IntegrityError
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.api.middleware.api_authentication import APIKeyAuthentication
|
||||
from plane.api.rate_limit import ApiKeyRateThrottle
|
||||
from plane.utils.paginator import BasePaginator
|
||||
from plane.bgtasks.webhook_task import send_webhook
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.paginator import BasePaginator
|
||||
|
||||
|
||||
class TimezoneMixin:
|
||||
@@ -106,27 +106,23 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
|
||||
|
||||
if isinstance(e, ValidationError):
|
||||
return Response(
|
||||
{
|
||||
"error": "The provided payload is not valid please try with a valid payload"
|
||||
},
|
||||
{"error": "Please provide valid detail"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if isinstance(e, ObjectDoesNotExist):
|
||||
return Response(
|
||||
{"error": "The required object does not exist."},
|
||||
{"error": "The requested resource does not exist."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if isinstance(e, KeyError):
|
||||
return Response(
|
||||
{"error": " The required key does not exist."},
|
||||
{"error": "The required key does not exist."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -215,9 +215,10 @@ class ModuleSerializer(DynamicBaseSerializer):
|
||||
|
||||
class ModuleDetailSerializer(ModuleSerializer):
|
||||
link_module = ModuleLinkSerializer(read_only=True, many=True)
|
||||
sub_issues = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta(ModuleSerializer.Meta):
|
||||
fields = ModuleSerializer.Meta.fields + ["link_module"]
|
||||
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"]
|
||||
|
||||
|
||||
class ModuleFavoriteSerializer(BaseSerializer):
|
||||
|
||||
@@ -102,6 +102,12 @@ class ProjectLiteSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class ProjectListSerializer(DynamicBaseSerializer):
|
||||
total_issues = serializers.IntegerField(read_only=True)
|
||||
archived_issues = serializers.IntegerField(read_only=True)
|
||||
archived_sub_issues = serializers.IntegerField(read_only=True)
|
||||
draft_issues = serializers.IntegerField(read_only=True)
|
||||
draft_sub_issues = serializers.IntegerField(read_only=True)
|
||||
sub_issues = serializers.IntegerField(read_only=True)
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
total_cycles = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
# Python imports
|
||||
import zoneinfo
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Django imports
|
||||
from django.urls import resolve
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.db import IntegrityError
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
|
||||
# Third part imports
|
||||
from rest_framework import status
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from sentry_sdk import capture_exception
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
# Module imports
|
||||
from plane.utils.paginator import BasePaginator
|
||||
from plane.bgtasks.webhook_task import send_webhook
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.paginator import BasePaginator
|
||||
|
||||
|
||||
class TimezoneMixin:
|
||||
@@ -87,7 +87,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
|
||||
try:
|
||||
return self.model.objects.all()
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
raise APIException(
|
||||
"Please check the view", status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
@@ -121,13 +121,13 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
|
||||
)
|
||||
|
||||
if isinstance(e, KeyError):
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return Response(
|
||||
{"error": "The required key does not exist."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -233,9 +233,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -15,10 +15,7 @@ from django.db.models import (
|
||||
Value,
|
||||
CharField,
|
||||
)
|
||||
from django.core import serializers
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import UUIDField
|
||||
@@ -32,9 +29,7 @@ from rest_framework import status
|
||||
from .. import BaseViewSet, BaseAPIView, WebhookMixin
|
||||
from plane.app.serializers import (
|
||||
CycleSerializer,
|
||||
CycleIssueSerializer,
|
||||
CycleFavoriteSerializer,
|
||||
IssueSerializer,
|
||||
CycleWriteSerializer,
|
||||
CycleUserPropertiesSerializer,
|
||||
)
|
||||
@@ -48,13 +43,10 @@ from plane.db.models import (
|
||||
CycleIssue,
|
||||
Issue,
|
||||
CycleFavorite,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
Label,
|
||||
CycleUserProperties,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
|
||||
@@ -106,15 +98,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
)
|
||||
.annotate(is_favorite=Exists(favorite_subquery))
|
||||
.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",
|
||||
@@ -201,7 +184,15 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
queryset = self.get_queryset()
|
||||
queryset = self.get_queryset().annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
cycle_view = request.GET.get("cycle_view", "all")
|
||||
|
||||
# Update the order by
|
||||
@@ -327,13 +318,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
}
|
||||
|
||||
if data[0]["start_date"] and data[0]["end_date"]:
|
||||
data[0]["distribution"][
|
||||
"completion_chart"
|
||||
] = burndown_plot(
|
||||
queryset=queryset.first(),
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
cycle_id=data[0]["id"],
|
||||
data[0]["distribution"]["completion_chart"] = (
|
||||
burndown_plot(
|
||||
queryset=queryset.first(),
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
cycle_id=data[0]["id"],
|
||||
)
|
||||
)
|
||||
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
@@ -355,8 +346,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
"external_id",
|
||||
"progress_snapshot",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"is_favorite",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
@@ -402,7 +393,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
"progress_snapshot",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
@@ -474,7 +464,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
"progress_snapshot",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
@@ -487,10 +476,42 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
queryset = self.get_queryset().filter(pk=pk)
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
data = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.annotate(
|
||||
total_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
parent__isnull=True,
|
||||
issue_cycle__cycle_id=pk,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
parent__isnull=False,
|
||||
issue_cycle__cycle_id=pk,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.values(
|
||||
# necessary fields
|
||||
"id",
|
||||
@@ -507,6 +528,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
"external_source",
|
||||
"external_id",
|
||||
"progress_snapshot",
|
||||
"sub_issues",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
|
||||
@@ -38,7 +38,6 @@ from plane.db.models import (
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueRelation,
|
||||
IssueAssignee,
|
||||
User,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# Python imports
|
||||
import json
|
||||
import random
|
||||
from itertools import chain
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
@@ -21,64 +19,38 @@ from django.db.models import (
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.db import IntegrityError
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Value, UUIDField
|
||||
from django.db.models import UUIDField
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, BaseAPIView, WebhookMixin
|
||||
from plane.app.serializers import (
|
||||
IssueActivitySerializer,
|
||||
IssueCommentSerializer,
|
||||
IssuePropertySerializer,
|
||||
IssueSerializer,
|
||||
IssueCreateSerializer,
|
||||
LabelSerializer,
|
||||
IssueFlatSerializer,
|
||||
IssueLinkSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
IssueSubscriberSerializer,
|
||||
ProjectMemberLiteSerializer,
|
||||
IssueReactionSerializer,
|
||||
CommentReactionSerializer,
|
||||
IssueRelationSerializer,
|
||||
RelatedIssueSerializer,
|
||||
IssueDetailSerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
WorkSpaceAdminPermission,
|
||||
ProjectMemberPermission,
|
||||
ProjectLitePermission,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
IssueComment,
|
||||
IssueProperty,
|
||||
Label,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
ProjectMember,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
IssueRelation,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from collections import defaultdict
|
||||
from plane.utils.cache import invalidate_cache
|
||||
|
||||
class IssueListEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
|
||||
# Django Imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import Prefetch, F, OuterRef, Exists, Count, Q
|
||||
from django.db.models import Prefetch, F, OuterRef, Exists, Count, Q, Func
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Value, UUIDField
|
||||
@@ -183,7 +183,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
"external_id",
|
||||
# computed fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
@@ -224,8 +223,8 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
"external_source",
|
||||
"external_id",
|
||||
# computed fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"is_favorite",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
@@ -237,7 +236,30 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
return Response(modules, status=status.HTTP_200_OK)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
queryset = self.get_queryset().filter(pk=pk)
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.annotate(
|
||||
total_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
parent__isnull=True,
|
||||
issue_module__module_id=pk,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
parent__isnull=False,
|
||||
issue_module__module_id=pk,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
@@ -380,7 +402,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
"external_id",
|
||||
# computed fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
|
||||
@@ -46,9 +46,11 @@ from plane.db.models import (
|
||||
Inbox,
|
||||
ProjectDeployBoard,
|
||||
IssueProperty,
|
||||
Issue,
|
||||
)
|
||||
from plane.utils.cache import cache_response
|
||||
|
||||
|
||||
class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
serializer_class = ProjectListSerializer
|
||||
model = Project
|
||||
@@ -171,6 +173,73 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
).data
|
||||
return Response(projects, status=status.HTTP_200_OK)
|
||||
|
||||
def retrieve(self, request, slug, pk):
|
||||
project = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.annotate(
|
||||
total_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
parent__isnull=True,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
parent__isnull=False,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
archived_issues=Issue.objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
archived_at__isnull=False,
|
||||
parent__isnull=True,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
archived_sub_issues=Issue.objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
archived_at__isnull=False,
|
||||
parent__isnull=False,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
draft_issues=Issue.objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
is_draft=True,
|
||||
parent__isnull=True,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
draft_sub_issues=Issue.objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
is_draft=True,
|
||||
parent__isnull=False,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
).first()
|
||||
|
||||
serializer = ProjectListSerializer(project)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def create(self, request, slug):
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
@@ -277,12 +346,12 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
except Workspace.DoesNotExist as e:
|
||||
except Workspace.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace does not exist"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except serializers.ValidationError as e:
|
||||
except serializers.ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
@@ -341,7 +410,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
{"error": "Project does not exist"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except serializers.ValidationError as e:
|
||||
except serializers.ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
@@ -471,6 +540,7 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
# Cache the below api for 24 hours
|
||||
@cache_response(60 * 60 * 24, user=False)
|
||||
def get(self, request):
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
# Python imports
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
row_mapping = {
|
||||
"state__name": "State",
|
||||
@@ -210,9 +210,9 @@ def generate_segmented_rows(
|
||||
None,
|
||||
)
|
||||
if assignee:
|
||||
generated_row[
|
||||
0
|
||||
] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
|
||||
generated_row[0] = (
|
||||
f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
|
||||
)
|
||||
|
||||
if x_axis == LABEL_ID:
|
||||
label = next(
|
||||
@@ -279,9 +279,9 @@ def generate_segmented_rows(
|
||||
None,
|
||||
)
|
||||
if assignee:
|
||||
row_zero[
|
||||
index + 2
|
||||
] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
|
||||
row_zero[index + 2] = (
|
||||
f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
|
||||
)
|
||||
|
||||
if segmented == LABEL_ID:
|
||||
for index, segm in enumerate(row_zero[2:]):
|
||||
@@ -366,9 +366,9 @@ def generate_non_segmented_rows(
|
||||
None,
|
||||
)
|
||||
if assignee:
|
||||
row[
|
||||
0
|
||||
] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
|
||||
row[0] = (
|
||||
f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
|
||||
)
|
||||
|
||||
if x_axis == LABEL_ID:
|
||||
label = next(
|
||||
@@ -504,10 +504,8 @@ def analytic_export_task(email, data, slug):
|
||||
|
||||
csv_buffer = generate_csv_from_rows(rows)
|
||||
send_export_email(email, slug, csv_buffer, rows)
|
||||
logging.getLogger("plane").info("Email sent succesfully.")
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import EmailNotificationLog, User, Issue
|
||||
from plane.db.models import EmailNotificationLog, Issue, User
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.settings.redis import redis_instance
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
# acquire and delete redis lock
|
||||
@@ -69,7 +70,9 @@ def stack_email_notification():
|
||||
receiver_notification.get("entity_identifier"), {}
|
||||
).setdefault(
|
||||
str(receiver_notification.get("triggered_by_id")), []
|
||||
).append(receiver_notification.get("data"))
|
||||
).append(
|
||||
receiver_notification.get("data")
|
||||
)
|
||||
# append processed notifications
|
||||
processed_notifications.append(receiver_notification.get("id"))
|
||||
email_notification_ids.append(receiver_notification.get("id"))
|
||||
@@ -296,7 +299,9 @@ def send_email_notification(
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane").info("Email Sent Successfully")
|
||||
|
||||
# Update the logs
|
||||
EmailNotificationLog.objects.filter(
|
||||
pk__in=email_notification_ids
|
||||
).update(sent_at=timezone.now())
|
||||
@@ -305,15 +310,20 @@ def send_email_notification(
|
||||
release_lock(lock_id=lock_id)
|
||||
return
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
# release the lock
|
||||
release_lock(lock_id=lock_id)
|
||||
return
|
||||
else:
|
||||
print("Duplicate task recived. Skipping...")
|
||||
logging.getLogger("plane").info(
|
||||
"Duplicate email received skipping"
|
||||
)
|
||||
return
|
||||
except (Issue.DoesNotExist, User.DoesNotExist) as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
log_exception(e)
|
||||
release_lock(lock_id=lock_id)
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
release_lock(lock_id=lock_id)
|
||||
return
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import uuid
|
||||
import os
|
||||
import uuid
|
||||
|
||||
# third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
from posthog import Posthog
|
||||
|
||||
# module imports
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
def posthogConfiguration():
|
||||
@@ -51,7 +51,8 @@ def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
@@ -77,4 +78,5 @@ def workspace_invite_event(
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
@@ -2,21 +2,22 @@
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import boto3
|
||||
import zipfile
|
||||
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
from botocore.client import Config
|
||||
from openpyxl import Workbook
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue, ExporterHistory
|
||||
from plane.db.models import ExporterHistory, Issue
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
def dateTimeConverter(time):
|
||||
@@ -403,8 +404,5 @@ def issue_export_task(
|
||||
exporter_instance.status = "failed"
|
||||
exporter_instance.reason = str(e)
|
||||
exporter_instance.save(update_fields=["status", "reason"])
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# Python import
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
@@ -60,10 +60,8 @@ def forgot_password(first_name, email, uidb64, token, current_site):
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane").info("Email sent successfully")
|
||||
return
|
||||
except Exception as e:
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
@@ -1,34 +1,36 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
from plane.app.serializers import IssueActivitySerializer
|
||||
from plane.bgtasks.notification_task import notifications
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Issue,
|
||||
Project,
|
||||
Label,
|
||||
IssueActivity,
|
||||
State,
|
||||
Cycle,
|
||||
Module,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
Cycle,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
IssueComment,
|
||||
IssueReaction,
|
||||
IssueSubscriber,
|
||||
Label,
|
||||
Module,
|
||||
Project,
|
||||
State,
|
||||
User,
|
||||
)
|
||||
from plane.app.serializers import IssueActivitySerializer
|
||||
from plane.bgtasks.notification_task import notifications
|
||||
from plane.settings.redis import redis_instance
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
# Track Changes in name
|
||||
@@ -1647,7 +1649,7 @@ def issue_activity(
|
||||
headers=headers,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
|
||||
if notification:
|
||||
notifications.delay(
|
||||
@@ -1668,8 +1670,5 @@ def issue_activity(
|
||||
|
||||
return
|
||||
except Exception as e:
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
@@ -2,18 +2,17 @@
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
from django.db.models import Q
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue, Project, State
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import Issue, Project, State
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
@@ -96,9 +95,7 @@ def archive_old_issues():
|
||||
]
|
||||
return
|
||||
except Exception as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@@ -179,7 +176,5 @@ def close_old_issues():
|
||||
]
|
||||
return
|
||||
except Exception as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
@@ -52,11 +52,8 @@ def magic_link(email, key, token, current_site):
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane").info("Email sent successfully.")
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# Python import
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Project, User, ProjectMemberInvite
|
||||
from plane.db.models import Project, ProjectMemberInvite, User
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
@@ -73,12 +73,10 @@ def project_invitation(email, project_id, token, current_site, invitor):
|
||||
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane").info("Email sent successfully.")
|
||||
return
|
||||
except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist):
|
||||
return
|
||||
except Exception as e:
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
@@ -1,44 +1,45 @@
|
||||
import requests
|
||||
import uuid
|
||||
import hashlib
|
||||
import json
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
import requests
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
from plane.db.models import (
|
||||
Webhook,
|
||||
WebhookLog,
|
||||
Project,
|
||||
Issue,
|
||||
Cycle,
|
||||
Module,
|
||||
ModuleIssue,
|
||||
CycleIssue,
|
||||
IssueComment,
|
||||
User,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
ProjectSerializer,
|
||||
CycleSerializer,
|
||||
ModuleSerializer,
|
||||
CycleIssueSerializer,
|
||||
ModuleIssueSerializer,
|
||||
IssueCommentSerializer,
|
||||
IssueExpandSerializer,
|
||||
)
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Module imports
|
||||
from plane.api.serializers import (
|
||||
CycleIssueSerializer,
|
||||
CycleSerializer,
|
||||
IssueCommentSerializer,
|
||||
IssueExpandSerializer,
|
||||
ModuleIssueSerializer,
|
||||
ModuleSerializer,
|
||||
ProjectSerializer,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
Issue,
|
||||
IssueComment,
|
||||
Module,
|
||||
ModuleIssue,
|
||||
Project,
|
||||
User,
|
||||
Webhook,
|
||||
WebhookLog,
|
||||
)
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
SERIALIZER_MAPPER = {
|
||||
"project": ProjectSerializer,
|
||||
@@ -174,7 +175,7 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site):
|
||||
except Exception as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@@ -241,7 +242,7 @@ def send_webhook(event, payload, kw, action, slug, bulk, current_site):
|
||||
except Exception as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@@ -295,8 +296,8 @@ def send_webhook_deactivation_email(
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
|
||||
logging.getLogger("plane").info("Email sent successfully.")
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Workspace, WorkspaceMemberInvite, User
|
||||
from plane.db.models import User, Workspace, WorkspaceMemberInvite
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
@@ -76,14 +76,12 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane").info("Email sent succesfully")
|
||||
|
||||
return
|
||||
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist):
|
||||
print("Workspace or WorkspaceMember Invite Does not exists")
|
||||
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e:
|
||||
log_exception(e)
|
||||
return
|
||||
except Exception as e:
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Django imports
|
||||
from django.core.cache import cache
|
||||
from django.core.management import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Clear Cache before starting the server to remove stale values"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
cache.clear()
|
||||
self.stdout.write(self.style.SUCCESS("Cache Cleared"))
|
||||
return
|
||||
except Exception:
|
||||
# Another ClientError occurred
|
||||
self.stdout.write(self.style.ERROR("Failed to clear cache"))
|
||||
return
|
||||
@@ -0,0 +1,61 @@
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.core.management import BaseCommand, CommandError
|
||||
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Django command to pause execution until db is available"""
|
||||
|
||||
def add_arguments(self, parser):
|
||||
# Positional argument
|
||||
parser.add_argument("to_email", type=str, help="receiver's email")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
receiver_email = options.get("to_email")
|
||||
|
||||
if not receiver_email:
|
||||
raise CommandError("Reciever email is required")
|
||||
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
timeout=30,
|
||||
)
|
||||
# Prepare email details
|
||||
subject = "Email Notification from Plane"
|
||||
message = (
|
||||
"This is a sample email notification sent from Plane application."
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Trying to send test email..."))
|
||||
|
||||
# Send the email
|
||||
try:
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=message,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[receiver_email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.send()
|
||||
self.stdout.write(self.style.SUCCESS("Email succesfully sent"))
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Error: Email could not be delivered due to {e}"
|
||||
)
|
||||
)
|
||||
@@ -1,16 +1,17 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
import string
|
||||
import random
|
||||
import string
|
||||
import uuid
|
||||
|
||||
import pytz
|
||||
from django.contrib.auth.models import (
|
||||
AbstractBaseUser,
|
||||
PermissionsMixin,
|
||||
UserManager,
|
||||
)
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import (
|
||||
AbstractBaseUser,
|
||||
UserManager,
|
||||
PermissionsMixin,
|
||||
)
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -3,19 +3,20 @@
|
||||
# Python imports
|
||||
import os
|
||||
import ssl
|
||||
import certifi
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Django imports
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
import certifi
|
||||
|
||||
# Third party imports
|
||||
import dj_database_url
|
||||
import sentry_sdk
|
||||
|
||||
# Django imports
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@@ -23,7 +24,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = False
|
||||
DEBUG = int(os.environ.get("DEBUG", "0"))
|
||||
|
||||
# Allowed Hosts
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
@@ -7,8 +7,8 @@ from .common import * # noqa
|
||||
DEBUG = True
|
||||
|
||||
# Debug Toolbar settings
|
||||
INSTALLED_APPS += ("debug_toolbar",)
|
||||
MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",)
|
||||
INSTALLED_APPS += ("debug_toolbar",) # noqa
|
||||
MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) # noqa
|
||||
|
||||
DEBUG_TOOLBAR_PATCH_SETTINGS = False
|
||||
|
||||
@@ -18,7 +18,7 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": REDIS_URL,
|
||||
"LOCATION": REDIS_URL, # noqa
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
@@ -28,7 +28,7 @@ CACHES = {
|
||||
INTERNAL_IPS = ("127.0.0.1",)
|
||||
|
||||
MEDIA_URL = "/uploads/"
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads") # noqa
|
||||
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:3000",
|
||||
@@ -36,3 +36,38 @@ CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:4000",
|
||||
"http://127.0.0.1:4000",
|
||||
]
|
||||
|
||||
LOG_DIR = os.path.join(BASE_DIR, "logs") # noqa
|
||||
|
||||
if not os.path.exists(LOG_DIR):
|
||||
os.makedirs(LOG_DIR)
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": "DEBUG",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"django.request": {
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
"plane": {
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"""Production settings"""
|
||||
|
||||
import os
|
||||
|
||||
from .common import * # noqa
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = int(os.environ.get("DEBUG", 0)) == 1
|
||||
|
||||
DEBUG = True
|
||||
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
INSTALLED_APPS += ("scout_apm.django",)
|
||||
INSTALLED_APPS += ("scout_apm.django",) # noqa
|
||||
|
||||
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
@@ -18,3 +19,62 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False)
|
||||
SCOUT_KEY = os.environ.get("SCOUT_KEY", "")
|
||||
SCOUT_NAME = "Plane"
|
||||
|
||||
LOG_DIR = os.path.join(BASE_DIR, "logs") # noqa
|
||||
|
||||
if not os.path.exists(LOG_DIR):
|
||||
os.makedirs(LOG_DIR)
|
||||
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
"json": {
|
||||
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
|
||||
"fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose",
|
||||
"level": "INFO",
|
||||
},
|
||||
"file": {
|
||||
"class": "plane.utils.logging.SizedTimedRotatingFileHandler",
|
||||
"filename": (
|
||||
os.path.join(BASE_DIR, "logs", "plane-debug.log") # noqa
|
||||
if DEBUG
|
||||
else os.path.join(BASE_DIR, "logs", "plane-error.log") # noqa
|
||||
),
|
||||
"when": "s",
|
||||
"maxBytes": 1024 * 1024 * 1,
|
||||
"interval": 1,
|
||||
"backupCount": 5,
|
||||
"formatter": "json",
|
||||
"level": "DEBUG" if DEBUG else "ERROR",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"django": {
|
||||
"handlers": ["console", "file"],
|
||||
"level": "INFO",
|
||||
"propagate": True,
|
||||
},
|
||||
"django.request": {
|
||||
"handlers": ["console", "file"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"plane": {
|
||||
"level": "DEBUG" if DEBUG else "ERROR",
|
||||
"handlers": ["console", "file"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@ DEBUG = True
|
||||
# Send it in a dummy outbox
|
||||
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
|
||||
|
||||
INSTALLED_APPS.append(
|
||||
INSTALLED_APPS.append( # noqa
|
||||
"plane.tests",
|
||||
)
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
# Python imports
|
||||
import zoneinfo
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Django imports
|
||||
from django.urls import resolve
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.db import IntegrityError
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
|
||||
# Third part imports
|
||||
from rest_framework import status
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from sentry_sdk import capture_exception
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
# Module imports
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.paginator import BasePaginator
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
|
||||
try:
|
||||
return self.model.objects.all()
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
raise APIException(
|
||||
"Please check the view", status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
@@ -90,14 +90,13 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
|
||||
)
|
||||
|
||||
if isinstance(e, KeyError):
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return Response(
|
||||
{"error": "The required key does not exist."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
print(e) if settings.DEBUG else print("Server Error")
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -185,9 +184,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
log_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from django.core.cache import cache
|
||||
# from django.utils.encoding import force_bytes
|
||||
# import hashlib
|
||||
# Python imports
|
||||
from functools import wraps
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
@@ -22,21 +26,20 @@ def cache_response(timeout=60 * 60, path=None, user=True):
|
||||
def _wrapped_view(instance, request, *args, **kwargs):
|
||||
# Function to generate cache key
|
||||
auth_header = (
|
||||
None if request.user.is_anonymous else str(request.user.id) if user else None
|
||||
None
|
||||
if request.user.is_anonymous
|
||||
else str(request.user.id) if user else None
|
||||
)
|
||||
custom_path = path if path is not None else request.get_full_path()
|
||||
key = generate_cache_key(custom_path, auth_header)
|
||||
cached_result = cache.get(key)
|
||||
if cached_result is not None:
|
||||
print("Cache Hit")
|
||||
return Response(
|
||||
cached_result["data"], status=cached_result["status"]
|
||||
)
|
||||
|
||||
print("Cache Miss")
|
||||
response = view_func(instance, request, *args, **kwargs)
|
||||
|
||||
if response.status_code == 200:
|
||||
if response.status_code == 200 and not settings.DEBUG:
|
||||
cache.set(
|
||||
key,
|
||||
{"data": response.data, "status": response.status_code},
|
||||
@@ -71,11 +74,12 @@ def invalidate_cache(path=None, url_params=False, user=True):
|
||||
)
|
||||
|
||||
auth_header = (
|
||||
None if request.user.is_anonymous else str(request.user.id) if user else None
|
||||
None
|
||||
if request.user.is_anonymous
|
||||
else str(request.user.id) if user else None
|
||||
)
|
||||
key = generate_cache_key(custom_path, auth_header)
|
||||
cache.delete(key)
|
||||
print("Invalidating cache")
|
||||
# Execute the view function
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
|
||||
def log_exception(e):
|
||||
# Log the error
|
||||
logger = logging.getLogger("plane")
|
||||
logger.error(e)
|
||||
|
||||
# Capture in sentry if configured
|
||||
capture_exception(e)
|
||||
return
|
||||
@@ -0,0 +1,46 @@
|
||||
import logging.handlers as handlers
|
||||
import time
|
||||
|
||||
|
||||
class SizedTimedRotatingFileHandler(handlers.TimedRotatingFileHandler):
|
||||
"""
|
||||
Handler for logging to a set of files, which switches from one file
|
||||
to the next when the current file reaches a certain size, or at certain
|
||||
timed intervals
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
filename,
|
||||
maxBytes=0,
|
||||
backupCount=0,
|
||||
encoding=None,
|
||||
delay=0,
|
||||
when="h",
|
||||
interval=1,
|
||||
utc=False,
|
||||
):
|
||||
handlers.TimedRotatingFileHandler.__init__(
|
||||
self, filename, when, interval, backupCount, encoding, delay, utc
|
||||
)
|
||||
self.maxBytes = maxBytes
|
||||
|
||||
def shouldRollover(self, record):
|
||||
"""
|
||||
Determine if rollover should occur.
|
||||
|
||||
Basically, see if the supplied record would cause the file to exceed
|
||||
the size limit we have.
|
||||
"""
|
||||
if self.stream is None: # delay was set...
|
||||
self.stream = self._open()
|
||||
if self.maxBytes > 0: # are we rolling over?
|
||||
msg = "%s\n" % self.format(record)
|
||||
# due to non-posix-compliant Windows feature
|
||||
self.stream.seek(0, 2)
|
||||
if self.stream.tell() + len(msg) >= self.maxBytes:
|
||||
return 1
|
||||
t = int(time.time())
|
||||
if t >= self.rolloverAt:
|
||||
return 1
|
||||
return 0
|
||||
@@ -27,6 +27,7 @@ psycopg-binary==3.1.12
|
||||
psycopg-c==3.1.12
|
||||
scout-apm==2.26.1
|
||||
openpyxl==3.1.2
|
||||
python-json-logger==2.0.7
|
||||
beautifulsoup4==4.12.2
|
||||
dj-database-url==2.1.0
|
||||
posthog==3.0.2
|
||||
|
||||
+50
-53
@@ -1,82 +1,79 @@
|
||||
# 1-Click Self-Hosting
|
||||
# One-click deploy
|
||||
|
||||
In this guide, we will walk you through the process of setting up a 1-click self-hosted environment. Self-hosting allows you to have full control over your applications and data. It's a great way to ensure privacy, control, and customization.
|
||||
Deployment methods for Plane have improved significantly to make self-managing super-easy. One of those is a single-line-command installation of Plane.
|
||||
|
||||
Let's get started!
|
||||
This short guide will guide you through the process, the background tasks that run with the command for the Community, One, and Enterprise editions, and the post-deployment configuration options available to you.
|
||||
|
||||
## Installing Plane
|
||||
### Requirements
|
||||
|
||||
Installing Plane is a very easy and minimal step process.
|
||||
- Operating systems: Debian, Ubuntu, CentOS
|
||||
- Supported CPU architectures: AMD64, ARM64, x86_64, AArch64
|
||||
|
||||
### Prerequisite
|
||||
### Download the latest stable release
|
||||
|
||||
- Operating System (latest): Debian / Ubuntu / Centos
|
||||
- Supported CPU Architechture: AMD64 / ARM64 / x86_64 / aarch64
|
||||
|
||||
### Downloading Latest Stable Release
|
||||
Run ↓ on any CLI.
|
||||
|
||||
```
|
||||
curl -fsSL https://raw.githubusercontent.com/makeplane/plane/master/deploy/1-click/install.sh | sh -
|
||||
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Downloading Preview Release</summary>
|
||||
### Download the Preview release
|
||||
|
||||
`Preview` builds do not support ARM64, AArch64 CPU architectures
|
||||
|
||||
Run ↓ on any CLI.
|
||||
|
||||
```
|
||||
export BRANCH=preview
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/makeplane/plane/preview/deploy/1-click/install.sh | sh -
|
||||
|
||||
```
|
||||
|
||||
NOTE: `Preview` builds do not support ARM64/AARCH64 CPU architecture
|
||||
|
||||
</details>
|
||||
|
||||
--
|
||||
|
||||
Expect this after a successful install
|
||||
|
||||

|
||||
|
||||
Access the application on a browser via http://server-ip-address
|
||||
|
||||
---
|
||||
|
||||
### Get Control of your Plane Server Setup
|
||||
### Successful installation
|
||||
|
||||
Plane App is available via the command `plane-app`. Running the command `plane-app --help` helps you to manage Plane
|
||||
You should see ↓ if there are no hitches. That output will also list the IP address you can use to access your Plane instance.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Manage your Plane instance
|
||||
|
||||
Use `plane-app` [OPERATOR] to manage your Plane instance easily. Get a list of all operators with `plane-app ---help`.
|
||||
|
||||

|
||||
|
||||
<ins>Basic Operations</ins>:
|
||||
1. Basic operators
|
||||
|
||||
1. Start Server using `plane-app start`
|
||||
1. Stop Server using `plane-app stop`
|
||||
1. Restart Server using `plane-app restart`
|
||||
1. `plane-app start` starts the Plane server.
|
||||
2. `plane-app restart` restarts the Plane server.
|
||||
3. `plane-app stop` stops the Plane server.
|
||||
|
||||
<ins>Advanced Operations</ins>:
|
||||
2. Advanced operators
|
||||
|
||||
1. Configure Plane using `plane-app --configure`. This will give you options to modify
|
||||
`plane-app --configure` will show advanced configurators.
|
||||
|
||||
- NGINX Port (default 80)
|
||||
- Domain Name (default is the local server public IP address)
|
||||
- File Upload Size (default 5MB)
|
||||
- External Postgres DB Url (optional - default empty)
|
||||
- External Redis URL (optional - default empty)
|
||||
- AWS S3 Bucket (optional - to be configured only in case the user wants to use an S3 Bucket)
|
||||
- Change your proxy or listening port
|
||||
<br>Default: 80
|
||||
- Change your domain name
|
||||
<br>Default: Deployed server's public IP address
|
||||
- File upload size
|
||||
<br>Default: 5MB
|
||||
- Specify external database address when using an external database
|
||||
<br>Default: `Empty`
|
||||
<br>`Default folder: /opt/plane/data/postgres`
|
||||
- Specify external Redis URL when using external Redis
|
||||
<br>Default: `Empty`
|
||||
<br>`Default folder: /opt/plane/data/redis`
|
||||
- Configure AWS S3 bucket
|
||||
<br>Use only when you or your users want to use S3
|
||||
<br>`Default folder: /opt/plane/data/minio`
|
||||
|
||||
1. Upgrade Plane using `plane-app --upgrade`. This will get the latest stable version of Plane files (docker-compose.yaml, .env, and docker images)
|
||||
3. Version operators
|
||||
|
||||
1. Updating Plane App installer using `plane-app --update-installer` will update the `plane-app` utility.
|
||||
|
||||
1. Uninstall Plane using `plane-app --uninstall`. This will uninstall the Plane application from the server and all docker containers but do not remove the data stored in Postgres, Redis, and Minio.
|
||||
|
||||
1. Plane App can be reinstalled using `plane-app --install`.
|
||||
|
||||
<ins>Application Data is stored in the mentioned folders</ins>:
|
||||
|
||||
1. DB Data: /opt/plane/data/postgres
|
||||
1. Redis Data: /opt/plane/data/redis
|
||||
1. Minio Data: /opt/plane/data/minio
|
||||
1. `plane-app --upgrade` gets the latest stable version of `docker-compose.yaml`, `.env`, and Docker images
|
||||
2. `plane-app --update-installer` updates the installer and the `plane-app` utility.
|
||||
3. `plane-app --uninstall` uninstalls the Plane application and all Docker containers from the server but leaves the data stored in
|
||||
Postgres, Redis, and Minio alone.
|
||||
4. `plane-app --install` installs the Plane app again.
|
||||
|
||||
@@ -494,13 +494,6 @@ function install() {
|
||||
update_env "CORS_ALLOWED_ORIGINS" "http://$MY_IP"
|
||||
|
||||
update_config "INSTALLATION_DATE" "$(date '+%Y-%m-%d')"
|
||||
|
||||
if command -v crontab &> /dev/null; then
|
||||
sudo touch /etc/cron.daily/makeplane
|
||||
sudo chmod +x /etc/cron.daily/makeplane
|
||||
sudo echo "0 2 * * * root /usr/local/bin/plane-app --upgrade" > /etc/cron.daily/makeplane
|
||||
sudo crontab /etc/cron.daily/makeplane
|
||||
fi
|
||||
|
||||
show_message "Plane Installed Successfully ✅"
|
||||
show_message ""
|
||||
@@ -606,11 +599,6 @@ function uninstall() {
|
||||
sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
|
||||
sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null
|
||||
sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
|
||||
|
||||
if command -v crontab &> /dev/null; then
|
||||
sudo crontab -r &> /dev/null
|
||||
sudo rm /etc/cron.daily/makeplane &> /dev/null
|
||||
fi
|
||||
|
||||
# rm -rf $PLANE_INSTALL_DIR &> /dev/null
|
||||
show_message "- Configuration Cleaned ✅"
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
version: "3.8"
|
||||
|
||||
x-app-env : &app-env
|
||||
x-app-env: &app-env
|
||||
environment:
|
||||
- NGINX_PORT=${NGINX_PORT:-80}
|
||||
- WEB_URL=${WEB_URL:-http://localhost}
|
||||
- DEBUG=${DEBUG:-0}
|
||||
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.production} # deprecated
|
||||
- NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} # deprecated
|
||||
- SENTRY_DSN=${SENTRY_DSN:-""}
|
||||
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-""}
|
||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-""}
|
||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
|
||||
- DOCKERIZED=${DOCKERIZED:-1} # deprecated
|
||||
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""}
|
||||
# Gunicorn Workers
|
||||
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
|
||||
@@ -28,20 +22,6 @@ x-app-env : &app-env
|
||||
- REDIS_HOST=${REDIS_HOST:-plane-redis}
|
||||
- REDIS_PORT=${REDIS_PORT:-6379}
|
||||
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
|
||||
# EMAIL SETTINGS - Deprecated can be configured through admin panel
|
||||
- EMAIL_HOST=${EMAIL_HOST:-""}
|
||||
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
|
||||
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
|
||||
- EMAIL_PORT=${EMAIL_PORT:-587}
|
||||
- EMAIL_FROM=${EMAIL_FROM:-"Team Plane <team@mailer.plane.so>"}
|
||||
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
|
||||
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
|
||||
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
|
||||
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
|
||||
# LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel
|
||||
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
|
||||
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
|
||||
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
|
||||
# Application secret
|
||||
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
|
||||
# DATA STORE SETTINGS
|
||||
@@ -90,6 +70,8 @@ services:
|
||||
command: ./bin/takeoff
|
||||
deploy:
|
||||
replicas: ${API_REPLICAS:-1}
|
||||
volumes:
|
||||
- logs_api:/code/plane/logs
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
@@ -100,6 +82,8 @@ services:
|
||||
pull_policy: ${PULL_POLICY:-always}
|
||||
restart: unless-stopped
|
||||
command: ./bin/worker
|
||||
volumes:
|
||||
- logs_worker:/code/plane/logs
|
||||
depends_on:
|
||||
- api
|
||||
- plane-db
|
||||
@@ -111,6 +95,8 @@ services:
|
||||
pull_policy: ${PULL_POLICY:-always}
|
||||
restart: unless-stopped
|
||||
command: ./bin/beat
|
||||
volumes:
|
||||
- logs_beat-worker:/code/plane/logs
|
||||
depends_on:
|
||||
- api
|
||||
- plane-db
|
||||
@@ -122,8 +108,10 @@ services:
|
||||
pull_policy: ${PULL_POLICY:-always}
|
||||
restart: no
|
||||
command: >
|
||||
sh -c "python manage.py wait_for_db &&
|
||||
python manage.py migrate"
|
||||
sh -c "python manage.py wait_for_db &&
|
||||
python manage.py migrate"
|
||||
volumes:
|
||||
- logs_migrator:/code/plane/logs
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
@@ -159,7 +147,7 @@ services:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-latest}
|
||||
pull_policy: ${PULL_POLICY:-always}
|
||||
ports:
|
||||
- ${NGINX_PORT}:80
|
||||
- ${NGINX_PORT}:80
|
||||
depends_on:
|
||||
- web
|
||||
- api
|
||||
@@ -169,3 +157,7 @@ volumes:
|
||||
pgdata:
|
||||
redisdata:
|
||||
uploads:
|
||||
logs_api:
|
||||
logs_worker:
|
||||
logs_beat-worker:
|
||||
logs_migrator:
|
||||
|
||||
@@ -7,13 +7,8 @@ API_REPLICAS=1
|
||||
NGINX_PORT=80
|
||||
WEB_URL=http://localhost
|
||||
DEBUG=0
|
||||
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENVIRONMENT=production
|
||||
GOOGLE_CLIENT_ID=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
DOCKERIZED=1 # deprecated
|
||||
CORS_ALLOWED_ORIGINS=http://localhost
|
||||
|
||||
#DB SETTINGS
|
||||
@@ -30,19 +25,7 @@ REDIS_HOST=plane-redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_URL=redis://${REDIS_HOST}:6379/
|
||||
|
||||
# EMAIL SETTINGS
|
||||
EMAIL_HOST=
|
||||
EMAIL_HOST_USER=
|
||||
EMAIL_HOST_PASSWORD=
|
||||
EMAIL_PORT=587
|
||||
EMAIL_FROM=Team Plane <team@mailer.plane.so>
|
||||
EMAIL_USE_TLS=1
|
||||
EMAIL_USE_SSL=0
|
||||
|
||||
# LOGIN/SIGNUP SETTINGS
|
||||
ENABLE_SIGNUP=1
|
||||
ENABLE_EMAIL_PASSWORD=1
|
||||
ENABLE_MAGIC_LINK_LOGIN=0
|
||||
# Secret Key
|
||||
SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
|
||||
|
||||
# DATA STORE SETTINGS
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
interface EditorClassNames {
|
||||
@@ -18,6 +19,19 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// Helper function to find the parent node of a specific type
|
||||
export function findParentNodeOfType(selection: Selection, typeName: string) {
|
||||
let depth = selection.$anchor.depth;
|
||||
while (depth > 0) {
|
||||
const node = selection.$anchor.node(depth);
|
||||
if (node.type.name === typeName) {
|
||||
return { node, pos: selection.$anchor.start(depth) - 1 };
|
||||
}
|
||||
depth--;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const findTableAncestor = (node: Node | null): HTMLTableElement | null => {
|
||||
while (node !== null && node.nodeName !== "TABLE") {
|
||||
node = node.parentNode;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { ReactNode } from "react";
|
||||
import { FC, ReactNode } from "react";
|
||||
|
||||
interface EditorContainerProps {
|
||||
editor: Editor | null;
|
||||
@@ -8,17 +8,54 @@ interface EditorContainerProps {
|
||||
hideDragHandle?: () => void;
|
||||
}
|
||||
|
||||
export const EditorContainer = ({ editor, editorClassNames, hideDragHandle, children }: EditorContainerProps) => (
|
||||
<div
|
||||
id="editor-container"
|
||||
onClick={() => {
|
||||
editor?.chain().focus(undefined, { scrollIntoView: false }).run();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
hideDragHandle?.();
|
||||
}}
|
||||
className={`cursor-text ${editorClassNames}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
const { editor, editorClassNames, hideDragHandle, children } = props;
|
||||
|
||||
const handleContainerClick = () => {
|
||||
if (!editor) return;
|
||||
if (!editor.isEditable) return;
|
||||
if (editor.isFocused) return; // If editor is already focused, do nothing
|
||||
|
||||
const { selection } = editor.state;
|
||||
const currentNode = selection.$from.node();
|
||||
|
||||
editor?.chain().focus("end", { scrollIntoView: false }).run(); // Focus the editor at the end
|
||||
|
||||
if (
|
||||
currentNode.content.size === 0 && // Check if the current node is empty
|
||||
!(
|
||||
editor.isActive("orderedList") ||
|
||||
editor.isActive("bulletList") ||
|
||||
editor.isActive("taskItem") ||
|
||||
editor.isActive("table") ||
|
||||
editor.isActive("blockquote") ||
|
||||
editor.isActive("codeBlock")
|
||||
) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert a new paragraph at the end of the document
|
||||
const endPosition = editor?.state.doc.content.size;
|
||||
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run();
|
||||
|
||||
// Focus the newly added paragraph for immediate editing
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(endPosition + 1)
|
||||
.run();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id="editor-container"
|
||||
onClick={handleContainerClick}
|
||||
onMouseLeave={() => {
|
||||
hideDragHandle?.();
|
||||
}}
|
||||
className={`cursor-text ${editorClassNames}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { Editor, EditorContent } from "@tiptap/react";
|
||||
import { ReactNode } from "react";
|
||||
import { FC, ReactNode } from "react";
|
||||
import { ImageResizer } from "src/ui/extensions/image/image-resize";
|
||||
|
||||
interface EditorContentProps {
|
||||
editor: Editor | null;
|
||||
editorContentCustomClassNames: string | undefined;
|
||||
children?: ReactNode;
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = "", children }: EditorContentProps) => (
|
||||
<div className={`contentEditor ${editorContentCustomClassNames}`}>
|
||||
<EditorContent editor={editor} />
|
||||
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
|
||||
const { editor, editorContentCustomClassNames = "", tabIndex, children } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`contentEditor ${editorContentCustomClassNames}`}
|
||||
tabIndex={tabIndex}
|
||||
onFocus={() => {
|
||||
editor?.chain().focus(undefined, { scrollIntoView: false }).run();
|
||||
}}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import ImageExt from "@tiptap/extension-image";
|
||||
import { onNodeDeleted, onNodeRestored } from "src/ui/plugins/delete-image";
|
||||
import { DeleteImage } from "src/types/delete-image";
|
||||
import { RestoreImage } from "src/types/restore-image";
|
||||
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
|
||||
import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
|
||||
|
||||
interface ImageNode extends ProseMirrorNode {
|
||||
attrs: {
|
||||
@@ -18,6 +20,12 @@ const IMAGE_NODE_TYPE = "image";
|
||||
|
||||
export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => any) =>
|
||||
ImageExt.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertLineBelowImageAction,
|
||||
ArrowUp: insertLineAboveImageAction,
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
UploadImagesPlugin(cancelUploadImage),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
|
||||
export const insertLineAboveImageAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||
const { selection, doc } = editor.state;
|
||||
const { $from, $to } = selection;
|
||||
|
||||
let imageNode: ProseMirrorNode | null = null;
|
||||
let imagePos: number | null = null;
|
||||
|
||||
// Check if the selection itself is an image node
|
||||
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
|
||||
if (node.type.name === "image") {
|
||||
imageNode = node;
|
||||
imagePos = pos;
|
||||
return false; // Stop iterating once an image node is found
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (imageNode === null || imagePos === null) return false;
|
||||
|
||||
// Since we want to insert above the image, we use the imagePos directly
|
||||
const insertPos = imagePos;
|
||||
|
||||
if (insertPos < 0) return false;
|
||||
|
||||
// Check for an existing node immediately before the image
|
||||
if (insertPos === 0) {
|
||||
// If the previous node doesn't exist or isn't a paragraph, create and insert a new empty node there
|
||||
editor.chain().insertContentAt(insertPos, { type: "paragraph" }).run();
|
||||
editor.chain().setTextSelection(insertPos).run();
|
||||
} else {
|
||||
const prevNode = doc.nodeAt(insertPos);
|
||||
|
||||
if (prevNode && prevNode.type.name === "paragraph") {
|
||||
// If the previous node is a paragraph, move the cursor there
|
||||
editor.chain().setTextSelection(insertPos).run();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
|
||||
export const insertLineBelowImageAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||
const { selection, doc } = editor.state;
|
||||
const { $from, $to } = selection;
|
||||
|
||||
let imageNode: ProseMirrorNode | null = null;
|
||||
let imagePos: number | null = null;
|
||||
|
||||
// Check if the selection itself is an image node
|
||||
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
|
||||
if (node.type.name === "image") {
|
||||
imageNode = node;
|
||||
imagePos = pos;
|
||||
return false; // Stop iterating once an image node is found
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (imageNode === null || imagePos === null) return false;
|
||||
|
||||
const guaranteedImageNode: ProseMirrorNode = imageNode;
|
||||
const nextNodePos = imagePos + guaranteedImageNode.nodeSize;
|
||||
|
||||
// Check for an existing node immediately after the image
|
||||
const nextNode = doc.nodeAt(nextNodePos);
|
||||
|
||||
if (nextNode && nextNode.type.name === "paragraph") {
|
||||
// If the next node is a paragraph, move the cursor there
|
||||
const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1;
|
||||
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||
} else if (!nextNode) {
|
||||
// If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there
|
||||
editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run();
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(nextNodePos + 1)
|
||||
.run();
|
||||
} else {
|
||||
// If the next node is not a paragraph, do not proceed
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -27,7 +27,7 @@ import { RestoreImage } from "src/types/restore-image";
|
||||
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
||||
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
|
||||
import { CustomTypographyExtension } from "src/ui/extensions/typography";
|
||||
import { CustomHorizontalRule } from "./horizontal-rule/horizontal-rule";
|
||||
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
|
||||
|
||||
export const CoreEditorExtensions = (
|
||||
mentionConfig: {
|
||||
@@ -66,7 +66,6 @@ export const CoreEditorExtensions = (
|
||||
CustomQuoteExtension.configure({
|
||||
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
|
||||
}),
|
||||
|
||||
CustomHorizontalRule.configure({
|
||||
HTMLAttributes: { class: "mt-4 mb-4" },
|
||||
}),
|
||||
|
||||
@@ -25,6 +25,8 @@ import { tableControls } from "src/ui/extensions/table/table/table-controls";
|
||||
import { TableView } from "src/ui/extensions/table/table/table-view";
|
||||
import { createTable } from "src/ui/extensions/table/table/utilities/create-table";
|
||||
import { deleteTableWhenAllCellsSelected } from "src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected";
|
||||
import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action";
|
||||
import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action";
|
||||
|
||||
export interface TableOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
@@ -231,6 +233,8 @@ export const Table = Node.create({
|
||||
"Mod-Backspace": deleteTableWhenAllCellsSelected,
|
||||
Delete: deleteTableWhenAllCellsSelected,
|
||||
"Mod-Delete": deleteTableWhenAllCellsSelected,
|
||||
ArrowDown: insertLineBelowTableAction,
|
||||
ArrowUp: insertLineAboveTableAction,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
import { findParentNodeOfType } from "src/lib/utils";
|
||||
|
||||
export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||
// Check if the current selection or the closest node is a table
|
||||
if (!editor.isActive("table")) return false;
|
||||
|
||||
// Get the current selection
|
||||
const { selection } = editor.state;
|
||||
|
||||
// Find the table node and its position
|
||||
const tableNode = findParentNodeOfType(selection, "table");
|
||||
if (!tableNode) return false;
|
||||
|
||||
const tablePos = tableNode.pos;
|
||||
|
||||
// Determine if the selection is in the first row of the table
|
||||
const firstRow = tableNode.node.child(0);
|
||||
const selectionPath = (selection.$anchor as any).path;
|
||||
const selectionInFirstRow = selectionPath.includes(firstRow);
|
||||
|
||||
if (!selectionInFirstRow) return false;
|
||||
|
||||
// Check if the table is at the very start of the document or its parent node
|
||||
if (tablePos === 0) {
|
||||
// The table is at the start, so just insert a paragraph at the current position
|
||||
editor.chain().insertContentAt(tablePos, { type: "paragraph" }).run();
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(tablePos + 1)
|
||||
.run();
|
||||
} else {
|
||||
// The table is not at the start, check for the node immediately before the table
|
||||
const prevNodePos = tablePos - 1;
|
||||
|
||||
if (prevNodePos <= 0) return false;
|
||||
|
||||
const prevNode = editor.state.doc.nodeAt(prevNodePos - 1);
|
||||
|
||||
if (prevNode && prevNode.type.name === "paragraph") {
|
||||
// If there's a paragraph before the table, move the cursor to the end of that paragraph
|
||||
const endOfParagraphPos = tablePos - prevNode.nodeSize;
|
||||
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
import { findParentNodeOfType } from "src/lib/utils";
|
||||
|
||||
export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||
// Check if the current selection or the closest node is a table
|
||||
if (!editor.isActive("table")) return false;
|
||||
|
||||
// Get the current selection
|
||||
const { selection } = editor.state;
|
||||
|
||||
// Find the table node and its position
|
||||
const tableNode = findParentNodeOfType(selection, "table");
|
||||
if (!tableNode) return false;
|
||||
|
||||
const tablePos = tableNode.pos;
|
||||
const table = tableNode.node;
|
||||
|
||||
// Determine if the selection is in the last row of the table
|
||||
const rowCount = table.childCount;
|
||||
const lastRow = table.child(rowCount - 1);
|
||||
const selectionPath = (selection.$anchor as any).path;
|
||||
const selectionInLastRow = selectionPath.includes(lastRow);
|
||||
|
||||
if (!selectionInLastRow) return false;
|
||||
|
||||
// Calculate the position immediately after the table
|
||||
const nextNodePos = tablePos + table.nodeSize;
|
||||
|
||||
// Check for an existing node immediately after the table
|
||||
const nextNode = editor.state.doc.nodeAt(nextNodePos);
|
||||
|
||||
if (nextNode && nextNode.type.name === "paragraph") {
|
||||
// If the next node is an paragraph, move the cursor there
|
||||
const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1;
|
||||
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||
} else if (!nextNode) {
|
||||
// If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there
|
||||
editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run();
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(nextNodePos + 1)
|
||||
.run();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import { Color } from "@tiptap/extension-color";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
import Gapcursor from "@tiptap/extension-gapcursor";
|
||||
|
||||
import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
|
||||
import { Table } from "src/ui/extensions/table/table";
|
||||
@@ -17,6 +16,11 @@ import { isValidHttpUrl } from "src/lib/utils";
|
||||
import { Mentions } from "src/ui/mentions";
|
||||
import { IMentionSuggestion } from "src/types/mention-suggestion";
|
||||
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
||||
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
|
||||
import { CustomQuoteExtension } from "src/ui/extensions/quote";
|
||||
import { CustomTypographyExtension } from "src/ui/extensions/typography";
|
||||
import { CustomCodeBlockExtension } from "src/ui/extensions/code";
|
||||
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
|
||||
|
||||
export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
mentionSuggestions: IMentionSuggestion[];
|
||||
@@ -38,36 +42,31 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
class: "leading-normal -mb-2",
|
||||
},
|
||||
},
|
||||
blockquote: {
|
||||
HTMLAttributes: {
|
||||
class: "border-l-4 border-custom-border-300",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
|
||||
spellcheck: "false",
|
||||
},
|
||||
},
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: {
|
||||
HTMLAttributes: { class: "mt-4 mb-4" },
|
||||
},
|
||||
dropcursor: {
|
||||
color: "rgba(var(--color-text-100))",
|
||||
width: 2,
|
||||
},
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
dropcursor: false,
|
||||
gapcursor: false,
|
||||
}),
|
||||
Gapcursor,
|
||||
CustomQuoteExtension.configure({
|
||||
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
|
||||
}),
|
||||
CustomHorizontalRule.configure({
|
||||
HTMLAttributes: { class: "mt-4 mb-4" },
|
||||
}),
|
||||
CustomLinkExtension.configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
protocols: ["http", "https"],
|
||||
validate: (url) => isValidHttpUrl(url),
|
||||
validate: (url: string) => isValidHttpUrl(url),
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
CustomTypographyExtension,
|
||||
ReadOnlyImageExtension.configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-lg border border-custom-border-300",
|
||||
@@ -87,6 +86,8 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
},
|
||||
nested: true,
|
||||
}),
|
||||
CustomCodeBlockExtension,
|
||||
CustomCodeInlineExtension,
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
transformCopiedText: true,
|
||||
|
||||
@@ -19,7 +19,7 @@ export const ContentBrowser = (props: ContentBrowserProps) => {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<h2 className="font-medium">Table of Contents</h2>
|
||||
<h2 className="font-medium">Outline</h2>
|
||||
<div className="h-full overflow-y-auto">
|
||||
{markings.length !== 0 ? (
|
||||
markings.map((marking) =>
|
||||
|
||||
@@ -29,11 +29,13 @@ type IPageRenderer = {
|
||||
editorContentCustomClassNames?: string;
|
||||
hideDragHandle?: () => void;
|
||||
readonly: boolean;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export const PageRenderer = (props: IPageRenderer) => {
|
||||
const {
|
||||
documentDetails,
|
||||
tabIndex,
|
||||
editor,
|
||||
editorClassNames,
|
||||
editorContentCustomClassNames,
|
||||
@@ -152,7 +154,7 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full pb-64 md:pl-7 pl-3 pt-5 page-renderer">
|
||||
<div className="w-full h-full pb-20 pl-7 pt-5 page-renderer">
|
||||
{!readonly ? (
|
||||
<input
|
||||
onChange={(e) => handlePageTitleChange(e.target.value)}
|
||||
@@ -169,7 +171,11 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
)}
|
||||
<div className="flex relative h-full w-full flex-col pr-5 editor-renderer" onMouseOver={handleLinkHover}>
|
||||
<EditorContainer hideDragHandle={hideDragHandle} editor={editor} editorClassNames={editorClassNames}>
|
||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||
<EditorContentWrapper
|
||||
tabIndex={tabIndex}
|
||||
editor={editor}
|
||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||
/>
|
||||
</EditorContainer>
|
||||
</div>
|
||||
{isOpen && linkViewProps && coordinates && (
|
||||
|
||||
@@ -47,6 +47,8 @@ interface IDocumentEditor {
|
||||
duplicationConfig?: IDuplicationConfig;
|
||||
pageLockConfig?: IPageLockConfig;
|
||||
pageArchiveConfig?: IPageArchiveConfig;
|
||||
|
||||
tabIndex?: number;
|
||||
}
|
||||
interface DocumentEditorProps extends IDocumentEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>;
|
||||
@@ -79,6 +81,7 @@ const DocumentEditor = ({
|
||||
cancelUploadImage,
|
||||
onActionCompleteHandler,
|
||||
rerenderOnPropsChange,
|
||||
tabIndex,
|
||||
}: IDocumentEditor) => {
|
||||
const { markings, updateMarkings } = useEditorMarkings();
|
||||
const [sidePeekVisible, setSidePeekVisible] = useState(true);
|
||||
@@ -160,6 +163,7 @@ const DocumentEditor = ({
|
||||
</div>
|
||||
<div className="h-full w-full md:w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
|
||||
<PageRenderer
|
||||
tabIndex={tabIndex}
|
||||
onActionCompleteHandler={onActionCompleteHandler}
|
||||
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||
readonly={false}
|
||||
|
||||
@@ -28,6 +28,7 @@ interface IDocumentReadOnlyEditor {
|
||||
message: string;
|
||||
type: "success" | "error" | "warning" | "info";
|
||||
}) => void;
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
|
||||
@@ -51,6 +52,7 @@ const DocumentReadOnlyEditor = ({
|
||||
pageArchiveConfig,
|
||||
rerenderOnPropsChange,
|
||||
onActionCompleteHandler,
|
||||
tabIndex,
|
||||
}: DocumentReadOnlyEditorProps) => {
|
||||
const router = useRouter();
|
||||
const [sidePeekVisible, setSidePeekVisible] = useState(true);
|
||||
@@ -108,9 +110,10 @@ const DocumentReadOnlyEditor = ({
|
||||
</div>
|
||||
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
|
||||
<PageRenderer
|
||||
tabIndex={tabIndex}
|
||||
onActionCompleteHandler={onActionCompleteHandler}
|
||||
updatePageTitle={() => Promise.resolve()}
|
||||
readonly={true}
|
||||
readonly
|
||||
editor={editor}
|
||||
editorClassNames={editorClassNames}
|
||||
documentDetails={documentDetails}
|
||||
|
||||
@@ -42,6 +42,7 @@ interface ILiteTextEditor {
|
||||
mentionHighlights?: string[];
|
||||
mentionSuggestions?: IMentionSuggestion[];
|
||||
submitButton?: React.ReactNode;
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
interface LiteTextEditorProps extends ILiteTextEditor {
|
||||
@@ -74,6 +75,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
|
||||
mentionHighlights,
|
||||
mentionSuggestions,
|
||||
submitButton,
|
||||
tabIndex,
|
||||
} = props;
|
||||
|
||||
const editor = useEditor({
|
||||
@@ -103,7 +105,11 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
|
||||
return (
|
||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||
<EditorContentWrapper
|
||||
tabIndex={tabIndex}
|
||||
editor={editor}
|
||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||
/>
|
||||
<div className="mt-4 w-full">
|
||||
<FixedMenu
|
||||
editor={editor}
|
||||
|
||||
@@ -8,6 +8,7 @@ interface ICoreReadOnlyEditor {
|
||||
borderOnFocus?: boolean;
|
||||
customClassName?: string;
|
||||
mentionHighlights: string[];
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
interface EditorCoreProps extends ICoreReadOnlyEditor {
|
||||
@@ -27,6 +28,7 @@ const LiteReadOnlyEditor = ({
|
||||
value,
|
||||
forwardedRef,
|
||||
mentionHighlights,
|
||||
tabIndex,
|
||||
}: EditorCoreProps) => {
|
||||
const editor = useReadOnlyEditor({
|
||||
value,
|
||||
@@ -45,7 +47,11 @@ const LiteReadOnlyEditor = ({
|
||||
return (
|
||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||
<EditorContentWrapper
|
||||
tabIndex={tabIndex}
|
||||
editor={editor}
|
||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||
/>
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ export type IRichTextEditor = {
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
mentionHighlights?: string[];
|
||||
mentionSuggestions?: IMentionSuggestion[];
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export interface RichTextEditorProps extends IRichTextEditor {
|
||||
@@ -68,6 +69,7 @@ const RichTextEditor = ({
|
||||
mentionHighlights,
|
||||
rerenderOnPropsChange,
|
||||
mentionSuggestions,
|
||||
tabIndex,
|
||||
}: RichTextEditorProps) => {
|
||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
|
||||
|
||||
@@ -100,17 +102,21 @@ const RichTextEditor = ({
|
||||
customClassName,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue);
|
||||
}, [editor, initialValue]);
|
||||
|
||||
// React.useEffect(() => {
|
||||
// if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue);
|
||||
// }, [editor, initialValue]);
|
||||
//
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer hideDragHandle={hideDragHandleOnMouseLeave} editor={editor} editorClassNames={editorClassNames}>
|
||||
{editor && <EditorBubbleMenu editor={editor} />}
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||
<EditorContentWrapper
|
||||
tabIndex={tabIndex}
|
||||
editor={editor}
|
||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||
/>
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
|
||||
@@ -121,7 +121,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
onClick={(e) => {
|
||||
item.command();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={cn(
|
||||
"p-2 text-custom-text-300 transition-colors hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5",
|
||||
{
|
||||
|
||||
@@ -33,8 +33,9 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
|
||||
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
|
||||
{ "bg-custom-background-100": isOpen }
|
||||
)}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
setIsOpen(!isOpen);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<p className="text-base">↗</p>
|
||||
@@ -60,6 +61,9 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
placeholder="Paste a link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="flex-1 border-r border-custom-border-300 bg-custom-background-100 p-1 text-sm outline-none placeholder:text-custom-text-400"
|
||||
defaultValue={editor.getAttributes("link").href || ""}
|
||||
/>
|
||||
@@ -67,9 +71,10 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
unsetLinkEditor(editor);
|
||||
setIsOpen(false);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
@@ -78,7 +83,8 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
|
||||
<button
|
||||
className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onLinkSubmit();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -47,7 +47,10 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
|
||||
<div className="relative h-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onClick={(e) => {
|
||||
setIsOpen(!isOpen);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
|
||||
>
|
||||
<span>{activeItem?.name}</span>
|
||||
@@ -60,9 +63,10 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
item.command();
|
||||
setIsOpen(false);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",
|
||||
|
||||
@@ -9,6 +9,7 @@ interface IRichTextReadOnlyEditor {
|
||||
borderOnFocus?: boolean;
|
||||
customClassName?: string;
|
||||
mentionHighlights?: string[];
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { TIssue, IIssueFilterOptions } from "@plane/types";
|
||||
|
||||
export type TCycleView = "all" | "active" | "upcoming" | "completed" | "draft";
|
||||
|
||||
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
|
||||
|
||||
export type TCycleLayout = "list" | "board" | "gantt";
|
||||
|
||||
export interface ICycle {
|
||||
backlog_issues: number;
|
||||
cancelled_issues: number;
|
||||
@@ -30,6 +26,7 @@ export interface ICycle {
|
||||
sort_order: number;
|
||||
start_date: string | null;
|
||||
started_issues: number;
|
||||
sub_issues: number;
|
||||
total_issues: number;
|
||||
unstarted_issues: number;
|
||||
updated_at: Date;
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
export type TCycleTabOptions = "active" | "all";
|
||||
|
||||
export type TCycleLayoutOptions = "list" | "board" | "gantt";
|
||||
|
||||
export type TCycleDisplayFilters = {
|
||||
active_tab?: TCycleTabOptions;
|
||||
layout?: TCycleLayoutOptions;
|
||||
};
|
||||
|
||||
export type TCycleFilters = {
|
||||
end_date?: string[] | null;
|
||||
start_date?: string[] | null;
|
||||
status?: string[] | null;
|
||||
};
|
||||
|
||||
export type TCycleStoredFilters = {
|
||||
display_filters?: TCycleDisplayFilters;
|
||||
filters?: TCycleFilters;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./cycle_filters";
|
||||
export * from "./cycle";
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
export * from "./github-importer";
|
||||
export * from "./jira-importer";
|
||||
|
||||
import { IProjectLite } from "../projects";
|
||||
import { IProjectLite } from "../project";
|
||||
// types
|
||||
import { IUserLite } from "../users";
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { TIssue } from "../issues/base";
|
||||
import type { IProjectLite } from "../projects";
|
||||
import type { IProjectLite } from "../project";
|
||||
|
||||
export type TInboxIssueExtended = {
|
||||
completed_at: string | null;
|
||||
|
||||
Vendored
+3
-3
@@ -1,11 +1,11 @@
|
||||
export * from "./users";
|
||||
export * from "./workspace";
|
||||
export * from "./cycles";
|
||||
export * from "./cycle";
|
||||
export * from "./dashboard";
|
||||
export * from "./projects";
|
||||
export * from "./project";
|
||||
export * from "./state";
|
||||
export * from "./issues";
|
||||
export * from "./modules";
|
||||
export * from "./module";
|
||||
export * from "./views";
|
||||
export * from "./integration";
|
||||
export * from "./pages";
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./module_filters";
|
||||
export * from "./modules";
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
export type TModuleOrderByOptions =
|
||||
| "name"
|
||||
| "-name"
|
||||
| "progress"
|
||||
| "-progress"
|
||||
| "issues_length"
|
||||
| "-issues_length"
|
||||
| "target_date"
|
||||
| "-target_date"
|
||||
| "created_at"
|
||||
| "-created_at";
|
||||
|
||||
export type TModuleLayoutOptions = "list" | "board" | "gantt";
|
||||
|
||||
export type TModuleDisplayFilters = {
|
||||
favorites?: boolean;
|
||||
layout?: TModuleLayoutOptions;
|
||||
order_by?: TModuleOrderByOptions;
|
||||
};
|
||||
|
||||
export type TModuleFilters = {
|
||||
lead?: string[] | null;
|
||||
members?: string[] | null;
|
||||
start_date?: string[] | null;
|
||||
status?: string[] | null;
|
||||
target_date?: string[] | null;
|
||||
};
|
||||
|
||||
export type TModuleStoredFilters = {
|
||||
display_filters?: TModuleDisplayFilters;
|
||||
filters?: TModuleFilters;
|
||||
};
|
||||
@@ -30,6 +30,7 @@ export interface IModule {
|
||||
name: string;
|
||||
project_id: string;
|
||||
sort_order: number;
|
||||
sub_issues: number;
|
||||
start_date: string | null;
|
||||
started_issues: number;
|
||||
status: TModuleStatus;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./project_filters";
|
||||
export * from "./projects";
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
export type TProjectOrderByOptions =
|
||||
| "sort_order"
|
||||
| "name"
|
||||
| "-name"
|
||||
| "created_at"
|
||||
| "-created_at"
|
||||
| "members_length"
|
||||
| "-members_length";
|
||||
|
||||
export type TProjectDisplayFilters = {
|
||||
my_projects?: boolean;
|
||||
order_by?: TProjectOrderByOptions;
|
||||
};
|
||||
|
||||
export type TProjectFilters = {
|
||||
access?: string[] | null;
|
||||
lead?: string[] | null;
|
||||
members?: string[] | null;
|
||||
created_at?: string[] | null;
|
||||
};
|
||||
|
||||
export type TProjectStoredFilters = {
|
||||
display_filters?: TProjectDisplayFilters;
|
||||
filters?: TProjectFilters;
|
||||
};
|
||||
+7
-1
@@ -7,7 +7,7 @@ import type {
|
||||
IWorkspace,
|
||||
IWorkspaceLite,
|
||||
TStateGroups,
|
||||
} from ".";
|
||||
} from "..";
|
||||
|
||||
export type TProjectLogoProps = {
|
||||
in_use: "emoji" | "icon";
|
||||
@@ -23,6 +23,8 @@ export type TProjectLogoProps = {
|
||||
|
||||
export interface IProject {
|
||||
archive_in: number;
|
||||
archived_issues: number;
|
||||
archived_sub_issues: number;
|
||||
close_in: number;
|
||||
created_at: Date;
|
||||
created_by: string;
|
||||
@@ -35,6 +37,8 @@ export interface IProject {
|
||||
default_assignee: IUser | string | null;
|
||||
default_state: string | null;
|
||||
description: string;
|
||||
draft_issues: number;
|
||||
draft_sub_issues: number;
|
||||
estimate: string | null;
|
||||
id: string;
|
||||
identifier: string;
|
||||
@@ -48,7 +52,9 @@ export interface IProject {
|
||||
network: number;
|
||||
project_lead: IUserLite | string | null;
|
||||
sort_order: number | null;
|
||||
sub_issues: number;
|
||||
total_cycles: number;
|
||||
total_issues: number;
|
||||
total_members: number;
|
||||
total_modules: number;
|
||||
updated_at: Date;
|
||||
@@ -1,4 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["custom"],
|
||||
};
|
||||
@@ -14,7 +14,6 @@
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm,cjs --dts --external react --minify",
|
||||
"dev": "tsup src/index.ts --format esm,cjs --watch --dts --external react",
|
||||
"lint": "eslint src/",
|
||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -103,7 +103,7 @@ export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
className={cn(
|
||||
"h-80 w-80 bg-custom-background-100 rounded-md border-[0.5px] border-custom-border-300 overflow-hidden",
|
||||
"w-80 bg-custom-background-100 rounded-md border-[0.5px] border-custom-border-300 overflow-hidden",
|
||||
dropdownClassName
|
||||
)}
|
||||
>
|
||||
@@ -146,7 +146,7 @@ export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
|
||||
}}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<Tab.Panel className="h-80 w-full">
|
||||
<IconsList
|
||||
defaultColor={defaultIconColor}
|
||||
onChange={(val) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ISvgIcons } from "../type";
|
||||
|
||||
export const CircleDotFullIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
|
||||
<svg viewBox="0 0 16 16" className={`${className} stroke-1`} fill="none" xmlns="http://www.w3.org/2000/svg" {...rest}>
|
||||
<circle cx="8.33333" cy="8.33333" r="5.33333" stroke="currentColor" stroke-linecap="round" />
|
||||
<circle cx="8.33333" cy="8.33333" r="5.33333" stroke="currentColor" strokeLinecap="round" />
|
||||
<circle cx="8.33333" cy="8.33333" r="4.33333" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ interface ITooltipProps {
|
||||
className?: string;
|
||||
openDelay?: number;
|
||||
closeDelay?: number;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<ITooltipProps> = ({
|
||||
@@ -40,6 +41,7 @@ export const Tooltip: React.FC<ITooltipProps> = ({
|
||||
className = "",
|
||||
openDelay = 200,
|
||||
closeDelay,
|
||||
isMobile = false,
|
||||
}) => (
|
||||
<Tooltip2
|
||||
disabled={disabled}
|
||||
@@ -47,7 +49,7 @@ export const Tooltip: React.FC<ITooltipProps> = ({
|
||||
hoverCloseDelay={closeDelay}
|
||||
content={
|
||||
<div
|
||||
className={`relative z-50 max-w-xs gap-1 overflow-hidden break-words rounded-md bg-custom-background-100 p-2 text-xs text-custom-text-200 shadow-md ${className}`}
|
||||
className={`relative ${isMobile ? "hidden" : "block"} z-50 max-w-xs gap-1 overflow-hidden break-words rounded-md bg-custom-background-100 p-2 text-xs text-custom-text-200 shadow-md ${className}`}
|
||||
>
|
||||
{tooltipHeading && <h5 className="font-medium text-custom-text-100">{tooltipHeading}</h5>}
|
||||
{tooltipContent}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { renderFormattedDate } from "helpers/date-time.helper";
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { IApiToken } from "@plane/types";
|
||||
// hooks
|
||||
import { usePlatformOS } from "hooks/use-platform-os";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
@@ -14,7 +16,7 @@ type Props = {
|
||||
|
||||
export const GeneratedTokenDetails: React.FC<Props> = (props) => {
|
||||
const { handleClose, tokenDetails } = props;
|
||||
|
||||
const { isMobile } = usePlatformOS();
|
||||
const copyApiToken = (token: string) => {
|
||||
copyTextToClipboard(token).then(() =>
|
||||
setToast({
|
||||
@@ -40,7 +42,7 @@ export const GeneratedTokenDetails: React.FC<Props> = (props) => {
|
||||
className="mt-4 flex w-full items-center justify-between rounded-md border-[0.5px] border-custom-border-200 px-3 py-2 text-sm font-medium outline-none"
|
||||
>
|
||||
{tokenDetails.token}
|
||||
<Tooltip tooltipContent="Copy secret key">
|
||||
<Tooltip tooltipContent="Copy secret key" isMobile={isMobile}>
|
||||
<Copy className="h-4 w-4 text-custom-text-400" />
|
||||
</Tooltip>
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { XCircle } from "lucide-react";
|
||||
// components
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { DeleteApiTokenModal } from "components/api-token";
|
||||
import { usePlatformOS } from "hooks/use-platform-os";
|
||||
// ui
|
||||
// helpers
|
||||
import { renderFormattedDate, calculateTimeAgo } from "helpers/date-time.helper";
|
||||
@@ -17,12 +18,14 @@ export const ApiTokenListItem: React.FC<Props> = (props) => {
|
||||
const { token } = props;
|
||||
// states
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteApiTokenModal isOpen={deleteModalOpen} onClose={() => setDeleteModalOpen(false)} tokenId={token.id} />
|
||||
<div className="group relative flex flex-col justify-center border-b border-custom-border-200 px-4 py-3">
|
||||
<Tooltip tooltipContent="Delete token">
|
||||
<Tooltip tooltipContent="Delete token" isMobile={isMobile}>
|
||||
<button
|
||||
onClick={() => setDeleteModalOpen(true)}
|
||||
className="absolute right-4 hidden place-items-center group-hover:grid"
|
||||
@@ -33,9 +36,8 @@ export const ApiTokenListItem: React.FC<Props> = (props) => {
|
||||
<div className="flex w-4/5 items-center">
|
||||
<h5 className="truncate text-sm font-medium">{token.label}</h5>
|
||||
<span
|
||||
className={`${
|
||||
token.is_active ? "bg-green-500/10 text-green-500" : "bg-custom-background-80 text-custom-text-400"
|
||||
} ml-2 flex h-4 max-h-fit items-center rounded-sm px-2 text-xs font-medium`}
|
||||
className={`${token.is_active ? "bg-green-500/10 text-green-500" : "bg-custom-background-80 text-custom-text-400"
|
||||
} ml-2 flex h-4 max-h-fit items-center rounded-sm px-2 text-xs font-medium`}
|
||||
>
|
||||
{token.is_active ? "Active" : "Expired"}
|
||||
</span>
|
||||
|
||||
@@ -20,12 +20,11 @@ import {
|
||||
} from "components/command-palette";
|
||||
import { ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
import { useApplication, useEventTracker, useProject } from "hooks/store";
|
||||
import { usePlatformOS } from "hooks/use-platform-os";
|
||||
// services
|
||||
import useDebounce from "hooks/use-debounce";
|
||||
import { IssueService } from "services/issue";
|
||||
import { WorkspaceService } from "services/workspace.service";
|
||||
// hooks
|
||||
// components
|
||||
// types
|
||||
import { IWorkspaceSearchResults } from "@plane/types";
|
||||
// fetch-keys
|
||||
@@ -37,6 +36,7 @@ const issueService = new IssueService();
|
||||
export const CommandModal: React.FC = observer(() => {
|
||||
// hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { isMobile } = usePlatformOS();
|
||||
// states
|
||||
const [placeholder, setPlaceholder] = useState("Type a command or search...");
|
||||
const [resultsCount, setResultsCount] = useState(0);
|
||||
@@ -197,7 +197,7 @@ export const CommandModal: React.FC = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
{projectId && (
|
||||
<Tooltip tooltipContent="Toggle workspace level search">
|
||||
<Tooltip tooltipContent="Toggle workspace level search" isMobile={isMobile}>
|
||||
<div className="flex flex-shrink-0 cursor-pointer items-center gap-1 self-end text-xs sm:self-center">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { usePlatformOS } from "hooks/use-platform-os";
|
||||
|
||||
type Props = {
|
||||
label?: string;
|
||||
@@ -9,8 +10,9 @@ type Props = {
|
||||
|
||||
export const BreadcrumbLink: React.FC<Props> = (props) => {
|
||||
const { href, label, icon } = props;
|
||||
const { isMobile } = usePlatformOS();
|
||||
return (
|
||||
<Tooltip tooltipContent={label} position="bottom">
|
||||
<Tooltip tooltipContent={label} position="bottom" isMobile={isMobile}>
|
||||
<li className="flex items-center space-x-2" tabIndex={-1}>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{href ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePlatformOS } from "hooks/use-platform-os";
|
||||
// store hooks
|
||||
// icons
|
||||
import {
|
||||
@@ -29,22 +30,25 @@ import { IIssueActivity } from "@plane/types";
|
||||
export const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
return (
|
||||
<Tooltip tooltipContent={activity?.issue_detail ? activity.issue_detail.name : "This issue has been deleted"}>
|
||||
<Tooltip
|
||||
tooltipContent={activity?.issue_detail ? activity.issue_detail.name : "This issue has been deleted"}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
{activity?.issue_detail ? (
|
||||
<a
|
||||
aria-disabled={activity.issue === null}
|
||||
href={`${`/${workspaceSlug ?? activity.workspace_detail?.slug}/projects/${activity.project}/issues/${activity.issue
|
||||
}`}`}
|
||||
href={`${`/${workspaceSlug ?? activity.workspace_detail?.slug}/projects/${activity.project}/issues/${
|
||||
activity.issue
|
||||
}`}`}
|
||||
target={activity.issue === null ? "_self" : "_blank"}
|
||||
rel={activity.issue === null ? "" : "noopener noreferrer"}
|
||||
className="border border-red-500 relative w-full overflow-hidden"
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<div className="border border-red-500 overflow-hidden relative inline-flex w-full items-center gap-1 font-medium text-custom-text-100 hover:underline">
|
||||
<div className="whitespace-nowrap break-all">{`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}</div>{" "}
|
||||
<div className="relative whitespace-nowrap break-all overflow-hidden w-full font-normal truncate line-clamp-1">{activity.issue_detail?.name}</div>
|
||||
</div>
|
||||
<span className="whitespace-nowrap">{`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}</span>{" "}
|
||||
<span className="font-normal">{activity.issue_detail?.name}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 font-medium text-custom-text-100 whitespace-nowrap">
|
||||
@@ -61,8 +65,9 @@ const UserLink = ({ activity }: { activity: IIssueActivity }) => {
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/${workspaceSlug ?? activity.workspace_detail?.slug}/profile/${activity.new_identifier ?? activity.old_identifier
|
||||
}`}
|
||||
href={`/${workspaceSlug ?? activity.workspace_detail?.slug}/profile/${
|
||||
activity.new_identifier ?? activity.old_identifier
|
||||
}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center font-medium text-custom-text-100 hover:underline"
|
||||
@@ -591,7 +596,7 @@ const activityDetails: {
|
||||
state: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
{/* set the state to <span className="border border-green-500 font-medium text-custom-text-100">{activity.new_value}</span> */}
|
||||
set the state to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
@@ -678,12 +683,12 @@ export const ActivityMessage = ({ activity, showIssue = false }: ActivityMessage
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
return (
|
||||
<div className="w-full border border-red-500 overflow-hidden">
|
||||
<>
|
||||
{activityDetails[activity.field as keyof typeof activityDetails]?.message(
|
||||
activity,
|
||||
showIssue,
|
||||
workspaceSlug ? workspaceSlug.toString() : activity.workspace_detail?.slug ?? ""
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Rocket, Search, X } from "lucide-react";
|
||||
import { Button, LayersIcon, Loader, ToggleSwitch, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
|
||||
import useDebounce from "hooks/use-debounce";
|
||||
|
||||
import { usePlatformOS } from "hooks/use-platform-os";
|
||||
import { ProjectService } from "services/project";
|
||||
// ui
|
||||
// types
|
||||
@@ -40,7 +40,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
|
||||
|
||||
const { isMobile } = usePlatformOS();
|
||||
const debouncedSearchTerm: string = useDebounce(searchTerm, 500);
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -154,7 +154,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
)}
|
||||
{workspaceLevelToggle && (
|
||||
<Tooltip tooltipContent="Toggle workspace level search">
|
||||
<Tooltip tooltipContent="Toggle workspace level search" isMobile={isMobile}>
|
||||
<div
|
||||
className={`flex flex-shrink-0 cursor-pointer items-center gap-1 text-xs ${
|
||||
isWorkspaceLevel ? "text-custom-text-100" : "text-custom-text-200"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ExternalLinkIcon, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { calculateTimeAgo } from "helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useMember } from "hooks/store";
|
||||
import { usePlatformOS } from "hooks/use-platform-os";
|
||||
// types
|
||||
import { ILinkDetails, UserAuth } from "@plane/types";
|
||||
|
||||
@@ -19,7 +20,7 @@ type Props = {
|
||||
|
||||
export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, handleEditLink, userAuth }) => {
|
||||
const { getUserDetails } = useMember();
|
||||
|
||||
const { isMobile } = usePlatformOS();
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
@@ -42,7 +43,7 @@ export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, h
|
||||
<span className="py-1">
|
||||
<LinkIcon className="h-3 w-3 flex-shrink-0" />
|
||||
</span>
|
||||
<Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url}>
|
||||
<Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url} isMobile={isMobile}>
|
||||
<span
|
||||
className="cursor-pointer truncate text-xs"
|
||||
onClick={() => copyToClipboard(link.title && link.title !== "" ? link.title : link.url)}
|
||||
|
||||
@@ -127,7 +127,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
<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 vertical-scrollbar scrollbar-sm"
|
||||
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
|
||||
>
|
||||
{distribution?.assignees.length > 0 ? (
|
||||
distribution.assignees.map((assignee, index) => {
|
||||
@@ -187,7 +187,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
</Tab.Panel>
|
||||
<Tab.Panel
|
||||
as="div"
|
||||
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
|
||||
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
|
||||
>
|
||||
{distribution?.labels.length > 0 ? (
|
||||
distribution.labels.map((label, index) => (
|
||||
@@ -230,7 +230,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
</Tab.Panel>
|
||||
<Tab.Panel
|
||||
as="div"
|
||||
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
|
||||
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
|
||||
>
|
||||
{Object.keys(groupedIssues).map((group, index) => (
|
||||
<SingleProgressStats
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./root";
|
||||
export * from "./stats";
|
||||
export * from "./upcoming-cycles-list-item";
|
||||
export * from "./upcoming-cycles-list";
|
||||
+70
-42
@@ -3,7 +3,8 @@ import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { useCycle, useIssues, useMember, useProject } from "hooks/store";
|
||||
import { useCycle, useCycleFilter, useIssues, useMember, useProject } from "hooks/store";
|
||||
import { usePlatformOS } from "hooks/use-platform-os";
|
||||
// ui
|
||||
import { SingleProgressStats } from "components/core";
|
||||
import {
|
||||
@@ -17,10 +18,11 @@ import {
|
||||
Avatar,
|
||||
CycleGroupIcon,
|
||||
setPromiseToast,
|
||||
getButtonStyling,
|
||||
} from "@plane/ui";
|
||||
// components
|
||||
import ProgressChart from "components/core/sidebar/progress-chart";
|
||||
import { ActiveCycleProgressStats } from "components/cycles";
|
||||
import { ActiveCycleProgressStats, UpcomingCyclesList } from "components/cycles";
|
||||
import { StateDropdown } from "components/dropdowns";
|
||||
import { EmptyState } from "components/empty-state";
|
||||
// icons
|
||||
@@ -28,6 +30,7 @@ import { ArrowRight, CalendarCheck, CalendarDays, Star, Target } from "lucide-re
|
||||
// helpers
|
||||
import { renderFormattedDate, findHowManyDaysLeft, renderFormattedDateWithoutYear } from "helpers/date-time.helper";
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { ICycle, TCycleGroups } from "@plane/types";
|
||||
// constants
|
||||
@@ -41,30 +44,36 @@ interface IActiveCycleDetails {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props) => {
|
||||
export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) => {
|
||||
// props
|
||||
const { workspaceSlug, projectId } = props;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
// store hooks
|
||||
const {
|
||||
issues: { fetchActiveCycleIssues },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const {
|
||||
fetchActiveCycle,
|
||||
currentProjectActiveCycleId,
|
||||
currentProjectUpcomingCycleIds,
|
||||
fetchActiveCycle,
|
||||
getActiveCycleById,
|
||||
addCycleToFavorites,
|
||||
removeCycleFromFavorites,
|
||||
} = useCycle();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { getUserDetails } = useMember();
|
||||
|
||||
// cycle filters hook
|
||||
const { updateDisplayFilters } = useCycleFilter();
|
||||
// derived values
|
||||
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
|
||||
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
|
||||
// fetch active cycle details
|
||||
const { isLoading } = useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null
|
||||
);
|
||||
|
||||
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
|
||||
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
|
||||
|
||||
// fetch active cycle issues
|
||||
const { data: activeCycleIssues } = useSWR(
|
||||
workspaceSlug && projectId && currentProjectActiveCycleId
|
||||
? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" })
|
||||
@@ -73,7 +82,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
? () => fetchActiveCycleIssues(workspaceSlug, projectId, currentProjectActiveCycleId)
|
||||
: null
|
||||
);
|
||||
|
||||
// show loader if active cycle is loading
|
||||
if (!activeCycle && isLoading)
|
||||
return (
|
||||
<Loader>
|
||||
@@ -81,10 +90,44 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
</Loader>
|
||||
);
|
||||
|
||||
if (!activeCycle) return <EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />;
|
||||
if (!activeCycle) {
|
||||
// show empty state if no active cycle is present
|
||||
if (currentProjectUpcomingCycleIds?.length === 0)
|
||||
return <EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />;
|
||||
// show upcoming cycles list, if present
|
||||
else
|
||||
return (
|
||||
<>
|
||||
<div className="h-52 w-full grid place-items-center mb-6">
|
||||
<div className="text-center">
|
||||
<h5 className="text-xl font-medium mb-1">No active cycle</h5>
|
||||
<p className="text-custom-text-400 text-base">
|
||||
Create new cycles to find them here or check
|
||||
<br />
|
||||
{"'"}All{"'"} cycles tab to see all cycles or{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-custom-primary-100 font-medium"
|
||||
onClick={() =>
|
||||
updateDisplayFilters(projectId, {
|
||||
active_tab: "all",
|
||||
})
|
||||
}
|
||||
>
|
||||
click here
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UpcomingCyclesList />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const endDate = new Date(activeCycle.end_date ?? "");
|
||||
const startDate = new Date(activeCycle.start_date ?? "");
|
||||
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
|
||||
const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups;
|
||||
|
||||
const groupedIssues: any = {
|
||||
backlog: activeCycle.backlog_issues,
|
||||
@@ -94,8 +137,6 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
cancelled: activeCycle.cancelled_issues,
|
||||
};
|
||||
|
||||
const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups;
|
||||
|
||||
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
@@ -148,8 +189,6 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
color: group.color,
|
||||
}));
|
||||
|
||||
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
|
||||
|
||||
return (
|
||||
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow">
|
||||
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-3 lg:divide-x lg:divide-y-0">
|
||||
@@ -161,7 +200,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
<span className="h-5 w-5">
|
||||
<CycleGroupIcon cycleGroup={cycleStatus} className="h-4 w-4" />
|
||||
</span>
|
||||
<Tooltip tooltipContent={activeCycle.name} position="top-left">
|
||||
<Tooltip tooltipContent={activeCycle.name} position="top-left" isMobile={isMobile}>
|
||||
<h3 className="break-words text-lg font-semibold">{truncateText(activeCycle.name, 70)}</h3>
|
||||
</Tooltip>
|
||||
</span>
|
||||
@@ -203,27 +242,15 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2.5 text-custom-text-200">
|
||||
{cycleOwnerDetails?.avatar && cycleOwnerDetails?.avatar !== "" ? (
|
||||
<img
|
||||
src={cycleOwnerDetails?.avatar}
|
||||
height={16}
|
||||
width={16}
|
||||
className="rounded-full"
|
||||
alt={cycleOwnerDetails?.display_name}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-background-100 capitalize">
|
||||
{cycleOwnerDetails?.display_name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<Avatar src={cycleOwnerDetails?.avatar} name={cycleOwnerDetails?.display_name} />
|
||||
<span className="text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
|
||||
</div>
|
||||
|
||||
{activeCycle.assignee_ids.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
<AvatarGroup>
|
||||
{activeCycle.assignee_ids.map((assigne_id) => {
|
||||
const member = getUserDetails(assigne_id);
|
||||
{activeCycle.assignee_ids.map((assignee_id) => {
|
||||
const member = getUserDetails(assignee_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
@@ -233,7 +260,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
|
||||
<div className="flex items-center gap-4 text-custom-text-200">
|
||||
<div className="flex gap-2">
|
||||
<LayersIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<LayersIcon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
{activeCycle.total_issues} issues
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -244,9 +271,9 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles/${activeCycle.id}`}
|
||||
className="w-min text-nowrap rounded-md bg-custom-primary px-4 py-2 text-center text-sm font-medium text-white hover:bg-custom-primary/90"
|
||||
className={cn(getButtonStyling("primary", "lg"), "w-min whitespace-nowrap")}
|
||||
>
|
||||
View Cycle
|
||||
View cycle
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,11 +314,11 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
</div>
|
||||
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-2 lg:divide-x lg:divide-y-0">
|
||||
<div className="flex max-h-60 flex-col gap-3 overflow-hidden p-4">
|
||||
<div className="text-custom-primary">High Priority Issues</div>
|
||||
<div className="text-custom-primary">High priority issues</div>
|
||||
<div className="flex h-full flex-col gap-2.5 overflow-y-scroll rounded-md">
|
||||
{activeCycleIssues ? (
|
||||
activeCycleIssues.length > 0 ? (
|
||||
activeCycleIssues.map((issue: any) => (
|
||||
activeCycleIssues.map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
@@ -301,6 +328,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
<PriorityIcon priority={issue.priority} withContainer size={12} />
|
||||
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipHeading="Issue ID"
|
||||
tooltipContent={`${currentProjectDetails?.identifier}-${issue.sequence_id}`}
|
||||
>
|
||||
@@ -308,20 +336,20 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
{currentProjectDetails?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip position="top-left" tooltipContent={issue.name}>
|
||||
<Tooltip position="top-left" tooltipContent={issue.name} isMobile={isMobile}>
|
||||
<span className="text-[0.825rem] text-custom-text-100">{truncateText(issue.name, 30)}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||
<StateDropdown
|
||||
value={issue.state_id ?? undefined}
|
||||
value={issue.state_id}
|
||||
onChange={() => {}}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
projectId={projectId}
|
||||
disabled
|
||||
buttonVariant="background-with-text"
|
||||
/>
|
||||
{issue.target_date && (
|
||||
<Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)}>
|
||||
<Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)} isMobile={isMobile}>
|
||||
<div className="flex h-full cursor-not-allowed items-center gap-1.5 rounded bg-custom-background-80 px-2 py-0.5 text-xs">
|
||||
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="text-xs">{renderFormattedDateWithoutYear(issue.target_date)}</span>
|
||||
@@ -359,10 +387,10 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>
|
||||
<LayersIcon className="h-5 w-5 flex-shrink-0 text-custom-text-200" />
|
||||
<LayersIcon className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-200" />
|
||||
</span>
|
||||
<span>
|
||||
Pending Issues -{" "}
|
||||
Pending issues-{" "}
|
||||
{activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)}
|
||||
</span>
|
||||
</div>
|
||||
+1
-1
@@ -134,7 +134,7 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
|
||||
</Tab.Panels>
|
||||
) : (
|
||||
<div className="mt-4 grid place-items-center text-center text-sm text-custom-text-200">
|
||||
There are no high priority issues present in this cycle.
|
||||
There are no issues present in this cycle.
|
||||
</div>
|
||||
)}
|
||||
</Tab.Group>
|
||||
@@ -0,0 +1,135 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react";
|
||||
import { Star, User2 } from "lucide-react";
|
||||
// hooks
|
||||
import { useCycle, useEventTracker, useMember } from "hooks/store";
|
||||
// components
|
||||
import { CycleQuickActions } from "components/cycles";
|
||||
// ui
|
||||
import { Avatar, AvatarGroup, setPromiseToast } from "@plane/ui";
|
||||
// helpers
|
||||
import { renderFormattedDate } from "helpers/date-time.helper";
|
||||
// constants
|
||||
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
|
||||
|
||||
type Props = {
|
||||
cycleId: string;
|
||||
};
|
||||
|
||||
export const UpcomingCycleListItem: React.FC<Props> = observer((props) => {
|
||||
const { cycleId } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
const { addCycleToFavorites, getCycleById, removeCycleFromFavorites } = useCycle();
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const cycle = getCycleById(cycleId);
|
||||
|
||||
const handleAddToFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then(
|
||||
() => {
|
||||
captureEvent(CYCLE_FAVORITED, {
|
||||
cycle_id: cycleId,
|
||||
element: "List layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
setPromiseToast(addToFavoritePromise, {
|
||||
loading: "Adding cycle to favorites...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Cycle added to favorites.",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Couldn't add the cycle to favorites. Please try again.",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const removeFromFavoritePromise = removeCycleFromFavorites(
|
||||
workspaceSlug?.toString(),
|
||||
projectId.toString(),
|
||||
cycleId
|
||||
).then(() => {
|
||||
captureEvent(CYCLE_UNFAVORITED, {
|
||||
cycle_id: cycleId,
|
||||
element: "List layout",
|
||||
state: "SUCCESS",
|
||||
});
|
||||
});
|
||||
|
||||
setPromiseToast(removeFromFavoritePromise, {
|
||||
loading: "Removing cycle from favorites...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Cycle removed from favorites.",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Couldn't remove the cycle from favorites. Please try again.",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!cycle) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`}
|
||||
className="py-5 px-2 flex items-center justify-between gap-2 hover:bg-custom-background-90"
|
||||
>
|
||||
<h6 className="font-medium text-base">{cycle.name}</h6>
|
||||
<div className="flex items-center gap-4">
|
||||
{cycle.start_date && cycle.end_date && (
|
||||
<div className="text-xs text-custom-text-300">
|
||||
{renderFormattedDate(cycle.start_date)} - {renderFormattedDate(cycle.end_date)}
|
||||
</div>
|
||||
)}
|
||||
{cycle.assignee_ids?.length > 0 ? (
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{cycle.assignee_ids?.map((assigneeId) => {
|
||||
const member = getUserDetails(assigneeId);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
|
||||
<User2 className="h-4 w-4 text-custom-text-400" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{cycle.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleAddToFavorites}>
|
||||
<Star className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{workspaceSlug && projectId && (
|
||||
<CycleQuickActions
|
||||
cycleId={cycleId}
|
||||
projectId={projectId.toString()}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useCycle } from "hooks/store";
|
||||
// components
|
||||
import { UpcomingCycleListItem } from "components/cycles";
|
||||
|
||||
export const UpcomingCyclesList = observer(() => {
|
||||
// store hooks
|
||||
const { currentProjectUpcomingCycleIds } = useCycle();
|
||||
|
||||
if (!currentProjectUpcomingCycleIds) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded inline-block">
|
||||
Upcoming cycles
|
||||
</div>
|
||||
<div className="mt-2 divide-y-[0.5px] divide-custom-border-200 border-b-[0.5px] border-custom-border-200">
|
||||
{currentProjectUpcomingCycleIds.map((cycleId) => (
|
||||
<UpcomingCycleListItem key={cycleId} cycleId={cycleId} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { X } from "lucide-react";
|
||||
// helpers
|
||||
import { renderFormattedDate } from "helpers/date-time.helper";
|
||||
import { capitalizeFirstLetter } from "helpers/string.helper";
|
||||
// constants
|
||||
import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters";
|
||||
|
||||
type Props = {
|
||||
editable: boolean | undefined;
|
||||
handleRemove: (val: string) => void;
|
||||
values: string[];
|
||||
};
|
||||
|
||||
export const AppliedDateFilters: React.FC<Props> = observer((props) => {
|
||||
const { editable, handleRemove, values } = props;
|
||||
|
||||
const getDateLabel = (value: string): string => {
|
||||
let dateLabel = "";
|
||||
|
||||
const dateDetails = DATE_AFTER_FILTER_OPTIONS.find((d) => d.value === value);
|
||||
|
||||
if (dateDetails) dateLabel = dateDetails.name;
|
||||
else {
|
||||
const dateParts = value.split(";");
|
||||
|
||||
if (dateParts.length === 2) {
|
||||
const [date, time] = dateParts;
|
||||
|
||||
dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return dateLabel;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{values.map((date) => (
|
||||
<div key={date} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||
<span className="normal-case">{getDateLabel(date)}</span>
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||
onClick={() => handleRemove(date)}
|
||||
>
|
||||
<X size={10} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./date";
|
||||
export * from "./root";
|
||||
export * from "./status";
|
||||
@@ -0,0 +1,90 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { X } from "lucide-react";
|
||||
// hooks
|
||||
import { useUser } from "hooks/store";
|
||||
// components
|
||||
import { AppliedDateFilters, AppliedStatusFilters } from "components/cycles";
|
||||
// helpers
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { TCycleFilters } from "@plane/types";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
|
||||
type Props = {
|
||||
appliedFilters: TCycleFilters;
|
||||
handleClearAllFilters: () => void;
|
||||
handleRemoveFilter: (key: keyof TCycleFilters, value: string | null) => void;
|
||||
alwaysAllowEditing?: boolean;
|
||||
};
|
||||
|
||||
const DATE_FILTERS = ["start_date", "end_date"];
|
||||
|
||||
export const CycleAppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||
const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props;
|
||||
// store hooks
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
|
||||
if (!appliedFilters) return null;
|
||||
|
||||
if (Object.keys(appliedFilters).length === 0) return null;
|
||||
|
||||
const isEditingAllowed = alwaysAllowEditing || (currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
|
||||
{Object.entries(appliedFilters).map(([key, value]) => {
|
||||
const filterKey = key as keyof TCycleFilters;
|
||||
|
||||
if (!value) return;
|
||||
if (Array.isArray(value) && value.length === 0) return;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={filterKey}
|
||||
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize"
|
||||
>
|
||||
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{filterKey === "status" && (
|
||||
<AppliedStatusFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter("status", val)}
|
||||
values={value}
|
||||
/>
|
||||
)}
|
||||
{DATE_FILTERS.includes(filterKey) && (
|
||||
<AppliedDateFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
|
||||
values={value}
|
||||
/>
|
||||
)}
|
||||
{isEditingAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||
onClick={() => handleRemoveFilter(filterKey, null)}
|
||||
>
|
||||
<X size={12} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isEditingAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearAllFilters}
|
||||
className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 text-xs text-custom-text-300 hover:text-custom-text-200"
|
||||
>
|
||||
Clear all
|
||||
<X size={12} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { X } from "lucide-react";
|
||||
import { CYCLE_STATUS } from "constants/cycle";
|
||||
import { cn } from "helpers/common.helper";
|
||||
|
||||
type Props = {
|
||||
handleRemove: (val: string) => void;
|
||||
values: string[];
|
||||
editable: boolean | undefined;
|
||||
};
|
||||
|
||||
export const AppliedStatusFilters: React.FC<Props> = observer((props) => {
|
||||
const { handleRemove, values, editable } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{values.map((status) => {
|
||||
const statusDetails = CYCLE_STATUS.find((s) => s.value === status);
|
||||
return (
|
||||
<div
|
||||
key={status}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded p-1 text-xs",
|
||||
statusDetails?.bgColor,
|
||||
statusDetails?.textColor
|
||||
)}
|
||||
>
|
||||
{statusDetails?.title}
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||
onClick={() => handleRemove(status)}
|
||||
>
|
||||
<X size={10} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
+15
-92
@@ -1,22 +1,13 @@
|
||||
import { FC, MouseEvent, useState } from "react";
|
||||
import { FC, MouseEvent } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
import { usePlatformOS } from "hooks/use-platform-os";
|
||||
// components
|
||||
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
CustomMenu,
|
||||
Tooltip,
|
||||
LayersIcon,
|
||||
CycleGroupIcon,
|
||||
TOAST_TYPE,
|
||||
setToast,
|
||||
setPromiseToast,
|
||||
} from "@plane/ui";
|
||||
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
|
||||
import { Info, Star } from "lucide-react";
|
||||
import { Avatar, AvatarGroup, Tooltip, LayersIcon, CycleGroupIcon, setPromiseToast } from "@plane/ui";
|
||||
import { CycleQuickActions } from "components/cycles";
|
||||
// ui
|
||||
// icons
|
||||
// helpers
|
||||
@@ -24,7 +15,6 @@ import { CYCLE_STATUS } from "constants/cycle";
|
||||
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper";
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// constants
|
||||
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
|
||||
//.types
|
||||
@@ -38,13 +28,10 @@ export interface ICyclesBoardCard {
|
||||
|
||||
export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
const { cycleId, workspaceSlug, projectId } = props;
|
||||
// states
|
||||
const [updateModal, setUpdateModal] = useState(false);
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store
|
||||
const { setTrackElement, captureEvent } = useEventTracker();
|
||||
const { captureEvent } = useEventTracker();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
@@ -52,11 +39,12 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
const { getUserDetails } = useMember();
|
||||
// computed
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
if (!cycleDetails) return null;
|
||||
|
||||
const cycleStatus = cycleDetails.status.toLocaleLowerCase();
|
||||
const isCompleted = cycleStatus === "completed";
|
||||
const endDate = new Date(cycleDetails.end_date ?? "");
|
||||
const startDate = new Date(cycleDetails.start_date ?? "");
|
||||
const isDateValid = cycleDetails.start_date || cycleDetails.end_date;
|
||||
@@ -78,24 +66,10 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
? cycleTotalIssues === 0
|
||||
? "0 Issue"
|
||||
: cycleTotalIssues === cycleDetails.completed_issues
|
||||
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
|
||||
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
|
||||
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
|
||||
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
|
||||
: "0 Issue";
|
||||
|
||||
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
|
||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "Cycle link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
@@ -152,20 +126,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTrackElement("Cycles page grid layout");
|
||||
setUpdateModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTrackElement("Cycles page grid layout");
|
||||
setDeleteModal(true);
|
||||
};
|
||||
|
||||
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
const { query } = router;
|
||||
e.preventDefault();
|
||||
@@ -181,22 +141,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CycleCreateUpdateModal
|
||||
data={cycleDetails}
|
||||
isOpen={updateModal}
|
||||
handleClose={() => setUpdateModal(false)}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
/>
|
||||
|
||||
<CycleDeleteModal
|
||||
cycle={cycleDetails}
|
||||
isOpen={deleteModal}
|
||||
handleClose={() => setDeleteModal(false)}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
/>
|
||||
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
||||
<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">
|
||||
@@ -204,7 +148,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
<span className="flex-shrink-0">
|
||||
<CycleGroupIcon cycleGroup={cycleStatus as TCycleGroups} className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<Tooltip tooltipContent={cycleDetails.name} position="top">
|
||||
<Tooltip tooltipContent={cycleDetails.name} position="top" isMobile={isMobile}>
|
||||
<span className="truncate text-base font-medium">{cycleDetails.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -235,7 +179,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
<span className="text-xs text-custom-text-300">{issueCount}</span>
|
||||
</div>
|
||||
{cycleDetails.assignee_ids.length > 0 && (
|
||||
<Tooltip tooltipContent={`${cycleDetails.assignee_ids.length} Members`}>
|
||||
<Tooltip tooltipContent={`${cycleDetails.assignee_ids.length} Members`} isMobile={isMobile}>
|
||||
<div className="flex cursor-default items-center gap-1">
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{cycleDetails.assignee_ids.map((assigne_id) => {
|
||||
@@ -249,6 +193,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={isNaN(completionPercentage) ? "0" : `${completionPercentage.toFixed(0)}%`}
|
||||
position="top-left"
|
||||
>
|
||||
@@ -288,30 +233,8 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
<Star className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
</button>
|
||||
))}
|
||||
<CustomMenu ellipsis className="z-10">
|
||||
{!isCompleted && isEditingAllowed && (
|
||||
<>
|
||||
<CustomMenu.MenuItem onClick={handleEditCycle}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
<span>Edit cycle</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<span>Delete cycle</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-3 w-3" />
|
||||
<span>Copy cycle link</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
|
||||
<CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user