Compare commits

..

1 Commits

Author SHA1 Message Date
pablohashescobar e2ecd4db0b dev: update server configuration commands 2024-02-07 11:57:40 +05:30
650 changed files with 5927 additions and 18315 deletions
+62 -90
View File
@@ -2,6 +2,11 @@ name: Branch Build
on:
workflow_dispatch:
inputs:
branch_name:
description: "Branch Name"
required: true
default: "preview"
push:
branches:
- master
@@ -11,71 +16,49 @@ on:
types: [released, prereleased]
env:
TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }}
TARGET_BRANCH: ${{ inputs.branch_name || github.ref_name || github.event.release.target_commitish }}
jobs:
branch_build_setup:
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_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
runs-on: ubuntu-20.04
steps:
- id: set_env_variables
name: Set Environment Variables
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ github.event_name }}" == "release" ]; then
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT
else
echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT
echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT
echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
fi
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
- name: Check out the repo
uses: actions/checkout@v3.3.0
outputs:
gh_branch_name: ${{ env.TARGET_BRANCH }}
branch_build_push_frontend:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Set Frontend Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
else
TAG=${{ env.FRONTEND_TAG }}
fi
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v4.1.1
@@ -84,7 +67,7 @@ jobs:
with:
context: .
file: ./web/Dockerfile.web
platforms: ${{ env.BUILDX_PLATFORMS }}
platforms: linux/amd64
tags: ${{ env.FRONTEND_TAG }}
push: true
env:
@@ -97,36 +80,33 @@ jobs:
needs: [branch_build_setup]
env:
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Set Space Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
else
TAG=${{ env.SPACE_TAG }}
fi
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v4.1.1
@@ -135,7 +115,7 @@ jobs:
with:
context: .
file: ./space/Dockerfile.space
platforms: ${{ env.BUILDX_PLATFORMS }}
platforms: linux/amd64
tags: ${{ env.SPACE_TAG }}
push: true
env:
@@ -148,36 +128,33 @@ jobs:
needs: [branch_build_setup]
env:
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Set Backend Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
else
TAG=${{ env.BACKEND_TAG }}
fi
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v4.1.1
@@ -186,7 +163,7 @@ jobs:
with:
context: ./apiserver
file: ./apiserver/Dockerfile.api
platforms: ${{ env.BUILDX_PLATFORMS }}
platforms: linux/amd64
push: true
tags: ${{ env.BACKEND_TAG }}
env:
@@ -194,42 +171,38 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_proxy:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Set Proxy Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
else
TAG=${{ env.PROXY_TAG }}
fi
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v4.1.1
@@ -238,11 +211,10 @@ jobs:
with:
context: ./nginx
file: ./nginx/Dockerfile
platforms: ${{ env.BUILDX_PLATFORMS }}
platforms: linux/amd64
tags: ${{ env.PROXY_TAG }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
+1
View File
@@ -41,6 +41,7 @@ USER captain
# Add in Django deps and generate Django's static files
COPY manage.py manage.py
COPY server.py server.py
COPY plane plane/
COPY templates templates/
COPY package.json package.json
+238
View File
@@ -0,0 +1,238 @@
# All the python scripts that are used for back migrations
import uuid
import random
from django.contrib.auth.hashers import make_password
from plane.db.models import ProjectIdentifier
from plane.db.models import (
Issue,
IssueComment,
User,
Project,
ProjectMember,
Label,
Integration,
)
# Update description and description html values for old descriptions
def update_description():
try:
issues = Issue.objects.all()
updated_issues = []
for issue in issues:
issue.description_html = f"<p>{issue.description}</p>"
issue.description_stripped = issue.description
updated_issues.append(issue)
Issue.objects.bulk_update(
updated_issues,
["description_html", "description_stripped"],
batch_size=100,
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_comments():
try:
issue_comments = IssueComment.objects.all()
updated_issue_comments = []
for issue_comment in issue_comments:
issue_comment.comment_html = (
f"<p>{issue_comment.comment_stripped}</p>"
)
updated_issue_comments.append(issue_comment)
IssueComment.objects.bulk_update(
updated_issue_comments, ["comment_html"], batch_size=100
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_project_identifiers():
try:
project_identifiers = ProjectIdentifier.objects.filter(
workspace_id=None
).select_related("project", "project__workspace")
updated_identifiers = []
for identifier in project_identifiers:
identifier.workspace_id = identifier.project.workspace_id
updated_identifiers.append(identifier)
ProjectIdentifier.objects.bulk_update(
updated_identifiers, ["workspace_id"], batch_size=50
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_user_empty_password():
try:
users = User.objects.filter(password="")
updated_users = []
for user in users:
user.password = make_password(uuid.uuid4().hex)
user.is_password_autoset = True
updated_users.append(user)
User.objects.bulk_update(updated_users, ["password"], batch_size=50)
print("Success")
except Exception as e:
print(e)
print("Failed")
def updated_issue_sort_order():
try:
issues = Issue.objects.all()
updated_issues = []
for issue in issues:
issue.sort_order = issue.sequence_id * random.randint(100, 500)
updated_issues.append(issue)
Issue.objects.bulk_update(
updated_issues, ["sort_order"], batch_size=100
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_project_cover_images():
try:
project_cover_images = [
"https://images.unsplash.com/photo-1677432658720-3d84f9d657b4?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
"https://images.unsplash.com/photo-1661107564401-57497d8fe86f?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80",
"https://images.unsplash.com/photo-1677352241429-dc90cfc7a623?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80",
"https://images.unsplash.com/photo-1677196728306-eeafea692454?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1331&q=80",
"https://images.unsplash.com/photo-1660902179734-c94c944f7830?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1255&q=80",
"https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
"https://images.unsplash.com/photo-1677040628614-53936ff66632?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
"https://images.unsplash.com/photo-1676920410907-8d5f8dd4b5ba?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80",
"https://images.unsplash.com/photo-1676846328604-ce831c481346?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1155&q=80",
"https://images.unsplash.com/photo-1676744843212-09b7e64c3a05?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
"https://images.unsplash.com/photo-1676798531090-1608bedeac7b?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
"https://images.unsplash.com/photo-1597088758740-56fd7ec8a3f0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1169&q=80",
"https://images.unsplash.com/photo-1676638392418-80aad7c87b96?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80",
"https://images.unsplash.com/photo-1649639194967-2fec0b4ea7bc?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
"https://images.unsplash.com/photo-1675883086902-b453b3f8146e?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80",
"https://images.unsplash.com/photo-1675887057159-40fca28fdc5d?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1173&q=80",
"https://images.unsplash.com/photo-1675373980203-f84c5a672aa5?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
"https://images.unsplash.com/photo-1675191475318-d2bf6bad1200?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80",
"https://images.unsplash.com/photo-1675456230532-2194d0c4bcc0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
"https://images.unsplash.com/photo-1675371788315-60fa0ef48267?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80",
]
projects = Project.objects.all()
updated_projects = []
for project in projects:
project.cover_image = project_cover_images[random.randint(0, 19)]
updated_projects.append(project)
Project.objects.bulk_update(
updated_projects, ["cover_image"], batch_size=100
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_user_view_property():
try:
project_members = ProjectMember.objects.all()
updated_project_members = []
for project_member in project_members:
project_member.default_props = {
"filters": {"type": None},
"orderBy": "-created_at",
"collapsed": True,
"issueView": "list",
"filterIssue": None,
"groupByProperty": None,
"showEmptyGroups": True,
}
updated_project_members.append(project_member)
ProjectMember.objects.bulk_update(
updated_project_members, ["default_props"], batch_size=100
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_label_color():
try:
labels = Label.objects.filter(color="")
updated_labels = []
for label in labels:
label.color = "#" + "%06x" % random.randint(0, 0xFFFFFF)
updated_labels.append(label)
Label.objects.bulk_update(updated_labels, ["color"], batch_size=100)
print("Success")
except Exception as e:
print(e)
print("Failed")
def create_slack_integration():
try:
_ = Integration.objects.create(
provider="slack", network=2, title="Slack"
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_integration_verified():
try:
integrations = Integration.objects.all()
updated_integrations = []
for integration in integrations:
integration.verified = True
updated_integrations.append(integration)
Integration.objects.bulk_update(
updated_integrations, ["verified"], batch_size=10
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_start_date():
try:
issues = Issue.objects.filter(
state__group__in=["started", "completed"]
)
updated_issues = []
for issue in issues:
issue.start_date = issue.created_at.date()
updated_issues.append(issue)
Issue.objects.bulk_update(
updated_issues, ["start_date"], batch_size=500
)
print("Success")
except Exception as e:
print(e)
print("Failed")
+1 -1
View File
@@ -28,4 +28,4 @@ python manage.py configure_instance
# Create the default bucket
python manage.py create_bucket
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
python server.py
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.15.1"
"version": "0.15.0"
}
-8
View File
@@ -1,8 +1,6 @@
# Python imports
import zoneinfo
import json
from urllib.parse import urlparse
# Django imports
from django.conf import settings
@@ -53,11 +51,6 @@ class WebhookMixin:
and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204]
):
url = request.build_absolute_uri()
parsed_url = urlparse(url)
# Extract the scheme and netloc
scheme = parsed_url.scheme
netloc = parsed_url.netloc
# Push the object to delay
send_webhook.delay(
event=self.webhook_event,
@@ -66,7 +59,6 @@ class WebhookMixin:
action=self.request.method,
slug=self.workspace_slug,
bulk=self.bulk,
current_site=f"{scheme}://{netloc}",
)
return response
+3 -3
View File
@@ -36,8 +36,9 @@ from .project import (
)
from .state import StateSerializer, StateLiteSerializer
from .view import (
ViewSerializer,
ViewFavoriteSerializer,
GlobalViewSerializer,
IssueViewSerializer,
IssueViewFavoriteSerializer,
)
from .cycle import (
CycleSerializer,
@@ -67,7 +68,6 @@ from .issue import (
IssueRelationSerializer,
RelatedIssueSerializer,
IssuePublicSerializer,
IssueDetailSerializer,
)
from .module import (
+1 -7
View File
@@ -586,6 +586,7 @@ class IssueSerializer(DynamicBaseSerializer):
"id",
"name",
"state_id",
"description_html",
"sort_order",
"completed_at",
"estimate_point",
@@ -617,13 +618,6 @@ class IssueSerializer(DynamicBaseSerializer):
return [module for module in obj.issue_module.values_list("module_id", flat=True)]
class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField()
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + ['description_html']
class IssueLiteSerializer(DynamicBaseSerializer):
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
+47 -10
View File
@@ -3,33 +3,69 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
from plane.db.models import View, ViewFavorite
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import GlobalView, IssueView, IssueViewFavorite
from plane.utils.issue_filters import issue_filters
class ViewSerializer(DynamicBaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
class GlobalViewSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(
source="workspace", read_only=True
)
class Meta:
model = View
model = GlobalView
fields = "__all__"
read_only_fields = [
"workspace",
"query",
]
def create(self, validated_data):
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
return GlobalView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)
class IssueViewSerializer(DynamicBaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(
source="workspace", read_only=True
)
class Meta:
model = IssueView
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"query",
"access",
]
def create(self, validated_data):
query_params = validated_data.get("filters", {})
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = {}
return View.objects.create(**validated_data)
return IssueView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("filters", {})
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
@@ -38,10 +74,11 @@ class ViewSerializer(DynamicBaseSerializer):
return super().update(instance, validated_data)
class ViewFavoriteSerializer(BaseSerializer):
class IssueViewFavoriteSerializer(BaseSerializer):
view_detail = IssueViewSerializer(source="issue_view", read_only=True)
class Meta:
model = ViewFavorite
model = IssueViewFavorite
fields = "__all__"
read_only_fields = [
"workspace",
+1 -82
View File
@@ -14,8 +14,6 @@ from plane.app.views import (
UserActivityGraphEndpoint,
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
UserWorkspaceViewViewSet,
UserProjectViewViewSet,
## End Workspaces
)
@@ -87,7 +85,6 @@ urlpatterns = [
UserIssueCompletedGraphEndpoint.as_view(),
name="completed-graph",
),
## End User Graph
path(
"users/me/workspaces/<str:slug>/dashboard/",
UserWorkspaceDashboardEndpoint.as_view(),
@@ -98,83 +95,5 @@ urlpatterns = [
SetUserPasswordEndpoint.as_view(),
name="set-password",
),
path(
"users/me/workspaces/<str:slug>/views/",
UserWorkspaceViewViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="user-workspace-views",
),
path(
"users/me/workspaces/<str:slug>/views/<uuid:pk>/",
UserWorkspaceViewViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="user-workspace-views",
),
path(
"users/me/workspaces/<str:slug>/views/<uuid:pk>/duplicate/",
UserWorkspaceViewViewSet.as_view(
{
"post": "duplicate",
}
),
name="user-workspace-views",
),
path(
"users/me/workspaces/<str:slug>/views/<uuid:pk>/lock/",
UserWorkspaceViewViewSet.as_view(
{
"post": "toggle_lock",
}
),
name="user-workspace-views-lock",
),
path(
"users/me/workspaces/<str:slug>/projects/<uuid:project_id>/views/",
UserProjectViewViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="user-project-views",
),
path(
"users/me/workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/",
UserProjectViewViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="user-project-views",
),
path(
"users/me/workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/lock/",
UserProjectViewViewSet.as_view(
{
"post": "toggle_lock",
}
),
name="user-project-lock-views",
),
path(
"users/me/workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/duplicate/",
UserWorkspaceViewViewSet.as_view(
{
"post": "duplicate",
}
),
name="user-project-duplicate-views",
),
## End User Graph
]
+48 -105
View File
@@ -2,86 +2,17 @@ from django.urls import path
from plane.app.views import (
ProjectViewViewSet,
WorkspaceViewViewSet,
WorkspaceViewFavoriteViewSet,
ProjectViewFavoriteViewSet,
WorkspaceViewIssuesViewSet,
IssueViewViewSet,
GlobalViewViewSet,
GlobalViewIssuesViewSet,
IssueViewFavoriteViewSet,
)
urlpatterns = [
path(
"workspaces/<str:slug>/issues/",
WorkspaceViewIssuesViewSet.as_view(
{
"get": "list",
}
),
name="workspace-view-issues",
),
path(
"workspaces/<str:slug>/views/",
WorkspaceViewViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="workspace-view",
),
path(
"workspaces/<str:slug>/views/<uuid:pk>/",
WorkspaceViewViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="workspace-view",
),
path(
"workspaces/<str:slug>/views/<uuid:view_id>/duplicate/",
WorkspaceViewFavoriteViewSet.as_view(
{
"post": "duplicate",
}
),
name="workspace-duplicate-view",
),
path(
"workspaces/<str:slug>/views/<uuid:view_id>/favorite/",
WorkspaceViewFavoriteViewSet.as_view(
{
"get": "list",
"post": "create",
"delete": "destroy",
}
),
name="workspace-favorite-view",
),
path(
"workspaces/<str:slug>/views/<uuid:pk>/visibility/",
WorkspaceViewViewSet.as_view(
{
"post": "visibility",
}
),
name="workspace-duplicate-view",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/lock/",
WorkspaceViewViewSet.as_view(
{
"post": "toggle_lock",
}
),
name="project-lock-views",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/",
ProjectViewViewSet.as_view(
IssueViewViewSet.as_view(
{
"get": "list",
"post": "create",
@@ -91,7 +22,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/",
ProjectViewViewSet.as_view(
IssueViewViewSet.as_view(
{
"get": "retrieve",
"put": "update",
@@ -102,41 +33,53 @@ urlpatterns = [
name="project-view",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/duplicate/",
ProjectViewViewSet.as_view(
{
"post": "duplicate",
}
),
name="project-duplicate-view",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/visibility/",
ProjectViewViewSet.as_view(
{
"post": "visibility",
}
),
name="project-duplicate-view",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/lock/",
ProjectViewViewSet.as_view(
{
"post": "toggle_lock",
}
),
name="project-lock-views",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:view_id>/favorite/",
ProjectViewFavoriteViewSet.as_view(
"workspaces/<str:slug>/views/",
GlobalViewViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="global-view",
),
path(
"workspaces/<str:slug>/views/<uuid:pk>/",
GlobalViewViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-favorite-view",
name="global-view",
),
path(
"workspaces/<str:slug>/issues/",
GlobalViewIssuesViewSet.as_view(
{
"get": "list",
}
),
name="global-view-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/",
IssueViewFavoriteViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="user-favorite-view",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/<uuid:view_id>/",
IssueViewFavoriteViewSet.as_view(
{
"delete": "destroy",
}
),
name="user-favorite-view",
),
]
+4 -8
View File
@@ -52,14 +52,10 @@ from .workspace import (
)
from .state import StateViewSet
from .view import (
WorkspaceViewViewSet,
ProjectViewViewSet,
WorkspaceViewFavoriteViewSet,
ProjectViewFavoriteViewSet,
UserWorkspaceViewViewSet,
UserProjectViewViewSet,
ProjectViewViewSet,
WorkspaceViewIssuesViewSet,
GlobalViewViewSet,
GlobalViewIssuesViewSet,
IssueViewViewSet,
IssueViewFavoriteViewSet,
)
from .cycle import (
CycleViewSet,
+6 -6
View File
@@ -401,8 +401,8 @@ class EmailCheckEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="Sign up",
medium="Magic link",
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=True,
)
key, token, current_attempt = generate_magic_token(email=email)
@@ -438,8 +438,8 @@ class EmailCheckEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="Sign in",
medium="Magic link",
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=False,
)
@@ -468,8 +468,8 @@ class EmailCheckEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="Sign in",
medium="Email",
event_name="SIGN_IN",
medium="EMAIL",
first_time=False,
)
+4 -4
View File
@@ -274,8 +274,8 @@ class SignInEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="Sign in",
medium="Email",
event_name="SIGN_IN",
medium="EMAIL",
first_time=False,
)
@@ -349,8 +349,8 @@ class MagicSignInEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="Sign in",
medium="Magic link",
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=False,
)
-1
View File
@@ -64,7 +64,6 @@ class WebhookMixin:
action=self.request.method,
slug=self.workspace_slug,
bulk=self.bulk,
current_site=request.META.get("HTTP_ORIGIN"),
)
return response
+1 -223
View File
@@ -20,7 +20,6 @@ from django.core import serializers
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework.response import Response
@@ -313,7 +312,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"labels": label_distribution,
"completion_chart": {},
}
if data[0]["start_date"] and data[0]["end_date"]:
data[0]["distribution"][
"completion_chart"
@@ -842,230 +840,10 @@ class TransferCycleIssueEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
new_cycle = Cycle.objects.filter(
new_cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
).first()
old_cycle = (
Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
total_estimates=Sum("issue_cycle__issue__estimate_point")
)
.annotate(
completed_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
)
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle.first(),
slug=slug,
project_id=project_id,
cycle_id=cycle_id,
)
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("display_name", "assignee_id", "avatar")
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
)
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None,
"avatar": item["avatar"],
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": str(item["label_id"]) if item["label_id"] else None,
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in label_distribution
]
current_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=cycle_id
).first()
current_cycle.progress_snapshot = {
"total_issues": old_cycle.first().total_issues,
"completed_issues": old_cycle.first().completed_issues,
"cancelled_issues": old_cycle.first().cancelled_issues,
"started_issues": old_cycle.first().started_issues,
"unstarted_issues": old_cycle.first().unstarted_issues,
"backlog_issues": old_cycle.first().backlog_issues,
"total_estimates": old_cycle.first().total_estimates,
"completed_estimates": old_cycle.first().completed_estimates,
"started_estimates": old_cycle.first().started_estimates,
"distribution":{
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
}
current_cycle.save(update_fields=["progress_snapshot"])
if (
new_cycle.end_date is not None
and new_cycle.end_date < timezone.now().date()
+2 -2
View File
@@ -27,7 +27,7 @@ from plane.app.serializers import (
InboxSerializer,
InboxIssueSerializer,
IssueCreateSerializer,
IssueDetailSerializer,
IssueStateInboxSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity
@@ -333,7 +333,7 @@ class InboxIssueViewSet(BaseViewSet):
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
issue = self.get_queryset().filter(pk=issue_id).first()
serializer = IssueDetailSerializer(issue, expand=self.expand,)
serializer = IssueSerializer(issue, expand=self.expand,)
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, inbox_id, issue_id):
+20 -21
View File
@@ -50,7 +50,6 @@ from plane.app.serializers import (
CommentReactionSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
IssueDetailSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
@@ -268,7 +267,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
def retrieve(self, request, slug, project_id, pk=None):
issue = self.get_queryset().filter(pk=pk).first()
return Response(
IssueDetailSerializer(
IssueSerializer(
issue, fields=self.fields, expand=self.expand
).data,
status=status.HTTP_200_OK,
@@ -1209,13 +1208,13 @@ class IssueArchiveViewSet(BaseViewSet):
return Response(issues, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk=None):
issue = self.get_queryset().filter(pk=pk).first()
return Response(
IssueDetailSerializer(
issue, fields=self.fields, expand=self.expand
).data,
status=status.HTTP_200_OK,
issue = Issue.objects.get(
workspace__slug=slug,
project_id=project_id,
archived_at__isnull=False,
pk=pk,
)
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
def unarchive(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
@@ -1669,9 +1668,15 @@ class IssueDraftViewSet(BaseViewSet):
def get_queryset(self):
return (
Issue.objects.filter(
project_id=self.kwargs.get("project_id")
Issue.objects.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(is_draft=True)
.select_related("workspace", "project", "state", "parent")
@@ -1705,7 +1710,7 @@ class IssueDraftViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
)
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
@@ -1827,10 +1832,7 @@ class IssueDraftViewSet(BaseViewSet):
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue = (
self.get_queryset().filter(pk=serializer.data["id"]).first()
)
return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, pk):
@@ -1866,13 +1868,10 @@ class IssueDraftViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk=None):
issue = self.get_queryset().filter(pk=pk).first()
return Response(
IssueSerializer(
issue, fields=self.fields, expand=self.expand
).data,
status=status.HTTP_200_OK,
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk, is_draft=True
)
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
+1 -1
View File
@@ -334,7 +334,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
def get_queryset(self):
return (
Issue.issue_objects.filter(
Issue.objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_module__module_id=self.kwargs.get("module_id")
+2 -2
View File
@@ -296,7 +296,7 @@ class OauthEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="Sign in",
event_name="SIGN_IN",
medium=medium.upper(),
first_time=False,
)
@@ -427,7 +427,7 @@ class OauthEndpoint(BaseAPIView):
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="Sign up",
event_name="SIGN_IN",
medium=medium.upper(),
first_time=True,
)
+8 -3
View File
@@ -17,7 +17,7 @@ from plane.db.models import (
Cycle,
Module,
Page,
View,
IssueView,
)
from plane.utils.issue_search import search_issues
@@ -161,7 +161,7 @@ class GlobalSearchEndpoint(BaseAPIView):
for field in fields:
q |= Q(**{f"{field}__icontains": query})
issue_views = View.objects.filter(
issue_views = IssueView.objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
@@ -247,7 +247,12 @@ class IssueSearchEndpoint(BaseAPIView):
if parent == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id))
~Q(pk=issue_id), ~Q(pk=issue.parent_id), parent__isnull=True
).exclude(
pk__in=Issue.issue_objects.filter(
parent__isnull=False
).values_list("parent_id", flat=True)
)
if issue_relation == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
+105 -387
View File
@@ -1,5 +1,6 @@
# Django imports
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
@@ -9,370 +10,68 @@ from django.db.models import (
When,
Exists,
Max,
Q,
)
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db.models import Prefetch, OuterRef, Exists
# Third party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from . import BaseViewSet
from . import BaseViewSet, BaseAPIView
from plane.app.serializers import (
ViewSerializer,
GlobalViewSerializer,
IssueViewSerializer,
IssueSerializer,
ViewFavoriteSerializer,
IssueViewFavoriteSerializer,
)
from plane.app.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission,
WorkspaceViewerPermission,
ProjectLitePermission,
)
from plane.db.models import (
Workspace,
View,
GlobalView,
IssueView,
Issue,
ViewFavorite,
IssueViewFavorite,
IssueReaction,
IssueLink,
IssueAttachment,
IssueSubscriber,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.grouper import group_results
class UserWorkspaceViewViewSet(BaseViewSet):
serializer_class = ViewSerializer
model = View
class GlobalViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
model = IssueView
permission_classes = [
WorkspaceEntityPermission,
]
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
serializer.save(
workspace_id=workspace.id, access=0, owned_by=self.request.user
)
serializer.save(workspace_id=workspace.id)
def get_queryset(self):
subquery = ViewFavorite.objects.filter(
user=self.request.user,
view_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
# .filter(project__isnull=True)
.filter(Q(owned_by=self.request.user) & Q(access=0))
.select_related("workspace")
.annotate(is_favorite=Exists(subquery))
.order_by(self.request.GET.get("order_by", "-is_pinned"))
.order_by("-is_pinned", "-created_at")
.distinct()
)
def partial_update(self, request, slug, pk):
view = View.objects.get(pk=pk, workspace__slug=slug)
if view.owned_by == self.request.user and not view.is_locked:
serializer = ViewSerializer(view, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
return Response(
{"error": "You cannot update the view"},
status=status.HTTP_403_FORBIDDEN,
)
def list(self, request, slug):
type = request.GET.get("type", None)
views = self.get_queryset()
if type == "workspace":
views = views.filter(project__isnull=True)
if type == "project":
views = views.filter(project__isnull=False)
serializer = ViewSerializer(views, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
def toggle_lock(self, request, slug, pk):
view = View.objects.get(pk=pk, workspace__slug=slug)
lock = request.data.get("lock", view.is_locked)
if view.owned_by != self.request.user:
return Response(
{"error": "You cannot lock the view"},
status=status.HTTP_403_FORBIDDEN,
)
view.is_locked = lock
view.save(update_fields=["is_locked"])
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
def duplicate(self, request, slug, pk):
view = View.objects.get(workspace__slug=slug, pk=pk)
# Create a shallow copy of the original view object
new_view = view
# Set the primary key of the new view to None to ensure it gets a new primary key
new_view.pk = None
# Modify the name of the new view to indicate that it's a copy
new_view.name = f"{view.name} (Copy)"
new_view.save(owned_by=request.user)
return Response(ViewSerializer(new_view).data, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, pk):
view = View.objects.get(workspace__slug=slug, pk=pk)
if view.owned_by != self.request.user:
return Response(
{"error": "You cannot delete the view"},
status=status.HTTP_403_FORBIDDEN,
)
view.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class WorkspaceViewViewSet(BaseViewSet):
serializer_class = ViewSerializer
model = View
permission_classes = [
WorkspaceEntityPermission,
]
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
serializer.save(workspace_id=workspace.id, owned_by=self.request.user)
def get_queryset(self):
subquery = ViewFavorite.objects.filter(
user=self.request.user,
view_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project__isnull=True)
.filter(Q(access=1))
.select_related("workspace")
.annotate(is_favorite=Exists(subquery))
.order_by(self.request.GET.get("order_by", "-created_at"))
.distinct()
)
def toggle_lock(self, request, slug, pk):
view = View.objects.get(pk=pk, workspace__slug=slug)
lock = request.data.get("lock", view.is_locked)
view.is_locked = lock
view.save(update_fields=["is_locked"])
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
def duplicate(self, request, slug, pk):
view = View.objects.get(workspace__slug=slug, pk=pk)
# Create a shallow copy of the original view object
new_view = view
# Set the primary key of the new view to None to ensure it gets a new primary key
new_view.pk = None
# Modify the name of the new view to indicate that it's a copy
new_view.name = f"{view.name} (Copy)"
new_view.save(owned_by=request.user)
return Response(ViewSerializer(new_view).data, status=status.HTTP_201_CREATED)
def visibility(self, request, slug, pk):
view = (
self.get_queryset()
.filter(pk=pk, workspace__slug=slug)
.first()
)
if view.owned_by != self.request.user:
return Response(
{"error": "You cannot update the view"},
status=status.HTTP_403_FORBIDDEN,
)
view.access = request.data.get("access", view.access)
view.save(update_fields=["access"])
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
class UserProjectViewViewSet(BaseViewSet):
serializer_class = ViewSerializer
model = View
permission_classes = [
ProjectEntityPermission,
]
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
serializer.save(
workspace_id=workspace.id,
project_id=self.kwargs.get("project_id"),
access=0,
owned_by=self.request.user,
)
def get_queryset(self):
subquery = ViewFavorite.objects.filter(
user=self.request.user,
view_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.filter(Q(owned_by=self.request.user) & Q(access=0))
.select_related("workspace")
.annotate(is_favorite=Exists(subquery))
.order_by(self.request.GET.get("order_by", "-created_at"))
.distinct()
)
def partial_update(self, request, slug, project_id, pk):
view = View.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
)
if view.owned_by == self.request.user and not view.is_locked:
serializer = ViewSerializer(view, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
return Response(
{"error": "You cannot update the view"},
status=status.HTTP_403_FORBIDDEN,
)
def toggle_lock(self, request, slug, project_id, pk):
view = View.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
)
lock = request.data.get("lock", view.is_locked)
if view.owned_by != self.request.user:
return Response(
{"error": "You cannot lock the view"},
status=status.HTTP_403_FORBIDDEN,
)
view.is_locked = lock
view.save(update_fields=["is_locked"])
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
def duplicate(self, request, slug, project_id, pk):
view = View.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
# Create a shallow copy of the original view object
new_view = view
# Set the primary key of the new view to None to ensure it gets a new primary key
new_view.pk = None
# Modify the name of the new view to indicate that it's a copy
new_view.name = f"{view.name} (Copy)"
new_view.save(owned_by=request.user)
return Response(ViewSerializer(new_view).data, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, pk):
view = View.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if view.owned_by != self.request.user:
return Response(
{"error": "You cannot delete the view"},
status=status.HTTP_403_FORBIDDEN,
)
view.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class ProjectViewViewSet(BaseViewSet):
serializer_class = ViewSerializer
model = View
permission_classes = [
ProjectEntityPermission,
]
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
serializer.save(
workspace_id=workspace.id,
project_id=self.kwargs.get("project_id"),
owned_by=self.request.user,
)
def get_queryset(self):
subquery = ViewFavorite.objects.filter(
user=self.request.user,
view_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.filter(Q(access=1))
.select_related("workspace")
.annotate(is_favorite=Exists(subquery))
.order_by(self.request.GET.get("order_by", "-created_at"))
.distinct()
)
def toggle_lock(self, request, slug, project_id, pk):
view = (
self.get_queryset()
.filter(pk=pk, project_id=project_id, workspace__slug=slug)
.first()
)
lock = request.data.get("lock", view.is_locked)
view.is_locked = lock
view.save(update_fields=["is_locked"])
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
def duplicate(self, request, slug, project_id, pk):
view = View.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
# Create a shallow copy of the original view object
new_view = view
# Set the primary key of the new view to None to ensure it gets a new primary key
new_view.pk = None
# Modify the name of the new view to indicate that it's a copy
new_view.name = f"{view.name} (Copy)"
new_view.save(owned_by=request.user)
return Response(ViewSerializer(new_view).data, status=status.HTTP_201_CREATED)
def visibility(self, request, slug, project_id, pk):
view = (
self.get_queryset()
.filter(pk=pk, project_id=project_id, workspace__slug=slug)
.first()
)
if view.owned_by != self.request.user:
return Response(
{"error": "You cannot update the view"},
status=status.HTTP_403_FORBIDDEN,
)
view.access = request.data.get("access", view.access)
view.save(update_fields=["access"])
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
class WorkspaceViewIssuesViewSet(BaseViewSet):
class GlobalViewIssuesViewSet(BaseViewSet):
permission_classes = [
WorkspaceEntityPermission,
]
@@ -388,31 +87,13 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
@@ -437,7 +118,34 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters)
issue_queryset = (
self.get_queryset()
.filter(**filters)
.filter(project__project_projectmember__member=self.request.user)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
@@ -505,9 +213,52 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
class WorkspaceViewFavoriteViewSet(BaseViewSet):
serializer_class = ViewFavoriteSerializer
model = ViewFavorite
class IssueViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
model = IssueView
permission_classes = [
ProjectEntityPermission,
]
def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id"))
def get_queryset(self):
subquery = IssueViewFavorite.objects.filter(
user=self.request.user,
view_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
.annotate(is_favorite=Exists(subquery))
.order_by("-is_favorite", "name")
.distinct()
)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
views = IssueViewSerializer(
queryset, many=True, fields=fields if fields else None
).data
return Response(views, status=status.HTTP_200_OK)
class IssueViewFavoriteViewSet(BaseViewSet):
serializer_class = IssueViewFavoriteSerializer
model = IssueViewFavorite
def get_queryset(self):
return self.filter_queryset(
@@ -518,52 +269,19 @@ class WorkspaceViewFavoriteViewSet(BaseViewSet):
.select_related("view")
)
def create(self, request, slug, view_id):
workspace = Workspace.objects.get(slug=slug)
view = ViewFavorite.objects.create(
view_id=view_id, user=request.user, workspace_id=workspace.id
)
return Response(
ViewFavoriteSerializer(view).data, status=status.HTTP_201_CREATED
)
def destroy(self, request, slug, view_id):
view_favorite = ViewFavorite.objects.get(
user=request.user,
workspace__slug=slug,
view_id=view_id,
)
view_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class ProjectViewFavoriteViewSet(BaseViewSet):
serializer_class = ViewFavoriteSerializer
model = ViewFavorite
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(user=self.request.user)
.select_related("view")
)
def create(self, request, slug, project_id, view_id):
view = ViewFavorite.objects.create(
view_id=view_id, user=request.user, project_id=project_id
)
return Response(
ViewFavoriteSerializer(view).data, status=status.HTTP_201_CREATED
)
def create(self, request, slug, project_id):
serializer = IssueViewFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(user=request.user, project_id=project_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, view_id):
view_favorite = ViewFavorite.objects.get(
view_favourite = IssueViewFavorite.objects.get(
project=project_id,
user=request.user,
workspace__slug=slug,
view_id=view_id,
)
view_favorite.delete()
view_favourite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+5 -3
View File
@@ -1516,9 +1516,11 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
workspace_properties, _ = WorkspaceUserProperties.objects.get_or_create(
user=request.user, workspace=workspace
(
workspace_properties,
_,
) = WorkspaceUserProperties.objects.get_or_create(
user=request.user, workspace__slug=slug
)
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
return Response(serializer.data, status=status.HTTP_200_OK)
+105 -139
View File
@@ -1,6 +1,5 @@
import json
from datetime import datetime
from bs4 import BeautifulSoup
# Third party imports
from celery import shared_task
@@ -10,6 +9,7 @@ from django.utils import timezone
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Module imports
from plane.db.models import EmailNotificationLog, User, Issue
@@ -40,7 +40,7 @@ def stack_email_notification():
processed_notifications = []
# Loop through all the issues to create the emails
for receiver_id in receivers:
# Notification triggered for the receiver
# Notifcation triggered for the receiver
receiver_notifications = [
notification
for notification in email_notifications
@@ -124,153 +124,119 @@ def create_payload(notification_data):
return data
def process_mention(mention_component):
soup = BeautifulSoup(mention_component, 'html.parser')
mentions = soup.find_all('mention-component')
for mention in mentions:
user_id = mention['id']
user = User.objects.get(pk=user_id)
user_name = user.display_name
highlighted_name = f"@{user_name}"
mention.replace_with(highlighted_name)
return str(soup)
def process_html_content(content):
processed_content_list = []
for html_content in content:
processed_content = process_mention(html_content)
processed_content_list.append(processed_content)
return processed_content_list
@shared_task
def send_email_notification(
issue_id, notification_data, receiver_id, email_notification_ids
):
try:
ri = redis_instance()
base_api = (ri.get(str(issue_id)).decode())
data = create_payload(notification_data=notification_data)
ri = redis_instance()
base_api = (ri.get(str(issue_id)).decode())
data = create_payload(notification_data=notification_data)
# Get email configurations
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
# Get email configurations
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
receiver = User.objects.get(pk=receiver_id)
issue = Issue.objects.get(pk=issue_id)
template_data = []
total_changes = 0
comments = []
actors_involved = []
for actor_id, changes in data.items():
actor = User.objects.get(pk=actor_id)
total_changes = total_changes + len(changes)
comment = changes.pop("comment", False)
mention = changes.pop("mention", False)
actors_involved.append(actor_id)
if comment:
comments.append(
{
"actor_comments": comment,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
)
if mention:
mention["new_value"] = process_html_content(mention.get("new_value"))
mention["old_value"] = process_html_content(mention.get("old_value"))
comments.append(
{
"actor_comments": mention,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
)
activity_time = changes.pop("activity_time")
# Parse the input string into a datetime object
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
if changes:
template_data.append(
{
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
"changes": changes,
"issue_details": {
"name": issue.name,
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
},
"activity_time": str(formatted_time),
}
receiver = User.objects.get(pk=receiver_id)
issue = Issue.objects.get(pk=issue_id)
template_data = []
total_changes = 0
comments = []
actors_involved = []
for actor_id, changes in data.items():
actor = User.objects.get(pk=actor_id)
total_changes = total_changes + len(changes)
comment = changes.pop("comment", False)
actors_involved.append(actor_id)
if comment:
comments.append(
{
"actor_comments": comment,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
)
activity_time = changes.pop("activity_time")
# Parse the input string into a datetime object
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
summary = "Updates were made to the issue by"
# Send the mail
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
context = {
"data": template_data,
"summary": summary,
"actors_involved": len(set(actors_involved)),
"issue": {
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
"name": issue.name,
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
},
"receiver": {
"email": receiver.email,
},
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
"workspace":str(issue.project.workspace.slug),
"project": str(issue.project.name),
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
}
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
if changes:
template_data.append(
{
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
"changes": changes,
"issue_details": {
"name": issue.name,
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
},
"activity_time": str(formatted_time),
}
)
text_content = strip_tags(html_content)
try:
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
)
summary = "Updates were made to the issue by"
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[receiver.email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
# Send the mail
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
context = {
"data": template_data,
"summary": summary,
"actors_involved": len(set(actors_involved)),
"issue": {
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
"name": issue.name,
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
},
"receiver": {
"email": receiver.email,
},
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
"workspace":str(issue.project.workspace.slug),
"project": str(issue.project.name),
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
}
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
)
text_content = strip_tags(html_content)
EmailNotificationLog.objects.filter(
pk__in=email_notification_ids
).update(sent_at=timezone.now())
return
except Exception as e:
print(e)
return
except Issue.DoesNotExist:
try:
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[receiver.email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
EmailNotificationLog.objects.filter(
pk__in=email_notification_ids
).update(sent_at=timezone.now())
return
except Exception as e:
print(e)
return
+1 -4
View File
@@ -515,7 +515,7 @@ def notifications(
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=mention_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
data={
@@ -552,7 +552,6 @@ def notifications(
"old_value": str(
issue_activity.get("old_value")
),
"activity_time": issue_activity.get("created_at"),
},
},
)
@@ -640,7 +639,6 @@ def notifications(
"old_value": str(
last_activity.old_value
),
"activity_time": issue_activity.get("created_at"),
},
},
)
@@ -697,7 +695,6 @@ def notifications(
"old_value"
)
),
"activity_time": issue_activity.get("created_at"),
},
},
)
+4 -75
View File
@@ -7,9 +7,6 @@ import hmac
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
# Third party imports
from celery import shared_task
@@ -25,10 +22,10 @@ from plane.db.models import (
ModuleIssue,
CycleIssue,
IssueComment,
User,
)
from plane.api.serializers import (
ProjectSerializer,
IssueSerializer,
CycleSerializer,
ModuleSerializer,
CycleIssueSerializer,
@@ -37,9 +34,6 @@ from plane.api.serializers import (
IssueExpandSerializer,
)
# Module imports
from plane.license.utils.instance_value import get_email_configuration
SERIALIZER_MAPPER = {
"project": ProjectSerializer,
"issue": IssueExpandSerializer,
@@ -78,7 +72,7 @@ def get_model_data(event, event_id, many=False):
max_retries=5,
retry_jitter=True,
)
def webhook_task(self, webhook, slug, event, event_data, action, current_site):
def webhook_task(self, webhook, slug, event, event_data, action):
try:
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
@@ -157,18 +151,7 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site):
response_body=str(e),
retry_count=str(self.request.retries),
)
# Retry logic
if self.request.retries >= self.max_retries:
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
if webhook:
# send email for the deactivation of the webhook
send_webhook_deactivation_email(
webhook_id=webhook.id,
receiver_id=webhook.created_by_id,
reason=str(e),
current_site=current_site,
)
return
raise requests.RequestException()
except Exception as e:
@@ -179,7 +162,7 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site):
@shared_task()
def send_webhook(event, payload, kw, action, slug, bulk, current_site):
def send_webhook(event, payload, kw, action, slug, bulk):
try:
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
@@ -233,7 +216,6 @@ def send_webhook(event, payload, kw, action, slug, bulk, current_site):
event=event,
event_data=data,
action=action,
current_site=current_site,
)
except Exception as e:
@@ -241,56 +223,3 @@ def send_webhook(event, payload, kw, action, slug, bulk, current_site):
print(e)
capture_exception(e)
return
@shared_task
def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reason):
# Get email configurations
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
receiver = User.objects.get(pk=receiver_id)
webhook = Webhook.objects.get(pk=webhook_id)
subject="Webhook Deactivated"
message=f"Webhook {webhook.url} has been deactivated due to failed requests."
# Send the mail
context = {
"email": receiver.email,
"message": message,
"webhook_url":f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}",
}
html_content = render_to_string(
"emails/notifications/webhook-deactivate.html", context
)
text_content = strip_tags(html_content)
try:
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[receiver.email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
return
except Exception as e:
print(e)
return
@@ -1,12 +1,11 @@
# Generated by Django 4.2.7 on 2024-01-02 13:15
from plane.db.models import ProjectMember
from plane.db.models import WorkspaceUserProperties, ProjectMember, IssueView
from django.db import migrations
def workspace_user_properties(apps, schema_editor):
WorkspaceMember = apps.get_model("db", "WorkspaceMember")
WorkspaceUserProperties = apps.get_model("db", "WorkspaceUserProperties")
updated_workspace_user_properties = []
for workspace_members in WorkspaceMember.objects.all():
updated_workspace_user_properties.append(
@@ -50,7 +49,6 @@ def project_user_properties(apps, schema_editor):
def issue_view(apps, schema_editor):
GlobalView = apps.get_model("db", "GlobalView")
IssueView = apps.get_model("db", "IssueView")
updated_issue_views = []
for global_view in GlobalView.objects.all():
@@ -1,33 +0,0 @@
# Generated by Django 4.2.7 on 2024-02-08 09:57
from django.db import migrations
def widgets_filter_change(apps, schema_editor):
Widget = apps.get_model("db", "Widget")
widgets_to_update = []
# Define the filter dictionaries for each widget key
filters_mapping = {
"assigned_issues": {"duration": "none", "tab": "pending"},
"created_issues": {"duration": "none", "tab": "pending"},
"issues_by_state_groups": {"duration": "none"},
"issues_by_priority": {"duration": "none"},
}
# Iterate over widgets and update filters if applicable
for widget in Widget.objects.all():
if widget.key in filters_mapping:
widget.filters = filters_mapping[widget.key]
widgets_to_update.append(widget)
# Bulk update the widgets
Widget.objects.bulk_update(widgets_to_update, ["filters"], batch_size=10)
class Migration(migrations.Migration):
dependencies = [
('db', '0058_alter_moduleissue_issue_and_more'),
]
operations = [
migrations.RunPython(widgets_filter_change)
]
@@ -1,18 +0,0 @@
# Generated by Django 4.2.7 on 2024-02-08 09:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0059_auto_20240208_0957'),
]
operations = [
migrations.AddField(
model_name='cycle',
name='progress_snapshot',
field=models.JSONField(default=dict),
),
]
@@ -1,70 +0,0 @@
# Generated by Django 4.2.7 on 2024-01-30 07:49
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
from django.db.models import F
def views_owned_by(apps, schema_editor):
View = apps.get_model("db", "View")
View.objects.update(owned_by=F('created_by'))
class Migration(migrations.Migration):
dependencies = [
('db', '0060_cycle_progress_snapshot'),
]
operations = [
migrations.RenameModel(
old_name='IssueView',
new_name='View',
),
migrations.AlterModelTable(
name='view',
table='views',
),
migrations.RenameModel(
old_name='IssueViewFavorite',
new_name='ViewFavorite',
),
migrations.AlterField(
model_name='viewfavorite',
name='project',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterModelTable(
name='workspaceuserproperties',
table='workspace_user_properties',
),
migrations.AlterModelOptions(
name='view',
options={'ordering': ('-created_at',), 'verbose_name': 'View', 'verbose_name_plural': 'Views'},
),
migrations.AddField(
model_name='view',
name='is_locked',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='view',
name='is_pinned',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='view',
name='owned_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='views', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='view',
name='access',
field=models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public'), (2, 'Shared')], default=1),
),
migrations.AlterField(
model_name='viewfavorite',
name='view',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='view_favorites', to='db.view'),
),
migrations.RunPython(views_owned_by)
]
+1 -1
View File
@@ -52,7 +52,7 @@ from .state import State
from .cycle import Cycle, CycleIssue, CycleFavorite, CycleUserProperties
from .view import View, ViewFavorite
from .view import GlobalView, IssueView, IssueViewFavorite
from .module import (
Module,
-1
View File
@@ -68,7 +68,6 @@ class Cycle(ProjectBaseModel):
sort_order = models.FloatField(default=65535)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
progress_snapshot = models.JSONField(default=dict)
class Meta:
verbose_name = "Cycle"
+8 -23
View File
@@ -3,7 +3,7 @@ from django.db import models
from django.conf import settings
# Module import
from . import BaseModel, WorkspaceBaseModel
from . import ProjectBaseModel, BaseModel, WorkspaceBaseModel
def get_default_filters():
@@ -84,7 +84,7 @@ class GlobalView(BaseModel):
return f"{self.name} <{self.workspace.name}>"
class View(WorkspaceBaseModel):
class IssueView(WorkspaceBaseModel):
name = models.CharField(max_length=255, verbose_name="View Name")
description = models.TextField(verbose_name="View Description", blank=True)
query = models.JSONField(verbose_name="View Query")
@@ -94,44 +94,29 @@ class View(WorkspaceBaseModel):
default=get_default_display_properties
)
access = models.PositiveSmallIntegerField(
default=1, choices=((0, "Private"), (1, "Public"), (2, "Shared"))
)
owned_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="views", null=True, blank=True
default=1, choices=((0, "Private"), (1, "Public"))
)
sort_order = models.FloatField(default=65535)
is_locked = models.BooleanField(default=False)
is_pinned = models.BooleanField(default=False)
class Meta:
verbose_name = "View"
verbose_name_plural = "Views"
db_table = "views"
verbose_name = "Issue View"
verbose_name_plural = "Issue Views"
db_table = "issue_views"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
largest_sort_order = View.objects.filter(
workspace=self.workspace
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(View, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the View"""
return f"{self.name} <{self.project.name}>"
class ViewFavorite(WorkspaceBaseModel):
class IssueViewFavorite(ProjectBaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="user_view_favorites",
)
view = models.ForeignKey(
"db.View", on_delete=models.CASCADE, related_name="view_favorites"
"db.IssueView", on_delete=models.CASCADE, related_name="view_favorites"
)
class Meta:
+1 -1
View File
@@ -326,7 +326,7 @@ class WorkspaceUserProperties(BaseModel):
unique_together = ["workspace", "user"]
verbose_name = "Workspace User Property"
verbose_name_plural = "Workspace User Property"
db_table = "workspace_user_properties"
db_table = "Workspace_user_properties"
ordering = ("-created_at",)
def __str__(self):
+2
View File
@@ -282,8 +282,10 @@ if REDIS_SSL:
redis_url = os.environ.get("REDIS_URL")
broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
CELERY_BROKER_URL = broker_url
CELERY_RESULT_BACKEND = broker_url
else:
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
CELERY_IMPORTS = (
"plane.bgtasks.issue_automation_task",
+2 -2
View File
@@ -1,6 +1,6 @@
# base requirements
Django==4.2.10
Django==4.2.7
psycopg==3.1.12
djangorestframework==3.14.0
redis==4.6.0
@@ -30,7 +30,7 @@ openpyxl==3.1.2
beautifulsoup4==4.12.2
dj-database-url==2.1.0
posthog==3.0.2
cryptography==42.0.0
cryptography==41.0.6
lxml==4.9.3
boto3==1.28.40
+17
View File
@@ -0,0 +1,17 @@
import os
import uvicorn
if __name__ == "__main__":
os.environ.setdefault(
"DJANGO_SETTINGS_MODULE", "plane.settings.production"
)
uvicorn.run(
"plane.asgi:application",
host=os.environ.get("HOST", "0.0.0.0"),
port=os.environ.get("PORT", 8000),
ws="auto",
workers=int(os.environ.get("GUNICORN_WORKERS", 1)),
log_level=os.environ.get("LOG_LEVEL", "info"),
lifespan="off",
access_log="on",
)
@@ -66,7 +66,7 @@
style="margin-left: 30px; margin-bottom: 20px; margin-top: 20px"
>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/plane-logo.png"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/plane-logo.webp"
width="130"
height="40"
border="0"
@@ -108,33 +108,25 @@
margin-bottom: 15px;
"
/>
{% if actors_involved == 1 %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
{{summary}}
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
{% if data|length > 0 %}
{{ data.0.actor_detail.first_name}}
{{data.0.actor_detail.last_name}}
{% else %}
{{ comments.0.actor_detail.first_name}}
{{comments.0.actor_detail.last_name}}
{% endif %}
</span>.
</p>
{% else %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
{{summary}}
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
{% if data|length > 0 %}
{{ data.0.actor_detail.first_name}}
{{data.0.actor_detail.last_name}}
{% else %}
{{ comments.0.actor_detail.first_name}}
{{comments.0.actor_detail.last_name}}
{% endif %}
</span>and others.
</p>
{% endif %}
{% if actors_involved == 1 %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
{{summary}}
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
{{ data.0.actor_detail.first_name}}
{{data.0.actor_detail.last_name}}
</span>.
</p>
{% else %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
{{summary}}
<span style="font-size: 1rem; font-weight: 700; line-height: 28px">
{{ data.0.actor_detail.first_name}}
{{data.0.actor_detail.last_name }}
</span>and others.
</p>
{% endif %}
<!-- {% if actors_involved == 1 %}
{% if data|length > 0 and comments|length == 0 %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
@@ -280,7 +272,7 @@
<tr>
<td>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/due-date.png"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/due-date.webp"
width="12"
height="12"
border="0"
@@ -341,7 +333,7 @@
<tr>
<td>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/duplicate.png"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/duplicate.webp"
width="12"
height="12"
border="0"
@@ -436,7 +428,7 @@
<tr>
<td valign="top" style="white-space: nowrap; padding: 0px;">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/assignee.png"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/assignee.webp"
width="12"
height="12"
border="0"
@@ -532,7 +524,7 @@
<tr>
<td valign="top" style="white-space: nowrap; padding: 0px;">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/labels.png"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/labels.webp"
width="12"
height="12"
border="0"
@@ -629,7 +621,7 @@
<tr>
<td>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/state.png"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/state.webp"
width="12"
height="12"
border="0"
@@ -647,17 +639,15 @@
State:
</p>
</td>
{% if update.changes.state.old_value.0 == 'Backlog' or update.changes.state.old_value.0 == 'In Progress' or update.changes.state.old_value.0 == 'Done' or update.changes.state.old_value.0 == 'Cancelled' %}
<td>
<td >
<img
src="{% if update.changes.state.old_value.0 == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.png{% endif %}{% if update.changes.state.old_value.0 == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.png{% endif %}{% if update.changes.state.old_value.0 == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.png{% endif %}{% if update.changes.state.old_value.0 == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.png{% endif %}"
src="{% if update.changes.state.old_value.0 == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.webp{% endif %}{% if update.changes.state.old_value.0 == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.webp{% endif %}{% if update.changes.state.old_value.0 == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.webp{% endif %}{% if update.changes.state.old_value.0 == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.webp{% endif %}"
width="12"
height="12"
border="0"
style="display: block; margin-left: 5px;"
/>
</td>
{% endif %}
<td>
<p
style="
@@ -671,24 +661,22 @@
</td>
<td style="padding-left: 10px; padding-right: 10px;">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.png"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.webp"
width="16"
height="16"
border="0"
style="display: block;"
/>
</td>
{% if update.changes.state.new_value|last == 'Backlog' or update.changes.state.new_value|last == 'In Progress' or update.changes.state.new_value|last == 'Done' or update.changes.state.new_value|last == 'Cancelled' %}
<td>
<td >
<img
src="{% if update.changes.state.new_value|last == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.png{% elif update.changes.state.new_value|last == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.png{% elif update.changes.state.new_value|last == 'Todo' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/todo.png{% elif update.changes.state.new_value|last == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.png{% elif update.changes.state.new_value|last == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.png{% endif %}"
src="{% if update.changes.state.new_value|last == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.webp{% elif update.changes.state.new_value|last == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.webp{% elif update.changes.state.new_value|last == 'Todo' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/todo.webp{% elif update.changes.state.new_value|last == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.webp{% elif update.changes.state.new_value|last == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.webp{% endif %}"
width="12"
height="12"
border="0"
style="display: block;"
/>
</td>
{% endif %}
<td>
<p
style="
@@ -711,7 +699,7 @@
<tr>
<td valign="top">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/link.png"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/link.webp"
width="12"
height="12"
border="0"
@@ -772,7 +760,7 @@
<tr>
<td>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/priority.png"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/priority.webp"
width="12"
height="12"
border="0"
@@ -812,7 +800,7 @@
</td>
<td style="padding-left: 10px; padding-right: 10px;">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.png"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.webp"
width="16"
height="16"
border="0"
@@ -850,7 +838,7 @@
<tr style="overflow-wrap: break-word;">
<td>
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/blocking.png"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/blocking.webp"
width="12"
height="12"
border="0"
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,6 +1,5 @@
#!/bin/bash
# Check if the user has sudo access
if command -v curl &> /dev/null; then
sudo curl -sSL \
-o /usr/local/bin/plane-app \
@@ -12,6 +11,6 @@ else
fi
sudo chmod +x /usr/local/bin/plane-app
sudo sed -i 's/export DEPLOY_BRANCH=${BRANCH:-master}/export DEPLOY_BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app
sudo sed -i 's/export BRANCH=${BRANCH:-master}/export BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app
plane-app --help
sudo plane-app --help
+80 -123
View File
@@ -17,7 +17,7 @@ Project management tool from the future
EOF
}
function update_env_file() {
function update_env_files() {
config_file=$1
key=$2
value=$3
@@ -25,16 +25,14 @@ function update_env_file() {
# Check if the config file exists
if [ ! -f "$config_file" ]; then
echo "Config file not found. Creating a new one..." >&2
sudo touch "$config_file"
touch "$config_file"
fi
# Check if the key already exists in the config file
if sudo grep "^$key=" "$config_file"; then
sudo awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" | sudo tee "$config_file.tmp" > /dev/null
sudo mv "$config_file.tmp" "$config_file" &> /dev/null
if grep -q "^$key=" "$config_file"; then
awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file"
else
# sudo echo "$key=$value" >> "$config_file"
echo -e "$key=$value" | sudo tee -a "$config_file" > /dev/null
echo "$key=$value" >> "$config_file"
fi
}
function read_env_file() {
@@ -44,12 +42,12 @@ function read_env_file() {
# Check if the config file exists
if [ ! -f "$config_file" ]; then
echo "Config file not found. Creating a new one..." >&2
sudo touch "$config_file"
touch "$config_file"
fi
# Check if the key already exists in the config file
if sudo grep -q "^$key=" "$config_file"; then
value=$(sudo awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file")
if grep -q "^$key=" "$config_file"; then
value=$(awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file")
echo "$value"
else
echo ""
@@ -57,19 +55,19 @@ function read_env_file() {
}
function update_config() {
config_file="$PLANE_INSTALL_DIR/config.env"
update_env_file $config_file $1 $2
update_env_files "$config_file" "$1" "$2"
}
function read_config() {
config_file="$PLANE_INSTALL_DIR/config.env"
read_env_file $config_file $1
read_env_file "$config_file" "$1"
}
function update_env() {
config_file="$PLANE_INSTALL_DIR/.env"
update_env_file $config_file $1 $2
update_env_files "$config_file" "$1" "$2"
}
function read_env() {
config_file="$PLANE_INSTALL_DIR/.env"
read_env_file $config_file $1
read_env_file "$config_file" "$1"
}
function show_message() {
print_header
@@ -89,14 +87,14 @@ function prepare_environment() {
show_message "Prepare Environment..." >&2
show_message "- Updating OS with required tools ✋" >&2
sudo "$PACKAGE_MANAGER" update -y
sudo "$PACKAGE_MANAGER" upgrade -y
sudo apt-get update -y &> /dev/null
sudo apt-get upgrade -y &> /dev/null
local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap")
required_tools=("curl" "awk" "wget" "nano" "dialog" "git")
for tool in "${required_tools[@]}"; do
if ! command -v $tool &> /dev/null; then
sudo "$PACKAGE_MANAGER" install -y $tool
sudo apt install -y $tool &> /dev/null
fi
done
@@ -105,30 +103,11 @@ function prepare_environment() {
# Install Docker if not installed
if ! command -v docker &> /dev/null; then
show_message "- Installing Docker ✋" >&2
# curl -o- https://get.docker.com | bash -
sudo curl -o- https://get.docker.com | bash -
if [ "$PACKAGE_MANAGER" == "yum" ]; then
sudo $PACKAGE_MANAGER install -y yum-utils
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo &> /dev/null
elif [ "$PACKAGE_MANAGER" == "apt-get" ]; then
# Add Docker's official GPG key:
sudo $PACKAGE_MANAGER update
sudo $PACKAGE_MANAGER install ca-certificates curl &> /dev/null
sudo install -m 0755 -d /etc/apt/keyrings &> /dev/null
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc &> /dev/null
sudo chmod a+r /etc/apt/keyrings/docker.asc &> /dev/null
# Add the repository to Apt sources:
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo $PACKAGE_MANAGER update
if [ "$EUID" -ne 0 ]; then
dockerd-rootless-setuptool.sh install &> /dev/null
fi
sudo $PACKAGE_MANAGER install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
show_message "- Docker Installed ✅" "replace_last_line" >&2
else
show_message "- Docker is already installed ✅" >&2
@@ -148,17 +127,17 @@ function prepare_environment() {
function download_plane() {
# Download Docker Compose File from github url
show_message "Downloading Plane Setup Files ✋" >&2
sudo curl -H 'Cache-Control: no-cache, no-store' \
curl -H 'Cache-Control: no-cache, no-store' \
-s -o $PLANE_INSTALL_DIR/docker-compose.yaml \
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s)
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s)
sudo curl -H 'Cache-Control: no-cache, no-store' \
curl -H 'Cache-Control: no-cache, no-store' \
-s -o $PLANE_INSTALL_DIR/variables-upgrade.env \
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s)
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s)
# if .env does not exists rename variables-upgrade.env to .env
if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then
sudo mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env
mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env
fi
show_message "Plane Setup Files Downloaded ✅" "replace_last_line" >&2
@@ -207,7 +186,7 @@ function build_local_image() {
PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp
sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null
sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $DEPLOY_BRANCH --single-branch -q > /dev/null
sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $BRANCH --single-branch -q > /dev/null
sudo cp $PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml $PLANE_TEMP_CODE_DIR/build.yml
@@ -220,26 +199,25 @@ function check_for_docker_images() {
show_message "" >&2
# show_message "Building Plane Images" >&2
update_env "DOCKERHUB_USER" "makeplane"
update_env "PULL_POLICY" "always"
CURR_DIR=$(pwd)
if [ "$DEPLOY_BRANCH" == "master" ]; then
if [ "$BRANCH" == "master" ]; then
update_env "APP_RELEASE" "latest"
export APP_RELEASE=latest
else
update_env "APP_RELEASE" "$DEPLOY_BRANCH"
export APP_RELEASE=$DEPLOY_BRANCH
update_env "APP_RELEASE" "$BRANCH"
export APP_RELEASE=$BRANCH
fi
if [ $USE_GLOBAL_IMAGES == 1 ]; then
if [ $CPU_ARCH == "amd64" ] || [ $CPU_ARCH == "x86_64" ]; then
# show_message "Building Plane Images for $CPU_ARCH is not required. Skipping... ✅" "replace_last_line" >&2
export DOCKERHUB_USER=makeplane
update_env "DOCKERHUB_USER" "$DOCKERHUB_USER"
update_env "PULL_POLICY" "always"
echo "Building Plane Images for $CPU_ARCH is not required. Skipping..."
else
export DOCKERHUB_USER=myplane
show_message "Building Plane Images for $CPU_ARCH " >&2
update_env "DOCKERHUB_USER" "$DOCKERHUB_USER"
update_env "DOCKERHUB_USER" "myplane"
update_env "PULL_POLICY" "never"
build_local_image
@@ -255,7 +233,7 @@ function check_for_docker_images() {
sudo sed -i "s|- uploads:|- $DATA_DIR/minio:|g" $PLANE_INSTALL_DIR/docker-compose.yaml
show_message "Downloading Plane Images for $CPU_ARCH ✋" >&2
sudo docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull
docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull
show_message "Plane Images Downloaded ✅" "replace_last_line" >&2
}
function configure_plane() {
@@ -475,11 +453,9 @@ function install() {
show_message ""
if [ "$(uname)" == "Linux" ]; then
OS="linux"
OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release)
OS_NAME=$(echo "$OS_NAME" | tr -d '"')
print_header
if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
# check the OS
if [ "$OS_NAME" == "ubuntu" ]; then
OS_SUPPORTED=true
show_message "******** Installing Plane ********"
show_message ""
@@ -512,8 +488,7 @@ function install() {
fi
else
OS_SUPPORTED=false
PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
PROGRESS_MSG="❌❌❌ Unsupported OS Detected ❌❌❌"
show_message ""
exit 1
fi
@@ -524,17 +499,12 @@ function install() {
fi
}
function upgrade() {
print_header
if [ "$(uname)" == "Linux" ]; then
OS="linux"
OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release)
OS_NAME=$(echo "$OS_NAME" | tr -d '"')
if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
# check the OS
if [ "$OS_NAME" == "ubuntu" ]; then
OS_SUPPORTED=true
show_message "******** Upgrading Plane ********"
show_message ""
prepare_environment
@@ -558,49 +528,53 @@ function upgrade() {
exit 1
fi
else
PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
PROGRESS_MSG="Unsupported OS Detected"
show_message ""
exit 1
fi
else
PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌"
PROGRESS_MSG="Unsupported OS Detected : $(uname)"
show_message ""
exit 1
fi
}
function uninstall() {
print_header
if [ "$(uname)" == "Linux" ]; then
OS="linux"
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
OS_NAME=$(echo "$OS_NAME" | tr -d '"')
if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
# check the OS
if [ "$OS_NAME" == "ubuntu" ]; then
OS_SUPPORTED=true
show_message "******** Uninstalling Plane ********"
show_message ""
stop_server
# CHECK IF PLANE SERVICE EXISTS
# if [ -f "/etc/systemd/system/plane.service" ]; then
# sudo systemctl stop plane.service &> /dev/null
# sudo systemctl disable plane.service &> /dev/null
# sudo rm /etc/systemd/system/plane.service &> /dev/null
# sudo systemctl daemon-reload &> /dev/null
# fi
# show_message "- Plane Service removed ✅"
if ! [ -x "$(command -v docker)" ]; then
echo "DOCKER_NOT_INSTALLED" &> /dev/null
else
# Ask of user input to confirm uninstall docker ?
CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --defaultno --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3)
CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3)
if [ $? -eq 0 ]; then
show_message "- Uninstalling Docker ✋"
sudo docker images -q | xargs -r sudo docker rmi -f &> /dev/null
sudo "$PACKAGE_MANAGER" remove -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null
sudo "$PACKAGE_MANAGER" autoremove -y docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null
sudo apt-get purge -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null
sudo apt-get autoremove -y --purge docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null
show_message "- Docker Uninstalled ✅" "replace_last_line" >&2
fi
fi
sudo rm $PLANE_INSTALL_DIR/.env &> /dev/null
sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null
sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
rm $PLANE_INSTALL_DIR/.env &> /dev/null
rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
rm $PLANE_INSTALL_DIR/config.env &> /dev/null
rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
# rm -rf $PLANE_INSTALL_DIR &> /dev/null
show_message "- Configuration Cleaned ✅"
@@ -619,12 +593,12 @@ function uninstall() {
show_message ""
show_message ""
else
PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌"
show_message ""
exit 1
fi
else
PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌"
PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌"
show_message ""
exit 1
fi
@@ -634,15 +608,15 @@ function start_server() {
env_file="$PLANE_INSTALL_DIR/.env"
# check if both the files exits
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
show_message "Starting Plane Server ($APP_RELEASE) ✋"
sudo docker compose -f $docker_compose_file --env-file=$env_file up -d
show_message "Starting Plane Server ✋"
docker compose -f $docker_compose_file --env-file=$env_file up -d
# Wait for containers to be running
echo "Waiting for containers to start..."
while ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do
while ! docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do
sleep 1
done
show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2
show_message "Plane Server Started ✅" "replace_last_line" >&2
else
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
fi
@@ -652,11 +626,11 @@ function stop_server() {
env_file="$PLANE_INSTALL_DIR/.env"
# check if both the files exits
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
show_message "Stopping Plane Server ($APP_RELEASE) ✋"
sudo docker compose -f $docker_compose_file --env-file=$env_file down
show_message "Plane Server Stopped ($APP_RELEASE) ✅" "replace_last_line" >&2
show_message "Stopping Plane Server ✋"
docker compose -f $docker_compose_file --env-file=$env_file down
show_message "Plane Server Stopped ✅" "replace_last_line" >&2
else
show_message "Plane Server not installed [Skipping] ✅" "replace_last_line" >&2
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
fi
}
function restart_server() {
@@ -664,9 +638,9 @@ function restart_server() {
env_file="$PLANE_INSTALL_DIR/.env"
# check if both the files exits
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
show_message "Restarting Plane Server ($APP_RELEASE) ✋"
sudo docker compose -f $docker_compose_file --env-file=$env_file restart
show_message "Plane Server Restarted ($APP_RELEASE) ✅" "replace_last_line" >&2
show_message "Restarting Plane Server ✋"
docker compose -f $docker_compose_file --env-file=$env_file restart
show_message "Plane Server Restarted ✅" "replace_last_line" >&2
else
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
fi
@@ -692,45 +666,28 @@ function show_help() {
}
function update_installer() {
show_message "Updating Plane Installer ✋" >&2
sudo curl -H 'Cache-Control: no-cache, no-store' \
curl -H 'Cache-Control: no-cache, no-store' \
-s -o /usr/local/bin/plane-app \
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s)
https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/1-click/install.sh?token=$(date +%s)
sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null
chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null
show_message "Plane Installer Updated ✅" "replace_last_line" >&2
}
export DEPLOY_BRANCH=${BRANCH:-master}
export APP_RELEASE=$DEPLOY_BRANCH
export BRANCH=${BRANCH:-master}
export APP_RELEASE=$BRANCH
export DOCKERHUB_USER=makeplane
export PULL_POLICY=always
if [ "$DEPLOY_BRANCH" == "master" ]; then
export APP_RELEASE=latest
fi
PLANE_INSTALL_DIR=/opt/plane
DATA_DIR=$PLANE_INSTALL_DIR/data
LOG_DIR=$PLANE_INSTALL_DIR/log
OS_SUPPORTED=false
CPU_ARCH=$(uname -m)
PROGRESS_MSG=""
USE_GLOBAL_IMAGES=0
PACKAGE_MANAGER=""
USE_GLOBAL_IMAGES=1
if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then
USE_GLOBAL_IMAGES=1
fi
sudo mkdir -p $PLANE_INSTALL_DIR/{data,log}
if command -v apt-get &> /dev/null; then
PACKAGE_MANAGER="apt-get"
elif command -v yum &> /dev/null; then
PACKAGE_MANAGER="yum"
elif command -v apk &> /dev/null; then
PACKAGE_MANAGER="apk"
fi
mkdir -p $PLANE_INSTALL_DIR/{data,log}
if [ "$1" == "start" ]; then
start_server
@@ -747,7 +704,7 @@ elif [ "$1" == "--upgrade" ] || [ "$1" == "-up" ]; then
upgrade
elif [ "$1" == "--uninstall" ] || [ "$1" == "-un" ]; then
uninstall
elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ]; then
elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ] ; then
update_installer
elif [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
show_help
+4
View File
@@ -38,6 +38,10 @@ x-app-env : &app-env
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
# OPENAI SETTINGS - Deprecated can be configured through admin panel
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-""}
- GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"}
# LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
+5 -5
View File
@@ -20,8 +20,8 @@ function buildLocalImage() {
DO_BUILD="2"
else
printf "\n" >&2
printf "${YELLOW}You are on ${CPU_ARCH} cpu architecture. ${NC}\n" >&2
printf "${YELLOW}Since the prebuilt ${CPU_ARCH} compatible docker images are not available for, we will be running the docker build on this system. ${NC} \n" >&2
printf "${YELLOW}You are on ${ARCH} cpu architecture. ${NC}\n" >&2
printf "${YELLOW}Since the prebuilt ${ARCH} compatible docker images are not available for, we will be running the docker build on this system. ${NC} \n" >&2
printf "${YELLOW}This might take ${YELLOW}5-30 min based on your system's hardware configuration. \n ${NC} \n" >&2
printf "\n" >&2
printf "${GREEN}Select an option to proceed: ${NC}\n" >&2
@@ -149,7 +149,7 @@ function upgrade() {
function askForAction() {
echo
echo "Select a Action you want to perform:"
echo " 1) Install (${CPU_ARCH})"
echo " 1) Install (${ARCH})"
echo " 2) Start"
echo " 3) Stop"
echo " 4) Restart"
@@ -193,8 +193,8 @@ function askForAction() {
}
# CPU ARCHITECHTURE BASED SETTINGS
CPU_ARCH=$(uname -m)
if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]];
ARCH=$(uname -m)
if [ $ARCH == "amd64" ] || [ $ARCH == "x86_64" ];
then
USE_GLOBAL_IMAGES=1
DOCKERHUB_USER=makeplane
+20 -15
View File
@@ -8,13 +8,13 @@ NGINX_PORT=80
WEB_URL=http://localhost
DEBUG=0
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
SENTRY_DSN=
SENTRY_ENVIRONMENT=production
GOOGLE_CLIENT_ID=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
SENTRY_DSN=""
SENTRY_ENVIRONMENT="production"
GOOGLE_CLIENT_ID=""
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
DOCKERIZED=1 # deprecated
CORS_ALLOWED_ORIGINS=http://localhost
CORS_ALLOWED_ORIGINS="http://localhost"
#DB SETTINGS
PGHOST=plane-db
@@ -31,14 +31,19 @@ REDIS_PORT=6379
REDIS_URL=redis://${REDIS_HOST}:6379/
# EMAIL SETTINGS
EMAIL_HOST=
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM=Team Plane <team@mailer.plane.so>
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS=1
EMAIL_USE_SSL=0
# OPENAI SETTINGS
OPENAI_API_BASE=https://api.openai.com/v1 # deprecated
OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # deprecated
# LOGIN/SIGNUP SETTINGS
ENABLE_SIGNUP=1
ENABLE_EMAIL_PASSWORD=1
@@ -47,13 +52,13 @@ SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
# DATA STORE SETTINGS
USE_MINIO=1
AWS_REGION=
AWS_ACCESS_KEY_ID=access-key
AWS_SECRET_ACCESS_KEY=secret-key
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
AWS_SECRET_ACCESS_KEY="secret-key"
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
AWS_S3_BUCKET_NAME=uploads
MINIO_ROOT_USER=access-key
MINIO_ROOT_PASSWORD=secret-key
MINIO_ROOT_USER="access-key"
MINIO_ROOT_PASSWORD="secret-key"
BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880
+1 -1
View File
@@ -137,7 +137,7 @@ services:
dockerfile: Dockerfile.dev
args:
DOCKER_BUILDKIT: 1
restart: "no"
restart: no
networks:
- dev_env
volumes:
+2 -2
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.15.1",
"version": "0.15.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@@ -34,4 +34,4 @@
"@types/react": "18.2.42"
},
"packageManager": "yarn@1.22.19"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor-core",
"version": "0.15.1",
"version": "0.15.0",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/document-editor",
"version": "0.15.1",
"version": "0.15.0",
"description": "Package that powers Plane's Pages Editor",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
@@ -6,16 +6,10 @@ import { scrollSummary } from "src/utils/editor-summary-utils";
interface ContentBrowserProps {
editor: Editor;
markings: IMarking[];
setSidePeekVisible?: (sidePeekState: boolean) => void;
}
export const ContentBrowser = (props: ContentBrowserProps) => {
const { editor, markings, setSidePeekVisible } = props;
const handleOnClick = (marking: IMarking) => {
scrollSummary(editor, marking);
if (setSidePeekVisible) setSidePeekVisible(false);
}
const { editor, markings } = props;
return (
<div className="flex h-full flex-col overflow-hidden">
@@ -24,11 +18,11 @@ export const ContentBrowser = (props: ContentBrowserProps) => {
{markings.length !== 0 ? (
markings.map((marking) =>
marking.level === 1 ? (
<HeadingComp onClick={() => handleOnClick(marking)} heading={marking.text} />
<HeadingComp onClick={() => scrollSummary(editor, marking)} heading={marking.text} />
) : marking.level === 2 ? (
<SubheadingComp onClick={() => handleOnClick(marking)} subHeading={marking.text} />
<SubheadingComp onClick={() => scrollSummary(editor, marking)} subHeading={marking.text} />
) : (
<HeadingThreeComp heading={marking.text} onClick={() => handleOnClick(marking)} />
<HeadingThreeComp heading={marking.text} onClick={() => scrollSummary(editor, marking)} />
)
)
) : (
@@ -42,8 +42,8 @@ export const EditorHeader = (props: IEditorHeader) => {
} = props;
return (
<div className="flex items-center border-b border-custom-border-200 md:px-5 px-3 py-2">
<div className="md:w-56 flex-shrink-0 lg:w-72 w-fit">
<div className="flex items-center border-b border-custom-border-200 px-5 py-2">
<div className="w-56 flex-shrink-0 lg:w-72">
<SummaryPopover
editor={editor}
markings={markings}
@@ -52,7 +52,7 @@ export const EditorHeader = (props: IEditorHeader) => {
/>
</div>
<div className="flex-shrink-0 hidden md:flex">
<div className="flex-shrink-0">
{!readonly && uploadFile && (
<FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />
)}
@@ -152,7 +152,7 @@ export const PageRenderer = (props: IPageRenderer) => {
);
return (
<div className="w-full pb-64 md:pl-7 pl-3 pt-5 page-renderer">
<div className="w-full pb-64 pl-7 pt-5 page-renderer">
{!readonly ? (
<input
onChange={(e) => handlePageTitleChange(e.target.value)}
@@ -33,36 +33,23 @@ export const SummaryPopover: React.FC<Props> = (props) => {
<button
type="button"
ref={setReferenceElement}
className={`grid h-7 w-7 place-items-center rounded ${sidePeekVisible ? "bg-custom-primary-100/20 text-custom-primary-100" : "text-custom-text-300"
}`}
className={`grid h-7 w-7 place-items-center rounded ${
sidePeekVisible ? "bg-custom-primary-100/20 text-custom-primary-100" : "text-custom-text-300"
}`}
onClick={() => setSidePeekVisible(!sidePeekVisible)}
>
<List className="h-4 w-4" />
</button>
<div className="md:hidden block">
{sidePeekVisible && (
<div
className="z-10 max-h-80 w-64 overflow-y-auto rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg"
ref={setPopperElement}
style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper}
>
<ContentBrowser setSidePeekVisible={setSidePeekVisible} editor={editor} markings={markings} />
</div>
)}
</div>
<div className="hidden md:block">
{!sidePeekVisible && (
<div
className="z-10 hidden max-h-80 w-64 overflow-y-auto rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg group-hover/summary-popover:block"
ref={setPopperElement}
style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper}
>
<ContentBrowser editor={editor} markings={markings} />
</div>
)}
</div>
{!sidePeekVisible && (
<div
className="z-10 hidden max-h-80 w-64 overflow-y-auto rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg group-hover/summary-popover:block"
ref={setPopperElement}
style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper}
>
<ContentBrowser editor={editor} markings={markings} />
</div>
)}
</div>
);
};
@@ -10,7 +10,6 @@ import { DocumentDetails } from "src/types/editor-types";
import { PageRenderer } from "src/ui/components/page-renderer";
import { getMenuOptions } from "src/utils/menu-options";
import { useRouter } from "next/router";
import { FixedMenu } from "src";
interface IDocumentEditor {
// document info
@@ -150,14 +149,11 @@ const DocumentEditor = ({
documentDetails={documentDetails}
isSubmitting={isSubmitting}
/>
<div className="flex-shrink-0 md:hidden border-b border-custom-border-200 pl-3 py-2">
{uploadFile && <FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />}
</div>
<div className="flex h-full w-full overflow-y-auto frame-renderer">
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-72 hidden md:block">
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-72">
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
</div>
<div className="h-full w-full md:w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
<PageRenderer
onActionCompleteHandler={onActionCompleteHandler}
hideDragHandle={hideDragHandleOnMouseLeave}
@@ -77,7 +77,7 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
}
return (
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
<div className="flex items-center divide-x divide-custom-border-200">
<div className="flex items-center gap-0.5 pr-2">
{basicMarkItems.map((item) => (
<button
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor-extensions",
"version": "0.15.1",
"version": "0.15.0",
"description": "Package that powers Plane's Editor with extensions",
"private": true,
"main": "./dist/index.mjs",
@@ -1,6 +1,6 @@
{
"name": "@plane/lite-text-editor",
"version": "0.15.1",
"version": "0.15.0",
"description": "Package that powers Plane's Comment Editor",
"private": true,
"main": "./dist/index.mjs",
@@ -1,6 +1,6 @@
{
"name": "@plane/rich-text-editor",
"version": "0.15.1",
"version": "0.15.0",
"description": "Rich Text Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "eslint-config-custom",
"private": true,
"version": "0.15.1",
"version": "0.15.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tailwind-config-custom",
"version": "0.15.1",
"version": "0.15.0",
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tsconfig",
"version": "0.15.1",
"version": "0.15.0",
"private": true,
"files": [
"base.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "0.15.1",
"version": "0.15.0",
"private": true,
"main": "./src/index.d.ts"
}
-18
View File
@@ -31,7 +31,6 @@ export interface ICycle {
issue: string;
name: string;
owned_by: string;
progress_snapshot: TProgressSnapshot;
project: string;
project_detail: IProjectLite;
status: TCycleGroups;
@@ -50,23 +49,6 @@ export interface ICycle {
workspace_detail: IWorkspaceLite;
}
export type TProgressSnapshot = {
backlog_issues: number;
cancelled_issues: number;
completed_estimates: number | null;
completed_issues: number;
distribution?: {
assignees: TAssigneesDistribution[];
completion_chart: TCompletionChartDistribution;
labels: TLabelsDistribution[];
};
started_estimates: number | null;
started_issues: number;
total_estimates: number | null;
total_issues: number;
unstarted_issues: number;
};
export type TAssigneesDistribution = {
assignee_id: string | null;
avatar: string | null;
+4 -4
View File
@@ -24,21 +24,21 @@ export type TDurationFilterOptions =
// widget filters
export type TAssignedIssuesWidgetFilters = {
duration?: TDurationFilterOptions;
target_date?: TDurationFilterOptions;
tab?: TIssuesListTypes;
};
export type TCreatedIssuesWidgetFilters = {
duration?: TDurationFilterOptions;
target_date?: TDurationFilterOptions;
tab?: TIssuesListTypes;
};
export type TIssuesByStateGroupsWidgetFilters = {
duration?: TDurationFilterOptions;
target_date?: TDurationFilterOptions;
};
export type TIssuesByPriorityWidgetFilters = {
duration?: TDurationFilterOptions;
target_date?: TDurationFilterOptions;
};
export type TWidgetFiltersFormData =
+72
View File
@@ -0,0 +1,72 @@
import { TIssue } from "./issues/base";
import type { IProjectLite } from "./projects";
export type TInboxIssueExtended = {
completed_at: string | null;
start_date: string | null;
target_date: string | null;
};
export interface IInboxIssue extends TIssue, TInboxIssueExtended {
issue_inbox: {
duplicate_to: string | null;
id: string;
snoozed_till: Date | null;
source: string;
status: -2 | -1 | 0 | 1 | 2;
}[];
}
export interface IInbox {
id: string;
project_detail: IProjectLite;
pending_issue_count: number;
created_at: Date;
updated_at: Date;
name: string;
description: string;
is_default: boolean;
created_by: string;
updated_by: string;
project: string;
view_props: { filters: IInboxFilterOptions };
workspace: string;
}
interface StatePending {
readonly status: -2;
}
interface StatusReject {
status: -1;
}
interface StatusSnoozed {
status: 0;
snoozed_till: Date;
}
interface StatusAccepted {
status: 1;
}
interface StatusDuplicate {
status: 2;
duplicate_to: string;
}
export type TInboxStatus =
| StatusReject
| StatusSnoozed
| StatusAccepted
| StatusDuplicate
| StatePending;
export interface IInboxFilterOptions {
priority?: string[] | null;
inbox_status?: number[] | null;
}
export interface IInboxQueryParams {
priority: string | null;
inbox_status: string | null;
}
+4 -1
View File
@@ -13,7 +13,11 @@ export * from "./pages";
export * from "./ai";
export * from "./estimate";
export * from "./importer";
// FIXME: Remove this after development and the refactor/mobx-store-issue branch is stable
export * from "./inbox";
export * from "./inbox/root";
export * from "./analytics";
export * from "./calendar";
export * from "./notifications";
@@ -27,7 +31,6 @@ export * from "./auth";
export * from "./api_token";
export * from "./instance";
export * from "./app";
export * from "./view/root";
export type NestedKeyOf<ObjectType extends object> = {
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
-9
View File
@@ -221,12 +221,3 @@ export interface IGroupByColumn {
export interface IIssueMap {
[key: string]: TIssue;
}
export interface IIssueListRow {
id: string;
groupId: string;
type: "HEADER" | "NO_ISSUES" | "QUICK_ADD" | "ISSUE";
name?: string;
icon?: ReactElement | undefined;
payload?: Partial<TIssue>;
}
+2 -8
View File
@@ -1,11 +1,5 @@
import { EUserProjectRoles } from "constants/project";
import type {
IUser,
IUserLite,
IWorkspace,
IWorkspaceLite,
TStateGroups,
} from ".";
import type { IUser, IUserLite, IWorkspace, IWorkspaceLite, TStateGroups } from ".";
export interface IProject {
archive_in: number;
@@ -123,7 +117,7 @@ export type TProjectIssuesSearchParams = {
parent?: boolean;
issue_relation?: boolean;
cycle?: boolean;
module?: string;
module?: string[];
sub_issue?: boolean;
issue_id?: string;
workspace_search: boolean;
-54
View File
@@ -1,54 +0,0 @@
import {
TViewFilters,
TViewDisplayFilters,
TViewDisplayProperties,
} from "./filter";
export type TViewTypes =
| "WORKSPACE_PRIVATE_VIEWS"
| "WORKSPACE_PUBLIC_VIEWS"
| "PROJECT_PRIVATE_VIEWS"
| "PROJECT_PUBLIC_VIEWS";
declare enum EViewAccess {
"public" = 0,
"private" = 1,
"shared" = 2,
}
export type TViewAccess =
| EViewAccess.public
| EViewAccess.private
| EViewAccess.shared;
export type TView = {
id: string | undefined;
workspace: string | undefined;
project: string | undefined;
name: string | undefined;
description: string | undefined;
query: string | undefined;
filters: TViewFilters;
display_filters: TViewDisplayFilters;
display_properties: TViewDisplayProperties;
access: TViewAccess | undefined;
owned_by: string | undefined;
sort_order: number | undefined;
is_locked: boolean;
is_pinned: boolean;
is_favorite: boolean;
created_by: string | undefined;
updated_by: string | undefined;
created_at: Date | undefined;
updated_at: Date | undefined;
// local view variables
is_local_view: boolean;
};
export type TUpdateView = {
name: string | undefined;
description: string | undefined;
filters: TViewFilters;
display_filters: TViewDisplayFilters;
display_properties: TViewDisplayProperties;
};
-134
View File
@@ -1,134 +0,0 @@
declare enum EViewLayouts {
LIST = "list",
KANBAN = "kanban",
CALENDAR = "calendar",
SPREADSHEET = "spreadsheet",
GANTT = "gantt",
}
export type TViewLayouts =
| EViewLayouts.LIST
| EViewLayouts.KANBAN
| EViewLayouts.CALENDAR
| EViewLayouts.SPREADSHEET
| EViewLayouts.GANTT;
export type TViewDisplayFiltersGrouped =
| "project"
| "state_detail.group"
| "state"
| "priority"
| "labels"
| "created_by"
| "assignees"
| "mentions"
| "modules"
| "cycles";
export type TViewDisplayFiltersOrderBy =
| "sort_order"
| "created_at"
| "-created_at"
| "updated_at"
| "-updated_at"
| "start_date"
| "-start_date"
| "target_date"
| "-target_date"
| "state__name"
| "-state__name"
| "priority"
| "-priority"
| "labels__name"
| "-labels__name"
| "assignees__first_name"
| "-assignees__first_name"
| "estimate_point"
| "-estimate_point"
| "link_count"
| "-link_count"
| "attachment_count"
| "-attachment_count"
| "sub_issues_count"
| "-sub_issues_count";
export type TViewDisplayFiltersExtraOptions = "sub_issue" | "show_empty_groups";
export type TViewDisplayFiltersType = "active" | "backlog";
export type TViewCalendarLayouts = "month" | "week";
export type TViewFilters = {
project: string[];
module: string[];
cycle: string[];
priority: string[];
state: string[];
state_group: string[];
assignees: string[];
mentions: string[];
subscriber: string[];
created_by: string[];
labels: string[];
start_date: string[];
target_date: string[];
};
export type TViewDisplayFilters = {
layout: TViewLayouts;
group_by: TViewDisplayFiltersGrouped | undefined;
sub_group_by: TViewDisplayFiltersGrouped | undefined;
order_by: TViewDisplayFiltersOrderBy;
type: TViewDisplayFiltersType | undefined;
sub_issue: boolean;
show_empty_groups: boolean;
calendar: {
show_weekends: boolean;
layout: TViewCalendarLayouts;
};
};
export type TViewDisplayProperties = {
assignee: boolean;
start_date: boolean;
due_date: boolean;
labels: boolean;
key: boolean;
priority: boolean;
state: boolean;
sub_issue_count: boolean;
link: boolean;
attachment_count: boolean;
estimate: boolean;
created_on: boolean;
updated_on: boolean;
};
export type TViewFilterProps = {
filters: TViewFilters;
display_filters: TViewDisplayFilters;
display_properties: TViewDisplayProperties;
};
export type TViewFilterPartialProps = {
filters: Partial<TViewFilters>;
display_filters: Partial<TViewDisplayFilters>;
display_properties: Partial<TViewDisplayProperties>;
};
export type TViewFilterQueryParams =
| "project"
| "module"
| "cycle"
| "priority"
| "state"
| "state_group"
| "assignees"
| "mentions"
| "subscriber"
| "created_by"
| "labels"
| "start_date"
| "target_date"
| "type"
| "sub_issue";
-3
View File
@@ -1,3 +0,0 @@
export * from "./filter";
export * from "./base";
export * from "./user-base";
-18
View File
@@ -1,18 +0,0 @@
import {
TViewFilters,
TViewDisplayFilters,
TViewDisplayProperties,
} from "./filter";
export type TUserView = {
id: string | undefined;
workspace: string | undefined;
user: string | undefined;
filters: TViewFilters;
display_filters: TViewDisplayFilters;
display_properties: TViewDisplayProperties;
created_by: string | undefined;
updated_by: string | undefined;
created_at: Date | undefined;
updated_at: Date | undefined;
};
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "0.15.1",
"version": "0.15.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
+17 -55
View File
@@ -1,73 +1,35 @@
import * as React from "react";
// icons
import { ChevronRight } from "lucide-react";
type BreadcrumbsProps = {
children: React.ReactNode;
onBack?: () => void;
children: any;
};
const Breadcrumbs = ({ children, onBack }: BreadcrumbsProps) => {
const [isSmallScreen, setIsSmallScreen] = React.useState(false);
React.useEffect(() => {
const handleResize = () => {
setIsSmallScreen(window.innerWidth <= 640); // Adjust this value as per your requirement
};
window.addEventListener("resize", handleResize);
handleResize(); // Call it initially to set the correct state
return () => window.removeEventListener("resize", handleResize);
}, []);
const childrenArray = React.Children.toArray(children);
return (
<div className="flex items-center space-x-2 overflow-hidden">
{!isSmallScreen && (
<>
{childrenArray.map((child, index) => (
<React.Fragment key={index}>
{index > 0 && !isSmallScreen && (
<div className="flex items-center gap-2.5">
<ChevronRight
className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400"
aria-hidden="true"
/>
</div>
)}
<div className={`flex items-center gap-2.5 ${isSmallScreen && index > 0 ? 'hidden sm:flex' : 'flex'}`}>
{child}
</div>
</React.Fragment>
))}
</>
)}
{isSmallScreen && childrenArray.length > 1 && (
<>
<div className="flex items-center gap-2.5">
{onBack && <span onClick={onBack} className="text-custom-text-200">...</span>}
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />
</div>
<div className="flex items-center gap-2.5">{childrenArray[childrenArray.length - 1]}</div>
</>
)}
{isSmallScreen && childrenArray.length === 1 && childrenArray}
</div>
);
};
const Breadcrumbs = ({ children }: BreadcrumbsProps) => (
<div className="flex items-center space-x-2">
{React.Children.map(children, (child, index) => (
<div key={index} className="flex flex-wrap items-center gap-2.5">
{child}
{index !== React.Children.count(children) - 1 && (
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />
)}
</div>
))}
</div>
);
type Props = {
type?: "text" | "component";
component?: React.ReactNode;
link?: JSX.Element;
};
const BreadcrumbItem: React.FC<Props> = (props) => {
const { type = "text", component, link } = props;
return <>{type !== "text" ? <div className="flex items-center space-x-2">{component}</div> : link}</>;
return <>{type != "text" ? <div className="flex items-center space-x-2">{component}</div> : link}</>;
};
Breadcrumbs.BreadcrumbItem = BreadcrumbItem;
export { Breadcrumbs, BreadcrumbItem };
export { Breadcrumbs, BreadcrumbItem };
+13 -33
View File
@@ -15,7 +15,6 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
const {
buttonClassName = "",
customButtonClassName = "",
customButtonTabIndex = 0,
placement,
children,
className = "",
@@ -30,7 +29,6 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
verticalEllipsis = false,
portalElement,
menuButtonOnClick,
onMenuClose,
tabIndex,
closeOnSelect,
} = props;
@@ -49,28 +47,18 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => {
isOpen && onMenuClose && onMenuClose();
setIsOpen(false);
};
const selectActiveItem = () => {
const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector(
`[data-headlessui-state="active"] button`
);
activeItem?.click();
};
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen, selectActiveItem);
const handleOnClick = () => {
if (closeOnSelect) closeDropdown();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
let menuItems = (
<Menu.Items className="fixed z-10" static>
<Menu.Items
className="fixed z-10"
onClick={() => {
if (closeOnSelect) closeDropdown();
}}
static
>
<div
className={cn(
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap",
@@ -101,8 +89,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("relative w-min text-left", className)}
onKeyDownCapture={handleKeyDown}
onClick={handleOnClick}
onKeyDown={handleKeyDown}
>
{({ open }) => (
<>
@@ -111,13 +98,11 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
<button
ref={setReferenceElement}
type="button"
onClick={(e) => {
e.stopPropagation();
onClick={() => {
openDropdown();
if (menuButtonOnClick) menuButtonOnClick();
}}
className={customButtonClassName}
tabIndex={customButtonTabIndex}
>
{customButton}
</button>
@@ -129,8 +114,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
<button
ref={setReferenceElement}
type="button"
onClick={(e) => {
e.stopPropagation();
onClick={() => {
openDropdown();
if (menuButtonOnClick) menuButtonOnClick();
}}
@@ -138,7 +122,6 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
className={`relative grid place-items-center rounded p-1 text-custom-text-200 outline-none hover:text-custom-text-100 ${
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
tabIndex={customButtonTabIndex}
>
<MoreHorizontal className={`h-3.5 w-3.5 ${verticalEllipsis ? "rotate-90" : ""}`} />
</button>
@@ -155,12 +138,10 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
onClick={(e) => {
e.stopPropagation();
onClick={() => {
openDropdown();
if (menuButtonOnClick) menuButtonOnClick();
}}
tabIndex={customButtonTabIndex}
>
{label}
{!noChevron && <ChevronDown className="h-3.5 w-3.5" />}
@@ -178,7 +159,6 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
const { children, onClick, className = "" } = props;
return (
<Menu.Item as="div">
{({ active, close }) => (
-2
View File
@@ -3,7 +3,6 @@ import { Placement } from "@blueprintjs/popover2";
export interface IDropdownProps {
customButtonClassName?: string;
customButtonTabIndex?: number;
buttonClassName?: string;
className?: string;
customButton?: JSX.Element;
@@ -24,7 +23,6 @@ export interface ICustomMenuDropdownProps extends IDropdownProps {
noBorder?: boolean;
verticalEllipsis?: boolean;
menuButtonOnClick?: (...args: any) => void;
onMenuClose?: () => void;
closeOnSelect?: boolean;
portalElement?: Element | null;
}
-67
View File
@@ -1,67 +0,0 @@
import * as React from "react";
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
intermediate?: boolean;
className?: string;
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>((props, ref) => {
const { id, name, checked, intermediate = false, disabled, className = "", ...rest } = props;
return (
<div className={`relative w-full flex gap-2 ${className}`}>
<input
id={id}
ref={ref}
type="checkbox"
name={name}
checked={checked}
className={`
appearance-none shrink-0 w-4 h-4 border rounded-[3px] focus:outline-1 focus:outline-offset-4 focus:outline-custom-primary-50
${
disabled
? "border-custom-border-200 bg-custom-background-80 cursor-not-allowed"
: `cursor-pointer ${
checked || intermediate
? "border-custom-primary-40 bg-custom-primary-100 hover:bg-custom-primary-200"
: "border-custom-border-300 hover:border-custom-border-400 bg-white"
}`
}
`}
disabled={disabled}
{...rest}
/>
<svg
className={`absolute w-4 h-4 p-0.5 pointer-events-none outline-none ${
disabled ? "stroke-custom-text-400 opacity-40" : "stroke-white"
} ${checked ? "block" : "hidden"}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
<svg
className={`absolute w-4 h-4 p-0.5 pointer-events-none outline-none ${
disabled ? "stroke-custom-text-400 opacity-40" : "stroke-white"
} ${intermediate && !checked ? "block" : "hidden"}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5.75 4H2.25" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
);
});
Checkbox.displayName = "form-checkbox-field";
export { Checkbox };
-1
View File
@@ -1,4 +1,3 @@
export * from "./input";
export * from "./textarea";
export * from "./input-color-picker";
export * from "./checkbox";
@@ -1,23 +1,16 @@
import { useCallback } from "react";
type TUseDropdownKeyDown = {
(
onOpen: () => void,
onClose: () => void,
isOpen: boolean,
selectActiveItem?: () => void
): (event: React.KeyboardEvent<HTMLElement>) => void;
(onOpen: () => void, onClose: () => void, isOpen: boolean): (event: React.KeyboardEvent<HTMLElement>) => void;
};
export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen, selectActiveItem?) => {
export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen) => {
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter") {
event.stopPropagation();
if (!isOpen) {
event.stopPropagation();
onOpen();
} else {
selectActiveItem && selectActiveItem();
}
} else if (event.key === "Escape" && isOpen) {
event.stopPropagation();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "0.15.1",
"version": "0.15.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -4,14 +4,12 @@ import { Controller, useForm } from "react-hook-form";
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
import { useEventTracker } from "hooks/store";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// icons
import { Eye, EyeOff } from "lucide-react";
import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED } from "constants/event-tracker";
type Props = {
email: string;
@@ -36,8 +34,6 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
// states
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// store hooks
const { captureEvent } = useEventTracker();
// toast alert
const { setToastAlert } = useToast();
// form info
@@ -67,34 +63,21 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
title: "Success!",
message: "Password created successfully.",
});
captureEvent(PASSWORD_CREATE_SELECTED, {
state: "SUCCESS",
first_time: false,
});
await handleSignInRedirection();
})
.catch((err) => {
captureEvent(PASSWORD_CREATE_SELECTED, {
state: "FAILED",
first_time: false,
});
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
});
});
})
);
};
const handleGoToWorkspace = async () => {
setIsGoingToWorkspace(true);
await handleSignInRedirection().finally(() => {
captureEvent(PASSWORD_CREATE_SKIPPED, {
state: "SUCCESS",
first_time: false,
});
setIsGoingToWorkspace(false);
});
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false));
};
return (
@@ -7,7 +7,7 @@ import { Eye, EyeOff, XCircle } from "lucide-react";
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
import { useApplication, useEventTracker } from "hooks/store";
import { useApplication } from "hooks/store";
// components
import { ESignInSteps, ForgotPasswordPopover } from "components/account";
// ui
@@ -16,8 +16,6 @@ import { Button, Input } from "@plane/ui";
import { checkEmailValidity } from "helpers/string.helper";
// types
import { IPasswordSignInData } from "@plane/types";
// constants
import { FORGOT_PASSWORD, SIGN_IN_WITH_PASSWORD } from "constants/event-tracker";
type Props = {
email: string;
@@ -48,7 +46,6 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
const {
config: { envConfig },
} = useApplication();
const { captureEvent } = useEventTracker();
// derived values
const isSmtpConfigured = envConfig?.is_smtp_configured;
// form info
@@ -75,13 +72,7 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
await authService
.passwordSignIn(payload)
.then(async () => {
captureEvent(SIGN_IN_WITH_PASSWORD, {
state: "SUCCESS",
first_time: false,
});
await onSubmit();
})
.then(async () => await onSubmit())
.catch((err) =>
setToastAlert({
type: "error",
@@ -191,10 +182,9 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
</div>
)}
/>
<div className="mt-2 w-full pb-3 text-right">
<div className="w-full text-right mt-2 pb-3">
{isSmtpConfigured ? (
<Link
onClick={() => captureEvent(FORGOT_PASSWORD)}
href={`/accounts/forgot-password?email=${email}`}
className="text-xs font-medium text-custom-primary-100"
>
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import Link from "next/link";
import { observer } from "mobx-react-lite";
// hooks
import { useApplication, useEventTracker } from "hooks/store";
import { useApplication } from "hooks/store";
import useSignInRedirection from "hooks/use-sign-in-redirection";
// components
import { LatestFeatureBlock } from "components/common";
@@ -13,8 +13,6 @@ import {
OAuthOptions,
SignInOptionalSetPasswordForm,
} from "components/account";
// constants
import { NAVIGATE_TO_SIGNUP } from "constants/event-tracker";
export enum ESignInSteps {
EMAIL = "EMAIL",
@@ -34,7 +32,6 @@ export const SignInRoot = observer(() => {
const {
config: { envConfig },
} = useApplication();
const { captureEvent } = useEventTracker();
// derived values
const isSmtpConfigured = envConfig?.is_smtp_configured;
@@ -113,11 +110,7 @@ export const SignInRoot = observer(() => {
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_in" />
<p className="text-xs text-onboarding-text-300 text-center mt-6">
Don{"'"}t have an account?{" "}
<Link
href="/accounts/sign-up"
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
className="text-custom-primary-100 font-medium underline"
>
<Link href="/accounts/sign-up" className="text-custom-primary-100 font-medium underline">
Sign up
</Link>
</p>
@@ -7,15 +7,12 @@ import { UserService } from "services/user.service";
// hooks
import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer";
import { useEventTracker } from "hooks/store";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// types
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
// constants
import { CODE_VERIFIED } from "constants/event-tracker";
type Props = {
email: string;
@@ -44,8 +41,6 @@ export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
// toast alert
const { setToastAlert } = useToast();
// store hooks
const { captureEvent } = useEventTracker();
// timer
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30);
// form info
@@ -74,22 +69,17 @@ export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
await authService
.magicSignIn(payload)
.then(async () => {
captureEvent(CODE_VERIFIED, {
state: "SUCCESS",
});
const currentUser = await userService.currentUser();
await onSubmit(currentUser.is_password_autoset);
})
.catch((err) => {
captureEvent(CODE_VERIFIED, {
state: "FAILED",
});
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
});
});
})
);
};
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
@@ -4,14 +4,12 @@ import { Controller, useForm } from "react-hook-form";
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
import { useEventTracker } from "hooks/store";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// constants
import { ESignUpSteps } from "components/account";
import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED, SETUP_PASSWORD } from "constants/event-tracker";
// icons
import { Eye, EyeOff } from "lucide-react";
@@ -39,8 +37,6 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
// states
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// store hooks
const { captureEvent } = useEventTracker();
// toast alert
const { setToastAlert } = useToast();
// form info
@@ -70,34 +66,21 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
title: "Success!",
message: "Password created successfully.",
});
captureEvent(SETUP_PASSWORD, {
state: "SUCCESS",
first_time: true,
});
await handleSignInRedirection();
})
.catch((err) => {
captureEvent(SETUP_PASSWORD, {
state: "FAILED",
first_time: true,
});
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
});
});
})
);
};
const handleGoToWorkspace = async () => {
setIsGoingToWorkspace(true);
await handleSignInRedirection().finally(() => {
captureEvent(PASSWORD_CREATE_SKIPPED, {
state: "SUCCESS",
first_time: true,
});
setIsGoingToWorkspace(false);
});
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false));
};
return (
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { useApplication, useEventTracker } from "hooks/store";
import { useApplication } from "hooks/store";
import useSignInRedirection from "hooks/use-sign-in-redirection";
// components
import {
@@ -12,8 +12,6 @@ import {
SignUpUniqueCodeForm,
} from "components/account";
import Link from "next/link";
// constants
import { NAVIGATE_TO_SIGNIN } from "constants/event-tracker";
export enum ESignUpSteps {
EMAIL = "EMAIL",
@@ -34,7 +32,6 @@ export const SignUpRoot = observer(() => {
const {
config: { envConfig },
} = useApplication();
const { captureEvent } = useEventTracker();
// step 1 submit handler- email verification
const handleEmailVerification = () => setSignInStep(ESignUpSteps.UNIQUE_CODE);
@@ -89,11 +86,7 @@ export const SignUpRoot = observer(() => {
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_up" />
<p className="text-xs text-onboarding-text-300 text-center mt-6">
Already using Plane?{" "}
<Link
href="/"
onClick={() => captureEvent(NAVIGATE_TO_SIGNIN, {})}
className="text-custom-primary-100 font-medium underline"
>
<Link href="/" className="text-custom-primary-100 font-medium underline">
Sign in
</Link>
</p>
@@ -8,15 +8,12 @@ import { UserService } from "services/user.service";
// hooks
import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer";
import { useEventTracker } from "hooks/store";
// ui
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// types
import { IEmailCheckData, IMagicSignInData } from "@plane/types";
// constants
import { CODE_VERIFIED } from "constants/event-tracker";
type Props = {
email: string;
@@ -42,8 +39,6 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
const { email, handleEmailClear, onSubmit } = props;
// states
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
// store hooks
const { captureEvent } = useEventTracker();
// toast alert
const { setToastAlert } = useToast();
// timer
@@ -74,22 +69,17 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
await authService
.magicSignIn(payload)
.then(async () => {
captureEvent(CODE_VERIFIED, {
state: "SUCCESS",
});
const currentUser = await userService.currentUser();
await onSubmit(currentUser.is_password_autoset);
})
.catch((err) => {
captureEvent(CODE_VERIFIED, {
state: "FAILED",
});
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
});
});
})
);
};
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
@@ -106,6 +96,7 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
title: "Success!",
message: "A new unique code has been sent to your email.",
});
reset({
email: formData.email,
token: "",
@@ -10,8 +10,6 @@ import { CustomAnalyticsSelectBar, CustomAnalyticsMainContent, CustomAnalyticsSi
import { IAnalyticsParams } from "@plane/types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
import { cn } from "helpers/common.helper";
import { useApplication } from "hooks/store";
type Props = {
additionalParams?: Partial<IAnalyticsParams>;
@@ -48,13 +46,11 @@ export const CustomAnalytics: React.FC<Props> = observer((props) => {
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
);
const { theme: themeStore } = useApplication();
const isProjectLevel = projectId ? true : false;
return (
<div className={cn("relative w-full h-full flex overflow-hidden", isProjectLevel ? "flex-col-reverse" : "")}>
<div className="w-full flex h-full flex-col overflow-hidden">
<div className={`flex flex-col-reverse overflow-hidden ${fullScreen ? "md:grid md:h-full md:grid-cols-4" : ""}`}>
<div className="col-span-3 flex h-full flex-col overflow-hidden">
<CustomAnalyticsSelectBar
control={control}
setValue={setValue}
@@ -65,22 +61,16 @@ export const CustomAnalytics: React.FC<Props> = observer((props) => {
<CustomAnalyticsMainContent
analytics={analytics}
error={analyticsError}
params={params}
fullScreen={fullScreen}
params={params}
/>
</div>
<div
className={cn(
"border-l border-custom-border-200 transition-all",
!isProjectLevel
? "absolute right-0 top-0 bottom-0 md:relative flex-shrink-0 h-full max-w-[250px] sm:max-w-full"
: ""
)}
style={themeStore.workspaceAnalyticsSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}}
>
<CustomAnalyticsSidebar analytics={analytics} params={params} isProjectLevel={isProjectLevel} />
</div>
<CustomAnalyticsSidebar
analytics={analytics}
params={params}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
/>
</div>
);
});
@@ -22,8 +22,9 @@ export const CustomAnalyticsSelectBar: React.FC<Props> = observer((props) => {
return (
<div
className={`grid items-center gap-4 px-5 py-2.5 ${isProjectLevel ? "grid-cols-1 sm:grid-cols-3" : "grid-cols-2"} ${fullScreen ? "md:py-5 lg:grid-cols-4" : ""
}`}
className={`grid items-center gap-4 px-5 py-2.5 ${isProjectLevel ? "grid-cols-3" : "grid-cols-2"} ${
fullScreen ? "md:py-5 lg:grid-cols-4" : ""
}`}
>
{!isProjectLevel && (
<div>
@@ -17,9 +17,9 @@ export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = observer((pro
const { getProjectById } = useProject();
return (
<div className="relative flex flex-col gap-4 h-full">
<div className="hidden h-full overflow-hidden md:flex md:flex-col">
<h4 className="font-medium">Selected Projects</h4>
<div className="relative space-y-6 overflow-hidden overflow-y-auto">
<div className="mt-4 h-full space-y-6 overflow-y-auto">
{projectIds.map((projectId) => {
const project = getProjectById(projectId);
@@ -26,7 +26,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
<>
{projectId ? (
cycleDetails ? (
<div className="h-full overflow-y-auto">
<div className="hidden h-full overflow-y-auto md:block">
<h4 className="break-words font-medium">Analytics for {cycleDetails.name}</h4>
<div className="mt-4 space-y-4">
<div className="flex items-center gap-2 text-xs">
@@ -52,7 +52,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
</div>
</div>
) : moduleDetails ? (
<div className="h-full overflow-y-auto">
<div className="hidden h-full overflow-y-auto md:block">
<h4 className="break-words font-medium">Analytics for {moduleDetails.name}</h4>
<div className="mt-4 space-y-4">
<div className="flex items-center gap-2 text-xs">
@@ -78,7 +78,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
</div>
</div>
) : (
<div className="h-full overflow-y-auto">
<div className="hidden h-full overflow-y-auto md:flex md:flex-col">
<div className="flex items-center gap-1">
{projectDetails?.emoji ? (
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">{renderEmoji(projectDetails.emoji)}</div>
@@ -19,18 +19,18 @@ import { renderFormattedDate } from "helpers/date-time.helper";
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
import { cn } from "helpers/common.helper";
type Props = {
analytics: IAnalyticsResponse | undefined;
params: IAnalyticsParams;
fullScreen: boolean;
isProjectLevel: boolean;
};
const analyticsService = new AnalyticsService();
export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
const { analytics, params, isProjectLevel = false } = props;
const { analytics, params, fullScreen, isProjectLevel = false } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
@@ -140,16 +140,16 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
return (
<div
className={cn(
"relative h-full flex w-full gap-2 justify-between items-start px-5 py-4 bg-custom-sidebar-background-100",
!isProjectLevel ? "flex-col" : ""
)}
className={`flex items-center justify-between space-y-2 px-5 py-2.5 ${
fullScreen
? "overflow-hidden border-l border-custom-border-200 md:h-full md:flex-col md:items-start md:space-y-4 md:border-l md:border-custom-border-200 md:py-5"
: ""
}`}
>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
<LayersIcon height={14} width={14} />
{analytics ? analytics.total : "..."}{" "}
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div>
{analytics ? analytics.total : "..."} Issues
</div>
{isProjectLevel && (
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
@@ -164,30 +164,30 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
</div>
)}
</div>
<div className={cn("h-full w-full overflow-hidden", isProjectLevel ? "hidden" : "block")}>
<>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
<CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} />
)}
<CustomAnalyticsSidebarHeader />
</>
<div className="h-full w-full overflow-hidden">
{fullScreen ? (
<>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
<CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} />
)}
<CustomAnalyticsSidebarHeader />
</>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 justify-end">
<div className="flex flex-wrap items-center gap-2 justify-self-end">
<Button
variant="neutral-primary"
prependIcon={<RefreshCw className="h-3 md:h-3.5 w-3 md:w-3.5" />}
prependIcon={<RefreshCw className="h-3.5 w-3.5" />}
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Refresh</div>
Refresh
</Button>
<Button variant="primary" prependIcon={<Download className="h-3.5 w-3.5" />} onClick={exportAnalytics}>
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Export as CSV</div>
Export as CSV
</Button>
</div>
</div>
@@ -20,15 +20,16 @@ export const ProjectAnalyticsModalMainContent: React.FC<Props> = observer((props
return (
<Tab.Group as={React.Fragment}>
<Tab.List as="div" className="flex space-x-2 border-b border-custom-border-200 px-0 md:px-5 py-0 md:py-3">
<Tab.List as="div" className="space-x-2 border-b border-custom-border-200 p-5 pt-0">
{ANALYTICS_TABS.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`rounded-0 w-full md:w-max md:rounded-3xl border-b md:border border-custom-border-200 focus:outline-none px-0 md:px-4 py-2 text-xs hover:bg-custom-background-80 ${selected ? "border-custom-primary-100 text-custom-primary-100 md:bg-custom-background-80 md:text-custom-text-200 md:border-custom-border-200" : "border-transparent"
`rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${
selected ? "bg-custom-background-80" : ""
}`
}
onClick={() => { }}
onClick={() => {}}
>
{tab.title}
</Tab>
@@ -38,12 +38,14 @@ export const ProjectAnalyticsModal: React.FC<Props> = observer((props) => {
>
<Dialog.Panel className="fixed inset-0 z-20 h-full w-full overflow-y-auto">
<div
className={`fixed right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${fullScreen ? "w-full p-2" : "w-full sm:w-full md:w-1/2"
}`}
className={`fixed right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${
fullScreen ? "w-full p-2" : "w-1/2"
}`}
>
<div
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${fullScreen ? "rounded-lg border" : "border-l"
}`}
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${
fullScreen ? "rounded-lg border" : "border-l"
}`}
>
<ProjectAnalyticsModalHeader
fullScreen={fullScreen}
@@ -163,8 +163,6 @@ export const CommandPalette: FC = observer(() => {
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
const isDraftIssue = router?.asPath?.includes("draft-issues") || false;
if (!currentUser) return null;
return (
@@ -219,7 +217,6 @@ export const CommandPalette: FC = observer(() => {
onClose={() => toggleCreateIssueModal(false)}
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined}
storeType={createIssueStoreType}
isDraft={isDraftIssue}
/>
{workspaceSlug && projectId && issueId && issueDetails && (
@@ -47,7 +47,7 @@ export const ShortcutsModal: FC<Props> = (props) => {
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="h-full w-full">
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
<div className="grid h-full w-full place-items-center p-5">
<div className="flex h-[61vh] w-full flex-col space-y-4 overflow-hidden rounded-lg bg-custom-background-100 p-5 shadow-custom-shadow-md transition-all sm:w-[28rem]">
<Dialog.Title as="h3" className="flex justify-between">
<span className="text-lg font-medium">Keyboard shortcuts</span>
+1 -1
View File
@@ -11,7 +11,7 @@ export const BreadcrumbLink: React.FC<Props> = (props) => {
const { href, label, icon } = props;
return (
<Tooltip tooltipContent={label} position="bottom">
<li className="flex items-center space-x-2" tabIndex={-1}>
<li className="flex items-center space-x-2">
<div className="flex flex-wrap items-center gap-2.5">
{href ? (
<Link
@@ -78,6 +78,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
useEffect(() => {
if (!isOpen || !workspaceSlug || !projectId) return;
if (issues.length <= 0) setIsSearching(true);
projectService
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
@@ -87,7 +88,16 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
})
.then((res) => setIssues(res))
.finally(() => setIsSearching(false));
}, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]);
}, [issues, debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]);
useEffect(() => {
setSearchTerm("");
setIssues([]);
setSelectedIssues([]);
setIsSearching(false);
setIsSubmitting(false);
setIsWorkspaceLevel(false);
}, [isOpen]);
return (
<>
@@ -1,82 +0,0 @@
import { cn } from "helpers/common.helper";
import React, { useState, useRef, useEffect, ReactNode, MutableRefObject } from "react";
type Props = {
defaultHeight?: string;
verticalOffset?: number;
horizonatlOffset?: number;
root?: MutableRefObject<HTMLElement | null>;
children: ReactNode;
as?: keyof JSX.IntrinsicElements;
classNames?: string;
alwaysRender?: boolean;
placeholderChildren?: ReactNode;
pauseHeightUpdateWhileRendering?: boolean;
changingReference?: any;
};
const RenderIfVisible: React.FC<Props> = (props) => {
const {
defaultHeight = "300px",
root,
verticalOffset = 50,
horizonatlOffset = 0,
as = "div",
children,
classNames = "",
alwaysRender = false, //render the children even if it is not visble in root
placeholderChildren = null, //placeholder children
pauseHeightUpdateWhileRendering = false, //while this is true the height of the blocks are maintained
changingReference, //This is to force render when this reference is changed
} = props;
const [shouldVisible, setShouldVisible] = useState<boolean>(alwaysRender);
const placeholderHeight = useRef<string>(defaultHeight);
const intersectionRef = useRef<HTMLElement | null>(null);
const isVisible = alwaysRender || shouldVisible;
// Set visibility with intersection observer
useEffect(() => {
if (intersectionRef.current) {
const observer = new IntersectionObserver(
(entries) => {
//DO no remove comments for future
// if (typeof window !== undefined && window.requestIdleCallback) {
// window.requestIdleCallback(() => setShouldVisible(entries[0].isIntersecting), {
// timeout: 300,
// });
// } else {
// setShouldVisible(entries[0].isIntersecting);
// }
setShouldVisible(entries[0].isIntersecting);
},
{
root: root?.current,
rootMargin: `${verticalOffset}% ${horizonatlOffset}% ${verticalOffset}% ${horizonatlOffset}%`,
}
);
observer.observe(intersectionRef.current);
return () => {
if (intersectionRef.current) {
observer.unobserve(intersectionRef.current);
}
};
}
}, [root?.current, intersectionRef, children, changingReference]);
//Set height after render
useEffect(() => {
if (intersectionRef.current && isVisible) {
placeholderHeight.current = `${intersectionRef.current.offsetHeight}px`;
}
}, [isVisible, intersectionRef, alwaysRender, pauseHeightUpdateWhileRendering]);
const child = isVisible ? <>{children}</> : placeholderChildren;
const style =
isVisible && !pauseHeightUpdateWhileRendering ? {} : { height: placeholderHeight.current, width: "100%" };
const className = isVisible ? classNames : cn(classNames, "bg-custom-background-80");
return React.createElement(as, { ref: intersectionRef, style, className }, child);
};
export default RenderIfVisible;
@@ -3,20 +3,12 @@ import { Menu } from "lucide-react";
import { useApplication } from "hooks/store";
import { observer } from "mobx-react";
type Props = {
onClick?: () => void;
}
export const SidebarHamburgerToggle: FC<Props> = observer((props) => {
const { onClick } = props
const { theme: themeStore } = useApplication();
export const SidebarHamburgerToggle: FC = observer (() => {
const { theme: themStore } = useApplication();
return (
<div
className="w-7 h-7 flex-shrink-0 rounded flex justify-center items-center bg-custom-background-80 transition-all hover:bg-custom-background-90 cursor-pointer group md:hidden"
onClick={() => {
if (onClick) onClick()
else themeStore.toggleMobileSidebar()
}}
className="w-7 h-7 rounded flex justify-center items-center bg-custom-background-80 transition-all hover:bg-custom-background-90 cursor-pointer group md:hidden"
onClick={() => themStore.toggleSidebar()}
>
<Menu size={14} className="text-custom-text-200 group-hover:text-custom-text-100 transition-all" />
</div>
@@ -34,8 +34,7 @@ import { ICycle, TCycleGroups } from "@plane/types";
// constants
import { EIssuesStoreType } from "constants/issue";
import { CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
import { CYCLE_STATE_GROUPS_DETAILS } from "constants/cycle";
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/empty-state";
import { CYCLE_EMPTY_STATE_DETAILS, CYCLE_STATE_GROUPS_DETAILS } from "constants/cycle";
interface IActiveCycleDetails {
workspaceSlug: string;
@@ -151,7 +150,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
color: group.color,
}));
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
const daysLeft = findHowManyDaysLeft(activeCycle.end_date ?? new Date());
return (
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow">

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