Compare commits

..

1 Commits

Author SHA1 Message Date
Ramesh Kumar Chandra 4c30d70671 dashboard responsive 2024-03-11 22:03:08 +05:30
303 changed files with 1972 additions and 7023 deletions
+6 -26
View File
@@ -2,27 +2,6 @@ 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
@@ -39,7 +18,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 }}
@@ -95,7 +74,7 @@ jobs:
- nginx/**
branch_build_push_frontend:
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' }}
if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
@@ -147,7 +126,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_space:
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' }}
if: ${{ needs.branch_build_setup.outputs.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:
@@ -199,7 +178,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_backend:
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' }}
if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
@@ -251,7 +230,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_proxy:
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' }}
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
@@ -301,3 +280,4 @@ jobs:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
+31 -157
View File
@@ -4,196 +4,70 @@ on:
workflow_dispatch:
inputs:
web-build:
required: false
description: 'Build Web'
required: true
type: boolean
default: true
space-build:
required: false
description: 'Build Space'
required: true
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:
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 }}
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 }}
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/${{ vars.FEATURE_PREVIEW_KUBE_VERSION }}/bin/linux/amd64/kubectl"
curl -LO "https://dl.k8s.io/release/${{secrets.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: |
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
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 }}
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
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)
if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ]; then
APP_NAME=$(echo $METADATA | jq -r '.name')
helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }}
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')
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
echo "****************************************"
echo "APP NAME ::: $APP_NAME"
echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME"
echo "****************************************"
-1
View File
@@ -51,7 +51,6 @@ staticfiles
mediafiles
.env
.DS_Store
logs/
node_modules/
assets/dist/
+20
View File
@@ -14,6 +14,10 @@ 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"
@@ -30,6 +34,11 @@ 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
@@ -39,8 +48,19 @@ 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
-2
View File
@@ -48,10 +48,8 @@ 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
+2 -6
View File
@@ -21,15 +21,11 @@ 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
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 -
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 -
+1 -4
View File
@@ -21,15 +21,12 @@ 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
+15 -11
View File
@@ -1,26 +1,26 @@
# Python imports
import zoneinfo
from urllib.parse import urlparse
import zoneinfo
# Django imports
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
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.bgtasks.webhook_task import send_webhook
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
from plane.bgtasks.webhook_task import send_webhook
class TimezoneMixin:
@@ -106,23 +106,27 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
if isinstance(e, ValidationError):
return Response(
{"error": "Please provide valid detail"},
{
"error": "The provided payload is not valid please try with a valid payload"
},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ObjectDoesNotExist):
return Response(
{"error": "The requested resource does not exist."},
{"error": "The required object 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,
)
log_exception(e)
if settings.DEBUG:
print(e)
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+1 -2
View File
@@ -215,10 +215,9 @@ 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", "sub_issues"]
fields = ModuleSerializer.Meta.fields + ["link_module"]
class ModuleFavoriteSerializer(BaseSerializer):
@@ -102,12 +102,6 @@ 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)
+15 -13
View File
@@ -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_filters.rest_framework import DjangoFilterBackend
from django.db import IntegrityError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
# 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 rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from sentry_sdk import capture_exception
from django_filters.rest_framework import DjangoFilterBackend
# Module imports
from plane.bgtasks.webhook_task import send_webhook
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
from plane.bgtasks.webhook_task import send_webhook
class TimezoneMixin:
@@ -87,7 +87,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
try:
return self.model.objects.all()
except Exception as e:
log_exception(e)
capture_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):
log_exception(e)
capture_exception(e)
return Response(
{"error": "The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
log_exception(e)
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -233,7 +233,9 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
status=status.HTTP_400_BAD_REQUEST,
)
log_exception(e)
if settings.DEBUG:
print(e)
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+29 -51
View File
@@ -15,7 +15,10 @@ 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
@@ -29,7 +32,9 @@ from rest_framework import status
from .. import BaseViewSet, BaseAPIView, WebhookMixin
from plane.app.serializers import (
CycleSerializer,
CycleIssueSerializer,
CycleFavoriteSerializer,
IssueSerializer,
CycleWriteSerializer,
CycleUserPropertiesSerializer,
)
@@ -43,10 +48,13 @@ 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
@@ -98,6 +106,15 @@ 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",
@@ -184,15 +201,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
def list(self, request, slug, project_id):
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,
),
)
)
queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all")
# Update the order by
@@ -318,13 +327,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)
@@ -346,8 +355,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"external_id",
"progress_snapshot",
# meta fields
"total_issues",
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
@@ -393,6 +402,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
@@ -464,6 +474,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
@@ -476,42 +487,10 @@ 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)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
)
queryset = self.get_queryset().filter(pk=pk)
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",
@@ -528,7 +507,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"external_source",
"external_id",
"progress_snapshot",
"sub_issues",
# meta fields
"is_favorite",
"total_issues",
@@ -38,6 +38,7 @@ from plane.db.models import (
IssueLink,
IssueAttachment,
IssueRelation,
IssueAssignee,
User,
)
from plane.app.serializers import (
+29 -1
View File
@@ -1,5 +1,7 @@
# Python imports
import json
import random
from itertools import chain
# Django imports
from django.utils import timezone
@@ -19,38 +21,64 @@ 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 UUIDField
from django.db.models import Value, 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):
+5 -26
View File
@@ -3,7 +3,7 @@ import json
# Django Imports
from django.utils import timezone
from django.db.models import Prefetch, F, OuterRef, Exists, Count, Q, Func
from django.db.models import Prefetch, F, OuterRef, Exists, Count, Q
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
@@ -183,6 +183,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"external_id",
# computed fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
@@ -223,8 +224,8 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"external_source",
"external_id",
# computed fields
"total_issues",
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
@@ -236,30 +237,7 @@ 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)
.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")
)
)
queryset = self.get_queryset().filter(pk=pk)
assignee_distribution = (
Issue.objects.filter(
@@ -402,6 +380,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"external_id",
# computed fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
+3 -73
View File
@@ -46,11 +46,9 @@ 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
@@ -173,73 +171,6 @@ 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)
@@ -346,12 +277,12 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
{"name": "The project name is already taken"},
status=status.HTTP_410_GONE,
)
except Workspace.DoesNotExist:
except Workspace.DoesNotExist as e:
return Response(
{"error": "Workspace does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
except serializers.ValidationError:
except serializers.ValidationError as e:
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_410_GONE,
@@ -410,7 +341,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
{"error": "Project does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
except serializers.ValidationError:
except serializers.ValidationError as e:
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_410_GONE,
@@ -540,7 +471,6 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
# Cache the below api for 24 hours
@cache_response(60 * 60 * 24, user=False)
def get(self, request):
+19 -17
View File
@@ -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.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
from plane.license.utils.instance_value import get_email_configuration
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,8 +504,10 @@ 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:
log_exception(e)
print(e)
if settings.DEBUG:
print(e)
capture_exception(e)
return
@@ -1,22 +1,21 @@
import logging
from datetime import datetime
from bs4 import BeautifulSoup
# Third party imports
from celery import shared_task
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from sentry_sdk import capture_exception
# 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, Issue, User
from plane.db.models import EmailNotificationLog, User, Issue
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
@@ -70,9 +69,7 @@ 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"))
@@ -299,9 +296,7 @@ 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())
@@ -310,20 +305,15 @@ def send_email_notification(
release_lock(lock_id=lock_id)
return
except Exception as e:
log_exception(e)
capture_exception(e)
# release the lock
release_lock(lock_id=lock_id)
return
else:
logging.getLogger("plane").info(
"Duplicate email received skipping"
)
print("Duplicate task recived. Skipping...")
return
except (Issue.DoesNotExist, User.DoesNotExist) as e:
log_exception(e)
release_lock(lock_id=lock_id)
return
except Exception as e:
log_exception(e)
if settings.DEBUG:
print(e)
release_lock(lock_id=lock_id)
return
@@ -1,13 +1,13 @@
import os
import uuid
import os
# 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,8 +51,7 @@ def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
},
)
except Exception as e:
log_exception(e)
return
capture_exception(e)
@shared_task
@@ -78,5 +77,4 @@ def workspace_invite_event(
},
)
except Exception as e:
log_exception(e)
return
capture_exception(e)
+11 -9
View File
@@ -2,22 +2,21 @@
import csv
import io
import json
import zipfile
import boto3
from botocore.client import Config
# Third party imports
from celery import shared_task
import zipfile
# 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 ExporterHistory, Issue
from plane.utils.exception_logger import log_exception
from plane.db.models import Issue, ExporterHistory
def dateTimeConverter(time):
@@ -404,5 +403,8 @@ def issue_export_task(
exporter_instance.status = "failed"
exporter_instance.reason = str(e)
exporter_instance.save(update_fields=["status", "reason"])
log_exception(e)
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return
@@ -1,17 +1,17 @@
# Python imports
import logging
# Third party imports
from celery import shared_task
# Python import
# 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,8 +60,10 @@ 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:
log_exception(e)
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return
+21 -20
View File
@@ -1,36 +1,34 @@
# 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
from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
# Third Party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import (
CommentReaction,
Cycle,
Issue,
IssueActivity,
IssueComment,
IssueReaction,
IssueSubscriber,
Label,
Module,
Project,
State,
User,
Issue,
Project,
Label,
IssueActivity,
State,
Cycle,
Module,
IssueReaction,
CommentReaction,
IssueComment,
IssueSubscriber,
)
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
@@ -1649,7 +1647,7 @@ def issue_activity(
headers=headers,
)
except Exception as e:
log_exception(e)
capture_exception(e)
if notification:
notifications.delay(
@@ -1670,5 +1668,8 @@ def issue_activity(
return
except Exception as e:
log_exception(e)
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return
@@ -2,17 +2,18 @@
import json
from datetime import timedelta
# Third party imports
from celery import shared_task
from django.db.models import Q
# 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
# Module imports
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
from plane.bgtasks.issue_activites_task import issue_activity
@shared_task
@@ -95,7 +96,9 @@ def archive_old_issues():
]
return
except Exception as e:
log_exception(e)
if settings.DEBUG:
print(e)
capture_exception(e)
return
@@ -176,5 +179,7 @@ def close_old_issues():
]
return
except Exception as e:
log_exception(e)
if settings.DEBUG:
print(e)
capture_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,8 +52,11 @@ 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:
log_exception(e)
print(e)
capture_exception(e)
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
return
@@ -1,18 +1,18 @@
# Python imports
import logging
# Third party imports
from celery import shared_task
# Python import
# 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, ProjectMemberInvite, User
from plane.db.models import Project, User, ProjectMemberInvite
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@shared_task
@@ -73,10 +73,12 @@ 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:
log_exception(e)
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return
+32 -33
View File
@@ -1,45 +1,44 @@
import hashlib
import hmac
import json
import logging
import uuid
import requests
# Third party imports
from celery import shared_task
import uuid
import hashlib
import json
import hmac
# Django imports
from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection
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
# Module imports
from plane.api.serializers import (
CycleIssueSerializer,
CycleSerializer,
IssueCommentSerializer,
IssueExpandSerializer,
ModuleIssueSerializer,
ModuleSerializer,
ProjectSerializer,
)
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from plane.db.models import (
Cycle,
CycleIssue,
Issue,
IssueComment,
Module,
ModuleIssue,
Project,
User,
Webhook,
WebhookLog,
Project,
Issue,
Cycle,
Module,
ModuleIssue,
CycleIssue,
IssueComment,
User,
)
from plane.api.serializers import (
ProjectSerializer,
CycleSerializer,
ModuleSerializer,
CycleIssueSerializer,
ModuleIssueSerializer,
IssueCommentSerializer,
IssueExpandSerializer,
)
# Module imports
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
SERIALIZER_MAPPER = {
"project": ProjectSerializer,
@@ -175,7 +174,7 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site):
except Exception as e:
if settings.DEBUG:
print(e)
log_exception(e)
capture_exception(e)
return
@@ -242,7 +241,7 @@ def send_webhook(event, payload, kw, action, slug, bulk, current_site):
except Exception as e:
if settings.DEBUG:
print(e)
log_exception(e)
capture_exception(e)
return
@@ -296,8 +295,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:
log_exception(e)
print(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 User, Workspace, WorkspaceMemberInvite
from plane.db.models import Workspace, WorkspaceMemberInvite, User
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@shared_task
@@ -76,12 +76,14 @@ 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) as e:
log_exception(e)
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist):
print("Workspace or WorkspaceMember Invite Does not exists")
return
except Exception as e:
log_exception(e)
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return
@@ -1,17 +0,0 @@
# 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
@@ -1,61 +0,0 @@
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}"
)
)
+7 -8
View File
@@ -1,17 +1,16 @@
# Python imports
import random
import string
import uuid
import string
import random
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
+5 -6
View File
@@ -3,20 +3,19 @@
# Python imports
import os
import ssl
import certifi
from datetime import timedelta
from urllib.parse import urlparse
import certifi
# Django imports
from django.core.management.utils import get_random_secret_key
# 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__)))
@@ -24,7 +23,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 = int(os.environ.get("DEBUG", "0"))
DEBUG = False
# Allowed Hosts
ALLOWED_HOSTS = ["*"]
+4 -39
View File
@@ -7,8 +7,8 @@ from .common import * # noqa
DEBUG = True
# Debug Toolbar settings
INSTALLED_APPS += ("debug_toolbar",) # noqa
MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) # noqa
INSTALLED_APPS += ("debug_toolbar",)
MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",)
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, # noqa
"LOCATION": REDIS_URL,
"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") # noqa
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
CORS_ALLOWED_ORIGINS = [
"http://localhost:3000",
@@ -36,38 +36,3 @@ 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,
},
},
}
+2 -62
View File
@@ -1,16 +1,15 @@
"""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",) # noqa
INSTALLED_APPS += ("scout_apm.django",)
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
@@ -19,62 +18,3 @@ 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,
},
},
}
+1 -1
View File
@@ -7,6 +7,6 @@ DEBUG = True
# Send it in a dummy outbox
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
INSTALLED_APPS.append( # noqa
INSTALLED_APPS.append(
"plane.tests",
)
+15 -12
View File
@@ -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_filters.rest_framework import DjangoFilterBackend
from django.db import IntegrityError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
# 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 rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from sentry_sdk import capture_exception
from django_filters.rest_framework import DjangoFilterBackend
# 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:
log_exception(e)
capture_exception(e)
raise APIException(
"Please check the view", status.HTTP_400_BAD_REQUEST
)
@@ -90,13 +90,14 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
)
if isinstance(e, KeyError):
log_exception(e)
capture_exception(e)
return Response(
{"error": "The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
log_exception(e)
print(e) if settings.DEBUG else print("Server Error")
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -184,7 +185,9 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
status=status.HTTP_400_BAD_REQUEST,
)
log_exception(e)
if settings.DEBUG:
print(e)
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+10 -14
View File
@@ -1,11 +1,7 @@
# Python imports
from functools import wraps
# Django imports
from django.conf import settings
from django.core.cache import cache
# Third party imports
# from django.utils.encoding import force_bytes
# import hashlib
from functools import wraps
from rest_framework.response import Response
@@ -26,20 +22,21 @@ 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 and not settings.DEBUG:
if response.status_code == 200:
cache.set(
key,
{"data": response.data, "status": response.status_code},
@@ -74,12 +71,11 @@ 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)
-15
View File
@@ -1,15 +0,0 @@
# 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
-46
View File
@@ -1,46 +0,0 @@
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
-1
View File
@@ -27,7 +27,6 @@ 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
+48 -45
View File
@@ -1,79 +1,82 @@
# One-click deploy
# 1-Click Self-Hosting
Deployment methods for Plane have improved significantly to make self-managing super-easy. One of those is a single-line-command installation of Plane.
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.
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.
Let's get started!
### Requirements
## Installing Plane
- Operating systems: Debian, Ubuntu, CentOS
- Supported CPU architectures: AMD64, ARM64, x86_64, AArch64
Installing Plane is a very easy and minimal step process.
### Download the latest stable release
### Prerequisite
Run ↓ on any CLI.
- Operating System (latest): Debian / Ubuntu / Centos
- Supported CPU Architechture: AMD64 / ARM64 / x86_64 / aarch64
### Downloading Latest Stable Release
```
curl -fsSL https://raw.githubusercontent.com/makeplane/plane/master/deploy/1-click/install.sh | sh -
```
### Download the Preview release
`Preview` builds do not support ARM64, AArch64 CPU architectures
Run ↓ on any CLI.
<details>
<summary>Downloading Preview Release</summary>
```
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
### Successful installation
</details>
You should see ↓ if there are no hitches. That output will also list the IP address you can use to access your Plane instance.
--
Expect this after a successful install
![Install Output](images/install.png)
Access the application on a browser via http://server-ip-address
---
### Manage your Plane instance
### Get Control of your Plane Server Setup
Use `plane-app` [OPERATOR] to manage your Plane instance easily. Get a list of all operators with `plane-app ---help`.
Plane App is available via the command `plane-app`. Running the command `plane-app --help` helps you to manage Plane
![Plane Help](images/help.png)
1. Basic operators
<ins>Basic Operations</ins>:
1. `plane-app start` starts the Plane server.
2. `plane-app restart` restarts the Plane server.
3. `plane-app stop` stops the Plane server.
1. Start Server using `plane-app start`
1. Stop Server using `plane-app stop`
1. Restart Server using `plane-app restart`
2. Advanced operators
<ins>Advanced Operations</ins>:
`plane-app --configure` will show advanced configurators.
1. Configure Plane using `plane-app --configure`. This will give you options to modify
- 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`
- 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)
3. Version operators
1. Upgrade Plane using `plane-app --upgrade`. This will get the latest stable version of Plane files (docker-compose.yaml, .env, and docker images)
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.
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
+12
View File
@@ -494,6 +494,13 @@ 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 ""
@@ -599,6 +606,11 @@ 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 ✅"
+24 -16
View File
@@ -1,12 +1,18 @@
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}
@@ -22,6 +28,20 @@ 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
@@ -70,8 +90,6 @@ services:
command: ./bin/takeoff
deploy:
replicas: ${API_REPLICAS:-1}
volumes:
- logs_api:/code/plane/logs
depends_on:
- plane-db
- plane-redis
@@ -82,8 +100,6 @@ services:
pull_policy: ${PULL_POLICY:-always}
restart: unless-stopped
command: ./bin/worker
volumes:
- logs_worker:/code/plane/logs
depends_on:
- api
- plane-db
@@ -95,8 +111,6 @@ services:
pull_policy: ${PULL_POLICY:-always}
restart: unless-stopped
command: ./bin/beat
volumes:
- logs_beat-worker:/code/plane/logs
depends_on:
- api
- plane-db
@@ -108,10 +122,8 @@ services:
pull_policy: ${PULL_POLICY:-always}
restart: no
command: >
sh -c "python manage.py wait_for_db &&
python manage.py migrate"
volumes:
- logs_migrator:/code/plane/logs
sh -c "python manage.py wait_for_db &&
python manage.py migrate"
depends_on:
- plane-db
- plane-redis
@@ -147,7 +159,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
@@ -157,7 +169,3 @@ volumes:
pgdata:
redisdata:
uploads:
logs_api:
logs_worker:
logs_beat-worker:
logs_migrator:
+18 -1
View File
@@ -7,8 +7,13 @@ 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
@@ -25,7 +30,19 @@ REDIS_HOST=plane-redis
REDIS_PORT=6379
REDIS_URL=redis://${REDIS_HOST}:6379/
# Secret Key
# 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=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
# DATA STORE SETTINGS
-14
View File
@@ -1,4 +1,3 @@
import { Selection } from "@tiptap/pm/state";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
interface EditorClassNames {
@@ -19,19 +18,6 @@ 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 { FC, ReactNode } from "react";
import { ReactNode } from "react";
interface EditorContainerProps {
editor: Editor | null;
@@ -8,54 +8,17 @@ interface EditorContainerProps {
hideDragHandle?: () => void;
}
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>
);
};
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>
);
@@ -1,28 +1,17 @@
import { Editor, EditorContent } from "@tiptap/react";
import { FC, ReactNode } from "react";
import { 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: 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>
);
};
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = "", children }: EditorContentProps) => (
<div className={`contentEditor ${editorContentCustomClassNames}`}>
<EditorContent editor={editor} />
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
{children}
</div>
);
@@ -5,8 +5,6 @@ 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: {
@@ -20,12 +18,6 @@ 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),
@@ -1,45 +0,0 @@
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;
};
@@ -1,46 +0,0 @@
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 "src/ui/extensions/horizontal-rule/horizontal-rule";
import { CustomHorizontalRule } from "./horizontal-rule/horizontal-rule";
export const CoreEditorExtensions = (
mentionConfig: {
@@ -66,6 +66,7 @@ export const CoreEditorExtensions = (
CustomQuoteExtension.configure({
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
}),
CustomHorizontalRule.configure({
HTMLAttributes: { class: "mt-4 mb-4" },
}),
@@ -25,8 +25,6 @@ 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>;
@@ -233,8 +231,6 @@ export const Table = Node.create({
"Mod-Backspace": deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected,
"Mod-Delete": deleteTableWhenAllCellsSelected,
ArrowDown: insertLineBelowTableAction,
ArrowUp: insertLineAboveTableAction,
};
},
@@ -1,50 +0,0 @@
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;
};
@@ -1,48 +0,0 @@
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,6 +5,7 @@ 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";
@@ -16,11 +17,6 @@ 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[];
@@ -42,31 +38,36 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
class: "leading-normal -mb-2",
},
},
code: false,
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",
},
},
codeBlock: false,
horizontalRule: false,
blockquote: false,
dropcursor: false,
horizontalRule: {
HTMLAttributes: { class: "mt-4 mb-4" },
},
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 2,
},
gapcursor: false,
}),
CustomQuoteExtension.configure({
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
}),
CustomHorizontalRule.configure({
HTMLAttributes: { class: "mt-4 mb-4" },
}),
Gapcursor,
CustomLinkExtension.configure({
openOnClick: true,
autolink: true,
linkOnPaste: true,
protocols: ["http", "https"],
validate: (url: string) => isValidHttpUrl(url),
validate: (url) => 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",
@@ -86,8 +87,6 @@ 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">Outline</h2>
<h2 className="font-medium">Table of Contents</h2>
<div className="h-full overflow-y-auto">
{markings.length !== 0 ? (
markings.map((marking) =>
@@ -29,13 +29,11 @@ type IPageRenderer = {
editorContentCustomClassNames?: string;
hideDragHandle?: () => void;
readonly: boolean;
tabIndex?: number;
};
export const PageRenderer = (props: IPageRenderer) => {
const {
documentDetails,
tabIndex,
editor,
editorClassNames,
editorContentCustomClassNames,
@@ -154,7 +152,7 @@ export const PageRenderer = (props: IPageRenderer) => {
);
return (
<div className="w-full h-full pb-20 pl-7 pt-5 page-renderer">
<div className="w-full pb-64 md:pl-7 pl-3 pt-5 page-renderer">
{!readonly ? (
<input
onChange={(e) => handlePageTitleChange(e.target.value)}
@@ -171,11 +169,7 @@ 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
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
</EditorContainer>
</div>
{isOpen && linkViewProps && coordinates && (
@@ -47,8 +47,6 @@ interface IDocumentEditor {
duplicationConfig?: IDuplicationConfig;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
tabIndex?: number;
}
interface DocumentEditorProps extends IDocumentEditor {
forwardedRef?: React.Ref<EditorHandle>;
@@ -81,7 +79,6 @@ const DocumentEditor = ({
cancelUploadImage,
onActionCompleteHandler,
rerenderOnPropsChange,
tabIndex,
}: IDocumentEditor) => {
const { markings, updateMarkings } = useEditorMarkings();
const [sidePeekVisible, setSidePeekVisible] = useState(true);
@@ -163,7 +160,6 @@ 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,7 +28,6 @@ interface IDocumentReadOnlyEditor {
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
tabIndex?: number;
}
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
@@ -52,7 +51,6 @@ const DocumentReadOnlyEditor = ({
pageArchiveConfig,
rerenderOnPropsChange,
onActionCompleteHandler,
tabIndex,
}: DocumentReadOnlyEditorProps) => {
const router = useRouter();
const [sidePeekVisible, setSidePeekVisible] = useState(true);
@@ -110,10 +108,9 @@ 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
readonly={true}
editor={editor}
editorClassNames={editorClassNames}
documentDetails={documentDetails}
@@ -42,7 +42,6 @@ interface ILiteTextEditor {
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
submitButton?: React.ReactNode;
tabIndex?: number;
}
interface LiteTextEditorProps extends ILiteTextEditor {
@@ -75,7 +74,6 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
mentionHighlights,
mentionSuggestions,
submitButton,
tabIndex,
} = props;
const editor = useEditor({
@@ -105,11 +103,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
return (
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<div className="flex flex-col">
<EditorContentWrapper
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
<div className="mt-4 w-full">
<FixedMenu
editor={editor}
@@ -8,7 +8,6 @@ interface ICoreReadOnlyEditor {
borderOnFocus?: boolean;
customClassName?: string;
mentionHighlights: string[];
tabIndex?: number;
}
interface EditorCoreProps extends ICoreReadOnlyEditor {
@@ -28,7 +27,6 @@ const LiteReadOnlyEditor = ({
value,
forwardedRef,
mentionHighlights,
tabIndex,
}: EditorCoreProps) => {
const editor = useReadOnlyEditor({
value,
@@ -47,11 +45,7 @@ const LiteReadOnlyEditor = ({
return (
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<div className="flex flex-col">
<EditorContentWrapper
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
</div>
</EditorContainer>
);
@@ -36,7 +36,6 @@ export type IRichTextEditor = {
debouncedUpdatesEnabled?: boolean;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
tabIndex?: number;
};
export interface RichTextEditorProps extends IRichTextEditor {
@@ -69,7 +68,6 @@ const RichTextEditor = ({
mentionHighlights,
rerenderOnPropsChange,
mentionSuggestions,
tabIndex,
}: RichTextEditorProps) => {
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
@@ -102,21 +100,17 @@ 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
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
</div>
</EditorContainer>
);
@@ -121,10 +121,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
<button
key={item.name}
type="button"
onClick={(e) => {
item.command();
e.stopPropagation();
}}
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 transition-colors hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5",
{
@@ -33,9 +33,8 @@ 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={(e) => {
onClick={() => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
>
<p className="text-base"></p>
@@ -61,9 +60,6 @@ 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 || ""}
/>
@@ -71,10 +67,9 @@ 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={(e) => {
onClick={() => {
unsetLinkEditor(editor);
setIsOpen(false);
e.stopPropagation();
}}
>
<Trash className="h-4 w-4" />
@@ -83,8 +78,7 @@ 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={(e) => {
e.stopPropagation();
onClick={() => {
onLinkSubmit();
}}
>
@@ -47,10 +47,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
<div className="relative h-full">
<button
type="button"
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
onClick={() => setIsOpen(!isOpen)}
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>
@@ -63,10 +60,9 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
<button
key={item.name}
type="button"
onClick={(e) => {
onClick={() => {
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,7 +9,6 @@ interface IRichTextReadOnlyEditor {
borderOnFocus?: boolean;
customClassName?: string;
mentionHighlights?: string[];
tabIndex?: number;
}
interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor {
-19
View File
@@ -1,19 +0,0 @@
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;
};
-2
View File
@@ -1,2 +0,0 @@
export * from "./cycle_filters";
export * from "./cycle";
@@ -1,7 +1,11 @@
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;
@@ -26,7 +30,6 @@ 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;
+1 -1
View File
@@ -1,7 +1,7 @@
export * from "./github-importer";
export * from "./jira-importer";
import { IProjectLite } from "../project";
import { IProjectLite } from "../projects";
// types
import { IUserLite } from "../users";
+1 -1
View File
@@ -1,5 +1,5 @@
import { TIssue } from "../issues/base";
import type { IProjectLite } from "../project";
import type { IProjectLite } from "../projects";
export type TInboxIssueExtended = {
completed_at: string | null;
+3 -3
View File
@@ -1,11 +1,11 @@
export * from "./users";
export * from "./workspace";
export * from "./cycle";
export * from "./cycles";
export * from "./dashboard";
export * from "./project";
export * from "./projects";
export * from "./state";
export * from "./issues";
export * from "./module";
export * from "./modules";
export * from "./views";
export * from "./integration";
export * from "./pages";
-2
View File
@@ -1,2 +0,0 @@
export * from "./module_filters";
export * from "./modules";
-32
View File
@@ -1,32 +0,0 @@
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,7 +30,6 @@ export interface IModule {
name: string;
project_id: string;
sort_order: number;
sub_issues: number;
start_date: string | null;
started_issues: number;
status: TModuleStatus;
-2
View File
@@ -1,2 +0,0 @@
export * from "./project_filters";
export * from "./projects";
-25
View File
@@ -1,25 +0,0 @@
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,7 +7,7 @@ import type {
IWorkspace,
IWorkspaceLite,
TStateGroups,
} from "..";
} from ".";
export type TProjectLogoProps = {
in_use: "emoji" | "icon";
@@ -23,8 +23,6 @@ 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;
@@ -37,8 +35,6 @@ 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;
@@ -52,9 +48,7 @@ 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;
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ["custom"],
};
+1
View File
@@ -14,6 +14,7 @@
"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": {
+2 -2
View File
@@ -103,7 +103,7 @@ export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
style={styles.popper}
{...attributes.popper}
className={cn(
"w-80 bg-custom-background-100 rounded-md border-[0.5px] border-custom-border-300 overflow-hidden",
"h-80 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 className="h-80 w-full">
<Tab.Panel>
<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" strokeLinecap="round" />
<circle cx="8.33333" cy="8.33333" r="5.33333" stroke="currentColor" stroke-linecap="round" />
<circle cx="8.33333" cy="8.33333" r="4.33333" fill="currentColor" />
</svg>
);
+1 -3
View File
@@ -29,7 +29,6 @@ interface ITooltipProps {
className?: string;
openDelay?: number;
closeDelay?: number;
isMobile?: boolean;
}
export const Tooltip: React.FC<ITooltipProps> = ({
@@ -41,7 +40,6 @@ export const Tooltip: React.FC<ITooltipProps> = ({
className = "",
openDelay = 200,
closeDelay,
isMobile = false,
}) => (
<Tooltip2
disabled={disabled}
@@ -49,7 +47,7 @@ export const Tooltip: React.FC<ITooltipProps> = ({
hoverCloseDelay={closeDelay}
content={
<div
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}`}
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}`}
>
{tooltipHeading && <h5 className="font-medium text-custom-text-100">{tooltipHeading}</h5>}
{tooltipContent}
@@ -6,8 +6,6 @@ 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;
@@ -16,7 +14,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({
@@ -42,7 +40,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" isMobile={isMobile}>
<Tooltip tooltipContent="Copy secret key">
<Copy className="h-4 w-4 text-custom-text-400" />
</Tooltip>
</button>
+4 -6
View File
@@ -3,7 +3,6 @@ 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";
@@ -18,14 +17,12 @@ 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" isMobile={isMobile}>
<Tooltip tooltipContent="Delete token">
<button
onClick={() => setDeleteModalOpen(true)}
className="absolute right-4 hidden place-items-center group-hover:grid"
@@ -36,8 +33,9 @@ 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,11 +20,12 @@ 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
@@ -36,7 +37,6 @@ 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" isMobile={isMobile}>
<Tooltip tooltipContent="Toggle workspace level search">
<div className="flex flex-shrink-0 cursor-pointer items-center gap-1 self-end text-xs sm:self-center">
<button
type="button"
+1 -3
View File
@@ -1,6 +1,5 @@
import Link from "next/link";
import { Tooltip } from "@plane/ui";
import { usePlatformOS } from "hooks/use-platform-os";
type Props = {
label?: string;
@@ -10,9 +9,8 @@ type Props = {
export const BreadcrumbLink: React.FC<Props> = (props) => {
const { href, label, icon } = props;
const { isMobile } = usePlatformOS();
return (
<Tooltip tooltipContent={label} position="bottom" isMobile={isMobile}>
<Tooltip tooltipContent={label} position="bottom">
<li className="flex items-center space-x-2" tabIndex={-1}>
<div className="flex flex-wrap items-center gap-2.5">
{href ? (
+13 -18
View File
@@ -1,7 +1,6 @@
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 {
@@ -30,25 +29,22 @@ 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"}
isMobile={isMobile}
>
<Tooltip tooltipContent={activity?.issue_detail ? activity.issue_detail.name : "This issue has been deleted"}>
{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="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
className="border border-red-500 relative w-full overflow-hidden"
>
<span className="whitespace-nowrap">{`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}</span>{" "}
<span className="font-normal">{activity.issue_detail?.name}</span>
<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>
</a>
) : (
<span className="inline-flex items-center gap-1 font-medium text-custom-text-100 whitespace-nowrap">
@@ -65,9 +61,8 @@ 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"
@@ -596,7 +591,7 @@ const activityDetails: {
state: {
message: (activity, showIssue) => (
<>
set the state to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
{/* set the state to <span className="border border-green-500 font-medium text-custom-text-100">{activity.new_value}</span> */}
{showIssue && (
<>
{" "}
@@ -683,12 +678,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" isMobile={isMobile}>
<Tooltip tooltipContent="Toggle workspace level search">
<div
className={`flex flex-shrink-0 cursor-pointer items-center gap-1 text-xs ${
isWorkspaceLevel ? "text-custom-text-100" : "text-custom-text-200"
+2 -3
View File
@@ -7,7 +7,6 @@ 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";
@@ -20,7 +19,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) => {
@@ -43,7 +42,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} isMobile={isMobile}>
<Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url}>
<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 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
className="flex h-44 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 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
className="flex h-44 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 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
>
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
@@ -3,8 +3,7 @@ import { observer } from "mobx-react-lite";
import Link from "next/link";
import useSWR from "swr";
// hooks
import { useCycle, useCycleFilter, useIssues, useMember, useProject } from "hooks/store";
import { usePlatformOS } from "hooks/use-platform-os";
import { useCycle, useIssues, useMember, useProject } from "hooks/store";
// ui
import { SingleProgressStats } from "components/core";
import {
@@ -18,11 +17,10 @@ import {
Avatar,
CycleGroupIcon,
setPromiseToast,
getButtonStyling,
} from "@plane/ui";
// components
import ProgressChart from "components/core/sidebar/progress-chart";
import { ActiveCycleProgressStats, UpcomingCyclesList } from "components/cycles";
import { ActiveCycleProgressStats } from "components/cycles";
import { StateDropdown } from "components/dropdowns";
import { EmptyState } from "components/empty-state";
// icons
@@ -30,7 +28,6 @@ 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
@@ -44,36 +41,30 @@ interface IActiveCycleDetails {
projectId: string;
}
export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) => {
export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props) => {
// props
const { workspaceSlug, projectId } = props;
// hooks
const { isMobile } = usePlatformOS();
// store hooks
const {
issues: { fetchActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE);
const {
currentProjectActiveCycleId,
currentProjectUpcomingCycleIds,
fetchActiveCycle,
currentProjectActiveCycleId,
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
);
// fetch active cycle issues
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
const { data: activeCycleIssues } = useSWR(
workspaceSlug && projectId && currentProjectActiveCycleId
? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" })
@@ -82,7 +73,7 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
? () => fetchActiveCycleIssues(workspaceSlug, projectId, currentProjectActiveCycleId)
: null
);
// show loader if active cycle is loading
if (!activeCycle && isLoading)
return (
<Loader>
@@ -90,44 +81,10 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
</Loader>
);
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 />
</>
);
}
if (!activeCycle) return <EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />;
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,
@@ -137,6 +94,8 @@ export const ActiveCycleRoot: 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;
@@ -189,6 +148,8 @@ export const ActiveCycleRoot: 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">
@@ -200,7 +161,7 @@ export const ActiveCycleRoot: 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" isMobile={isMobile}>
<Tooltip tooltipContent={activeCycle.name} position="top-left">
<h3 className="break-words text-lg font-semibold">{truncateText(activeCycle.name, 70)}</h3>
</Tooltip>
</span>
@@ -242,15 +203,27 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
<div className="flex items-center gap-4">
<div className="flex items-center gap-2.5 text-custom-text-200">
<Avatar src={cycleOwnerDetails?.avatar} name={cycleOwnerDetails?.display_name} />
{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>
)}
<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((assignee_id) => {
const member = getUserDetails(assignee_id);
{activeCycle.assignee_ids.map((assigne_id) => {
const member = getUserDetails(assigne_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
@@ -260,7 +233,7 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
<div className="flex items-center gap-4 text-custom-text-200">
<div className="flex gap-2">
<LayersIcon className="h-3.5 w-3.5 flex-shrink-0" />
<LayersIcon className="h-4 w-4 flex-shrink-0" />
{activeCycle.total_issues} issues
</div>
<div className="flex items-center gap-2">
@@ -271,9 +244,9 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
<Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${activeCycle.id}`}
className={cn(getButtonStyling("primary", "lg"), "w-min whitespace-nowrap")}
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"
>
View cycle
View Cycle
</Link>
</div>
</div>
@@ -314,11 +287,11 @@ export const ActiveCycleRoot: 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) => (
activeCycleIssues.map((issue: any) => (
<Link
key={issue.id}
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
@@ -328,7 +301,6 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
<PriorityIcon priority={issue.priority} withContainer size={12} />
<Tooltip
isMobile={isMobile}
tooltipHeading="Issue ID"
tooltipContent={`${currentProjectDetails?.identifier}-${issue.sequence_id}`}
>
@@ -336,20 +308,20 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
{currentProjectDetails?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
<Tooltip position="top-left" tooltipContent={issue.name} isMobile={isMobile}>
<Tooltip position="top-left" tooltipContent={issue.name}>
<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}
value={issue.state_id ?? undefined}
onChange={() => {}}
projectId={projectId}
projectId={projectId?.toString() ?? ""}
disabled
buttonVariant="background-with-text"
/>
{issue.target_date && (
<Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)} isMobile={isMobile}>
<Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)}>
<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>
@@ -387,10 +359,10 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
</div>
<div className="flex items-center gap-1">
<span>
<LayersIcon className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-200" />
<LayersIcon className="h-5 w-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>
@@ -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 issues present in this cycle.
There are no high priority issues present in this cycle.
</div>
)}
</Tab.Group>
@@ -1,4 +0,0 @@
export * from "./root";
export * from "./stats";
export * from "./upcoming-cycles-list-item";
export * from "./upcoming-cycles-list";
@@ -1,135 +0,0 @@
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>
);
});
@@ -1,25 +0,0 @@
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>
);
});
@@ -1,55 +0,0 @@
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>
))}
</>
);
});
@@ -1,3 +0,0 @@
export * from "./date";
export * from "./root";
export * from "./status";
@@ -1,90 +0,0 @@
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>
);
});
@@ -1,43 +0,0 @@
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>
);
})}
</>
);
});
@@ -1,25 +0,0 @@
// components
import { CyclesBoardCard } from "components/cycles";
type Props = {
cycleIds: string[];
peekCycle: string | undefined;
projectId: string;
workspaceSlug: string;
};
export const CyclesBoardMap: React.FC<Props> = (props) => {
const { cycleIds, peekCycle, projectId, workspaceSlug } = props;
return (
<div
className={`w-full grid grid-cols-1 gap-6 ${
peekCycle ? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3" : "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
} auto-rows-max transition-all`}
>
{cycleIds.map((cycleId) => (
<CyclesBoardCard key={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
))}
</div>
);
};

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