Merge branch 'develop' into sync/ce-ee

This commit is contained in:
sriram veeraghanta
2024-05-01 18:12:18 +05:30
committed by GitHub
106 changed files with 6103 additions and 481 deletions

390
.github/workflows/build-branch-ee.yml vendored Normal file
View File

@@ -0,0 +1,390 @@
name: Branch Build Enterprise
on:
workflow_dispatch:
push:
branches:
- master
- preview
- develop
release:
types: [released, prereleased]
env:
TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }}
jobs:
branch_build_setup:
name: Build-Push Web/Space/API/Proxy Docker Image
runs-on: ubuntu-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 }}
build_web: ${{ steps.changed_files.outputs.web_any_changed }}
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
build_backend: ${{ steps.changed_files.outputs.backend_any_changed }}
build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }}
artifact_upload_to_s3: ${{ steps.set_env_variables.outputs.artifact_upload_to_s3 }}
artifact_s3_suffix: ${{ steps.set_env_variables.outputs.artifact_s3_suffix }}
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
BR_NAME=$( echo "${{ env.TARGET_BRANCH }}" | tr / -)
echo "TARGET_BRANCH=$BR_NAME" >> $GITHUB_OUTPUT
if [ "${{ github.event_name }}" == "release" ]; then
echo "artifact_upload_to_s3=true" >> $GITHUB_OUTPUT
echo "artifact_s3_suffix=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
echo "artifact_upload_to_s3=true" >> $GITHUB_OUTPUT
echo "artifact_s3_suffix=latest" >> $GITHUB_OUTPUT
elif [ "${{ env.TARGET_BRANCH }}" == "preview" ] || [ "${{ env.TARGET_BRANCH }}" == "develop" ]; then
echo "artifact_upload_to_s3=true" >> $GITHUB_OUTPUT
echo "artifact_s3_suffix=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
else
echo "artifact_upload_to_s3=false" >> $GITHUB_OUTPUT
echo "artifact_s3_suffix=$BR_NAME" >> $GITHUB_OUTPUT
fi
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
- name: Get changed files
id: changed_files
uses: tj-actions/changed-files@v42
with:
files_yaml: |
web:
- web/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
space:
- space/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
backend:
- apiserver/**
proxy:
- nginx/**
branch_build_push_web:
if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
WEB_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/web-enterprise:${{ 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 Web Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/web-enterprise:stable
TAG=${TAG},${{ secrets.DOCKERHUB_USERNAME }}/web-enterprise:${{ github.event.release.tag_name }}
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/web-enterprise:stable
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/web-enterprise:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/web-enterprise:latest
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/web-enterprise:latest
else
TAG=${{ env.WEB_TAG }}
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/web-enterprise:${{ needs.branch_build_setup.outputs.gh_branch_name }}
fi
echo "WEB_TAG=${TAG}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Harbor
uses: docker/login-action@v3
with:
username: ${{ secrets.HARBOR_USERNAME }}
password: ${{ secrets.HARBOR_TOKEN }}
registry: ${{ vars.HARBOR_REGISTRY }}
- 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
- name: Build and Push Web to Docker Container Registry
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./web/Dockerfile.web
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.WEB_TAG }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_space:
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/space-enterprise:${{ 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
TAG=${{ secrets.DOCKERHUB_USERNAME }}/space-enterprise:stable
TAG=${TAG},${{ secrets.DOCKERHUB_USERNAME }}/space-enterprise:${{ github.event.release.tag_name }}
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/space-enterprise:stable
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/space-enterprise:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/space-enterprise:latest
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/space-enterprise:latest
else
TAG=${{ env.SPACE_TAG }}
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/space-enterprise:${{ needs.branch_build_setup.outputs.gh_branch_name }}
fi
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Harbor
uses: docker/login-action@v3
with:
username: ${{ secrets.HARBOR_USERNAME }}
password: ${{ secrets.HARBOR_TOKEN }}
registry: ${{ vars.HARBOR_REGISTRY }}
- 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
- name: Build and Push Space to Docker Hub
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./space/Dockerfile.space
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.SPACE_TAG }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_backend:
if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/backend-enterprise:${{ 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
TAG=${{ secrets.DOCKERHUB_USERNAME }}/backend-enterprise:stable
TAG=${TAG},${{ secrets.DOCKERHUB_USERNAME }}/backend-enterprise:${{ github.event.release.tag_name }}
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/backend-enterprise:stable
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/backend-enterprise:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/backend-enterprise:latest
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/backend-enterprise:latest
else
TAG=${{ env.BACKEND_TAG }}
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/backend-enterprise:${{ needs.branch_build_setup.outputs.gh_branch_name }}
fi
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Harbor
uses: docker/login-action@v3
with:
username: ${{ secrets.HARBOR_USERNAME }}
password: ${{ secrets.HARBOR_TOKEN }}
registry: ${{ vars.HARBOR_REGISTRY }}
- 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
- name: Build and Push Backend to Docker Hub
uses: docker/build-push-action@v5.1.0
with:
context: ./apiserver
file: ./apiserver/Dockerfile.api
platforms: ${{ env.BUILDX_PLATFORMS }}
push: true
tags: ${{ env.BACKEND_TAG }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_proxy:
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/proxy-enterprise:${{ 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
TAG=${{ secrets.DOCKERHUB_USERNAME }}/proxy-enterprise:stable
TAG=${TAG},${{ secrets.DOCKERHUB_USERNAME }}/proxy-enterprise:${{ github.event.release.tag_name }}
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/proxy-enterprise:stable
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/proxy-enterprise:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/proxy-enterprise:latest
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/proxy-enterprise:latest
else
TAG=${{ env.PROXY_TAG }}
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/proxy-enterprise:${{ needs.branch_build_setup.outputs.gh_branch_name }}
fi
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Harbor
uses: docker/login-action@v3
with:
username: ${{ secrets.HARBOR_USERNAME }}
password: ${{ secrets.HARBOR_TOKEN }}
registry: ${{ vars.HARBOR_REGISTRY }}
- 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
- name: Build and Push Plane-Proxy to Docker Hub
uses: docker/build-push-action@v5.1.0
with:
context: ./nginx
file: ./nginx/Dockerfile
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.PROXY_TAG }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
upload_artifacts_s3:
if: ${{ needs.branch_build_setup.outputs.artifact_upload_to_s3 == 'true' }}
name: Upload artifacts to S3 Bucket
runs-on: ubuntu-latest
needs:
[
branch_build_setup,
branch_build_push_web,
branch_build_push_space,
branch_build_push_backend,
branch_build_push_proxy,
]
container:
image: docker:20.10.7
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
ARTIFACT_SUFFIX: ${{ needs.branch_build_setup.outputs.artifact_s3_suffix }}
AWS_ACCESS_KEY_ID: ${{ secrets.SELF_HOST_BUCKET_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SELF_HOST_BUCKET_SECRET_KEY }}
TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }}
steps:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
- name: Upload artifacts
run: |
apk update
apk add --no-cache aws-cli
mkdir -p ~/${{ env.ARTIFACT_SUFFIX }}
cp deploy/cli-install/variables.env ~/${{ env.ARTIFACT_SUFFIX }}/variables.env
cp deploy/cli-install/Caddyfile ~/${{ env.ARTIFACT_SUFFIX }}/Caddyfile
sed -e 's@${APP_RELEASE_VERSION}@'${{ env.ARTIFACT_SUFFIX }}'@' deploy/cli-install/docker-compose.yml > ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose.yml
sed -e 's@${APP_RELEASE_VERSION}@'${{ env.ARTIFACT_SUFFIX }}'@' deploy/cli-install/docker-compose-caddy.yml > ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose-caddy.yml
aws s3 cp ~/${{ env.ARTIFACT_SUFFIX }} s3://${{ vars.SELF_HOST_BUCKET_NAME }}/plane-enterprise/${{ env.ARTIFACT_SUFFIX }} --recursive
rm -rf ~/${{ env.ARTIFACT_SUFFIX }}

70
.github/workflows/create-release.yml vendored Normal file
View File

@@ -0,0 +1,70 @@
name: Manual Release Workflow
on:
workflow_dispatch:
inputs:
release_tag:
description: 'Release Tag (e.g., v0.16-cannary-1)'
required: true
prerelease:
description: 'Pre-Release'
required: true
default: true
type: boolean
draft:
description: 'Draft'
required: true
default: true
type: boolean
permissions:
contents: write
jobs:
create-release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
with:
fetch-depth: 0 # Necessary to fetch all history for tags
- name: Set up Git
run: |
git config user.name "github-actions"
git config user.email "github-actions@github.com"
- name: Check for the Prerelease
run: |
echo ${{ github.event.release.prerelease }}
- name: Generate Release Notes
id: generate_notes
run: |
bash ./generate_release_notes.sh
# Directly use the content of RELEASE_NOTES.md for the release body
RELEASE_NOTES=$(cat RELEASE_NOTES.md)
echo "RELEASE_NOTES<<EOF" >> $GITHUB_ENV
echo "$RELEASE_NOTES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create Tag
run: |
git tag ${{ github.event.inputs.release_tag }}
git push origin ${{ github.event.inputs.release_tag }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ github.event.inputs.release_tag }}
body_path: RELEASE_NOTES.md
draft: ${{ github.event.inputs.draft }}
prerelease: ${{ github.event.inputs.prerelease }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,28 @@
#!/bin/bash
set -e
python manage.py wait_for_db
python manage.py migrate
# Create the default bucket
#!/bin/bash
# Collect system information
HOSTNAME=$(hostname)
MAC_ADDRESS=$(ip link show | awk '/ether/ {print $2}' | head -n 1)
CPU_INFO=$(cat /proc/cpuinfo)
MEMORY_INFO=$(free -h)
DISK_INFO=$(df -h)
# Concatenate information and compute SHA-256 hash
SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256sum | awk '{print $1}')
# Export the variables
export MACHINE_SIGNATURE=$SIGNATURE
# Register instance
python manage.py setup_instance $INSTANCE_ADMIN_EMAIL
# 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 -

View File

@@ -40,6 +40,9 @@ from .view import (
IssueViewSerializer,
IssueViewFavoriteSerializer,
)
from .active_cycle import ActiveCycleSerializer
from .cycle import (
CycleSerializer,
CycleIssueSerializer,
@@ -124,3 +127,13 @@ from .exporter import ExporterHistorySerializer
from .webhook import WebhookSerializer, WebhookLogSerializer
from .dashboard import DashboardSerializer, WidgetSerializer
from .integration import (
IntegrationSerializer,
WorkspaceIntegrationSerializer,
GithubIssueSyncSerializer,
GithubRepositorySerializer,
GithubRepositorySyncSerializer,
GithubCommentSyncSerializer,
SlackProjectSyncSerializer,
)

View File

@@ -0,0 +1,58 @@
# Third party imports
from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .project import ProjectLiteSerializer
from plane.db.models import (
Cycle,
)
class ActiveCycleSerializer(BaseSerializer):
# favorite
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
# state group wise distribution
cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True)
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
# active | draft | upcoming | completed
status = serializers.CharField(read_only=True)
# project details
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
model = Cycle
fields = [
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"status",
"project_detail",
]
read_only_fields = fields

View File

@@ -3,6 +3,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .issue import IssueStateSerializer
from plane.db.models import (
Cycle,

View File

@@ -0,0 +1,8 @@
from .base import IntegrationSerializer, WorkspaceIntegrationSerializer
from .github import (
GithubRepositorySerializer,
GithubRepositorySyncSerializer,
GithubIssueSyncSerializer,
GithubCommentSyncSerializer,
)
from .slack import SlackProjectSyncSerializer

View File

@@ -0,0 +1,22 @@
# Module imports
from plane.app.serializers import BaseSerializer
from plane.db.models import Integration, WorkspaceIntegration
class IntegrationSerializer(BaseSerializer):
class Meta:
model = Integration
fields = "__all__"
read_only_fields = [
"verified",
]
class WorkspaceIntegrationSerializer(BaseSerializer):
integration_detail = IntegrationSerializer(
read_only=True, source="integration"
)
class Meta:
model = WorkspaceIntegration
fields = "__all__"

View File

@@ -0,0 +1,45 @@
# Module imports
from plane.app.serializers import BaseSerializer
from plane.db.models import (
GithubIssueSync,
GithubRepository,
GithubRepositorySync,
GithubCommentSync,
)
class GithubRepositorySerializer(BaseSerializer):
class Meta:
model = GithubRepository
fields = "__all__"
class GithubRepositorySyncSerializer(BaseSerializer):
repo_detail = GithubRepositorySerializer(source="repository")
class Meta:
model = GithubRepositorySync
fields = "__all__"
class GithubIssueSyncSerializer(BaseSerializer):
class Meta:
model = GithubIssueSync
fields = "__all__"
read_only_fields = [
"project",
"workspace",
"repository_sync",
]
class GithubCommentSyncSerializer(BaseSerializer):
class Meta:
model = GithubCommentSync
fields = "__all__"
read_only_fields = [
"project",
"workspace",
"repository_sync",
"issue_sync",
]

View File

@@ -0,0 +1,14 @@
# Module imports
from plane.app.serializers import BaseSerializer
from plane.db.models import SlackProjectSync
class SlackProjectSyncSerializer(BaseSerializer):
class Meta:
model = SlackProjectSync
fields = "__all__"
read_only_fields = [
"project",
"workspace",
"workspace_integration",
]

View File

@@ -19,6 +19,9 @@ from .views import urlpatterns as view_urls
from .workspace import urlpatterns as workspace_urls
from .api import urlpatterns as api_urls
from .webhook import urlpatterns as webhook_urls
from .importer import urlpatterns as importer_urls
from .integration import urlpatterns as integration_urls
from .active_cycle import urlpatterns as active_cycle_urls
urlpatterns = [
@@ -43,4 +46,8 @@ urlpatterns = [
*workspace_urls,
*api_urls,
*webhook_urls,
# ee
*active_cycle_urls,
*integration_urls,
*importer_urls,
]

View File

@@ -0,0 +1,13 @@
from django.urls import path
from plane.app.views import (
ActiveCycleEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/active-cycles/",
ActiveCycleEndpoint.as_view(),
name="workspace-active-cycle",
),
]

View File

@@ -0,0 +1,43 @@
from django.urls import path
from plane.app.views import (
ServiceIssueImportSummaryEndpoint,
ImportServiceEndpoint,
UpdateServiceImportStatusEndpoint,
BulkImportIssuesEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/importers/<str:service>/",
ServiceIssueImportSummaryEndpoint.as_view(),
name="importer-summary",
),
path(
"workspaces/<str:slug>/projects/importers/<str:service>/",
ImportServiceEndpoint.as_view(),
name="importer",
),
path(
"workspaces/<str:slug>/importers/",
ImportServiceEndpoint.as_view(),
name="importer",
),
path(
"workspaces/<str:slug>/importers/<str:service>/<uuid:pk>/",
ImportServiceEndpoint.as_view(),
name="importer",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/service/<str:service>/importers/<uuid:importer_id>/",
UpdateServiceImportStatusEndpoint.as_view(),
name="importer-status",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-import-issues/<str:service>/",
BulkImportIssuesEndpoint.as_view(),
name="bulk-import-issues",
),
]

View File

@@ -0,0 +1,150 @@
from django.urls import path
from plane.app.views import (
IntegrationViewSet,
WorkspaceIntegrationViewSet,
GithubRepositoriesEndpoint,
GithubRepositorySyncViewSet,
GithubIssueSyncViewSet,
GithubCommentSyncViewSet,
BulkCreateGithubIssueSyncEndpoint,
SlackProjectSyncViewSet,
)
urlpatterns = [
path(
"integrations/",
IntegrationViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="integrations",
),
path(
"integrations/<uuid:pk>/",
IntegrationViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="integrations",
),
path(
"workspaces/<str:slug>/workspace-integrations/",
WorkspaceIntegrationViewSet.as_view(
{
"get": "list",
}
),
name="workspace-integrations",
),
path(
"workspaces/<str:slug>/workspace-integrations/<str:provider>/",
WorkspaceIntegrationViewSet.as_view(
{
"post": "create",
}
),
name="workspace-integrations",
),
path(
"workspaces/<str:slug>/workspace-integrations/<uuid:pk>/provider/",
WorkspaceIntegrationViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
name="workspace-integrations",
),
# Github Integrations
path(
"workspaces/<str:slug>/workspace-integrations/<uuid:workspace_integration_id>/github-repositories/",
GithubRepositoriesEndpoint.as_view(),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/",
GithubRepositorySyncViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/<uuid:pk>/",
GithubRepositorySyncViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/",
GithubIssueSyncViewSet.as_view(
{
"post": "create",
"get": "list",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/bulk-create-github-issue-sync/",
BulkCreateGithubIssueSyncEndpoint.as_view(),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:pk>/",
GithubIssueSyncViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/",
GithubCommentSyncViewSet.as_view(
{
"post": "create",
"get": "list",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/<uuid:pk>/",
GithubCommentSyncViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
),
## End Github Integrations
# Slack Integration
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/project-slack-sync/",
SlackProjectSyncViewSet.as_view(
{
"post": "create",
"get": "list",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/project-slack-sync/<uuid:pk>/",
SlackProjectSyncViewSet.as_view(
{
"delete": "destroy",
"get": "retrieve",
}
),
),
## End Slack Integration
]

View File

@@ -4,6 +4,7 @@ from django.urls import path
from plane.app.views import (
GlobalSearchEndpoint,
IssueSearchEndpoint,
SearchEndpoint,
)
@@ -18,4 +19,9 @@ urlpatterns = [
IssueSearchEndpoint.as_view(),
name="project-issue-search",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/search/",
SearchEndpoint.as_view(),
name="search",
),
]

View File

@@ -93,6 +93,7 @@ from .cycle.base import (
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
)
from .cycle.active_cycle import ActiveCycleEndpoint
from .cycle.issue import (
CycleIssueViewSet,
)
@@ -193,7 +194,7 @@ from .page.base import (
SubPagesEndpoint,
)
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
from .search import GlobalSearchEndpoint, IssueSearchEndpoint, SearchEndpoint
from .external.base import (
@@ -236,3 +237,26 @@ from .webhook.base import (
from .dashboard.base import DashboardEndpoint, WidgetsEndpoint
from .error_404 import custom_404_view
from .importer.base import (
ServiceIssueImportSummaryEndpoint,
ImportServiceEndpoint,
UpdateServiceImportStatusEndpoint,
BulkImportIssuesEndpoint,
BulkImportModulesEndpoint,
)
from .integration.base import (
IntegrationViewSet,
WorkspaceIntegrationViewSet,
)
from .integration.github import (
GithubRepositoriesEndpoint,
GithubRepositorySyncViewSet,
GithubIssueSyncViewSet,
GithubCommentSyncViewSet,
BulkCreateGithubIssueSyncEndpoint,
)
from .integration.slack import SlackProjectSyncViewSet

View File

@@ -0,0 +1,259 @@
# Django imports
from django.db.models import (
Case,
CharField,
Count,
Exists,
F,
OuterRef,
Prefetch,
Q,
Value,
When,
)
from django.utils import timezone
# Module imports
from plane.app.permissions import (
WorkspaceUserPermission,
)
from plane.app.serializers import (
ActiveCycleSerializer,
)
from plane.db.models import (
Cycle,
CycleFavorite,
Issue,
Label,
User,
)
from plane.utils.analytics_plot import burndown_plot
from plane.app.views.base import BaseAPIView
class ActiveCycleEndpoint(BaseAPIView):
permission_classes = [
WorkspaceUserPermission,
]
def get_results_controller(self, results, active_cycles=None):
for cycle in results:
assignee_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle["id"],
project_id=cycle["project_id"],
workspace__slug=self.kwargs.get("slug"),
)
.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(
"assignee_id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"assignee_id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"assignee_id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
label_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle["id"],
project_id=cycle["project_id"],
workspace__slug=self.kwargs.get("slug"),
)
.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(
"label_id",
filter=Q(archived_at__isnull=True, is_draft=False),
)
)
.annotate(
completed_issues=Count(
"label_id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"label_id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
cycle["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
if cycle["start_date"] and cycle["end_date"]:
cycle["distribution"]["completion_chart"] = burndown_plot(
queryset=active_cycles.get(pk=cycle["id"]),
slug=self.kwargs.get("slug"),
project_id=cycle["project_id"],
cycle_id=cycle["id"],
)
return results
def get(self, request, slug):
subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
active_cycles = (
Cycle.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
start_date__lte=timezone.now(),
end_date__gte=timezone.now(),
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
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(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(), then=Value("UPCOMING")
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
output_field=CharField(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__labels",
queryset=Label.objects.only(
"name", "color", "id"
).distinct(),
)
)
.order_by("-created_at")
)
return self.paginate(
request=request,
queryset=active_cycles,
on_results=lambda active_cycles: ActiveCycleSerializer(
active_cycles, many=True
).data,
controller=lambda results: self.get_results_controller(
results, active_cycles
),
default_per_page=int(request.GET.get("per_page", 3)),
)

View File

@@ -24,6 +24,7 @@ from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
@@ -45,8 +46,6 @@ from plane.db.models import (
User,
)
from plane.utils.analytics_plot import burndown_plot
# Module imports
from .. import BaseAPIView, BaseViewSet, WebhookMixin

View File

@@ -0,0 +1,560 @@
# Python imports
import uuid
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Django imports
from django.db.models import Max, Q
# Module imports
from plane.app.views import BaseAPIView
from plane.db.models import (
WorkspaceIntegration,
Importer,
APIToken,
Project,
State,
IssueSequence,
Issue,
IssueActivity,
IssueComment,
IssueLink,
IssueLabel,
Workspace,
IssueAssignee,
Module,
ModuleLink,
ModuleIssue,
Label,
)
from plane.app.serializers import (
ImporterSerializer,
IssueFlatSerializer,
ModuleSerializer,
)
from plane.utils.integrations.github import get_github_repo_details
from plane.utils.importers.jira import (
jira_project_issue_summary,
is_allowed_hostname,
)
from plane.bgtasks.importer_task import service_importer
from plane.utils.html_processor import strip_tags
from plane.app.permissions import WorkSpaceAdminPermission
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
def get(self, request, slug, service):
if service == "github":
owner = request.GET.get("owner", False)
repo = request.GET.get("repo", False)
if not owner or not repo:
return Response(
{"error": "Owner and repo are required"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace_integration = WorkspaceIntegration.objects.get(
integration__provider="github", workspace__slug=slug
)
access_tokens_url = workspace_integration.metadata.get(
"access_tokens_url", False
)
if not access_tokens_url:
return Response(
{
"error": "There was an error during the installation of the GitHub app. To resolve this issue, we recommend reinstalling the GitHub app."
},
status=status.HTTP_400_BAD_REQUEST,
)
issue_count, labels, collaborators = get_github_repo_details(
access_tokens_url, owner, repo
)
return Response(
{
"issue_count": issue_count,
"labels": labels,
"collaborators": collaborators,
},
status=status.HTTP_200_OK,
)
if service == "jira":
# Check for all the keys
params = {
"project_key": "Project key is required",
"api_token": "API token is required",
"email": "Email is required",
"cloud_hostname": "Cloud hostname is required",
}
for key, error_message in params.items():
if not request.GET.get(key, False):
return Response(
{"error": error_message},
status=status.HTTP_400_BAD_REQUEST,
)
project_key = request.GET.get("project_key", "")
api_token = request.GET.get("api_token", "")
email = request.GET.get("email", "")
cloud_hostname = request.GET.get("cloud_hostname", "")
response = jira_project_issue_summary(
email, api_token, project_key, cloud_hostname
)
if "error" in response:
return Response(response, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
response,
status=status.HTTP_200_OK,
)
return Response(
{"error": "Service not supported yet"},
status=status.HTTP_400_BAD_REQUEST,
)
class ImportServiceEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def post(self, request, slug, service):
project_id = request.data.get("project_id", False)
if not project_id:
return Response(
{"error": "Project ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
if service == "github":
data = request.data.get("data", False)
metadata = request.data.get("metadata", False)
config = request.data.get("config", False)
if not data or not metadata or not config:
return Response(
{"error": "Data, config and metadata are required"},
status=status.HTTP_400_BAD_REQUEST,
)
api_token = APIToken.objects.filter(
user=request.user, workspace=workspace
).first()
if api_token is None:
api_token = APIToken.objects.create(
user=request.user,
label="Importer",
workspace=workspace,
)
importer = Importer.objects.create(
service=service,
project_id=project_id,
status="queued",
initiated_by=request.user,
data=data,
metadata=metadata,
token=api_token,
config=config,
created_by=request.user,
updated_by=request.user,
)
service_importer.delay(service, importer.id)
serializer = ImporterSerializer(importer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
if service == "jira":
data = request.data.get("data", False)
metadata = request.data.get("metadata", False)
config = request.data.get("config", False)
cloud_hostname = metadata.get("cloud_hostname", False)
if not cloud_hostname:
return Response(
{"error": "Cloud hostname is required"},
status=status.HTTP_400_BAD_REQUEST,
)
if not is_allowed_hostname(cloud_hostname):
return Response(
{"error": "Hostname is not a valid hostname."},
status=status.HTTP_400_BAD_REQUEST,
)
if not data or not metadata:
return Response(
{"error": "Data, config and metadata are required"},
status=status.HTTP_400_BAD_REQUEST,
)
api_token = APIToken.objects.filter(
user=request.user, workspace=workspace
).first()
if api_token is None:
api_token = APIToken.objects.create(
user=request.user,
label="Importer",
workspace=workspace,
)
importer = Importer.objects.create(
service=service,
project_id=project_id,
status="queued",
initiated_by=request.user,
data=data,
metadata=metadata,
token=api_token,
config=config,
created_by=request.user,
updated_by=request.user,
)
service_importer.delay(service, importer.id)
serializer = ImporterSerializer(importer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
{"error": "Servivce not supported yet"},
status=status.HTTP_400_BAD_REQUEST,
)
def get(self, request, slug):
imports = (
Importer.objects.filter(workspace__slug=slug)
.order_by("-created_at")
.select_related("initiated_by", "project", "workspace")
)
serializer = ImporterSerializer(imports, many=True)
return Response(serializer.data)
def delete(self, request, slug, service, pk):
importer = Importer.objects.get(
pk=pk, service=service, workspace__slug=slug
)
if importer.imported_data is not None:
# Delete all imported Issues
imported_issues = importer.imported_data.get("issues", [])
Issue.issue_objects.filter(id__in=imported_issues).delete()
# Delete all imported Labels
imported_labels = importer.imported_data.get("labels", [])
Label.objects.filter(id__in=imported_labels).delete()
if importer.service == "jira":
imported_modules = importer.imported_data.get("modules", [])
Module.objects.filter(id__in=imported_modules).delete()
importer.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, service, pk):
importer = Importer.objects.get(
pk=pk, service=service, workspace__slug=slug
)
serializer = ImporterSerializer(
importer, 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)
class UpdateServiceImportStatusEndpoint(BaseAPIView):
def post(self, request, slug, project_id, service, importer_id):
importer = Importer.objects.get(
pk=importer_id,
workspace__slug=slug,
project_id=project_id,
service=service,
)
importer.status = request.data.get("status", "processing")
importer.save()
return Response(status.HTTP_200_OK)
class BulkImportIssuesEndpoint(BaseAPIView):
def post(self, request, slug, project_id, service):
# Get the project
project = Project.objects.get(pk=project_id, workspace__slug=slug)
# Get the default state
default_state = State.objects.filter(
~Q(name="Triage"), project_id=project_id, default=True
).first()
# if there is no default state assign any random state
if default_state is None:
default_state = State.objects.filter(
~Q(name="Triage"), project_id=project_id
).first()
# Get the maximum sequence_id
last_id = IssueSequence.objects.filter(
project_id=project_id
).aggregate(largest=Max("sequence"))["largest"]
last_id = 1 if last_id is None else last_id + 1
# Get the maximum sort order
largest_sort_order = Issue.objects.filter(
project_id=project_id, state=default_state
).aggregate(largest=Max("sort_order"))["largest"]
largest_sort_order = (
65535 if largest_sort_order is None else largest_sort_order + 10000
)
# Get the issues_data
issues_data = request.data.get("issues_data", [])
if not len(issues_data):
return Response(
{"error": "Issue data is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Issues
bulk_issues = []
for issue_data in issues_data:
bulk_issues.append(
Issue(
project_id=project_id,
workspace_id=project.workspace_id,
state_id=(
issue_data.get("state")
if issue_data.get("state", False)
else default_state.id
),
name=issue_data.get("name", "Issue Created through Bulk"),
description_html=issue_data.get(
"description_html", "<p></p>"
),
description_stripped=(
None
if (
issue_data.get("description_html") == ""
or issue_data.get("description_html") is None
)
else strip_tags(issue_data.get("description_html"))
),
sequence_id=last_id,
sort_order=largest_sort_order,
start_date=issue_data.get("start_date", None),
target_date=issue_data.get("target_date", None),
priority=issue_data.get("priority", "none"),
created_by=request.user,
)
)
largest_sort_order = largest_sort_order + 10000
last_id = last_id + 1
issues = Issue.objects.bulk_create(
bulk_issues,
batch_size=100,
ignore_conflicts=True,
)
# Sequences
_ = IssueSequence.objects.bulk_create(
[
IssueSequence(
issue=issue,
sequence=issue.sequence_id,
project_id=project_id,
workspace_id=project.workspace_id,
)
for issue in issues
],
batch_size=100,
)
# Attach Labels
bulk_issue_labels = []
for issue, issue_data in zip(issues, issues_data):
labels_list = issue_data.get("labels_list", [])
bulk_issue_labels = bulk_issue_labels + [
IssueLabel(
issue=issue,
label_id=label_id,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for label_id in labels_list
]
_ = IssueLabel.objects.bulk_create(
bulk_issue_labels, batch_size=100, ignore_conflicts=True
)
# Attach Assignees
bulk_issue_assignees = []
for issue, issue_data in zip(issues, issues_data):
assignees_list = issue_data.get("assignees_list", [])
bulk_issue_assignees = bulk_issue_assignees + [
IssueAssignee(
issue=issue,
assignee_id=assignee_id,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for assignee_id in assignees_list
]
_ = IssueAssignee.objects.bulk_create(
bulk_issue_assignees, batch_size=100, ignore_conflicts=True
)
# Track the issue activities
IssueActivity.objects.bulk_create(
[
IssueActivity(
issue=issue,
actor=request.user,
project_id=project_id,
workspace_id=project.workspace_id,
comment=f"imported the issue from {service}",
verb="created",
created_by=request.user,
)
for issue in issues
],
batch_size=100,
)
# Create Comments
bulk_issue_comments = []
for issue, issue_data in zip(issues, issues_data):
comments_list = issue_data.get("comments_list", [])
bulk_issue_comments = bulk_issue_comments + [
IssueComment(
issue=issue,
comment_html=comment.get("comment_html", "<p></p>"),
actor=request.user,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for comment in comments_list
]
_ = IssueComment.objects.bulk_create(
bulk_issue_comments, batch_size=100
)
# Attach Links
_ = IssueLink.objects.bulk_create(
[
IssueLink(
issue=issue,
url=issue_data.get("link", {}).get(
"url", "https://github.com"
),
title=issue_data.get("link", {}).get(
"title", "Original Issue"
),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for issue, issue_data in zip(issues, issues_data)
]
)
return Response(
{"issues": IssueFlatSerializer(issues, many=True).data},
status=status.HTTP_201_CREATED,
)
class BulkImportModulesEndpoint(BaseAPIView):
def post(self, request, slug, project_id, service):
modules_data = request.data.get("modules_data", [])
project = Project.objects.get(pk=project_id, workspace__slug=slug)
modules = Module.objects.bulk_create(
[
Module(
name=module.get("name", uuid.uuid4().hex),
description=module.get("description", ""),
start_date=module.get("start_date", None),
target_date=module.get("target_date", None),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for module in modules_data
],
batch_size=100,
ignore_conflicts=True,
)
modules = Module.objects.filter(
id__in=[module.id for module in modules]
)
if len(modules) == len(modules_data):
_ = ModuleLink.objects.bulk_create(
[
ModuleLink(
module=module,
url=module_data.get("link", {}).get(
"url", "https://plane.so"
),
title=module_data.get("link", {}).get(
"title", "Original Issue"
),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for module, module_data in zip(modules, modules_data)
],
batch_size=100,
ignore_conflicts=True,
)
bulk_module_issues = []
for module, module_data in zip(modules, modules_data):
module_issues_list = module_data.get("module_issues_list", [])
bulk_module_issues = bulk_module_issues + [
ModuleIssue(
issue_id=issue,
module=module,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for issue in module_issues_list
]
_ = ModuleIssue.objects.bulk_create(
bulk_module_issues, batch_size=100, ignore_conflicts=True
)
serializer = ModuleSerializer(modules, many=True)
return Response(
{"modules": serializer.data}, status=status.HTTP_201_CREATED
)
else:
return Response(
{
"message": "Modules created but issues could not be imported"
},
status=status.HTTP_200_OK,
)

View File

@@ -0,0 +1,9 @@
from .base import IntegrationViewSet, WorkspaceIntegrationViewSet
from .github import (
GithubRepositorySyncViewSet,
GithubIssueSyncViewSet,
BulkCreateGithubIssueSyncEndpoint,
GithubCommentSyncViewSet,
GithubRepositoriesEndpoint,
)
from .slack import SlackProjectSyncViewSet

View File

@@ -0,0 +1,181 @@
# Python improts
import uuid
# Django imports
from django.contrib.auth.hashers import make_password
# Third party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from plane.app.views import BaseViewSet
from plane.db.models import (
Integration,
WorkspaceIntegration,
Workspace,
User,
WorkspaceMember,
APIToken,
)
from plane.app.serializers import (
IntegrationSerializer,
WorkspaceIntegrationSerializer,
)
from plane.utils.integrations.github import (
get_github_metadata,
delete_github_installation,
)
from plane.app.permissions import WorkSpaceAdminPermission
from plane.utils.integrations.slack import slack_oauth
class IntegrationViewSet(BaseViewSet):
serializer_class = IntegrationSerializer
model = Integration
def create(self, request):
serializer = IntegrationSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, pk):
integration = Integration.objects.get(pk=pk)
if integration.verified:
return Response(
{"error": "Verified integrations cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IntegrationSerializer(
integration, 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)
def destroy(self, request, pk):
integration = Integration.objects.get(pk=pk)
if integration.verified:
return Response(
{"error": "Verified integrations cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
integration.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class WorkspaceIntegrationViewSet(BaseViewSet):
serializer_class = WorkspaceIntegrationSerializer
model = WorkspaceIntegration
permission_classes = [
WorkSpaceAdminPermission,
]
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("integration")
)
def create(self, request, slug, provider):
workspace = Workspace.objects.get(slug=slug)
integration = Integration.objects.get(provider=provider)
config = {}
if provider == "github":
installation_id = request.data.get("installation_id", None)
if not installation_id:
return Response(
{"error": "Installation ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
metadata = get_github_metadata(installation_id)
config = {"installation_id": installation_id}
if provider == "slack":
code = request.data.get("code", False)
if not code:
return Response(
{"error": "Code is required"},
status=status.HTTP_400_BAD_REQUEST,
)
slack_response = slack_oauth(code=code)
metadata = slack_response
access_token = metadata.get("access_token", False)
team_id = metadata.get("team", {}).get("id", False)
if not metadata or not access_token or not team_id:
return Response(
{
"error": "Slack could not be installed. Please try again later"
},
status=status.HTTP_400_BAD_REQUEST,
)
config = {"team_id": team_id, "access_token": access_token}
# Create a bot user
bot_user = User.objects.create(
email=f"{uuid.uuid4().hex}@plane.so",
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
is_bot=True,
first_name=integration.title,
avatar=(
integration.avatar_url
if integration.avatar_url is not None
else ""
),
)
# Create an API Token for the bot user
api_token = APIToken.objects.create(
user=bot_user,
user_type=1, # bot user
workspace=workspace,
)
workspace_integration = WorkspaceIntegration.objects.create(
workspace=workspace,
integration=integration,
actor=bot_user,
api_token=api_token,
metadata=metadata,
config=config,
)
# Add bot user as a member of workspace
_ = WorkspaceMember.objects.create(
workspace=workspace_integration.workspace,
member=bot_user,
role=20,
)
return Response(
WorkspaceIntegrationSerializer(workspace_integration).data,
status=status.HTTP_201_CREATED,
)
def destroy(self, request, slug, pk):
workspace_integration = WorkspaceIntegration.objects.get(
pk=pk, workspace__slug=slug
)
if workspace_integration.integration.provider == "github":
installation_id = workspace_integration.config.get(
"installation_id", False
)
if installation_id:
delete_github_installation(installation_id=installation_id)
workspace_integration.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,201 @@
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.views import BaseViewSet, BaseAPIView
from plane.db.models import (
GithubIssueSync,
GithubRepositorySync,
GithubRepository,
WorkspaceIntegration,
ProjectMember,
Label,
GithubCommentSync,
Project,
)
from plane.app.serializers import (
GithubIssueSyncSerializer,
GithubRepositorySyncSerializer,
GithubCommentSyncSerializer,
)
from plane.utils.integrations.github import get_github_repos
from plane.app.permissions import (
ProjectBasePermission,
ProjectEntityPermission,
)
class GithubRepositoriesEndpoint(BaseAPIView):
permission_classes = [
ProjectBasePermission,
]
def get(self, request, slug, workspace_integration_id):
page = request.GET.get("page", 1)
workspace_integration = WorkspaceIntegration.objects.get(
workspace__slug=slug, pk=workspace_integration_id
)
if workspace_integration.integration.provider != "github":
return Response(
{"error": "Not a github integration"},
status=status.HTTP_400_BAD_REQUEST,
)
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
repositories_url = (
workspace_integration.metadata["repositories_url"]
+ f"?per_page=100&page={page}"
)
repositories = get_github_repos(access_tokens_url, repositories_url)
return Response(repositories, status=status.HTTP_200_OK)
class GithubRepositorySyncViewSet(BaseViewSet):
permission_classes = [
ProjectBasePermission,
]
serializer_class = GithubRepositorySyncSerializer
model = GithubRepositorySync
def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id"))
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
)
def create(self, request, slug, project_id, workspace_integration_id):
name = request.data.get("name", False)
url = request.data.get("url", False)
config = request.data.get("config", {})
repository_id = request.data.get("repository_id", False)
owner = request.data.get("owner", False)
if not name or not url or not repository_id or not owner:
return Response(
{"error": "Name, url, repository_id and owner are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the workspace integration
workspace_integration = WorkspaceIntegration.objects.get(
pk=workspace_integration_id
)
# Delete the old repository object
GithubRepositorySync.objects.filter(
project_id=project_id, workspace__slug=slug
).delete()
GithubRepository.objects.filter(
project_id=project_id, workspace__slug=slug
).delete()
# Create repository
repo = GithubRepository.objects.create(
name=name,
url=url,
config=config,
repository_id=repository_id,
owner=owner,
project_id=project_id,
)
# Create a Label for github
label = Label.objects.filter(
name="GitHub",
project_id=project_id,
).first()
if label is None:
label = Label.objects.create(
name="GitHub",
project_id=project_id,
description="Label to sync Plane issues with GitHub issues",
color="#003773",
)
# Create repo sync
repo_sync = GithubRepositorySync.objects.create(
repository=repo,
workspace_integration=workspace_integration,
actor=workspace_integration.actor,
credentials=request.data.get("credentials", {}),
project_id=project_id,
label=label,
)
# Add bot as a member in the project
_ = ProjectMember.objects.get_or_create(
member=workspace_integration.actor, role=20, project_id=project_id
)
# Return Response
return Response(
GithubRepositorySyncSerializer(repo_sync).data,
status=status.HTTP_201_CREATED,
)
class GithubIssueSyncViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = GithubIssueSyncSerializer
model = GithubIssueSync
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
repository_sync_id=self.kwargs.get("repo_sync_id"),
)
class BulkCreateGithubIssueSyncEndpoint(BaseAPIView):
def post(self, request, slug, project_id, repo_sync_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
github_issue_syncs = request.data.get("github_issue_syncs", [])
github_issue_syncs = GithubIssueSync.objects.bulk_create(
[
GithubIssueSync(
issue_id=github_issue_sync.get("issue"),
repo_issue_id=github_issue_sync.get("repo_issue_id"),
issue_url=github_issue_sync.get("issue_url"),
github_issue_id=github_issue_sync.get("github_issue_id"),
repository_sync_id=repo_sync_id,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for github_issue_sync in github_issue_syncs
],
batch_size=100,
ignore_conflicts=True,
)
serializer = GithubIssueSyncSerializer(github_issue_syncs, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class GithubCommentSyncViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = GithubCommentSyncSerializer
model = GithubCommentSync
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
issue_sync_id=self.kwargs.get("issue_sync_id"),
)

View File

@@ -0,0 +1,95 @@
# Django import
from django.db import IntegrityError
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
# Module imports
from plane.app.views import BaseViewSet
from plane.db.models import (
SlackProjectSync,
WorkspaceIntegration,
ProjectMember,
)
from plane.app.serializers import SlackProjectSyncSerializer
from plane.app.permissions import (
ProjectBasePermission,
)
from plane.utils.integrations.slack import slack_oauth
class SlackProjectSyncViewSet(BaseViewSet):
permission_classes = [
ProjectBasePermission,
]
serializer_class = SlackProjectSyncSerializer
model = SlackProjectSync
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
)
def create(self, request, slug, project_id, workspace_integration_id):
try:
code = request.data.get("code", False)
if not code:
return Response(
{"error": "Code is required"},
status=status.HTTP_400_BAD_REQUEST,
)
slack_response = slack_oauth(code=code)
workspace_integration = WorkspaceIntegration.objects.get(
workspace__slug=slug, pk=workspace_integration_id
)
workspace_integration = WorkspaceIntegration.objects.get(
pk=workspace_integration_id, workspace__slug=slug
)
slack_project_sync = SlackProjectSync.objects.create(
access_token=slack_response.get("access_token"),
scopes=slack_response.get("scope"),
bot_user_id=slack_response.get("bot_user_id"),
webhook_url=slack_response.get("incoming_webhook", {}).get(
"url"
),
data=slack_response,
team_id=slack_response.get("team", {}).get("id"),
team_name=slack_response.get("team", {}).get("name"),
workspace_integration=workspace_integration,
project_id=project_id,
)
_ = ProjectMember.objects.get_or_create(
member=workspace_integration.actor,
role=20,
project_id=project_id,
)
serializer = SlackProjectSyncSerializer(slack_project_sync)
return Response(serializer.data, status=status.HTTP_200_OK)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"error": "Slack is already installed for the project"},
status=status.HTTP_410_GONE,
)
capture_exception(e)
return Response(
{
"error": "Slack could not be installed. Please try again later"
},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -18,6 +18,7 @@ from plane.db.models import (
Module,
Page,
IssueView,
ProjectMember,
)
from plane.utils.issue_search import search_issues
@@ -249,7 +250,7 @@ class IssueSearchEndpoint(BaseAPIView):
workspace__slug=slug,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True
project__archived_at__isnull=True,
)
if workspace_search == "false":
@@ -300,3 +301,201 @@ class IssueSearchEndpoint(BaseAPIView):
),
status=status.HTTP_200_OK,
)
class SearchEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
query = request.query_params.get("query", False)
query_type = request.query_params.get("query_type", "issue")
count = int(request.query_params.get("count", 5))
if query_type == "mention":
fields = ["member__first_name", "member__last_name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
users = (
ProjectMember.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project_id=project_id,
workspace__slug=slug,
)
.order_by("-created_at")
.values(
"member__first_name",
"member__last_name",
"member__avatar",
"member__display_name",
"member__id",
)[:count]
)
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
pages = (
Page.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
access=0,
)
.order_by("-created_at")
.values("name", "id")[:count]
)
return Response(
{"users": users, "pages": pages}, status=status.HTTP_200_OK
)
if query_type == "project":
fields = ["name", "identifier"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user)
| Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values("name", "id", "identifier", "workspace__slug")[:count]
)
return Response(projects, status=status.HTTP_200_OK)
if query_type == "issue":
fields = ["name", "sequence_id", "project__identifier"]
q = Q()
if query:
for field in fields:
if field == "sequence_id":
# Match whole integers only (exclude decimal numbers)
sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
issues = (
Issue.issue_objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
project_id=project_id,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"sequence_id",
"project__identifier",
"project_id",
"priority",
"state_id",
)[:count]
)
return Response(issues, status=status.HTTP_200_OK)
if query_type == "cycle":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
cycles = (
Cycle.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)[:count]
)
return Response(cycles, status=status.HTTP_200_OK)
if query_type == "module":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
modules = (
Module.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)[:count]
)
return Response(modules, status=status.HTTP_200_OK)
if query_type == "page":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
pages = (
Page.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project_id=project_id,
workspace__slug=slug,
access=0,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)[:count]
)
return Response(pages, status=status.HTTP_200_OK)
return Response(
{"error": "Please provide a valid query"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -0,0 +1,598 @@
# Python imports
import random
from datetime import datetime
# Django imports
from django.db.models import Max
# Third party imports
from celery import shared_task
from faker import Faker
# Module imports
from plane.db.models import (
Workspace,
WorkspaceMember,
User,
Project,
ProjectMember,
State,
Label,
Cycle,
Module,
Issue,
IssueSequence,
IssueAssignee,
IssueLabel,
IssueActivity,
CycleIssue,
ModuleIssue,
)
def create_workspace_members(workspace, members):
members = User.objects.filter(email__in=members)
_ = WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
workspace=workspace,
member=member,
role=20,
)
for member in members
],
ignore_conflicts=True,
)
return
def create_project(workspace, user_id):
fake = Faker()
name = fake.name()
project = Project.objects.create(
workspace=workspace,
name=name,
identifier=name[
: random.randint(2, 12 if len(name) - 1 >= 12 else len(name) - 1)
].upper(),
created_by_id=user_id,
)
# Add current member as project member
_ = ProjectMember.objects.create(
project=project,
member_id=user_id,
role=20,
)
return project
def create_project_members(workspace, project, members):
members = User.objects.filter(email__in=members)
_ = ProjectMember.objects.bulk_create(
[
ProjectMember(
project=project,
workspace=workspace,
member=member,
role=20,
sort_order=random.randint(0, 65535),
)
for member in members
],
ignore_conflicts=True,
)
return
def create_states(workspace, project, user_id):
states = [
{
"name": "Backlog",
"color": "#A3A3A3",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#3A3A3A",
"sequence": 25000,
"group": "unstarted",
},
{
"name": "In Progress",
"color": "#F59E0B",
"sequence": 35000,
"group": "started",
},
{
"name": "Done",
"color": "#16A34A",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#EF4444",
"sequence": 55000,
"group": "cancelled",
},
]
states = State.objects.bulk_create(
[
State(
name=state["name"],
color=state["color"],
project=project,
sequence=state["sequence"],
workspace=workspace,
group=state["group"],
default=state.get("default", False),
created_by_id=user_id,
)
for state in states
]
)
return states
def create_labels(workspace, project, user_id):
fake = Faker()
Faker.seed(0)
return Label.objects.bulk_create(
[
Label(
name=fake.color_name(),
color=fake.hex_color(),
project=project,
workspace=workspace,
created_by_id=user_id,
sort_order=random.randint(0, 65535),
)
for _ in range(0, 50)
],
ignore_conflicts=True,
)
def create_cycles(workspace, project, user_id, cycle_count):
fake = Faker()
Faker.seed(0)
cycles = []
used_date_ranges = set() # Track used date ranges
while len(cycles) <= cycle_count:
# Generate a start date, allowing for None
start_date_option = [None, fake.date_this_year()]
start_date = start_date_option[random.randint(0, 1)]
# Initialize end_date based on start_date
end_date = (
None
if start_date is None
else fake.date_between_dates(
date_start=start_date,
date_end=datetime.now().date().replace(month=12, day=31),
)
)
# Ensure end_date is strictly after start_date if start_date is not None
while start_date is not None and (
end_date <= start_date
or (start_date, end_date) in used_date_ranges
):
end_date = fake.date_this_year()
# Add the unique date range to the set
(
used_date_ranges.add((start_date, end_date))
if (end_date is not None and start_date is not None)
else None
)
# Append the cycle with unique date range
cycles.append(
Cycle(
name=fake.name(),
owned_by_id=user_id,
sort_order=random.randint(0, 65535),
start_date=start_date,
end_date=end_date,
project=project,
workspace=workspace,
)
)
return Cycle.objects.bulk_create(cycles, ignore_conflicts=True)
def create_modules(workspace, project, user_id, module_count):
fake = Faker()
Faker.seed(0)
modules = []
for _ in range(0, module_count):
start_date = [None, fake.date_this_year()][random.randint(0, 1)]
end_date = (
None
if start_date is None
else fake.date_between_dates(
date_start=start_date,
date_end=datetime.now().date().replace(month=12, day=31),
)
)
modules.append(
Module(
name=fake.name(),
sort_order=random.randint(0, 65535),
start_date=start_date,
target_date=end_date,
project=project,
workspace=workspace,
)
)
return Module.objects.bulk_create(modules, ignore_conflicts=True)
def create_issues(workspace, project, user_id, issue_count):
fake = Faker()
Faker.seed(0)
states = State.objects.values_list("id", flat=True)
creators = ProjectMember.objects.values_list("member_id", flat=True)
issues = []
# Get the maximum sequence_id
last_id = IssueSequence.objects.filter(
project=project,
).aggregate(
largest=Max("sequence")
)["largest"]
last_id = 1 if last_id is None else last_id + 1
# Get the maximum sort order
largest_sort_order = Issue.objects.filter(
project=project,
state_id=states[random.randint(0, len(states) - 1)],
).aggregate(largest=Max("sort_order"))["largest"]
largest_sort_order = (
65535 if largest_sort_order is None else largest_sort_order + 10000
)
for _ in range(0, issue_count):
start_date = [None, fake.date_this_year()][random.randint(0, 1)]
end_date = (
None
if start_date is None
else fake.date_between_dates(
date_start=start_date,
date_end=datetime.now().date().replace(month=12, day=31),
)
)
sentence = fake.sentence()
issues.append(
Issue(
state_id=states[random.randint(0, len(states) - 1)],
project=project,
workspace=workspace,
name=sentence[:254],
description_html=f"<p>{sentence}</p>",
description_stripped=sentence,
sequence_id=last_id,
sort_order=largest_sort_order,
start_date=start_date,
target_date=end_date,
priority=["urgent", "high", "medium", "low", "none"][
random.randint(0, 4)
],
created_by_id=creators[random.randint(0, len(creators) - 1)],
)
)
largest_sort_order = largest_sort_order + random.randint(0, 1000)
last_id = last_id + 1
issues = Issue.objects.bulk_create(
issues, ignore_conflicts=True, batch_size=1000
)
# Sequences
_ = IssueSequence.objects.bulk_create(
[
IssueSequence(
issue=issue,
sequence=issue.sequence_id,
project=project,
workspace=workspace,
)
for issue in issues
],
batch_size=100,
)
# Track the issue activities
IssueActivity.objects.bulk_create(
[
IssueActivity(
issue=issue,
actor_id=user_id,
project=project,
workspace=workspace,
comment="created the issue",
verb="created",
created_by_id=user_id,
)
for issue in issues
],
batch_size=100,
)
return
def create_issue_parent(workspace, project, user_id, issue_count):
parent_count = issue_count / 4
parent_issues = Issue.objects.filter(project=project).values_list(
"id", flat=True
)[: int(parent_count)]
sub_issues = Issue.objects.filter(project=project).exclude(
pk__in=parent_issues
)[: int(issue_count / 2)]
bulk_sub_issues = []
for sub_issue in sub_issues:
sub_issue.parent_id = parent_issues[
random.randint(0, int(parent_count - 1))
]
Issue.objects.bulk_update(bulk_sub_issues, ["parent"], batch_size=1000)
def create_issue_assignees(workspace, project, user_id, issue_count):
# assignees
assignees = ProjectMember.objects.filter(project=project).values_list(
"member_id", flat=True
)
issues = random.sample(
list(
Issue.objects.filter(project=project).values_list("id", flat=True)
),
int(issue_count / 2),
)
# Bulk issue
bulk_issue_assignees = []
for issue in issues:
for assignee in random.sample(
list(assignees), random.randint(0, len(assignees) - 1)
):
bulk_issue_assignees.append(
IssueAssignee(
issue_id=issue,
assignee_id=assignee,
project=project,
workspace=workspace,
)
)
# Issue assignees
IssueAssignee.objects.bulk_create(
bulk_issue_assignees, batch_size=1000, ignore_conflicts=True
)
def create_issue_labels(workspace, project, user_id, issue_count):
# assignees
labels = Label.objects.filter(project=project).values_list("id", flat=True)
issues = random.sample(
list(
Issue.objects.filter(project=project).values_list("id", flat=True)
),
int(issue_count / 2),
)
# Bulk issue
bulk_issue_labels = []
for issue in issues:
for label in random.sample(
list(labels), random.randint(0, len(labels) - 1)
):
bulk_issue_labels.append(
IssueLabel(
issue_id=issue,
label_id=label,
project=project,
workspace=workspace,
)
)
# Issue assignees
IssueLabel.objects.bulk_create(
bulk_issue_labels, batch_size=1000, ignore_conflicts=True
)
def create_cycle_issues(workspace, project, user_id, issue_count):
# assignees
cycles = Cycle.objects.filter(project=project).values_list("id", flat=True)
issues = random.sample(
list(
Issue.objects.filter(project=project).values_list("id", flat=True)
),
int(issue_count / 2),
)
# Bulk issue
bulk_cycle_issues = []
for issue in issues:
cycle = cycles[random.randint(0, len(cycles) - 1)]
bulk_cycle_issues.append(
CycleIssue(
cycle_id=cycle,
issue_id=issue,
project=project,
workspace=workspace,
)
)
# Issue assignees
CycleIssue.objects.bulk_create(
bulk_cycle_issues, batch_size=1000, ignore_conflicts=True
)
def create_module_issues(workspace, project, user_id, issue_count):
# assignees
modules = Module.objects.filter(project=project).values_list(
"id", flat=True
)
issues = random.sample(
list(
Issue.objects.filter(project=project).values_list("id", flat=True)
),
int(issue_count / 2),
)
# Bulk issue
bulk_module_issues = []
for issue in issues:
module = modules[random.randint(0, len(modules) - 1)]
bulk_module_issues.append(
ModuleIssue(
module_id=module,
issue_id=issue,
project=project,
workspace=workspace,
)
)
# Issue assignees
ModuleIssue.objects.bulk_create(
bulk_module_issues, batch_size=1000, ignore_conflicts=True
)
@shared_task
def create_fake_data(
slug, email, members, issue_count, cycle_count, module_count
):
workspace = Workspace.objects.get(slug=slug)
user = User.objects.get(email=email)
user_id = user.id
# create workspace members
print("creating workspace members")
create_workspace_members(workspace=workspace, members=members)
print("Done creating workspace members")
# Create a project
print("Creating project")
project = create_project(workspace=workspace, user_id=user_id)
print("Done creating projects")
# create project members
print("Creating project members")
create_project_members(
workspace=workspace, project=project, members=members
)
print("Done creating project members")
# Create states
print("Creating states")
_ = create_states(workspace=workspace, project=project, user_id=user_id)
print("Done creating states")
# Create labels
print("Creating labels")
_ = create_labels(workspace=workspace, project=project, user_id=user_id)
print("Done creating labels")
# create cycles
print("Creating cycles")
_ = create_cycles(
workspace=workspace,
project=project,
user_id=user_id,
cycle_count=cycle_count,
)
print("Done creating cycles")
# create modules
print("Creating modules")
_ = create_modules(
workspace=workspace,
project=project,
user_id=user_id,
module_count=module_count,
)
print("Done creating modules")
print("Creating issues")
create_issues(
workspace=workspace,
project=project,
user_id=user_id,
issue_count=issue_count,
)
print("Done creating issues")
print("Creating parent and sub issues")
create_issue_parent(
workspace=workspace,
project=project,
user_id=user_id,
issue_count=issue_count,
)
print("Done creating parent and sub issues")
print("Creating issue assignees")
create_issue_assignees(
workspace=workspace,
project=project,
user_id=user_id,
issue_count=issue_count,
)
print("Done creating issue assignees")
print("Creating issue labels")
create_issue_labels(
workspace=workspace,
project=project,
user_id=user_id,
issue_count=issue_count,
)
print("Done creating issue labels")
print("Creating cycle issues")
create_cycle_issues(
workspace=workspace,
project=project,
user_id=user_id,
issue_count=issue_count,
)
print("Done creating cycle issues")
print("Creating module issues")
create_module_issues(
workspace=workspace,
project=project,
user_id=user_id,
issue_count=issue_count,
)
print("Done creating module issues")
return

View File

@@ -0,0 +1,212 @@
# Python imports
import json
import requests
import uuid
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.auth.hashers import make_password
# Third Party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.app.serializers import ImporterSerializer
from plane.db.models import (
Importer,
WorkspaceMember,
GithubRepositorySync,
GithubRepository,
ProjectMember,
WorkspaceIntegration,
Label,
User,
IssueProperty,
UserNotificationPreference,
)
from plane.bgtasks.user_welcome_task import send_welcome_slack
@shared_task
def service_importer(service, importer_id):
try:
importer = Importer.objects.get(pk=importer_id)
importer.status = "processing"
importer.save()
users = importer.data.get("users", [])
# Check if we need to import users as well
if len(users):
# For all invited users create the users
new_users = User.objects.bulk_create(
[
User(
email=user.get("email").strip().lower(),
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
for user in users
if user.get("import", False) == "invite"
],
batch_size=100,
ignore_conflicts=True,
)
_ = UserNotificationPreference.objects.bulk_create(
[UserNotificationPreference(user=user) for user in new_users],
batch_size=100,
)
_ = [
send_welcome_slack.delay(
str(user.id),
True,
f"{user.email} was imported to Plane from {service}",
)
for user in new_users
]
workspace_users = User.objects.filter(
email__in=[
user.get("email").strip().lower()
for user in users
if user.get("import", False) == "invite"
or user.get("import", False) == "map"
]
)
# Check if any of the users are already member of workspace
_ = WorkspaceMember.objects.filter(
member__in=[user for user in workspace_users],
workspace_id=importer.workspace_id,
).update(is_active=True)
# Add new users to Workspace and project automatically
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
member=user,
workspace_id=importer.workspace_id,
created_by=importer.created_by,
)
for user in workspace_users
],
batch_size=100,
ignore_conflicts=True,
)
ProjectMember.objects.bulk_create(
[
ProjectMember(
project_id=importer.project_id,
workspace_id=importer.workspace_id,
member=user,
created_by=importer.created_by,
)
for user in workspace_users
],
batch_size=100,
ignore_conflicts=True,
)
IssueProperty.objects.bulk_create(
[
IssueProperty(
project_id=importer.project_id,
workspace_id=importer.workspace_id,
user=user,
created_by=importer.created_by,
)
for user in workspace_users
],
batch_size=100,
ignore_conflicts=True,
)
# Check if sync config is on for github importers
if service == "github" and importer.config.get("sync", False):
name = importer.metadata.get("name", False)
url = importer.metadata.get("url", False)
config = importer.metadata.get("config", {})
owner = importer.metadata.get("owner", False)
repository_id = importer.metadata.get("repository_id", False)
workspace_integration = WorkspaceIntegration.objects.get(
workspace_id=importer.workspace_id,
integration__provider="github",
)
# Delete the old repository object
GithubRepositorySync.objects.filter(
project_id=importer.project_id
).delete()
GithubRepository.objects.filter(
project_id=importer.project_id
).delete()
# Create a Label for github
label = Label.objects.filter(
name="GitHub", project_id=importer.project_id
).first()
if label is None:
label = Label.objects.create(
name="GitHub",
project_id=importer.project_id,
description="Label to sync Plane issues with GitHub issues",
color="#003773",
)
# Create repository
repo = GithubRepository.objects.create(
name=name,
url=url,
config=config,
repository_id=repository_id,
owner=owner,
project_id=importer.project_id,
)
# Create repo sync
_ = GithubRepositorySync.objects.create(
repository=repo,
workspace_integration=workspace_integration,
actor=workspace_integration.actor,
credentials=importer.data.get("credentials", {}),
project_id=importer.project_id,
label=label,
)
# Add bot as a member in the project
_ = ProjectMember.objects.get_or_create(
member=workspace_integration.actor,
role=20,
project_id=importer.project_id,
)
if settings.PROXY_BASE_URL:
headers = {"Content-Type": "application/json"}
import_data_json = json.dumps(
ImporterSerializer(importer).data,
cls=DjangoJSONEncoder,
)
_ = requests.post(
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(importer.workspace_id)}/projects/{str(importer.project_id)}/importers/{str(service)}/",
json=import_data_json,
headers=headers,
)
return
except Exception as e:
importer = Importer.objects.get(pk=importer_id)
importer.status = "failed"
importer.save()
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return

View File

@@ -0,0 +1,36 @@
# Django imports
from django.conf import settings
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
# Module imports
from plane.db.models import User
@shared_task
def send_welcome_slack(user_id, created, message):
try:
instance = User.objects.get(pk=user_id)
if created and not instance.is_bot:
# Send message on slack as well
if settings.SLACK_BOT_TOKEN:
client = WebClient(token=settings.SLACK_BOT_TOKEN)
try:
_ = client.chat_postMessage(
channel="#trackers",
text=message,
)
except SlackApiError as e:
print(f"Got an error: {e.response['error']}")
return
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return

View File

@@ -3,8 +3,11 @@ import logging
# Third party imports
from celery import shared_task
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
# Django imports
from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
@@ -15,6 +18,18 @@ from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
def push_updated_to_slack(workspace, workspace_member_invite):
# Send message on slack as well
client = WebClient(token=settings.SLACK_BOT_TOKEN)
try:
_ = client.chat_postMessage(
channel="#trackers",
text=f"{workspace_member_invite.email} has been invited to {workspace.name} as a {workspace_member_invite.role}",
)
except SlackApiError as e:
print(f"Got an error: {e.response['error']}")
@shared_task
def workspace_invitation(email, workspace_id, token, current_site, invitor):
try:
@@ -80,6 +95,10 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
msg.send()
logging.getLogger("plane").info("Email sent succesfully")
# Send message on slack as well
if settings.SLACK_BOT_TOKEN:
push_updated_to_slack(workspace, workspace_member_invite)
return
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e:
log_exception(e)

View File

@@ -0,0 +1,79 @@
# Django imports
from typing import Any
from django.core.management.base import BaseCommand, CommandError
# Module imports
from plane.db.models import User, Workspace, WorkspaceMember
class Command(BaseCommand):
help = "Create dump issues, cycles etc. for a project in a given workspace"
def handle(self, *args: Any, **options: Any) -> str | None:
try:
workspace_name = input("Workspace Name: ")
workspace_slug = input("Workspace slug: ")
if workspace_slug == "":
raise CommandError("Workspace slug is required")
if Workspace.objects.filter(slug=workspace_slug).exists():
raise CommandError("Workspace already exists")
creator = input("Your email: ")
if (
creator == ""
or not User.objects.filter(email=creator).exists()
):
raise CommandError(
"User email is required and should be existing in Database"
)
user = User.objects.get(email=creator)
members = input("Enter Member emails (comma separated): ")
members = members.split(",") if members != "" else []
issue_count = int(
input("Number of issues to be created: ")
)
cycle_count = int(
input("Number of cycles to be created: ")
)
module_count = int(
input("Number of modules to be created: ")
)
# Create workspace
workspace = Workspace.objects.create(
slug=workspace_slug,
name=workspace_name,
owner=user,
)
# Create workspace member
WorkspaceMember.objects.create(
workspace=workspace, role=20, member=user
)
from plane.bgtasks.create_faker import create_fake_data
create_fake_data.delay(
slug=workspace_slug,
email=creator,
members=members,
issue_count=issue_count,
cycle_count=cycle_count,
module_count=module_count,
)
self.stdout.write(
self.style.SUCCESS("Data is pushed to the queue")
)
return
except Exception as e:
self.stdout.write(
self.style.ERROR(f"Command errored out {str(e)}")
)
return

View File

@@ -15,6 +15,12 @@ from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from django.conf import settings
# Third party imports
from sentry_sdk import capture_exception
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
def get_default_onboarding():
@@ -154,3 +160,23 @@ def create_user_notification(sender, instance, created, **kwargs):
mention=False,
issue_completed=False,
)
@receiver(post_save, sender=User)
def send_welcome_slack(sender, instance, created, **kwargs):
try:
if created and not instance.is_bot:
# Send message on slack as well
if settings.SLACK_BOT_TOKEN:
client = WebClient(token=settings.SLACK_BOT_TOKEN)
try:
_ = client.chat_postMessage(
channel="#trackers",
text=f"New user {instance.email} has signed up and begun the onboarding journey.",
)
except SlackApiError as e:
print(f"Got an error: {e.response['error']}")
return
except Exception as e:
capture_exception(e)
return

View File

@@ -0,0 +1,74 @@
# Python imports
import json
import secrets
import uuid
# Django imports
from django.contrib.auth.hashers import make_password
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
# Module imports
from plane.license.models import Instance, InstanceAdmin
from plane.db.models import User
class Command(BaseCommand):
help = "Check if instance in registered else register"
def add_arguments(self, parser):
# Positional argument
parser.add_argument("admin_email", type=str, help="Admin Email")
def handle(self, *args, **options):
with open("package.json", "r") as file:
# Load JSON content from the file
data = json.load(file)
admin_email = options.get("admin_email", False)
if not admin_email:
raise CommandError("admin email is required")
user_count = User.objects.filter(is_bot=False).count()
user = User.objects.filter(email=admin_email).first()
if user is None:
user = User.objects.create(
email=admin_email,
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
)
try:
# Check if the instance is registered
instance = Instance.objects.first()
if instance is None:
instance = Instance.objects.create(
instance_name="Plane Enterprise",
instance_id=secrets.token_hex(12),
license_key=None,
api_key=secrets.token_hex(8),
version=data.get("version"),
last_checked_at=timezone.now(),
user_count=user_count,
is_verified=True,
is_setup_done=True,
is_signup_screen_visited=True,
)
# Get or create an instance admin
_, created = InstanceAdmin.objects.get_or_create(
user=user, instance=instance, role=20, is_verified=True
)
if not created:
self.stdout.write(
self.style.WARNING("given email is already an instance admin")
)
self.stdout.write(self.style.SUCCESS("Successful"))
except Exception as e:
print(e)
raise CommandError("Failure")

View File

@@ -320,7 +320,7 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get(
# Application Envs
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) # For External
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
# Unsplash Access key

View File

@@ -0,0 +1,117 @@
import requests
import re
from requests.auth import HTTPBasicAuth
from sentry_sdk import capture_exception
from urllib.parse import urlparse, urljoin
def is_allowed_hostname(hostname):
allowed_domains = [
"atl-paas.net",
"atlassian.com",
"atlassian.net",
"jira.com",
]
parsed_uri = urlparse(f"https://{hostname}")
domain = parsed_uri.netloc.split(":")[0] # Ensures no port is included
base_domain = ".".join(domain.split(".")[-2:])
return base_domain in allowed_domains
def is_valid_project_key(project_key):
if project_key:
project_key = project_key.strip().upper()
# Adjust the regular expression as needed based on your specific requirements.
if len(project_key) > 30:
return False
# Check the validity of the key as well
pattern = re.compile(r"^[A-Z0-9]{1,10}$")
return pattern.match(project_key) is not None
else:
False
def generate_valid_project_key(project_key):
return project_key.strip().upper()
def generate_url(hostname, path):
if not is_allowed_hostname(hostname):
raise ValueError("Invalid or unauthorized hostname")
return urljoin(f"https://{hostname}", path)
def jira_project_issue_summary(email, api_token, project_key, hostname):
try:
if not is_allowed_hostname(hostname):
return {"error": "Invalid or unauthorized hostname"}
if not is_valid_project_key(project_key):
return {"error": "Invalid project key"}
auth = HTTPBasicAuth(email, api_token)
headers = {"Accept": "application/json"}
# make the project key upper case
project_key = generate_valid_project_key(project_key)
# issues
issue_url = generate_url(
hostname,
f"/rest/api/3/search?jql=project={project_key} AND issuetype!=Epic",
)
issue_response = requests.request(
"GET", issue_url, headers=headers, auth=auth
).json()["total"]
# modules
module_url = generate_url(
hostname,
f"/rest/api/3/search?jql=project={project_key} AND issuetype=Epic",
)
module_response = requests.request(
"GET", module_url, headers=headers, auth=auth
).json()["total"]
# status
status_url = generate_url(
hostname, f"/rest/api/3/project/${project_key}/statuses"
)
status_response = requests.request(
"GET", status_url, headers=headers, auth=auth
).json()
# labels
labels_url = generate_url(
hostname, f"/rest/api/3/label/?jql=project={project_key}"
)
labels_response = requests.request(
"GET", labels_url, headers=headers, auth=auth
).json()["total"]
# users
users_url = generate_url(
hostname, f"/rest/api/3/users/search?jql=project={project_key}"
)
users_response = requests.request(
"GET", users_url, headers=headers, auth=auth
).json()
return {
"issues": issue_response,
"modules": module_response,
"labels": labels_response,
"states": len(status_response),
"users": (
[
user
for user in users_response
if user.get("accountType") == "atlassian"
]
),
}
except Exception as e:
capture_exception(e)
return {
"error": "Something went wrong could not fetch information from jira"
}

View File

@@ -0,0 +1,154 @@
import os
import jwt
import requests
from urllib.parse import urlparse, parse_qs
from datetime import datetime, timedelta
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.backends import default_backend
from django.conf import settings
def get_jwt_token():
app_id = os.environ.get("GITHUB_APP_ID", "")
secret = bytes(
os.environ.get("GITHUB_APP_PRIVATE_KEY", ""), encoding="utf8"
)
current_timestamp = int(datetime.now().timestamp())
due_date = datetime.now() + timedelta(minutes=10)
expiry = int(due_date.timestamp())
payload = {
"iss": app_id,
"sub": app_id,
"exp": expiry,
"iat": current_timestamp,
"aud": "https://github.com/login/oauth/access_token",
}
priv_rsakey = load_pem_private_key(secret, None, default_backend())
token = jwt.encode(payload, priv_rsakey, algorithm="RS256")
return token
def get_github_metadata(installation_id):
token = get_jwt_token()
url = f"https://api.github.com/app/installations/{installation_id}"
headers = {
"Authorization": "Bearer " + str(token),
"Accept": "application/vnd.github+json",
}
response = requests.get(url, headers=headers).json()
return response
def get_github_repos(access_tokens_url, repositories_url):
token = get_jwt_token()
headers = {
"Authorization": "Bearer " + str(token),
"Accept": "application/vnd.github+json",
}
oauth_response = requests.post(
access_tokens_url,
headers=headers,
).json()
oauth_token = oauth_response.get("token", "")
headers = {
"Authorization": "Bearer " + str(oauth_token),
"Accept": "application/vnd.github+json",
}
response = requests.get(
repositories_url,
headers=headers,
).json()
return response
def delete_github_installation(installation_id):
token = get_jwt_token()
url = f"https://api.github.com/app/installations/{installation_id}"
headers = {
"Authorization": "Bearer " + str(token),
"Accept": "application/vnd.github+json",
}
response = requests.delete(url, headers=headers)
return response
def get_github_repo_details(access_tokens_url, owner, repo):
token = get_jwt_token()
headers = {
"Authorization": "Bearer " + str(token),
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
oauth_response = requests.post(
access_tokens_url,
headers=headers,
).json()
oauth_token = oauth_response.get("token")
headers = {
"Authorization": "Bearer " + oauth_token,
"Accept": "application/vnd.github+json",
}
open_issues = requests.get(
f"https://api.github.com/repos/{owner}/{repo}",
headers=headers,
).json()["open_issues_count"]
total_labels = 0
labels_response = requests.get(
f"https://api.github.com/repos/{owner}/{repo}/labels?per_page=100&page=1",
headers=headers,
)
# Check if there are more pages
if len(labels_response.links.keys()):
# get the query parameter of last
last_url = labels_response.links.get("last").get("url")
parsed_url = urlparse(last_url)
last_page_value = parse_qs(parsed_url.query)["page"][0]
total_labels = total_labels + 100 * (int(last_page_value) - 1)
# Get labels in last page
last_page_labels = requests.get(last_url, headers=headers).json()
total_labels = total_labels + len(last_page_labels)
else:
total_labels = len(labels_response.json())
# Currently only supporting upto 100 collaborators
# TODO: Update this function to fetch all collaborators
collaborators = requests.get(
f"https://api.github.com/repos/{owner}/{repo}/collaborators?per_page=100&page=1",
headers=headers,
).json()
return open_issues, total_labels, collaborators
def get_release_notes():
token = settings.GITHUB_ACCESS_TOKEN
if token:
headers = {
"Authorization": "Bearer " + str(token),
"Accept": "application/vnd.github.v3+json",
}
else:
headers = {
"Accept": "application/vnd.github.v3+json",
}
url = "https://api.github.com/repos/makeplane/plane/releases?per_page=5&page=1"
response = requests.get(url, headers=headers)
if response.status_code != 200:
return {"error": "Unable to render information from Github Repository"}
return response.json()

View File

@@ -0,0 +1,21 @@
import os
import requests
def slack_oauth(code):
SLACK_OAUTH_URL = os.environ.get("SLACK_OAUTH_URL", False)
SLACK_CLIENT_ID = os.environ.get("SLACK_CLIENT_ID", False)
SLACK_CLIENT_SECRET = os.environ.get("SLACK_CLIENT_SECRET", False)
# Oauth Slack
if SLACK_OAUTH_URL and SLACK_CLIENT_ID and SLACK_CLIENT_SECRET:
response = requests.get(
SLACK_OAUTH_URL,
params={
"code": code,
"client_id": SLACK_CLIENT_ID,
"client_secret": SLACK_CLIENT_SECRET,
},
)
return response.json()
return {}

View File

@@ -0,0 +1,18 @@
{
email {$CERT_EMAIL}
{$CERT_ACME_DNS}
}
{$APP_PROTOCOL}://{$DOMAIN_NAME} {
request_body {
max_size {$FILE_SIZE_LIMIT}
}
reverse_proxy /spaces/* space:3000
reverse_proxy /api/* api:8000
reverse_proxy /{$BUCKET_NAME}/* plane-minio:9000
reverse_proxy /* web:3000
}

View File

@@ -0,0 +1,161 @@
x-proxy-env: &proxy-env
environment:
- DOMAIN_NAME=${DOMAIN_NAME:-localhost}
- CERT_EMAIL=${CERT_EMAIL:-admin@localhost}
- APP_PROTOCOL=${APP_PROTOCOL:-http}
- CERT_ACME_DNS=${CERT_ACME_DNS:-}
- BUCKET_NAME=${BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
- LISTEN_HTTP_PORT=${LISTEN_HTTP_PORT:-80}
- LISTEN_HTTPS_PORT=${LISTEN_HTTPS_PORT:-443}
x-app-env: &app-env
environment:
- NGINX_PORT=${NGINX_PORT:-80}
- WEB_URL=${WEB_URL:-http://localhost}
- DEBUG=${DEBUG:-0}
- SENTRY_DSN=${SENTRY_DSN:-""}
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-}
# Gunicorn Workers
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
#DB SETTINGS
- PGHOST=${PGHOST:-plane-db}
- PGDATABASE=${PGDATABASE:-plane}
- POSTGRES_USER=${POSTGRES_USER:-plane}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane}
- POSTGRES_DB=${POSTGRES_DB:-plane}
- PGDATA=${PGDATA:-/var/lib/postgresql/data}
- DATABASE_URL=${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
# REDIS SETTINGS
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://plane-redis:6379/}
# Application secret
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
# DATA STORE SETTINGS
- USE_MINIO=${USE_MINIO:-1}
- AWS_REGION=${AWS_REGION:-""}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-"access-key"}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-"secret-key"}
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-"access-key"}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-"secret-key"}
- BUCKET_NAME=${BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
services:
web:
<<: *app-env
image: registry.plane.tools/plane/web-enterprise:${APP_RELEASE_VERSION}
restart: unless-stopped
command: /usr/local/bin/start.sh web/server.js web
deploy:
replicas: ${WEB_REPLICAS:-1}
depends_on:
- api
- worker
space:
<<: *app-env
image: registry.plane.tools/plane/space-enterprise:${APP_RELEASE_VERSION}
restart: unless-stopped
command: /usr/local/bin/start.sh space/server.js space
deploy:
replicas: ${SPACE_REPLICAS:-1}
depends_on:
- api
- worker
- web
api:
<<: *app-env
image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION}
restart: unless-stopped
command: ./bin/takeoff
deploy:
replicas: ${API_REPLICAS:-1}
# volumes:
# - ${INSTALL_DIR}/logs/api:/code/plane/logs
depends_on:
- plane-db
- plane-redis
worker:
<<: *app-env
image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION}
restart: unless-stopped
command: ./bin/worker
# volumes:
# - ${INSTALL_DIR}/logs/worker:/code/plane/logs
depends_on:
- api
- plane-db
- plane-redis
beat-worker:
<<: *app-env
image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION}
restart: unless-stopped
command: ./bin/beat
# volumes:
# - ${INSTALL_DIR}/logs/beat-worker:/code/plane/logs
depends_on:
- api
- plane-db
- plane-redis
migrator:
<<: *app-env
image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION}
restart: no
command: >
sh -c "python manage.py wait_for_db &&
python manage.py migrate"
# volumes:
# - ${INSTALL_DIR}/logs/migrator:/code/plane/logs
depends_on:
- plane-db
- plane-redis
plane-db:
<<: *app-env
image: postgres:15.5-alpine
restart: unless-stopped
command: postgres -c 'max_connections=1000'
volumes:
- ${INSTALL_DIR}/data/db:/var/lib/postgresql/data
plane-redis:
<<: *app-env
image: redis:7.2.4-alpine
restart: unless-stopped
volumes:
- ${INSTALL_DIR}/data/redis:/data
plane-minio:
<<: *app-env
image: minio/minio:latest
restart: unless-stopped
command: server /export --console-address ":9090"
volumes:
- ${INSTALL_DIR}/data/minio/uploads:/export
- ${INSTALL_DIR}/data/minio/data:/data
# Comment this if you already have a reverse proxy running
proxy:
<<: *proxy-env
image: makeplane/caddy:latest
restart: unless-stopped
ports:
- ${LISTEN_HTTP_PORT:-80}:80
- ${LISTEN_HTTPS_PORT:-443}:443
volumes:
- ${INSTALL_DIR}/Caddyfile:/etc/caddy/Caddyfile
- ${INSTALL_DIR}/caddy/config:/config
- ${INSTALL_DIR}/caddy/data:/data
depends_on:
- web
- api
- space

View File

@@ -0,0 +1,156 @@
# version: "3.8"
x-app-env: &app-env
environment:
- NGINX_PORT=${NGINX_PORT:-80}
- WEB_URL=${WEB_URL:-http://localhost}
- DEBUG=${DEBUG:-0}
- SENTRY_DSN=${SENTRY_DSN:-""}
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-}
# Gunicorn Workers
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
#DB SETTINGS
- PGHOST=${PGHOST:-plane-db}
- PGDATABASE=${PGDATABASE:-plane}
- POSTGRES_USER=${POSTGRES_USER:-plane}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane}
- POSTGRES_DB=${POSTGRES_DB:-plane}
- PGDATA=${PGDATA:-/var/lib/postgresql/data}
- DATABASE_URL=${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
# REDIS SETTINGS
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://plane-redis:6379/}
# Application secret
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
# DATA STORE SETTINGS
- USE_MINIO=${USE_MINIO:-1}
- AWS_REGION=${AWS_REGION:-""}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-"access-key"}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-"secret-key"}
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-"access-key"}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-"secret-key"}
- BUCKET_NAME=${BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
services:
web:
<<: *app-env
image: registry.plane.tools/plane/web-enterprise:${APP_RELEASE_VERSION}
pull_policy: if_not_present
restart: unless-stopped
command: /usr/local/bin/start.sh web/server.js web
deploy:
replicas: ${WEB_REPLICAS:-1}
depends_on:
- api
- worker
space:
<<: *app-env
image: registry.plane.tools/plane/space-enterprise:${APP_RELEASE_VERSION}
pull_policy: if_not_present
restart: unless-stopped
command: /usr/local/bin/start.sh space/server.js space
deploy:
replicas: ${SPACE_REPLICAS:-1}
depends_on:
- api
- worker
- web
api:
<<: *app-env
image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION}
pull_policy: if_not_present
restart: unless-stopped
command: ./bin/takeoff
deploy:
replicas: ${API_REPLICAS:-1}
# volumes:
# - ${INSTALL_DIR}/logs/api:/code/plane/logs
depends_on:
- plane-db
- plane-redis
worker:
<<: *app-env
image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION}
pull_policy: if_not_present
restart: unless-stopped
command: ./bin/worker
# volumes:
# - ${INSTALL_DIR}/logs/worker:/code/plane/logs
depends_on:
- api
- plane-db
- plane-redis
beat-worker:
<<: *app-env
image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION}
pull_policy: if_not_present
restart: unless-stopped
command: ./bin/beat
# volumes:
# - ${INSTALL_DIR}/logs/beat-worker:/code/plane/logs
depends_on:
- api
- plane-db
- plane-redis
migrator:
<<: *app-env
image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION}
pull_policy: if_not_present
restart: no
command: >
sh -c "python manage.py wait_for_db &&
python manage.py migrate"
# volumes:
# - ${INSTALL_DIR}/logs/migrator:/code/plane/logs
depends_on:
- plane-db
- plane-redis
plane-db:
<<: *app-env
image: postgres:15.5-alpine
pull_policy: if_not_present
restart: unless-stopped
command: postgres -c 'max_connections=1000'
volumes:
- ${INSTALL_DIR}/data/db:/var/lib/postgresql/data
plane-redis:
<<: *app-env
image: redis:7.2.4-alpine
pull_policy: if_not_present
restart: unless-stopped
volumes:
- ${INSTALL_DIR}/data/redis:/data
plane-minio:
<<: *app-env
image: minio/minio:latest
pull_policy: if_not_present
restart: unless-stopped
command: server /export --console-address ":9090"
volumes:
- ${INSTALL_DIR}/data/minio/uploads:/export
- ${INSTALL_DIR}/data/minio/data:/data
# Comment this if you already have a reverse proxy running
proxy:
<<: *app-env
image: registry.plane.tools/plane/proxy-enterprise:${APP_RELEASE_VERSION}
pull_policy: if_not_present
restart: unless-stopped
ports:
- ${NGINX_PORT}:80
depends_on:
- web
- api
- space

View File

@@ -0,0 +1,56 @@
INSTALL_DIR=/opt/plane
WEB_REPLICAS=1
SPACE_REPLICAS=1
API_REPLICAS=1
NGINX_PORT=80
LISTEN_HTTP_PORT=80
LISTEN_HTTPS_PORT=443
APP_PROTOCOL=http
# If SSL Cert to be generated, set CERT_EMAIL and APP_PROTOCOL to https
CERT_EMAIL=admin@localhost
# For DNS Challenge based certificate generation, set the CERT_ACME_DNS
# CERT_ACME_DNS=acme_dns CERT_DNS_PROVIDER CERT_DNS_PROVIDER_API_KEY
CERT_ACME_DNS=
WEB_URL=http://localhost
DEBUG=0
SENTRY_DSN=
SENTRY_ENVIRONMENT=production
CORS_ALLOWED_ORIGINS=http://localhost
#DB SETTINGS
PGHOST=plane-db
PGDATABASE=plane
POSTGRES_USER=plane
POSTGRES_PASSWORD=plane
POSTGRES_DB=plane
PGDATA=/var/lib/postgresql/data
DATABASE_URL=
# REDIS SETTINGS
REDIS_HOST=plane-redis
REDIS_PORT=6379
REDIS_URL=
# Secret Key
SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
# DATA STORE SETTINGS
USE_MINIO=1
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
BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880
# Gunicorn Workers
GUNICORN_WORKERS=2

85
generate_release_notes.sh Normal file
View File

@@ -0,0 +1,85 @@
#!/bin/bash
# Initialize temporary files for each category
FEATURES_FILE=$(mktemp)
IMPROVEMENTS_FILE=$(mktemp)
BUGS_FILE=$(mktemp)
OTHERS_FILE=$(mktemp)
FEATURES_COUNT=0
IMPROVEMENTS_COUNT=0
BUGS_COUNT=0
OTHERS_COUNT=0
# Check if there are any tags in the repository
if git describe --tags --abbrev=0 > /dev/null 2>&1; then
# Fetch all commits from the last tag to HEAD
COMMITS=$(git log $(git describe --tags --abbrev=0)..HEAD --pretty=format:"%s|%h")
else
# If no tags are found, list all commits
COMMITS=$(git log --pretty=format:"%s|%h")
fi
# Save IFS and set it to newline to handle commits correctly
OLD_IFS=$IFS
IFS=$'\n'
# Loop through each commit to categorize
for commit in $COMMITS; do
IFS="|" read -r commit_message hash <<< "$commit"
# Normalize commit message to handle case sensitivity
normalized_message=$(echo "$commit_message" | tr '[:upper:]' '[:lower:]')
# Skip commits that start with "merge" or "chore" or do not contain a PR number
if echo "$normalized_message" | grep -qE '^(merge|chore)'; then
continue
fi
# Extract PR number if present
PR_NUMBER=$(echo $commit_message | grep -o -E "#[0-9]+" || echo "")
if [[ -z "$PR_NUMBER" ]]; then
continue # Skip commits without a PR number
fi
# Format the commit message
CLEAN_MESSAGE=$(echo $commit_message | sed -E "s/#[0-9]+//; s/^(feat|refactor|fix|chore): //I; s/^([Ff]eat|[Rr]efactor|[Ff]ix|[Cc]hore) //I; s/^\[.*\] //; s/[:\-] / /; s/\(\) //")
CLEAN_MESSAGE="$(tr '[:lower:]' '[:upper:]' <<< ${CLEAN_MESSAGE:0:1})${CLEAN_MESSAGE:1}."
CLEAN_MESSAGE=$(echo $CLEAN_MESSAGE | sed 's/()//g') # Remove empty brackets
# Categorize and limit the number of commits under each heading
if [[ $FEATURES_COUNT -lt 30 && $normalized_message =~ ^feat ]]; then
echo "- $CLEAN_MESSAGE $PR_NUMBER" >> "$FEATURES_FILE"
((FEATURES_COUNT++))
elif [[ $IMPROVEMENTS_COUNT -lt 30 && $normalized_message =~ ^refactor ]]; then
echo "- $CLEAN_MESSAGE $PR_NUMBER" >> "$IMPROVEMENTS_FILE"
((IMPROVEMENTS_COUNT++))
elif [[ $BUGS_COUNT -lt 30 && $normalized_message =~ ^fix ]]; then
echo "- $CLEAN_MESSAGE $PR_NUMBER" >> "$BUGS_FILE"
((BUGS_COUNT++))
elif [[ $OTHERS_COUNT -lt 30 ]]; then
echo "- $CLEAN_MESSAGE $PR_NUMBER" >> "$OTHERS_FILE"
((OTHERS_COUNT++))
fi
done
# Restore IFS
IFS=$OLD_IFS
# Generate the release notes by concatenating the temporary files
{
echo '## What Changed'
echo "## Features"
cat "$FEATURES_FILE"
echo "## Improvements"
cat "$IMPROVEMENTS_FILE"
echo "## Bugs"
cat "$BUGS_FILE"
echo "## Others"
cat "$OTHERS_FILE"
} > RELEASE_NOTES.md
# Clean up temporary files
rm "$FEATURES_FILE" "$IMPROVEMENTS_FILE" "$BUGS_FILE" "$OTHERS_FILE"
echo "Release notes generated in RELEASE_NOTES.md"

View File

@@ -384,3 +384,7 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
margin-top: 0;
}
/* end tailwind typography */
.ProseMirror .issue-embed img {
margin: 0 !important;
}

View File

@@ -14,10 +14,7 @@ export function CoreEditorProps(editorClassName: string): EditorProps {
// prevent default event listeners from firing when slash command is active
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
console.log("registered");
return true;
}
if (slashCommand) return true;
}
},
},

View File

@@ -7,3 +7,5 @@ export { useEditorMarkings } from "src/hooks/use-editor-markings";
export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core";
export type { IMarking } from "src/types/editor-types";
export type { TEmbedItem } from "src/ui/extensions/widgets/issue-embed/block/types";

View File

@@ -0,0 +1,70 @@
import Placeholder from "@tiptap/extension-placeholder";
// plane imports
import { SlashCommand, DragAndDrop } from "@plane/editor-extensions";
import { UploadImage, ISlashCommandItem } from "@plane/editor-core";
// ui
import { LayersIcon } from "@plane/ui";
import { IssueEmbedSuggestions, IssueWidget, IssueListRenderer, TIssueEmbedConfig } from "src/ui/extensions";
export const DocumentEditorExtensions = (
uploadFile: UploadImage,
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void,
issueEmbedConfig?: TIssueEmbedConfig
) => {
const additionalOptions: ISlashCommandItem[] = [
{
key: "issue_embed",
title: "Issue embed",
description: "Embed an issue from the project.",
searchTerms: ["issue", "link", "embed"],
icon: <LayersIcon className="h-3.5 w-3.5" />,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.insertContentAt(
range,
"<p class='text-sm bg-gray-300 w-fit pl-3 pr-3 pt-1 pb-1 rounded shadow-sm'>#issue_</p>"
)
.run();
},
},
];
const extensions = [
SlashCommand(uploadFile, additionalOptions),
DragAndDrop(setHideDragHandle),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
return "";
}
return "Press '/' for commands...";
},
includeChildren: true,
}),
];
if (issueEmbedConfig) {
extensions.push(
// TODO: check this
// @ts-expect-error resolve this
IssueWidget({
widgetCallback: issueEmbedConfig.widgetCallback,
}).configure({
issueEmbedConfig,
}),
IssueEmbedSuggestions.configure({
suggestion: {
render: () => IssueListRenderer(issueEmbedConfig.searchCallback),
},
})
);
}
return extensions;
};

View File

@@ -0,0 +1,2 @@
export * from "./widgets";
export * from "./extensions";

View File

@@ -1,15 +0,0 @@
import { IssueWidgetPlaceholder } from "src/ui/extensions/widgets/issue-embed-widget";
import { SlashCommand, DragAndDrop } from "@plane/editor-extensions";
import { UploadImage } from "@plane/editor-core";
type TArguments = {
uploadFile: UploadImage;
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
};
export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle }: TArguments) => [
SlashCommand(uploadFile),
DragAndDrop(setHideDragHandle),
IssueWidgetPlaceholder(),
];

View File

@@ -0,0 +1 @@
export * from "./issue-embed";

View File

@@ -1,54 +0,0 @@
import { Editor, Range } from "@tiptap/react";
import { IssueEmbedSuggestions } from "src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension";
import { getIssueSuggestionItems } from "src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-items";
import { IssueListRenderer } from "src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer";
import { v4 as uuidv4 } from "uuid";
export type CommandProps = {
editor: Editor;
range: Range;
};
export interface IIssueListSuggestion {
title: string;
priority: "high" | "low" | "medium" | "urgent";
identifier: string;
state: "Cancelled" | "In Progress" | "Todo" | "Done" | "Backlog";
command: ({ editor, range }: CommandProps) => void;
}
export const IssueSuggestions = (suggestions: any[]) => {
const mappedSuggestions: IIssueListSuggestion[] = suggestions.map((suggestion): IIssueListSuggestion => {
const transactionId = uuidv4();
return {
title: suggestion.name,
priority: suggestion.priority.toString(),
identifier: `${suggestion.project_detail.identifier}-${suggestion.sequence_id}`,
state: suggestion.state_detail && suggestion.state_detail.name ? suggestion.state_detail.name : "Todo",
command: ({ editor, range }) => {
editor
.chain()
.focus()
.insertContentAt(range, {
type: "issue-embed-component",
attrs: {
entity_identifier: suggestion.id,
id: transactionId,
title: suggestion.name,
project_identifier: suggestion.project_detail.identifier,
sequence_id: suggestion.sequence_id,
entity_name: "issue",
},
})
.run();
},
};
});
return IssueEmbedSuggestions.configure({
suggestion: {
items: getIssueSuggestionItems(mappedSuggestions),
render: IssueListRenderer,
},
});
};

View File

@@ -1,15 +0,0 @@
import { IIssueListSuggestion } from "src/ui/extensions/widgets/issue-embed-suggestion-list";
export const getIssueSuggestionItems =
(issueSuggestions: Array<IIssueListSuggestion>) =>
({ query }: { query: string }) => {
const search = query.toLowerCase();
const filteredSuggestions = issueSuggestions.filter(
(item) =>
item.title.toLowerCase().includes(search) ||
item.identifier.toLowerCase().includes(search) ||
item.priority.toLowerCase().includes(search)
);
return filteredSuggestions;
};

View File

@@ -1,254 +0,0 @@
import { cn } from "@plane/editor-core";
import { Editor } from "@tiptap/core";
import tippy from "tippy.js";
import { ReactRenderer } from "@tiptap/react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { PriorityIcon } from "@plane/ui";
const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
const itemHeight = item ? item.offsetHeight : 0;
const top = item.offsetTop;
const bottom = top + itemHeight;
if (top < container.scrollTop) {
// container.scrollTop = top - containerHeight;
item.scrollIntoView({
behavior: "smooth",
block: "center",
});
} else if (bottom > containerHeight + container.scrollTop) {
// container.scrollTop = bottom - containerHeight;
item.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
};
interface IssueSuggestionProps {
title: string;
priority: "high" | "low" | "medium" | "urgent" | "none";
state: "Cancelled" | "In Progress" | "Todo" | "Done" | "Backlog";
identifier: string;
}
const IssueSuggestionList = ({
items,
command,
editor,
}: {
items: IssueSuggestionProps[];
command: any;
editor: Editor;
range: any;
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const [currentSection, setCurrentSection] = useState<string>("Backlog");
const sections = ["Backlog", "In Progress", "Todo", "Done", "Cancelled"];
const [displayedItems, setDisplayedItems] = useState<{
[key: string]: IssueSuggestionProps[];
}>({});
const [displayedTotalLength, setDisplayedTotalLength] = useState(0);
const commandListContainer = useRef<HTMLDivElement>(null);
useEffect(() => {
const newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {};
let totalLength = 0;
sections.forEach((section) => {
newDisplayedItems[section] = items.filter((item) => item.state === section).slice(0, 5);
totalLength += newDisplayedItems[section].length;
});
setDisplayedTotalLength(totalLength);
setDisplayedItems(newDisplayedItems);
}, [items]);
const selectItem = useCallback(
(section: string, index: number) => {
const item = displayedItems[section][index];
if (item) {
command(item);
}
},
[command, displayedItems, currentSection]
);
useEffect(() => {
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"];
const onKeyDown = (e: KeyboardEvent) => {
if (navigationKeys.includes(e.key)) {
// if (editor.isFocused) {
// editor.chain().blur();
// commandListContainer.current?.focus();
// }
if (e.key === "ArrowUp") {
setSelectedIndex(
(selectedIndex + displayedItems[currentSection].length - 1) % displayedItems[currentSection].length
);
return true;
}
if (e.key === "ArrowDown") {
const nextIndex = (selectedIndex + 1) % displayedItems[currentSection].length;
setSelectedIndex(nextIndex);
if (nextIndex === 4) {
const nextItems = items
.filter((item) => item.state === currentSection)
.slice(displayedItems[currentSection].length, displayedItems[currentSection].length + 5);
setDisplayedItems((prevItems) => ({
...prevItems,
[currentSection]: [...prevItems[currentSection], ...nextItems],
}));
}
return true;
}
if (e.key === "Enter") {
selectItem(currentSection, selectedIndex);
return true;
}
if (e.key === "Tab") {
const currentSectionIndex = sections.indexOf(currentSection);
const nextSectionIndex = (currentSectionIndex + 1) % sections.length;
setCurrentSection(sections[nextSectionIndex]);
setSelectedIndex(0);
return true;
}
return false;
} else if (e.key === "Escape") {
if (!editor.isFocused) {
editor.chain().focus();
}
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [displayedItems, selectedIndex, setSelectedIndex, selectItem, currentSection]);
useLayoutEffect(() => {
const container = commandListContainer?.current;
if (container) {
const sectionContainer = container?.querySelector(`#${currentSection}-container`) as HTMLDivElement;
if (sectionContainer) {
updateScrollView(container, sectionContainer);
}
const sectionScrollContainer = container?.querySelector(`#${currentSection}`) as HTMLElement;
const item = sectionScrollContainer?.children[selectedIndex] as HTMLElement;
if (item && sectionScrollContainer) {
updateScrollView(sectionScrollContainer, item);
}
}
}, [selectedIndex, currentSection]);
return displayedTotalLength > 0 ? (
<div
id="issue-list-container"
ref={commandListContainer}
className=" fixed z-[10] max-h-80 w-96 overflow-y-auto overflow-x-hidden rounded-md border border-custom-border-100 bg-custom-background-100 px-1 shadow-custom-shadow-xs transition-all"
>
{sections.map((section) => {
const sectionItems = displayedItems[section];
return (
sectionItems &&
sectionItems.length > 0 && (
<div className={"flex h-full w-full flex-col"} key={`${section}-container`} id={`${section}-container`}>
<h6
className={
"sticky top-0 z-[10] bg-custom-background-100 px-2 py-1 text-xs font-medium text-custom-text-400"
}
>
{section}
</h6>
<div key={section} id={section} className={"max-h-[140px] overflow-x-hidden overflow-y-scroll"}>
{sectionItems.map((item: IssueSuggestionProps, index: number) => (
<button
className={cn(
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
{
"bg-custom-primary-100/5 text-custom-text-100":
section === currentSection && index === selectedIndex,
}
)}
key={item.identifier}
onClick={() => selectItem(section, index)}
>
<h5 className="whitespace-nowrap text-xs text-custom-text-300">{item.identifier}</h5>
<PriorityIcon priority={item.priority} />
<div className="w-full truncate">
<p className="flex-grow w-full truncate text-xs">{item.title}</p>
</div>
</button>
))}
</div>
</div>
)
);
})}
</div>
) : null;
};
export const IssueListRenderer = () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
const container = document.querySelector(".frame-renderer") as HTMLElement;
component = new ReactRenderer(IssueSuggestionList, {
props,
// @ts-ignore
editor: props.editor,
});
// @ts-ignore
popup = tippy(".frame-renderer", {
flipbehavior: ["bottom", "top"],
appendTo: () => document.querySelector(".frame-renderer") as HTMLElement,
flip: true,
flipOnUpdate: true,
getReferenceClientRect: props.clientRect,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
container.addEventListener("scroll", () => {
popup?.[0].destroy();
});
},
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component?.updateProps(props);
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"];
if (navigationKeys.includes(props.event.key)) {
// @ts-ignore
component?.ref?.onKeyDown(props);
return true;
}
return false;
},
onExit: (e) => {
const container = document.querySelector(".frame-renderer") as HTMLElement;
if (container) {
container.removeEventListener("scroll", () => {});
}
popup?.[0].destroy();
setTimeout(() => {
component?.destroy();
}, 300);
},
};
};

View File

@@ -1,3 +0,0 @@
import { IssueWidget } from "src/ui/extensions/widgets/issue-embed-widget/issue-widget-node";
export const IssueWidgetPlaceholder = () => IssueWidget.configure({});

View File

@@ -1,33 +0,0 @@
// @ts-nocheck
import { Button } from "@plane/ui";
import { NodeViewWrapper } from "@tiptap/react";
import { Crown } from "lucide-react";
export const IssueWidgetCard = (props) => (
<NodeViewWrapper className="issue-embed-component m-2">
<div
className={`${
props.selected ? "border-custom-primary-200 border-[2px]" : ""
} w-full h-[100px] cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 shadow-custom-shadow-2xs`}
>
<h5 className="h-[20%] text-xs text-custom-text-300 p-2">
{props.node.attrs.project_identifier}-{props.node.attrs.sequence_id}
</h5>
<div className="relative h-[71%]">
<div className="h-full backdrop-filter backdrop-blur-[30px] bg-custom-background-80 bg-opacity-30 flex items-center w-full justify-between gap-5 mt-2.5 pl-4 pr-5 py-3 max-md:max-w-full max-md:flex-wrap relative">
<div className="flex gap-2 items-center">
<div className="rounded">
<Crown className="m-2" size={16} color="#FFBA18" />
</div>
<div className="text-custom-text text-sm">
Embed and access issues in pages seamlessly, upgrade to plane pro now.
</div>
</div>
<a href="https://plane.so/pricing" target="_blank" rel="noreferrer">
<Button>Upgrade</Button>
</a>
</div>
</div>
</div>
</NodeViewWrapper>
);

View File

@@ -1,63 +0,0 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { IssueWidgetCard } from "src/ui/extensions/widgets/issue-embed-widget/issue-widget-card";
import { ReactNodeViewRenderer } from "@tiptap/react";
export const IssueWidget = Node.create({
name: "issue-embed-component",
group: "block",
atom: true,
addAttributes() {
return {
id: {
default: null,
},
class: {
default: "w-[600px]",
},
title: {
default: null,
},
entity_name: {
default: null,
},
entity_identifier: {
default: null,
},
project_identifier: {
default: null,
},
sequence_id: {
default: null,
},
};
},
addNodeView() {
return ReactNodeViewRenderer((props: Object) => <IssueWidgetCard {...props} />);
},
parseHTML() {
return [
{
tag: "issue-embed-component",
getAttrs: (node: string | HTMLElement) => {
if (typeof node === "string") {
return null;
}
return {
id: node.getAttribute("id") || "",
title: node.getAttribute("title") || "",
entity_name: node.getAttribute("entity_name") || "",
entity_identifier: node.getAttribute("entity_identifier") || "",
project_identifier: node.getAttribute("project_identifier") || "",
sequence_id: node.getAttribute("sequence_id") || "",
};
},
},
];
},
renderHTML({ HTMLAttributes }) {
return ["issue-embed-component", mergeAttributes(HTMLAttributes)];
},
});

View File

@@ -0,0 +1,2 @@
export * from "./issue-widget-node";
export * from "./types";

View File

@@ -0,0 +1,54 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
type Props = {
widgetCallback: (issueId: string) => React.ReactNode;
};
export const IssueWidget = (props: Props) =>
Node.create({
name: "issue-embed-component",
group: "block",
atom: true,
addAttributes() {
return {
entity_identifier: {
default: undefined,
},
id: {
default: undefined,
},
};
},
addNodeView() {
return ReactNodeViewRenderer((issueProps: any) => (
<NodeViewWrapper>{props.widgetCallback(issueProps.node.attrs.entity_identifier)}</NodeViewWrapper>
));
},
parseHTML() {
return [
{
tag: "issue-embed-component",
getAttrs: (node: string | HTMLElement) => {
if (typeof node === "string") {
return null;
}
return {
id: node.getAttribute("id") || "",
title: node.getAttribute("title") || "",
entity_name: node.getAttribute("entity_name") || "",
entity_identifier: node.getAttribute("entity_identifier") || "",
project_identifier: node.getAttribute("project_identifier") || "",
sequence_id: node.getAttribute("sequence_id") || "",
};
},
},
];
},
renderHTML({ HTMLAttributes }) {
return ["issue-embed-component", mergeAttributes(HTMLAttributes)];
},
});

View File

@@ -0,0 +1,19 @@
export type TEmbedConfig = {
issue?: TIssueEmbedConfig;
};
export type TReadOnlyEmbedConfig = {
issue?: Omit<TIssueEmbedConfig, "searchCallback">;
};
export type TIssueEmbedConfig = {
searchCallback: (searchQuery: string) => Promise<TEmbedItem[]>;
widgetCallback: (issueId: string) => React.ReactNode;
};
export type TEmbedItem = {
id: string;
title: string;
subTitle: string;
icon: React.ReactNode;
};

View File

@@ -0,0 +1,2 @@
export * from "./block";
export * from "./suggestions-list";

View File

@@ -0,0 +1,2 @@
export * from "./issue-suggestion-extension";
export * from "./issue-suggestion-renderer";

View File

@@ -0,0 +1,188 @@
import { useCallback, useEffect, useState } from "react";
import { Editor } from "@tiptap/core";
import { ReactRenderer, Range } from "@tiptap/react";
import tippy from "tippy.js";
import { v4 as uuidv4 } from "uuid";
// core
import { cn } from "@plane/editor-core";
// types
import { TEmbedItem } from "src/ui/extensions";
type TSuggestionsListProps = {
editor: Editor;
searchCallback: (searchQuery: string) => Promise<TEmbedItem[]>;
query: string;
range: Range;
};
const IssueSuggestionList = (props: TSuggestionsListProps) => {
const { editor, searchCallback, query, range } = props;
// states
const [items, setItems] = useState<TEmbedItem[] | undefined>(undefined);
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = useCallback(
(item: TEmbedItem) => {
try {
const docSize = editor.state.doc.content.size;
if (range.from < 0 || range.to >= docSize) return;
const transactionId = uuidv4();
editor
.chain()
.focus()
.insertContentAt(range, {
type: "issue-embed-component",
attrs: {
entity_identifier: item?.id,
id: transactionId,
},
})
.run();
} catch (error) {
console.log("Error inserting issue embed", error);
}
},
[editor, range]
);
useEffect(() => {
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"];
const onKeyDown = (e: KeyboardEvent) => {
if (!items) return;
if (navigationKeys.includes(e.key)) {
if (e.key === "ArrowUp") {
const newIndex = selectedIndex - 1;
setSelectedIndex(newIndex < 0 ? items.length - 1 : newIndex);
return true;
}
if (e.key === "ArrowDown") {
const newIndex = selectedIndex + 1;
setSelectedIndex(newIndex >= items.length ? 0 : newIndex);
return true;
}
if (e.key === "Enter") {
const item = items[selectedIndex];
selectItem(item);
return true;
}
return false;
} else if (e.key === "Escape") {
if (!editor.isFocused) editor.chain().focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [editor, items, selectedIndex, setSelectedIndex, selectItem]);
useEffect(() => {
setItems(undefined);
searchCallback(query).then((data) => {
setItems(data);
});
}, [query, searchCallback]);
return (
<div
id="issue-list-container"
className="z-10 overflow-y-auto overflow-x-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 max-h-60 w-96 px-2 py-2.5 shadow-custom-shadow-rg whitespace-nowrap transition-all"
>
{items ? (
items.length > 0 ? (
items.map((item, index) => (
<button
key={item.id}
type="button"
className={cn(
"w-full flex items-center gap-2 select-none truncate rounded px-1 py-1.5 text-left text-xs text-custom-text-200 hover:bg-custom-background-90",
{
"bg-custom-background-90": index === selectedIndex,
}
)}
onClick={() => selectItem(item)}
>
<h5 className="whitespace-nowrap text-xs text-custom-text-300 flex-shrink-0">{item.subTitle}</h5>
{item.icon}
<p className="flex-grow w-full truncate text-xs">{item.title}</p>
</button>
))
) : (
<div className="text-center text-xs text-custom-text-400">No results found</div>
)
) : (
<div className="text-center text-xs text-custom-text-400">Loading</div>
)}
</div>
);
};
export const IssueListRenderer = (searchCallback: (searchQuery: string) => Promise<TEmbedItem[]>) => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
const tippyContainer = document.querySelector(".active-editor") ?? document.querySelector("#editor-container");
component = new ReactRenderer(IssueSuggestionList, {
props: {
...props,
searchCallback,
},
editor: props.editor,
});
// @ts-expect-error Tippy overloads are messed up
popup = tippy("body", {
flipbehavior: ["bottom", "top"],
appendTo: tippyContainer,
flip: true,
flipOnUpdate: true,
getReferenceClientRect: props.clientRect,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
tippyContainer?.addEventListener("scroll", () => {
popup?.[0].destroy();
});
},
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component?.updateProps(props);
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"];
if (navigationKeys.includes(props.event.key)) {
// @ts-expect-error fix the types
component?.ref?.onKeyDown(props);
return true;
}
return false;
},
onExit: () => {
const container = document.querySelector(".frame-renderer") as HTMLElement;
if (container) {
container.removeEventListener("scroll", () => {});
}
popup?.[0].destroy();
setTimeout(() => {
component?.destroy();
}, 300);
},
};
};

View File

@@ -9,8 +9,8 @@ import {
IMentionHighlight,
IMentionSuggestion,
} from "@plane/editor-core";
import { DocumentEditorExtensions } from "src/ui/extensions";
import { PageRenderer } from "src/ui/components/page-renderer";
import { DocumentEditorExtensions, TEmbedConfig } from "src/ui/extensions";
interface IDocumentEditor {
initialValue: string;
@@ -31,6 +31,8 @@ interface IDocumentEditor {
suggestions: () => Promise<IMentionSuggestion[]>;
};
tabIndex?: number;
// embed configuration
embedHandler?: TEmbedConfig;
placeholder?: string | ((isFocused: boolean, value: string) => string);
}
@@ -46,6 +48,7 @@ const DocumentEditor = (props: IDocumentEditor) => {
handleEditorReady,
forwardedRef,
tabIndex,
embedHandler,
placeholder,
} = props;
// states
@@ -71,10 +74,7 @@ const DocumentEditor = (props: IDocumentEditor) => {
handleEditorReady,
forwardedRef,
mentionHandler,
extensions: DocumentEditorExtensions({
uploadFile: fileHandler.upload,
setHideDragHandle: setHideDragHandleFunction,
}),
extensions: DocumentEditorExtensions(fileHandler.upload, setHideDragHandleFunction, embedHandler?.issue),
placeholder,
tabIndex,
});

View File

@@ -2,7 +2,7 @@ import { forwardRef, MutableRefObject } from "react";
import { EditorReadOnlyRefApi, getEditorClassNames, IMentionHighlight, useReadOnlyEditor } from "@plane/editor-core";
// components
import { PageRenderer } from "src/ui/components/page-renderer";
import { IssueWidgetPlaceholder } from "../extensions/widgets/issue-embed-widget";
import { IssueWidget, TReadOnlyEmbedConfig } from "src/ui/extensions";
interface IDocumentReadOnlyEditor {
initialValue: string;
@@ -14,6 +14,7 @@ interface IDocumentReadOnlyEditor {
highlights: () => Promise<IMentionHighlight[]>;
};
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
embedHandler?: TReadOnlyEmbedConfig;
}
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
@@ -25,6 +26,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
tabIndex,
handleEditorReady,
mentionHandler,
embedHandler,
} = props;
const editor = useReadOnlyEditor({
initialValue,
@@ -32,7 +34,15 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
mentionHandler,
forwardedRef,
handleEditorReady,
extensions: [IssueWidgetPlaceholder()],
extensions: embedHandler?.issue
? [
IssueWidget({
widgetCallback: embedHandler?.issue?.widgetCallback,
}).configure({
issueEmbedConfig: embedHandler?.issue,
}),
]
: [],
});
if (!editor) {

View File

@@ -64,15 +64,24 @@
box-shadow: none;
}
.ProseMirror:not(.dragging) .ProseMirror-selectednode::after {
content: "";
position: absolute;
top: 0;
left: -5px;
height: 100%;
width: 100%;
background-color: rgba(var(--color-primary-100), 0.2);
border-radius: 4px;
.ProseMirror:not(.dragging) .ProseMirror-selectednode {
--horizontal-offset: 5px;
&:has(.issue-embed) {
--horizontal-offset: 0px;
}
&::after {
content: "";
position: absolute;
top: 0;
left: calc(-1 * var(--horizontal-offset));
height: 100%;
width: calc(100% + (var(--horizontal-offset) * 2));
background-color: rgba(var(--color-primary-100), 0.2);
border-radius: 4px;
pointer-events: none;
}
}
.ProseMirror img {

5
packages/types/src/active-cycle.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
import type { IProjectLite, ICycle } from "@plane/types";
export interface IActiveCycle extends ICycle {
project_detail: IProjectLite;
}

View File

@@ -28,3 +28,5 @@ export * from "./instance";
export * from "./app";
export * from "./common";
export * from "./pragmatic";
// enterprise
export * from "./active-cycle";

View File

@@ -1,4 +1,5 @@
import { EPageAccess } from "./enums";
import { TIssuePriorities } from "./issues";
export type TPage = {
access: EPageAccess | undefined;
@@ -43,3 +44,15 @@ export type TPageFilters = {
sortBy: TPageFiltersSortBy;
filters?: TPageFilterProps;
};
export type TPageEmbedType = "mention" | "issue";
export type TPageEmbedResponse = {
id: string;
name: string;
priority: TIssuePriorities;
project__identifier: string;
project_id: string;
sequence_id: string;
state_id: string;
};

View File

@@ -68,6 +68,12 @@ export interface IProjectLite {
id: string;
name: string;
identifier: string;
emoji: string | null;
logo_props: TProjectLogoProps;
icon_prop: {
name: string;
color: string;
} | null;
}
type ProjectPreferences = {

View File

@@ -1,5 +1,6 @@
import { EUserWorkspaceRoles } from "@/constants/workspace";
import type {
ICycle,
IProjectMember,
IUser,
IUserLite,
@@ -182,3 +183,14 @@ export interface IProductUpdateResponse {
eyes: number;
};
}
export interface IWorkspaceActiveCyclesResponse {
count: number;
extra_stats: null;
next_cursor: string;
next_page_results: boolean;
prev_cursor: string;
prev_page_results: boolean;
results: ICycle[];
total_pages: number;
}

View File

@@ -17,6 +17,8 @@
"NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_POSTHOG_HOST",
"NEXT_PUBLIC_POSTHOG_DEBUG",
"NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL",
"NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL",
"SENTRY_AUTH_TOKEN"
],
"pipeline": {

View File

@@ -0,0 +1,38 @@
import { FC } from "react";
// types
import { IActiveCycle } from "@plane/types";
// components
import {
ActiveCyclesProjectTitle,
ActiveCycleHeader,
ActiveCycleProgress,
ActiveCycleProductivity,
ActiveCycleStats,
} from "@/components/active-cycles";
export type ActiveCycleInfoCardProps = {
cycle: IActiveCycle;
workspaceSlug: string;
projectId: string;
};
export const ActiveCycleInfoCard: FC<ActiveCycleInfoCardProps> = (props) => {
const { cycle, workspaceSlug, projectId } = props;
return (
<div
key={cycle.id}
className="flex flex-col gap-4 p-4 rounded-xl border border-custom-border-200 bg-custom-background-100"
>
<ActiveCyclesProjectTitle project={cycle.project_detail} />
<ActiveCycleHeader cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2 xl:grid-cols-3">
<ActiveCycleProgress cycle={cycle} />
<ActiveCycleProductivity cycle={cycle} />
<ActiveCycleStats cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
</div>
</div>
);
};

View File

@@ -0,0 +1,284 @@
import { FC, Fragment } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import useSWR from "swr";
// icons
import { CalendarCheck } from "lucide-react";
// headless ui
import { Tab } from "@headlessui/react";
// types
import { IActiveCycle } from "@plane/types";
// ui
import { Tooltip, Loader, PriorityIcon, Avatar } from "@plane/ui";
// components
import { SingleProgressStats } from "@/components/core";
import { StateDropdown } from "@/components/dropdowns";
// constants
import { EmptyStateType } from "@/constants/empty-state";
import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys";
import { EIssuesStoreType } from "@/constants/issue";
// helpers
import { cn } from "@/helpers/common.helper";
import { renderFormattedDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper";
// hooks
import { useIssues, useProjectState } from "@/hooks/store";
import useLocalStorage from "@/hooks/use-local-storage";
import { EmptyState } from "../empty-state";
export type ActiveCycleStatsProps = {
workspaceSlug: string;
projectId: string;
cycle: IActiveCycle;
};
export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
const { workspaceSlug, projectId, cycle } = props;
const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees");
const currentValue = (tab: string | null) => {
switch (tab) {
case "Priority-Issues":
return 0;
case "Assignees":
return 1;
case "Labels":
return 2;
default:
return 0;
}
};
const {
issues: { fetchActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE);
const { fetchWorkspaceStates } = useProjectState();
const { data: activeCycleIssues } = useSWR(
workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES_WITH_PARAMS(cycle.id, { priority: "urgent,high" }) : null,
workspaceSlug && projectId && cycle.id ? () => fetchActiveCycleIssues(workspaceSlug, projectId, cycle.id) : null
);
useSWR(
workspaceSlug ? `WORKSPACE_STATES_${workspaceSlug}` : null,
workspaceSlug ? () => fetchWorkspaceStates(workspaceSlug.toString()) : null
);
const cycleIssues = activeCycleIssues ?? [];
return (
<div className="flex flex-col gap-4 p-4 min-h-[17rem] overflow-hidden col-span-1 lg:col-span-2 xl:col-span-1 border border-custom-border-200 rounded-lg">
<Tab.Group
as={Fragment}
defaultIndex={currentValue(tab)}
onChange={(i) => {
switch (i) {
case 0:
return setTab("Priority-Issues");
case 1:
return setTab("Assignees");
case 2:
return setTab("Labels");
default:
return setTab("Priority-Issues");
}
}}
>
<Tab.List
as="div"
className="relative border-[0.5px] border-custom-border-200 rounded bg-custom-background-80 p-[1px] grid"
style={{
gridTemplateColumns: `repeat(3, 1fr)`,
}}
>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
{
"text-custom-text-300 bg-custom-background-100": selected,
"hover:text-custom-text-300": !selected,
}
)
}
>
Priority Issues
</Tab>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
{
"text-custom-text-300 bg-custom-background-100": selected,
"hover:text-custom-text-300": !selected,
}
)
}
>
Assignees
</Tab>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
{
"text-custom-text-300 bg-custom-background-100": selected,
"hover:text-custom-text-300": !selected,
}
)
}
>
Labels
</Tab>
</Tab.List>
<Tab.Panels as={Fragment}>
<Tab.Panel
as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
<div className="flex flex-col gap-1 h-full w-full overflow-y-auto vertical-scrollbar scrollbar-sm">
{cycleIssues ? (
cycleIssues.length > 0 ? (
cycleIssues.map((issue: any) => (
<Link
key={issue.id}
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
className="group flex cursor-pointer items-center justify-between gap-2 rounded-md hover:bg-custom-background-90 p-1"
>
<div className="flex items-center gap-1.5 flex-grow w-full min-w-24 truncate">
<PriorityIcon priority={issue.priority} withContainer size={12} />
<Tooltip
tooltipHeading="Issue ID"
tooltipContent={`${cycle.project_detail?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-custom-text-200">
{cycle.project_detail?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-[0.825rem] text-custom-text-100 truncate">{issue.name}</span>
</Tooltip>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<StateDropdown
value={issue.state_id ?? undefined}
onChange={() => {}}
projectId={projectId?.toString() ?? ""}
disabled
buttonVariant="background-with-text"
buttonContainerClassName="cursor-pointer max-w-24"
showTooltip
/>
{issue.target_date && (
<Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)}>
<div className="h-full flex truncate items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80 group-hover:bg-custom-background-100 cursor-pointer">
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
<span className="text-xs truncate">
{renderFormattedDateWithoutYear(issue.target_date)}
</span>
</div>
</Tooltip>
)}
</div>
</Link>
))
) : (
<div className="flex items-center justify-center h-full w-full">
<EmptyState
type={EmptyStateType.ACTIVE_CYCLE_PRIORITY_ISSUE_EMPTY_STATE}
layout="screen-simple"
size="sm"
/>
</div>
)
) : (
<Loader className="space-y-3">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</div>
</Tab.Panel>
<Tab.Panel
as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
{cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? (
cycle.distribution.assignees.map((assignee, index) => {
if (assignee.assignee_id)
return (
<SingleProgressStats
key={assignee.assignee_id}
title={
<div className="flex items-center gap-2">
<Avatar name={assignee?.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
<span>{assignee.display_name}</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
/>
);
else
return (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
</div>
<span>No assignee</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
/>
);
})
) : (
<div className="flex items-center justify-center h-full w-full">
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_ASSIGNEE_EMPTY_STATE} layout="screen-simple" size="sm" />
</div>
)}
</Tab.Panel>
<Tab.Panel
as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
{cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? (
cycle.distribution.labels.map((label, index) => (
<SingleProgressStats
key={label.label_id ?? `no-label-${index}`}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: label.color ?? "#000000",
}}
/>
<span className="text-xs">{label.label_name ?? "No labels"}</span>
</div>
}
completed={label.completed_issues}
total={label.total_issues}
/>
))
) : (
<div className="flex items-center justify-center h-full w-full">
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_LABEL_EMPTY_STATE} layout="screen-simple" size="sm" />
</div>
)}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
);
});

View File

@@ -0,0 +1,77 @@
import { FC } from "react";
import Link from "next/link";
// types
import { ICycle, TCycleGroups } from "@plane/types";
// ui
import { Tooltip, CycleGroupIcon, getButtonStyling, Avatar, AvatarGroup } from "@plane/ui";
// helpers
import { renderFormattedDate, findHowManyDaysLeft } from "@/helpers/date-time.helper";
import { truncateText } from "@/helpers/string.helper";
// hooks
import { useMember } from "@/hooks/store";
export type ActiveCycleHeaderProps = {
cycle: ICycle;
workspaceSlug: string;
projectId: string;
};
export const ActiveCycleHeader: FC<ActiveCycleHeaderProps> = (props) => {
const { cycle, workspaceSlug, projectId } = props;
// store
const { getUserDetails } = useMember();
const cycleOwnerDetails = cycle && cycle.owned_by_id ? getUserDetails(cycle.owned_by_id) : undefined;
const daysLeft = findHowManyDaysLeft(cycle.end_date) ?? 0;
const currentCycleStatus = cycle?.status?.toLocaleLowerCase() as TCycleGroups;
const cycleAssignee = (cycle.distribution?.assignees ?? []).filter((assignee) => assignee.display_name);
return (
<div className="flex items-center justify-between px-3 py-1.5 rounded border-[0.5px] border-custom-border-100 bg-custom-background-90">
<div className="flex items-center gap-2 cursor-default">
<CycleGroupIcon cycleGroup={currentCycleStatus} className="h-4 w-4" />
<Tooltip tooltipContent={cycle.name} position="top-left">
<h3 className="break-words text-lg font-medium">{truncateText(cycle.name, 70)}</h3>
</Tooltip>
<Tooltip
tooltipContent={`Start date: ${renderFormattedDate(cycle.start_date ?? "")} Due Date: ${renderFormattedDate(
cycle.end_date ?? ""
)}`}
position="top-left"
>
<span className="flex gap-1 whitespace-nowrap rounded-sm text-custom-text-400 font-semibold text-sm leading-5">
{`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`}
</span>
</Tooltip>
</div>
<div className="flex items-center gap-4">
<div className="rounded-sm text-sm">
<div className="flex gap-2 divide-x spac divide-x-border-300 text-sm whitespace-nowrap text-custom-text-300 font-medium">
<Avatar name={cycleOwnerDetails?.display_name} src={cycleOwnerDetails?.avatar} />
{cycleAssignee.length > 0 && (
<span className="pl-2">
<AvatarGroup showTooltip>
{cycleAssignee.map((member) => (
<Avatar
key={member.assignee_id}
name={member?.display_name ?? ""}
src={member?.avatar ?? ""}
showTooltip={false}
/>
))}
</AvatarGroup>
</span>
)}
</div>
</div>
<Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}
className={`${getButtonStyling("outline-primary", "sm")} cursor-pointer`}
>
View Cycle
</Link>
</div>
</div>
);
};

View File

@@ -0,0 +1,7 @@
export * from "./header";
export * from "./progress";
export * from "./project-title";
export * from "./productivity";
export * from "./cycle-stats";
export * from "./card";
export * from "./list-page";

View File

@@ -0,0 +1,54 @@
import { FC, useEffect } from "react";
import useSWR from "swr";
// ui
import { Spinner } from "@plane/ui";
// components
import { ActiveCycleInfoCard } from "@/components/active-cycles";
// constants
import { WORKSPACE_ACTIVE_CYCLES_LIST } from "@/constants/fetch-keys";
// services
import { CycleService } from "@/services/cycle.service";
const cycleService = new CycleService();
export type ActiveCyclesListPageProps = {
workspaceSlug: string;
cursor: string;
perPage: number;
updateTotalPages: (count: number) => void;
updateResultsCount: (count: number) => void;
};
export const ActiveCyclesListPage: FC<ActiveCyclesListPageProps> = (props) => {
const { workspaceSlug, cursor, perPage, updateTotalPages, updateResultsCount } = props;
// fetching active cycles in workspace
const { data: workspaceActiveCycles } = useSWR(
workspaceSlug && cursor ? WORKSPACE_ACTIVE_CYCLES_LIST(workspaceSlug as string, cursor, `${perPage}`) : null,
workspaceSlug && cursor ? () => cycleService.workspaceActiveCycles(workspaceSlug.toString(), cursor, perPage) : null
);
useEffect(() => {
if (workspaceActiveCycles) {
updateTotalPages(workspaceActiveCycles.total_pages);
updateResultsCount(workspaceActiveCycles.results.length);
}
}, [updateTotalPages, updateResultsCount, workspaceActiveCycles]);
if (!workspaceActiveCycles) {
return (
<div className="flex items-center justify-center h-full w-full">
<Spinner />
</div>
);
}
return (
<>
{workspaceActiveCycles.results.map((cycle: any) => (
<div key={cycle.id} className="px-5 pt-5 last:pb-5">
<ActiveCycleInfoCard workspaceSlug={workspaceSlug?.toString()} projectId={cycle.project_id} cycle={cycle} />
</div>
))}
</>
);
};

View File

@@ -0,0 +1,57 @@
import { FC } from "react";
// types
import { ICycle } from "@plane/types";
// components
import ProgressChart from "@/components/core/sidebar/progress-chart";
import { EmptyStateType } from "@/constants/empty-state";
import { EmptyState } from "../empty-state";
export type ActiveCycleProductivityProps = {
cycle: ICycle;
};
export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (props) => {
const { cycle } = props;
return (
<div className="flex flex-col justify-center min-h-[17rem] gap-5 py-4 px-3.5 border border-custom-border-200 rounded-lg">
<div className="flex items-center justify-between gap-4">
<h3 className="text-base text-custom-text-300 font-semibold">Issue burndown</h3>
</div>
{cycle.total_issues > 0 ? (
<>
<div className="h-full w-full px-2">
<div className="flex items-center justify-between gap-4 py-1 text-xs text-custom-text-300">
<div className="flex items-center gap-3 text-custom-text-300">
<div className="flex items-center justify-center gap-1">
<span className="h-2 w-2 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2 w-2 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
<span>{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}</span>
</div>
<div className="relative h-full">
<ProgressChart
className="h-full"
distribution={cycle.distribution?.completion_chart ?? {}}
startDate={cycle.start_date ?? ""}
endDate={cycle.end_date ?? ""}
totalIssues={cycle.total_issues}
/>
</div>
</div>
</>
) : (
<>
<div className="flex items-center justify-center h-full w-full">
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_CHART_EMPTY_STATE} layout="screen-simple" size="sm" />
</div>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,89 @@
import { FC } from "react";
// types
import { ICycle } from "@plane/types";
// ui
import { LinearProgressIndicator } from "@plane/ui";
// constants
import { WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle";
import { EmptyStateType } from "@/constants/empty-state";
import { EmptyState } from "../empty-state";
export type ActiveCycleProgressProps = {
cycle: ICycle;
};
export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
const { cycle } = props;
const progressIndicatorData = WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({
id: index,
name: group.title,
value: cycle.total_issues > 0 ? (cycle[group.key as keyof ICycle] as number) : 0,
color: group.color,
}));
const groupedIssues: any = {
completed: cycle.completed_issues,
started: cycle.started_issues,
unstarted: cycle.unstarted_issues,
backlog: cycle.backlog_issues,
};
return (
<div className="flex flex-col min-h-[17rem] gap-5 py-4 px-3.5 border border-custom-border-200 rounded-lg">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-4">
<h3 className="text-base text-custom-text-300 font-semibold">Progress</h3>
{cycle.total_issues > 0 && (
<span className="flex gap-1 text-sm text-custom-text-400 font-medium whitespace-nowrap rounded-sm px-3 py-1 ">
{`${cycle.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${
cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue"
} closed`}
</span>
)}
</div>
{cycle.total_issues > 0 && <LinearProgressIndicator size="lg" data={progressIndicatorData} />}
</div>
{cycle.total_issues > 0 ? (
<div className="flex flex-col gap-5">
{Object.keys(groupedIssues).map((group, index) => (
<>
{groupedIssues[group] > 0 && (
<div key={index}>
<div className="flex items-center justify-between gap-2 text-sm">
<div className="flex items-center gap-1.5">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS[index].color,
}}
/>
<span className="text-custom-text-300 capitalize font-medium w-16">{group}</span>
</div>
<span className="text-custom-text-300">{`${groupedIssues[group]} ${
groupedIssues[group] > 1 ? "Issues" : "Issue"
}`}</span>
</div>
</div>
)}
</>
))}
{cycle.cancelled_issues > 0 && (
<span className="flex items-center gap-2 text-sm text-custom-text-300">
<span>
{`${cycle.cancelled_issues} cancelled ${
cycle.cancelled_issues > 1 ? "issues are" : "issue is"
} excluded from this report.`}{" "}
</span>
</span>
)}
</div>
) : (
<div className="flex items-center justify-center h-full w-full">
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_PROGRESS_EMPTY_STATE} layout="screen-simple" size="sm" />
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,19 @@
import { FC } from "react";
// types
import { IProject } from "@plane/types";
// ui
import { ProjectLogo } from "../project";
export type ActiveCyclesProjectTitleProps = {
project: Partial<IProject> | undefined;
};
export const ActiveCyclesProjectTitle: FC<ActiveCyclesProjectTitleProps> = (props) => {
const { project } = props;
return (
<div className="flex items-center gap-1.5">
{project?.logo_props && <ProjectLogo logo={project.logo_props} />}
<h2 className="text-xl font-semibold">{project?.name}</h2>
</div>
);
};

View File

@@ -0,0 +1,44 @@
import { FC } from "react";
import { observer } from "mobx-react";
// types
import { IActiveCycle } from "@plane/types";
// components
import {
ActiveCyclesProjectTitle,
ActiveCycleHeader,
ActiveCycleProgress,
ActiveCycleProductivity,
ActiveCycleStats,
} from "@/components/active-cycles";
// hooks
import { useProject } from "@/hooks/store";
export type ActiveCycleInfoProps = {
cycle: IActiveCycle;
workspaceSlug: string;
projectId: string;
};
export const ActiveCycleInfo: FC<ActiveCycleInfoProps> = observer((props) => {
const { cycle, workspaceSlug, projectId } = props;
const { getProjectById } = useProject();
const projectDetails = getProjectById(projectId);
if (!projectDetails) return null;
return (
<>
<ActiveCyclesProjectTitle project={projectDetails} />
<div className="flex flex-col gap-2 rounded border border-custom-border-200">
<ActiveCycleHeader cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
<ActiveCycleProgress cycle={cycle} />
<ActiveCycleProductivity cycle={cycle} />
<ActiveCycleStats cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
</div>
</div>
</>
);
});

View File

@@ -1,3 +1,5 @@
export * from "./cycles-view";
export * from "./active-cycle-info";
export * from "./active-cycle";
export * from "./applied-filters";
export * from "./board/";

View File

@@ -1,9 +1,8 @@
import { observer } from "mobx-react-lite";
// ui
import { Crown } from "lucide-react";
import { Breadcrumbs, ContrastIcon } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
// icons
export const WorkspaceActiveCycleHeader = observer(() => (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
@@ -20,7 +19,9 @@ export const WorkspaceActiveCycleHeader = observer(() => (
}
/>
</Breadcrumbs>
<Crown className="h-3.5 w-3.5 text-amber-400" />
<span className="flex items-center justify-center px-3.5 py-0.5 text-xs leading-4 rounded-xl text-orange-500 bg-orange-500/20">
Beta
</span>
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
export * from "./pro-plan-modal";

View File

@@ -0,0 +1,192 @@
import { FC, Fragment, useState } from "react";
// icons
import { CheckCircle } from "lucide-react";
// ui
import { Dialog, Transition, Tab } from "@headlessui/react";
// store
import { useEventTracker } from "@/hooks/store";
function classNames(...classes: any[]) {
return classes.filter(Boolean).join(" ");
}
const PRICING_CATEGORIES = ["Monthly", "Yearly"];
const MONTHLY_PLAN_ITEMS = [
"White-glove onboarding for your use-cases",
"Bespoke implementation",
"Priority integrations",
"Priority Support and SLAs",
"Early access to all paid features",
"Locked-in discount for a whole year",
];
const YEARLY_PLAN_ITEMS = [
"White-glove onboarding for your use-cases",
"Bespoke implementation",
"Priority integrations",
"Priority Support and SLAs",
"Early access to all paid features",
"Tiered discounts for the second and third years",
];
export type ProPlanModalProps = {
isOpen: boolean;
handleClose: () => void;
};
export const ProPlanModal: FC<ProPlanModalProps> = (props) => {
const { isOpen, handleClose } = props;
// store
const { captureEvent } = useEventTracker();
// states
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [tabIndex, setTabIndex] = useState(0);
const handleProPlaneMonthRedirection = () => {
if (process.env.NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL) {
window.open(process.env.NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL, "_blank");
captureEvent("pro_plan_modal_month_redirection", {});
}
};
const handleProPlanYearlyRedirection = () => {
if (process.env.NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL) {
window.open(process.env.NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL, "_blank");
captureEvent("pro_plan_modal_yearly_redirection", {});
}
};
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-lg transform overflow-hidden rounded-2xl bg-custom-background-100 p-6 text-left align-middle shadow-xl transition-all border-[0.5px] border-custom-border-100">
<Dialog.Title as="h2" className="text-2xl font-bold leading-6 mt-4 flex justify-center items-center">
Early-adopter pricing for believers
</Dialog.Title>
<div className="mt-2 mb-5">
<p className="text-center text-sm mb-6 px-10 text-custom-text-200">
Build Plane to your specs. You decide what we prioritize and build for everyone. Also get tailored
onboarding + implementation and priority support.
</p>
<Tab.Group>
<div className="flex w-full justify-center">
<Tab.List className="flex space-x-1 rounded-xl bg-custom-background-80 p-1 w-[72%]">
{PRICING_CATEGORIES.map((category, index) => (
<Tab
key={category}
className={({ selected }) =>
classNames(
"w-full rounded-lg py-2 text-sm font-medium leading-5",
"ring-white/60 ring-offset-2 ring-offset-custom-primary-90 focus:outline-none",
selected
? "bg-custom-background-100 text-custom-primary-100 shadow"
: "hover:bg-custom-background-80 text-custom-text-300 hover:text-custom-text-100"
)
}
onClick={() => setTabIndex(index)}
>
<>
{category}
{category === "Yearly" && (
<span className="bg-custom-primary-100 text-white rounded-full px-2 py-1 ml-1 text-xs">
-28%
</span>
)}
</>
</Tab>
))}
</Tab.List>
</div>
<Tab.Panels className="mt-2">
<Tab.Panel className={classNames("rounded-xl bg-custom-background-100 p-3")}>
<p className="ml-4 text-4xl font-bold mb-2">
$7
<span className="text-sm ml-3 text-custom-text-300">/user/month</span>
</p>
<ul>
{MONTHLY_PLAN_ITEMS.map((item) => (
<li key={item} className="relative rounded-md p-3 flex">
<p className="text-sm font-medium leading-5 flex items-center">
<CheckCircle className="h-4 w-4 mr-4" />
<span>{item}</span>
</p>
</li>
))}
</ul>
<div className="flex justify-center w-full">
<div className="relative inline-flex group mt-8">
<div className="absolute transition-all duration-1000 opacity-50 -inset-px bg-gradient-to-r from-[#44BCFF] via-[#FF44EC] to-[#FF675E] rounded-xl blur-lg group-hover:opacity-100 group-hover:-inset-1 group-hover:duration-200 animate-tilt" />
<button
type="button"
className="relative inline-flex items-center justify-center px-8 py-4 text-sm font-medium border-custom-border-100 border-[1.5px] transition-all duration-200 bg-custom-background-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-custom-border-200"
onClick={handleProPlaneMonthRedirection}
>
Become Early Adopter
</button>
</div>
</div>
</Tab.Panel>
<Tab.Panel className={classNames("rounded-xl bg-custom-background-100 p-3")}>
<p className="ml-4 text-4xl font-bold mb-2">
$5
<span className="text-sm ml-3 text-custom-text-300">/user/month</span>
</p>
<ul>
{YEARLY_PLAN_ITEMS.map((item) => (
<li key={item} className="relative rounded-md p-3 flex">
<p className="text-sm font-medium leading-5 flex items-center">
<CheckCircle className="h-4 w-4 mr-4" />
<span>{item}</span>
</p>
</li>
))}
</ul>
<div className="flex justify-center w-full">
<div className="relative inline-flex group mt-8">
<div className="absolute transition-all duration-1000 opacity-50 -inset-px bg-gradient-to-r from-[#44BCFF] via-[#FF44EC] to-[#FF675E] rounded-xl blur-lg group-hover:opacity-100 group-hover:-inset-1 group-hover:duration-200 animate-tilt" />
<button
type="button"
className="relative inline-flex items-center justify-center px-8 py-4 text-sm font-medium border-custom-border-100 border-[1.5px] transition-all duration-200 bg-custom-background-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-custom-border-200"
onClick={handleProPlanYearlyRedirection}
>
Become Early Adopter
</button>
</div>
</div>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
};

View File

@@ -13,12 +13,13 @@ import {
// types
import { IUserLite, TPage } from "@plane/types";
// components
import { PageContentBrowser, PageEditorTitle } from "@/components/pages";
import { IssueEmbedCard, PageContentBrowser, PageEditorTitle } from "@/components/pages";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
import { usePageFilters } from "@/hooks/use-page-filters";
import { useIssueEmbed } from "@/hooks/use-issue-embed";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// services
import { FileService } from "@/services/file.service";
@@ -79,8 +80,11 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
members: projectMemberDetails,
user: currentUser ?? undefined,
});
// page filters
const { isFullWidth } = usePageFilters();
// issue-embed
const { fetchIssues } = useIssueEmbed(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "");
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
@@ -88,6 +92,11 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
updateMarkings(description_html ?? "<p></p>");
}, [description_html, updateMarkings]);
const handleIssueSearch = async (searchQuery: string) => {
const response = await fetchIssues(searchQuery);
return response;
};
return (
<div className="flex items-center h-full w-full overflow-y-auto">
<div
@@ -147,6 +156,24 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
highlights: mentionHighlights,
suggestions: mentionSuggestions,
}}
embedHandler={{
issue: {
searchCallback: async (query) =>
new Promise((resolve) => {
setTimeout(async () => {
const response = await handleIssueSearch(query);
resolve(response);
}, 300);
}),
widgetCallback: (issueId) => (
<IssueEmbedCard
issueId={issueId}
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
),
},
}}
/>
)}
/>
@@ -160,6 +187,17 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
mentionHandler={{
highlights: mentionHighlights,
}}
embedHandler={{
issue: {
widgetCallback: (issueId) => (
<IssueEmbedCard
issueId={issueId}
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
),
},
}}
/>
)}
</div>

View File

@@ -0,0 +1 @@
export * from "./issue-embed";

View File

@@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { AlertTriangle } from "lucide-react";
// types
import { IIssueDisplayProperties } from "@plane/types";
// ui
import { Loader } from "@plane/ui";
// components
import { IssueProperties } from "@/components/issues/issue-layouts/properties/all-properties";
// constants
import { ISSUE_DISPLAY_PROPERTIES } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project";
// hooks
import { useIssueDetail, useProject, useUser } from "@/hooks/store";
type Props = {
issueId: string;
projectId: string;
workspaceSlug: string;
};
export const IssueEmbedCard: React.FC<Props> = observer((props) => {
const { issueId, projectId, workspaceSlug } = props;
// states
const [error, setError] = useState<any | null>(null);
// store hooks
const {
membership: { currentWorkspaceAllProjectsRole },
} = useUser();
const { getProjectById } = useProject();
const {
setPeekIssue,
issue: { fetchIssue, getIssueById, updateIssue },
} = useIssueDetail();
// derived values
const projectRole = currentWorkspaceAllProjectsRole?.[projectId];
const projectDetails = getProjectById(projectId);
const issueDetails = getIssueById(issueId);
// auth
const isReadOnly = !!projectRole && projectRole < EUserProjectRoles.MEMBER;
// issue display properties
const displayProperties: IIssueDisplayProperties = {};
ISSUE_DISPLAY_PROPERTIES.forEach((property) => {
displayProperties[property.key] = true;
});
// fetch issue details if not available
useEffect(() => {
if (!issueDetails) {
fetchIssue(workspaceSlug, projectId, issueId)
.then(() => setError(null))
.catch((error) => setError(error));
}
}, [fetchIssue, issueDetails, issueId, projectId, workspaceSlug]);
if (!issueDetails && !error)
return (
<div className="rounded-md p-3 my-2">
<Loader className="px-6">
<Loader.Item height="30px" />
<div className="mt-3 space-y-2">
<Loader.Item height="20px" width="70%" />
<Loader.Item height="20px" width="60%" />
</div>
</Loader>
</div>
);
if (error)
return (
<div className="flex items-center gap-3 rounded-md border-2 border-orange-500 bg-orange-500/10 text-orange-500 px-4 py-3 my-2 text-base">
<AlertTriangle className="text-orange-500 size-8" />
This Issue embed is not found in any project. It can no longer be updated or accessed from here.
</div>
);
return (
<div
className="issue-embed cursor-pointer space-y-2 rounded-md bg-custom-background-90 p-3 my-2"
role="button"
onClick={() =>
setPeekIssue({
issueId,
projectId,
workspaceSlug,
})
}
>
<h5 className="text-xs text-custom-text-300">
{projectDetails?.identifier}-{issueDetails?.sequence_id}
</h5>
<h4 className="text-sm font-medium line-clamp-2 break-words">{issueDetails?.name}</h4>
{issueDetails && (
<IssueProperties
className="flex flex-wrap items-center gap-2 whitespace-nowrap text-custom-text-300 pt-1.5"
issue={issueDetails}
displayProperties={displayProperties}
activeLayout="Page issue embed"
updateIssue={async (projectId, issueId, data) => await updateIssue(workspaceSlug, projectId, issueId, data)}
isReadOnly={isReadOnly}
/>
)}
</div>
);
});

View File

@@ -1,3 +1,4 @@
export * from "./embed";
export * from "./header";
export * from "./summary";
export * from "./editor-body";

View File

@@ -1,14 +1,16 @@
import React, { useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import Link from "next/link";
// headless ui
import { FileText, HelpCircle, MessagesSquare, MoveLeft, Zap } from "lucide-react";
import { Transition } from "@headlessui/react";
// icons
import { FileText, HelpCircle, MessagesSquare, MoveLeft, Zap } from "lucide-react";
// headless ui
import { Transition } from "@headlessui/react";
// ui
import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui";
import { DiscordIcon, GithubIcon, Tooltip, Button } from "@plane/ui";
// components
import { ProPlanModal } from "@/components/license";
// hooks
import { useApplication } from "@/hooks/store";
import { useApplication, useEventTracker } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os";
// assets
@@ -37,11 +39,14 @@ export interface WorkspaceHelpSectionProps {
}
export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(() => {
// states
const [isProPlanModalOpen, setIsProPlanModalOpen] = useState(false);
// store hooks
const {
theme: { sidebarCollapsed, toggleSidebar },
commandPalette: { toggleShortcutModal },
} = useApplication();
const { captureEvent } = useEventTracker();
const { isMobile } = usePlatformOS();
// states
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
@@ -58,17 +63,27 @@ export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = observe
const isCollapsed = sidebarCollapsed || false;
const handleProPlanModalOpen = () => {
setIsProPlanModalOpen(true);
captureEvent("pro_plan_modal_opened", {});
};
return (
<>
<ProPlanModal isOpen={isProPlanModalOpen} handleClose={() => setIsProPlanModalOpen(false)} />
<div
className={`flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-border-200 bg-custom-sidebar-background-100 px-4 py-[6px] ${
isCollapsed ? "flex-col" : ""
}`}
>
{!isCollapsed && (
<div className="w-1/2 cursor-default rounded-md bg-green-500/10 px-2.5 py-1.5 text-center text-sm font-medium text-green-500 outline-none">
Free Plan
</div>
<Button
variant="outline-primary"
className="w-1/2 cursor-pointer rounded-2xl px-2.5 py-1.5 text-center text-sm font-medium outline-none"
onClick={handleProPlanModalOpen}
>
Plane Pro
</Button>
)}
<div className={`flex items-center gap-1 ${isCollapsed ? "flex-col justify-center" : "w-1/2 justify-evenly"}`}>
<Tooltip tooltipContent="Shortcuts" isMobile={isMobile}>

View File

@@ -9,3 +9,6 @@ export * from "./sidebar-dropdown";
export * from "./sidebar-menu";
export * from "./sidebar-quick-action";
export * from "./workspace-active-cycles-upgrade";
// ee imports
export * from "./workspace-active-cycles-list";

View File

@@ -2,7 +2,6 @@ import React from "react";
import { observer } from "mobx-react-lite";
import Link from "next/link";
import { useRouter } from "next/router";
import { Crown } from "lucide-react";
// ui
import { Tooltip } from "@plane/ui";
// components
@@ -70,7 +69,9 @@ export const WorkspaceSidebarMenu = observer(() => {
}
{!themeStore?.sidebarCollapsed && <p className="leading-5">{link.label}</p>}
{!themeStore?.sidebarCollapsed && link.key === "active-cycles" && (
<Crown className="h-3.5 w-3.5 text-amber-400" />
<span className="flex items-center justify-center px-3.5 py-0.5 text-xs leading-4 rounded-xl text-orange-500 bg-orange-500/20">
Beta
</span>
)}
</div>
</Tooltip>

View File

@@ -0,0 +1,66 @@
import { useState } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import { Button } from "@plane/ui";
import { ActiveCyclesListPage } from "@/components/active-cycles";
import { EmptyStateType } from "@/constants/empty-state";
import { EmptyState } from "../empty-state";
const perPage = 3;
export const WorkspaceActiveCyclesList = observer(() => {
// state
const [pageCount, setPageCount] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [resultsCount, setResultsCount] = useState(0); // workspaceActiveCycles.results.length
// router
const router = useRouter();
const { workspaceSlug } = router.query;
const activeCyclesPages = [];
const updateTotalPages = (count: number) => {
setTotalPages(count);
};
const updateResultsCount = (count: number) => {
setResultsCount(count);
};
const handleLoadMore = () => {
setPageCount(pageCount + 1);
};
if (!workspaceSlug) {
return null;
}
for (let i = 1; i <= pageCount; i++) {
activeCyclesPages.push(
<ActiveCyclesListPage
cursor={`${perPage}:${i - 1}:0`}
perPage={perPage}
workspaceSlug={workspaceSlug.toString()}
updateTotalPages={updateTotalPages}
updateResultsCount={updateResultsCount}
key={i}
/>
);
}
return (
<div className="h-full w-full overflow-y-scroll bg-custom-background-90 vertical-scrollbar scrollbar-md">
{activeCyclesPages}
{pageCount < totalPages && resultsCount !== 0 && (
<div className="flex items-center justify-center gap-4 text-xs w-full py-5">
<Button variant="outline-primary" size="sm" onClick={handleLoadMore}>
Load More
</Button>
</div>
)}
{resultsCount === 0 && <EmptyState type={EmptyStateType.WORKSPACE_ACTIVE_CYCLES} />}
</div>
);
});

View File

@@ -150,3 +150,27 @@ export const WORKSPACE_ACTIVE_CYCLES_DETAILS = [
icon: Microscope,
},
];
// ee
export const WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS = [
{
key: "completed_issues",
title: "Completed",
color: "#6490FE",
},
{
key: "started_issues",
title: "Started",
color: "#FDD97F",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#FEB055",
},
{
key: "backlog_issues",
title: "Backlog",
color: "#F0F0F3",
},
];

View File

@@ -88,6 +88,7 @@ export enum EmptyStateType {
ACTIVE_CYCLE_ASSIGNEE_EMPTY_STATE = "active-cycle-assignee-empty-state",
ACTIVE_CYCLE_LABEL_EMPTY_STATE = "active-cycle-label-empty-state",
WORKSPACE_ACTIVE_CYCLES = "workspace-active-cycles",
DISABLED_PROJECT_INBOX = "disabled-project-inbox",
DISABLED_PROJECT_CYCLE = "disabled-project-cycle",
DISABLED_PROJECT_MODULE = "disabled-project-module",
@@ -598,6 +599,13 @@ const emptyStateDetails = {
title: "Add labels to issues to see the \n breakdown of work by labels.",
path: "/empty-state/active-cycle/label",
},
[EmptyStateType.WORKSPACE_ACTIVE_CYCLES]: {
key: EmptyStateType.WORKSPACE_ACTIVE_CYCLES,
title: "No active cycles",
description:
"Cycles of your projects that includes any period that encompasses today's date within its range. Find the progress and details of all your active cycle here.",
path: "/empty-state/onboarding/workspace-active-cycles",
},
[EmptyStateType.DISABLED_PROJECT_INBOX]: {
key: EmptyStateType.DISABLED_PROJECT_INBOX,
title: "Inbox is not enabled for the project.",

View File

@@ -140,6 +140,8 @@ export const WORKSPACE_LABELS = (workspaceSlug: string) => `WORKSPACE_LABELS_${w
export const PROJECT_GITHUB_REPOSITORY = (projectId: string) => `PROJECT_GITHUB_REPOSITORY_${projectId.toUpperCase()}`;
// cycles
export const WORKSPACE_ACTIVE_CYCLES_LIST = (workspaceSlug: string, cursor: string, per_page: string) =>
`WORKSPACE_ACTIVE_CYCLES_LIST_${workspaceSlug.toUpperCase()}_${cursor.toUpperCase()}_${per_page.toUpperCase()}`;
export const CYCLES_LIST = (projectId: string) => `CYCLE_LIST_${projectId.toUpperCase()}`;
export const INCOMPLETE_CYCLES_LIST = (projectId: string) => `INCOMPLETE_CYCLES_LIST_${projectId.toUpperCase()}`;
export const CURRENT_CYCLE_LIST = (projectId: string) => `CURRENT_CYCLE_LIST_${projectId.toUpperCase()}`;

View File

@@ -115,6 +115,14 @@ export const PROJECT_SETTINGS_LINKS: {
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels`,
Icon: SettingIcon,
},
{
key: "integrations",
label: "Integrations",
href: `/settings/integrations`,
access: EUserProjectRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/integrations`,
Icon: SettingIcon,
},
{
key: "estimates",
label: "Estimates",

View File

@@ -149,6 +149,22 @@ export const WORKSPACE_SETTINGS_LINKS: {
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing`,
Icon: SettingIcon,
},
{
key: "integrations",
label: "Integrations",
href: `/settings/integrations`,
access: EUserWorkspaceRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/integrations`,
Icon: SettingIcon,
},
{
key: "import",
label: "Imports",
href: `/settings/imports`,
access: EUserWorkspaceRoles.ADMIN,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/imports`,
Icon: SettingIcon,
},
{
key: "export",
label: "Exports",

View File

@@ -0,0 +1,37 @@
// editor
import { TEmbedItem } from "@plane/document-editor";
// types
import { TPageEmbedResponse } from "@plane/types";
// ui
import { PriorityIcon } from "@plane/ui";
// services
import { PageService } from "@/services/page.service";
const pageService = new PageService();
export const useIssueEmbed = (workspaceSlug: string, projectId: string) => {
const fetchIssues = async (searchQuery: string): Promise<TEmbedItem[]> =>
await pageService
.searchEmbed<TPageEmbedResponse[]>(workspaceSlug, projectId, {
query_type: "issue",
query: searchQuery,
count: 10,
})
.then((res) => {
const structuredIssues: TEmbedItem[] = (res ?? []).map((issue) => ({
id: issue.id,
subTitle: `${issue.project__identifier}-${issue.sequence_id}`,
title: issue.name,
icon: <PriorityIcon priority={issue.priority} />,
}));
return structuredIssues;
})
.catch((err) => {
throw Error(err);
});
return {
fetchIssues,
};
};

View File

@@ -3,13 +3,12 @@ import { observer } from "mobx-react";
// components
import { PageHead } from "@/components/core";
import { WorkspaceActiveCycleHeader } from "@/components/headers";
import { WorkspaceActiveCyclesUpgrade } from "@/components/workspace";
import { WorkspaceActiveCyclesList } from "@/components/workspace";
// layouts
import { useWorkspace } from "@/hooks/store";
import { AppLayout } from "@/layouts/app-layout";
// types
import { NextPageWithLayout } from "@/lib/types";
// hooks
const WorkspaceActiveCyclesPage: NextPageWithLayout = observer(() => {
const { currentWorkspace } = useWorkspace();
@@ -19,7 +18,7 @@ const WorkspaceActiveCyclesPage: NextPageWithLayout = observer(() => {
return (
<>
<PageHead title={pageTitle} />
<WorkspaceActiveCyclesUpgrade />
<WorkspaceActiveCyclesList />
</>
);
});

View File

@@ -0,0 +1,86 @@
import { ReactElement } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { IProject } from "@plane/types";
// hooks
import { PageHead } from "@/components/core";
import { EmptyState } from "@/components/empty-state";
import { ProjectSettingHeader } from "@/components/headers";
import { IntegrationCard } from "@/components/project";
import { IntegrationsSettingsLoader } from "@/components/ui";
// layouts
import { EmptyStateType } from "@/constants/empty-state";
import { PROJECT_DETAILS, WORKSPACE_INTEGRATIONS } from "@/constants/fetch-keys";
import { AppLayout } from "@/layouts/app-layout";
import { ProjectSettingLayout } from "@/layouts/settings-layout";
// services
import { NextPageWithLayout } from "@/lib/types";
import { IntegrationService } from "@/services/integrations";
import { ProjectService } from "@/services/project";
// components
// ui
// types
// fetch-keys
// constants
// services
const integrationService = new IntegrationService();
const projectService = new ProjectService();
const ProjectIntegrationsPage: NextPageWithLayout = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// fetch project details
const { data: projectDetails } = useSWR<IProject>(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId ? () => projectService.getProject(workspaceSlug as string, projectId as string) : null
);
// fetch Integrations list
const { data: workspaceIntegrations } = useSWR(
workspaceSlug ? WORKSPACE_INTEGRATIONS(workspaceSlug as string) : null,
() => (workspaceSlug ? integrationService.getWorkspaceIntegrationsList(workspaceSlug as string) : null)
);
// derived values
const isAdmin = projectDetails?.member_role === 20;
const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Integrations` : undefined;
return (
<>
<PageHead title={pageTitle} />
<div className={`w-full gap-10 overflow-y-auto py-8 pr-9 ${isAdmin ? "" : "opacity-60"}`}>
<div className="flex items-center border-b border-custom-border-100 py-3.5">
<h3 className="text-xl font-medium">Integrations</h3>
</div>
{workspaceIntegrations ? (
workspaceIntegrations.length > 0 ? (
<div>
{workspaceIntegrations.map((integration) => (
<IntegrationCard key={integration.integration_detail.id} integration={integration} />
))}
</div>
) : (
<div className="h-full w-full py-8">
<EmptyState
type={EmptyStateType.PROJECT_SETTINGS_INTEGRATIONS}
primaryButtonLink={`/${workspaceSlug}/settings/integrations`}
/>
</div>
)
) : (
<IntegrationsSettingsLoader />
)}
</div>
</>
);
});
ProjectIntegrationsPage.getLayout = function getLayout(page: ReactElement) {
return (
<AppLayout withProjectWrapper header={<ProjectSettingHeader title="Integrations Settings" />}>
<ProjectSettingLayout>{page}</ProjectSettingLayout>
</AppLayout>
);
};
export default ProjectIntegrationsPage;

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