Compare commits

..

75 Commits

Author SHA1 Message Date
pablohashescobar e433266fe5 dev: update the docker directory create 2024-03-18 13:32:26 +05:30
pablohashescobar 72556d407a dev: remove mkdir logs 2024-03-18 13:14:28 +05:30
pablohashescobar 51be035a30 dev: add create folder 2024-03-15 17:44:29 +05:30
pablohashescobar 59d5452248 Merge branch 'dev/api_logging' of github.com:makeplane/plane into build-api-logging 2024-03-15 17:28:38 +05:30
pablohashescobar 37b30fbce9 dev: add log paths in compose 2024-03-15 17:27:59 +05:30
pablohashescobar e9121b6798 dev: add permision for captain user in Plane 2024-03-15 16:53:33 +05:30
pablohashescobar 9e3f7d0873 dev: change permission for code 2024-03-15 16:53:02 +05:30
pablohashescobar aa2d61cdf7 dev: logging cofiguration 2024-03-15 15:39:10 +05:30
pablohashescobar 192482dc30 dev: add logs for migrator 2024-03-15 15:12:36 +05:30
pablohashescobar 2fee027f2c fix: linting errors 2024-03-15 15:08:22 +05:30
pablohashescobar 5a745314b1 dev: logging configuration for django 2024-03-15 15:07:19 +05:30
pablohashescobar a2773e284f dev: linting fix 2024-03-15 13:12:12 +05:30
pablohashescobar 7fdfe39bf0 Merge branch 'develop' of github.com:makeplane/plane into dev/api_logging 2024-03-15 13:11:35 +05:30
Aaryan Khandelwal 6bc133e3b1 chore: swapped dates and status properties in the cycle list item (#3957) 2024-03-14 20:02:18 +05:30
sriram veeraghanta 80761d3507 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-03-14 12:42:33 +05:30
pablohashescobar 7a5c6e0e96 Merge branch 'develop' of github.com:makeplane/plane into dev/api_logging 2024-03-13 17:34:50 +05:30
sriram veeraghanta 9c13dbd957 chore: 1click docs readme update 2024-03-13 17:26:52 +05:30
sriram veeraghanta 8d9adf4d87 chore: 1click docs readme update 2024-03-13 17:26:04 +05:30
sriram veeraghanta 4ccb505f36 chore: 1click docs changes 2024-03-13 17:24:37 +05:30
sriram veeraghanta 884856b021 chore: 1click docs update 2024-03-13 17:18:11 +05:30
sriram veeraghanta 552c66457a chore: 1click docs update 2024-03-13 17:17:40 +05:30
P B 1363ef0b2d Updated one-click deploy's readme for readability and presentation (#3962)
Changed title, first paragraph, and bullets.
2024-03-13 17:13:11 +05:30
guru_sainath 898cf98be3 [WEB-749] fix: rendering empty states with "showEmptyGroup" filter in issue grouping (#3954)
* fix: Fixed show empty states in groupBy and subGroupBy in kanban

* lint: lint issue resolved
2024-03-13 16:59:12 +05:30
Ramesh Kumar Chandra cb632408f9 [WEB-713] style: remove tooltips in mobile responsiveness (#3948)
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-03-12 20:39:36 +05:30
Aaryan Khandelwal b930d98665 [WEB-554] feat: modules filtering, searching and ordering (#3947)
* feat: modules filtering, searching and ordering implemented

* fix: modules ordering

* chore: total issues in list endpoint

* fix: modules ordering

* fix: build errors

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-03-12 20:24:21 +05:30
Aaryan Khandelwal 69e110f4a8 [WEB-578] feat: projects list filtering and ordering (#3926)
* style: project card UI updated

* dev: initialize project filter store and types

* chore: implemented filtering logic

* chore: implemented ordering

* chore: my projects filter added

* chore: update created at date filter options

* refactor: order by dropdown

* style: revert project card UI

* fix: project card z-index

* fix: members filtering

* fix: build errors
2024-03-12 19:36:40 +05:30
Lakhan Baheti c3c6ef8830 chore: removed module tooltip from peek-overview & issue detail (#3946) 2024-03-12 19:13:04 +05:30
Anmol Singh Bhatia 8aca74c68d [WEB-720] chore: reaction tooltip added (#3945)
* chore: reaction tooltip added

* chore: reaction tooltip updated

* chore: issue reaction tooltip updated

* chore: helper function updated
2024-03-12 19:12:25 +05:30
Prateek Shourya c97b994311 [WEB-717] fix: delete issue modal UI fixes. (#3944)
* fix: avoid closing the peek overview when the close button within the delete model popup is clicked.

* fix: distortion in issue detail sidebar UI when any modal is open.
2024-03-12 19:11:36 +05:30
Lakhan Baheti 443b93f897 fix: label mutation in peek-overview & issue detail (#3942) 2024-03-12 19:10:35 +05:30
Aaryan Khandelwal 730e556bea fix: emoji picker unnecessary scroll (#3941) 2024-03-12 19:09:51 +05:30
Aaryan Khandelwal f77f4b8221 fix: gantt sidebar links (#3940) 2024-03-12 19:07:12 +05:30
Anmol Singh Bhatia 5c4c3f5c04 fix: project page empty state (#3939) 2024-03-12 19:05:15 +05:30
Ramesh Kumar Chandra 73c91654eb [WEB-716] style: list view issues responsiveness (#3938) 2024-03-12 12:29:00 +05:30
Bavisetti Narayan 578bd29f6f chore: active issue total count (#3936) 2024-03-12 12:24:58 +05:30
Nikhil 2e5e14556d feat: test email script (#3937)
* feat: script for testing emails

* chore: rename subject and body for the mail
2024-03-12 12:00:18 +05:30
sriram veeraghanta ecac45e885 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-03-12 01:12:57 +05:30
sriram veeraghanta 6ec9c64f7c fix: build branch optional builds workflow changes 2024-03-12 01:06:37 +05:30
sriram veeraghanta f493a03f56 fix: adding github workflow events 2024-03-12 01:01:03 +05:30
Anmol Singh Bhatia 48c9b78397 [WEB-545] chore: completed cycle empty state (#3931)
* chore: completed cycle issue transfer empty state added

* chore: cycle empty state variable updated

* chore: empty state config file updated
2024-03-11 21:23:09 +05:30
Anmol Singh Bhatia 9c29ad1a28 chore: list layout scrollbar improvement (#3933) 2024-03-11 21:12:59 +05:30
Aaryan Khandelwal e3ac075ee2 chore: error state for the issues widgets (#3934) 2024-03-11 21:12:28 +05:30
pablohashescobar 171b664fe7 Merge branch 'develop' of github.com:makeplane/plane into dev/api_logging 2024-03-11 21:09:41 +05:30
Anmol Singh Bhatia 8c9d328c24 chore: profile setting scroll fix and scrollbar added in activity section (#3929) 2024-03-11 21:09:41 +05:30
Aaryan Khandelwal b2146098e2 chore: added view less button to the collaborators widget (#3928) 2024-03-11 21:09:16 +05:30
Nikhil 4a06572f73 chore: remove deprecated envs (#3927) 2024-03-11 21:08:10 +05:30
pablohashescobar 7f67fe12af fix: information leaking through print logs 2024-03-11 21:06:01 +05:30
M. Palanikannan d34df5c805 fix: updated extensions in read only instance of the editor (#3925) 2024-03-11 21:05:27 +05:30
Anmol Singh Bhatia 01702e9f66 [WEB-402] chore: project issues count (#3911)
* chore: added project issues count

* chore: project module and cycle issue count

* chore: resolved merge conflicts

* chore: added import statement

* chore: issue count type added

* chore: issue count added in project, cycle and module issues

* fix: lint fixes

* chore: tooltip added in issue count badge

* chore: tooltip added in issue count badge

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-03-11 21:05:00 +05:30
Nikhil b57c389c75 fix: caching (#3923) 2024-03-11 21:04:43 +05:30
Lakhan Baheti 27acd1bec1 fix: cycle & module sidebar progress-stats scroll (#3932) 2024-03-11 21:03:30 +05:30
Anmol Singh Bhatia 539afb0792 chore: issue quick action dropdown max height updated (#3919) 2024-03-11 21:02:28 +05:30
Anmol Singh Bhatia 6c7ba3bc79 chore: applied filter ui improvement (#3918) 2024-03-11 21:01:59 +05:30
Anmol Singh Bhatia c8ab650008 fix: module empty state create issue (#3916) 2024-03-11 21:01:27 +05:30
Anmol Singh Bhatia 89d019df57 fix: parent select modal params updated (#3914) 2024-03-11 21:01:07 +05:30
Aaryan Khandelwal 535731141f [WEB-682] feat: cycles list filtering and searching (#3910)
* chore: implemented cycles list filters and ordering

* chore: active cycle tab updated

* refactor: cycles folder structure

* fix: name search inout auto-focus

* fix: cycles ordering

* refactor: move cycle filters logic to mobx store from local storage

* chore: show completed cycles in a disclosure

* chore: added completed cycles count

* refactor: cycles mapping logic

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-03-11 21:00:05 +05:30
M. Palanikannan 4b30339a59 [WEB-638] feat: add tabIndex prop support to navigate using Tab key in all the editors (#3902)
* feat: added tab index support to navigate using Tab key in all the editors

* chore: changed the name of Table of Contents in Pages

* chore: file formatting

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-03-11 20:58:23 +05:30
M. Palanikannan 899771a678 [WEB-434] feat: add support to insert a new empty line on clicking at bottom of the editor (#3856)
* fix: horizontal rule no more causes issues on last node

* fixed the mismatched transaction by using native tiptap stuff

* added support to add new line onclick at bottom if last node is an image

TODO: blockquote at last node

* fix: simplified adding node at end of the document logic

* feat: rewrite entire logic handling all cases

* feat: arrow down and arrow up keys add empty node at top and bottom of doc from first/last row's cells

* feat: added arrow up and down key support to images too

* remove unnecessary console logs

* chore: formatting components

* fix: reduced bottom padding to increase onclick area

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-03-11 20:55:24 +05:30
sriram veeraghanta 8997ee2e3e fix: eslint fixes 2024-03-11 20:45:51 +05:30
pablohashescobar 31a2bbc4de dev: api logging and moving the capture exception to utils for logging and then capturing 2024-03-11 20:25:25 +05:30
pablohashescobar 6be72149d9 fix: logging configuration 2024-03-11 19:19:49 +05:30
pablohashescobar 775d8fbd90 dev: add rotating logger 2024-03-11 16:29:49 +05:30
pablohashescobar 58d82313cc Merge branch 'dev/api_logging' of github.com:makeplane/plane into dev/api_logging 2024-03-11 15:59:43 +05:30
pablohashescobar f32645458c Merge branch 'develop' of github.com:makeplane/plane into dev/api_logging 2024-03-11 15:41:12 +05:30
sriram veeraghanta cb78ccad1f fix: 1click deployment fixes 2024-03-08 16:58:37 +05:30
Manish Gupta 6c6b7156bb helm variable update 2024-03-08 16:00:18 +05:30
Manish Gupta 8cc372679c Web Build fixes 2024-03-08 15:56:05 +05:30
Manish Gupta 94327b8311 chore: feature build process optimization (#3907)
* process changed to build tar and use for deployment

* fixes
2024-03-08 15:16:32 +05:30
Nikhil 4fb9ab3998 Merge branch 'develop' into dev/api_logging 2023-11-12 00:13:45 +05:30
pablohashescobar 8d4e4a7485 dev: enable global level log settings 2023-10-31 12:38:52 +05:30
pablohashescobar edb4280ec1 Merge branch 'develop' of github.com:makeplane/plane into dev/api_logging 2023-10-31 11:49:54 +05:30
pablohashescobar 099cd5955c dev: remove worker counts 2023-10-30 14:42:25 +05:30
pablohashescobar 93cfa13955 dev: enable logger instead of printing 2023-10-30 14:41:44 +05:30
pablohashescobar f231ac0a79 Merge branch 'develop' of github.com:makeplane/plane into dev/api_logging 2023-10-30 11:35:38 +05:30
pablohashescobar 00757a6704 dev: enable api logging and control worker count through env 2023-10-25 19:25:14 +05:30
303 changed files with 7030 additions and 1979 deletions
+26 -6
View File
@@ -2,6 +2,27 @@ name: Branch Build
on:
workflow_dispatch:
inputs:
build-web:
required: false
description: "Build Web"
type: boolean
default: false
build-space:
required: false
description: "Build Space"
type: boolean
default: false
build-api:
required: false
description: "Build API"
type: boolean
default: false
build-proxy:
required: false
description: "Build Proxy"
type: boolean
default: false
push:
branches:
- master
@@ -18,7 +39,7 @@ jobs:
name: Build-Push Web/Space/API/Proxy Docker Image
runs-on: ubuntu-latest
outputs:
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
@@ -74,7 +95,7 @@ jobs:
- nginx/**
branch_build_push_frontend:
if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event.inputs.build-web=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
@@ -126,7 +147,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_space:
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event.inputs.build-space=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
@@ -178,7 +199,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_backend:
if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event.inputs.build-api=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
@@ -230,7 +251,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_proxy:
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event.inputs.build-web=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
@@ -280,4 +301,3 @@ jobs:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
+157 -31
View File
@@ -4,70 +4,196 @@ on:
workflow_dispatch:
inputs:
web-build:
required: true
required: false
description: 'Build Web'
type: boolean
default: true
space-build:
required: true
required: false
description: 'Build Space'
type: boolean
default: false
env:
BUILD_WEB: ${{ github.event.inputs.web-build }}
BUILD_SPACE: ${{ github.event.inputs.space-build }}
jobs:
setup-feature-build:
name: Feature Build Setup
runs-on: ubuntu-latest
steps:
- name: Checkout
run: |
echo "BUILD_WEB=$BUILD_WEB"
echo "BUILD_SPACE=$BUILD_SPACE"
outputs:
web-build: ${{ env.BUILD_WEB}}
space-build: ${{env.BUILD_SPACE}}
feature-build-web:
if: ${{ needs.setup-feature-build.outputs.web-build == 'true' }}
needs: setup-feature-build
name: Feature Build Web
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
steps:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install AWS cli
run: |
sudo apt-get update
sudo apt-get install -y python3-pip
pip3 install awscli
- name: Checkout
uses: actions/checkout@v4
with:
path: plane
- name: Install Dependencies
run: |
cd $GITHUB_WORKSPACE/plane
yarn install
- name: Build Web
id: build-web
run: |
cd $GITHUB_WORKSPACE/plane
yarn build --filter=web
cd $GITHUB_WORKSPACE
TAR_NAME="web.tar.gz"
tar -czf $TAR_NAME ./plane
FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
feature-build-space:
if: ${{ needs.setup-feature-build.outputs.space-build == 'true' }}
needs: setup-feature-build
name: Feature Build Space
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1
NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
outputs:
do-build: ${{ needs.setup-feature-build.outputs.space-build }}
s3-url: ${{ steps.build-space.outputs.S3_PRESIGNED_URL }}
steps:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install AWS cli
run: |
sudo apt-get update
sudo apt-get install -y python3-pip
pip3 install awscli
- name: Checkout
uses: actions/checkout@v4
with:
path: plane
- name: Install Dependencies
run: |
cd $GITHUB_WORKSPACE/plane
yarn install
- name: Build Space
id: build-space
run: |
cd $GITHUB_WORKSPACE/plane
yarn build --filter=space
cd $GITHUB_WORKSPACE
TAR_NAME="space.tar.gz"
tar -czf $TAR_NAME ./plane
FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
feature-deploy:
if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true') }}
needs: [feature-build-web, feature-build-space]
name: Feature Deploy
runs-on: ubuntu-latest
env:
KUBE_CONFIG_FILE: ${{ secrets.KUBE_CONFIG }}
BUILD_WEB: ${{ (github.event.inputs.web-build == '' && true) || github.event.inputs.web-build }}
BUILD_SPACE: ${{ (github.event.inputs.space-build == '' && false) || github.event.inputs.space-build }}
AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
KUBE_CONFIG_FILE: ${{ secrets.FEATURE_PREVIEW_KUBE_CONFIG }}
steps:
- name: Install AWS cli
run: |
sudo apt-get update
sudo apt-get install -y python3-pip
pip3 install awscli
- name: Tailscale
uses: tailscale/github-action@v2
with:
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }}
tags: tag:ci
- name: Kubectl Setup
run: |
curl -LO "https://dl.k8s.io/release/${{secrets.KUBE_VERSION}}/bin/linux/amd64/kubectl"
curl -LO "https://dl.k8s.io/release/${{ vars.FEATURE_PREVIEW_KUBE_VERSION }}/bin/linux/amd64/kubectl"
chmod +x kubectl
mkdir -p ~/.kube
echo "$KUBE_CONFIG_FILE" > ~/.kube/config
chmod 600 ~/.kube/config
- name: HELM Setup
run: |
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
chmod 700 get_helm.sh
./get_helm.sh
- name: App Deploy
run: |
helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ secrets.FEATURE_PREVIEW_HELM_CHART_URL }}
GIT_BRANCH=${{ github.ref_name }}
APP_NAMESPACE=${{ secrets.FEATURE_PREVIEW_NAMESPACE }}
WEB_S3_URL=""
if [ ${{ env.BUILD_WEB }} == true ]; then
WEB_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/web.tar.gz --expires-in 3600)
fi
METADATA=$(helm install feature-preview/${{ secrets.FEATURE_PREVIEW_HELM_CHART_NAME }} \
--kube-insecure-skip-tls-verify \
--generate-name \
--namespace $APP_NAMESPACE \
--set shared_config.git_repo=${{github.server_url}}/${{ github.repository }}.git \
--set shared_config.git_branch="$GIT_BRANCH" \
--set web.enabled=${{ env.BUILD_WEB }} \
--set space.enabled=${{ env.BUILD_SPACE }} \
--output json \
--timeout 1000s)
SPACE_S3_URL=""
if [ ${{ env.BUILD_SPACE }} == true ]; then
SPACE_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/space.tar.gz --expires-in 3600)
fi
APP_NAME=$(echo $METADATA | jq -r '.name')
if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ]; then
INGRESS_HOSTNAME=$(kubectl get ingress -n feature-builds --insecure-skip-tls-verify \
-o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \
jq -r '.spec.rules[0].host')
helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }}
echo "****************************************"
echo "APP NAME ::: $APP_NAME"
echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME"
echo "****************************************"
APP_NAMESPACE="${{ vars.FEATURE_PREVIEW_NAMESPACE }}"
DEPLOY_SCRIPT_URL="${{ vars.FEATURE_PREVIEW_DEPLOY_SCRIPT_URL }}"
METADATA=$(helm --kube-insecure-skip-tls-verify install feature-preview/${{ vars.FEATURE_PREVIEW_HELM_CHART_NAME }} \
--generate-name \
--namespace $APP_NAMESPACE \
--set ingress.primaryDomain=${{vars.FEATURE_PREVIEW_PRIMARY_DOMAIN || 'feature.plane.tools' }} \
--set web.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
--set web.enabled=${{ env.BUILD_WEB || false }} \
--set web.artifact_url=$WEB_S3_URL \
--set space.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
--set space.enabled=${{ env.BUILD_SPACE || false }} \
--set space.artifact_url=$SPACE_S3_URL \
--set shared_config.deploy_script_url=$DEPLOY_SCRIPT_URL \
--set shared_config.api_base_url=${{vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL}} \
--output json \
--timeout 1000s)
APP_NAME=$(echo $METADATA | jq -r '.name')
INGRESS_HOSTNAME=$(kubectl get ingress -n feature-builds --insecure-skip-tls-verify \
-o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \
jq -r '.spec.rules[0].host')
echo "****************************************"
echo "APP NAME ::: $APP_NAME"
echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME"
echo "****************************************"
fi
+1
View File
@@ -51,6 +51,7 @@ staticfiles
mediafiles
.env
.DS_Store
logs/
node_modules/
assets/dist/
-20
View File
@@ -14,10 +14,6 @@ POSTGRES_HOST="plane-db"
POSTGRES_DB="plane"
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
# Oauth variables
GOOGLE_CLIENT_ID=""
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
# Redis Settings
REDIS_HOST="plane-redis"
@@ -34,11 +30,6 @@ AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # deprecated
# Settings related to Docker
DOCKERIZED=1 # deprecated
@@ -48,19 +39,8 @@ USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# SignUps
ENABLE_SIGNUP="1"
# Enable Email/Password Signup
ENABLE_EMAIL_PASSWORD="1"
# Enable Magic link Login
ENABLE_MAGIC_LINK_LOGIN="0"
# Email redirections and minio domain settings
WEB_URL="http://localhost"
# Gunicorn Workers
GUNICORN_WORKERS=2
+2
View File
@@ -48,8 +48,10 @@ USER root
RUN apk --no-cache add "bash~=5.2"
COPY ./bin ./bin/
RUN mkdir /code/plane/logs
RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
RUN chmod -R 777 /code
RUN chown -R captain:plane /code
USER captain
+6 -2
View File
@@ -21,11 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256
export MACHINE_SIGNATURE=$SIGNATURE
# Register instance
python manage.py register_instance $MACHINE_SIGNATURE
python manage.py register_instance "$MACHINE_SIGNATURE"
# Load the configuration variable
python manage.py configure_instance
# Create the default bucket
python manage.py create_bucket
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
# Clear Cache before starting to remove stale values
python manage.py clear_cache
exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
+4 -1
View File
@@ -21,12 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256
export MACHINE_SIGNATURE=$SIGNATURE
# Register instance
python manage.py register_instance $MACHINE_SIGNATURE
python manage.py register_instance "$MACHINE_SIGNATURE"
# Load the configuration variable
python manage.py configure_instance
# Create the default bucket
python manage.py create_bucket
# Clear Cache before starting to remove stale values
python manage.py clear_cache
python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local
+11 -15
View File
@@ -1,26 +1,26 @@
# Python imports
import zoneinfo
from urllib.parse import urlparse
import zoneinfo
# Django imports
from django.conf import settings
from django.db import IntegrityError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
from django.utils import timezone
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
# Third party imports
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework import status
from sentry_sdk import capture_exception
# Module imports
from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.api.rate_limit import ApiKeyRateThrottle
from plane.utils.paginator import BasePaginator
from plane.bgtasks.webhook_task import send_webhook
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
class TimezoneMixin:
@@ -106,27 +106,23 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
if isinstance(e, ValidationError):
return Response(
{
"error": "The provided payload is not valid please try with a valid payload"
},
{"error": "Please provide valid detail"},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ObjectDoesNotExist):
return Response(
{"error": "The required object does not exist."},
{"error": "The requested resource does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
if isinstance(e, KeyError):
return Response(
{"error": " The required key does not exist."},
{"error": "The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
if settings.DEBUG:
print(e)
capture_exception(e)
log_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+2 -1
View File
@@ -215,9 +215,10 @@ class ModuleSerializer(DynamicBaseSerializer):
class ModuleDetailSerializer(ModuleSerializer):
link_module = ModuleLinkSerializer(read_only=True, many=True)
sub_issues = serializers.IntegerField(read_only=True)
class Meta(ModuleSerializer.Meta):
fields = ModuleSerializer.Meta.fields + ["link_module"]
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"]
class ModuleFavoriteSerializer(BaseSerializer):
@@ -102,6 +102,12 @@ class ProjectLiteSerializer(BaseSerializer):
class ProjectListSerializer(DynamicBaseSerializer):
total_issues = serializers.IntegerField(read_only=True)
archived_issues = serializers.IntegerField(read_only=True)
archived_sub_issues = serializers.IntegerField(read_only=True)
draft_issues = serializers.IntegerField(read_only=True)
draft_sub_issues = serializers.IntegerField(read_only=True)
sub_issues = serializers.IntegerField(read_only=True)
is_favorite = serializers.BooleanField(read_only=True)
total_members = serializers.IntegerField(read_only=True)
total_cycles = serializers.IntegerField(read_only=True)
+13 -15
View File
@@ -1,27 +1,27 @@
# Python imports
import zoneinfo
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
# Django imports
from django.urls import resolve
from django.conf import settings
from django.utils import timezone
from django.db import IntegrityError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django_filters.rest_framework import DjangoFilterBackend
# Third part imports
from rest_framework import status
from rest_framework.viewsets import ModelViewSet
from rest_framework.response import Response
from rest_framework.exceptions import APIException
from rest_framework.views import APIView
from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
from sentry_sdk import capture_exception
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
# Module imports
from plane.utils.paginator import BasePaginator
from plane.bgtasks.webhook_task import send_webhook
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
class TimezoneMixin:
@@ -87,7 +87,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
try:
return self.model.objects.all()
except Exception as e:
capture_exception(e)
log_exception(e)
raise APIException(
"Please check the view", status.HTTP_400_BAD_REQUEST
)
@@ -121,13 +121,13 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
)
if isinstance(e, KeyError):
capture_exception(e)
log_exception(e)
return Response(
{"error": "The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
capture_exception(e)
log_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -233,9 +233,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
status=status.HTTP_400_BAD_REQUEST,
)
if settings.DEBUG:
print(e)
capture_exception(e)
log_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+51 -29
View File
@@ -15,10 +15,7 @@ from django.db.models import (
Value,
CharField,
)
from django.core import serializers
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import UUIDField
@@ -32,9 +29,7 @@ from rest_framework import status
from .. import BaseViewSet, BaseAPIView, WebhookMixin
from plane.app.serializers import (
CycleSerializer,
CycleIssueSerializer,
CycleFavoriteSerializer,
IssueSerializer,
CycleWriteSerializer,
CycleUserPropertiesSerializer,
)
@@ -48,13 +43,10 @@ from plane.db.models import (
CycleIssue,
Issue,
CycleFavorite,
IssueLink,
IssueAttachment,
Label,
CycleUserProperties,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.utils.analytics_plot import burndown_plot
@@ -106,15 +98,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
)
.annotate(is_favorite=Exists(favorite_subquery))
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
@@ -201,7 +184,15 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
queryset = self.get_queryset().annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
cycle_view = request.GET.get("cycle_view", "all")
# Update the order by
@@ -327,13 +318,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
}
if data[0]["start_date"] and data[0]["end_date"]:
data[0]["distribution"][
"completion_chart"
] = burndown_plot(
queryset=queryset.first(),
slug=slug,
project_id=project_id,
cycle_id=data[0]["id"],
data[0]["distribution"]["completion_chart"] = (
burndown_plot(
queryset=queryset.first(),
slug=slug,
project_id=project_id,
cycle_id=data[0]["id"],
)
)
return Response(data, status=status.HTTP_200_OK)
@@ -355,8 +346,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"external_id",
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",
"started_issues",
@@ -402,7 +393,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
@@ -474,7 +464,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
@@ -487,10 +476,42 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk):
queryset = self.get_queryset().filter(pk=pk)
queryset = (
self.get_queryset()
.filter(pk=pk)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
)
data = (
self.get_queryset()
.filter(pk=pk)
.annotate(
total_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
parent__isnull=True,
issue_cycle__cycle_id=pk,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
parent__isnull=False,
issue_cycle__cycle_id=pk,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.values(
# necessary fields
"id",
@@ -507,6 +528,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"external_source",
"external_id",
"progress_snapshot",
"sub_issues",
# meta fields
"is_favorite",
"total_issues",
@@ -38,7 +38,6 @@ from plane.db.models import (
IssueLink,
IssueAttachment,
IssueRelation,
IssueAssignee,
User,
)
from plane.app.serializers import (
+1 -29
View File
@@ -1,7 +1,5 @@
# Python imports
import json
import random
from itertools import chain
# Django imports
from django.utils import timezone
@@ -21,64 +19,38 @@ from django.db.models import (
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db import IntegrityError
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models import UUIDField
from django.db.models.functions import Coalesce
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.parsers import MultiPartParser, FormParser
# Module imports
from .. import BaseViewSet, BaseAPIView, WebhookMixin
from plane.app.serializers import (
IssueActivitySerializer,
IssueCommentSerializer,
IssuePropertySerializer,
IssueSerializer,
IssueCreateSerializer,
LabelSerializer,
IssueFlatSerializer,
IssueLinkSerializer,
IssueLiteSerializer,
IssueAttachmentSerializer,
IssueSubscriberSerializer,
ProjectMemberLiteSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
IssueDetailSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
WorkSpaceAdminPermission,
ProjectMemberPermission,
ProjectLitePermission,
)
from plane.db.models import (
Project,
Issue,
IssueActivity,
IssueComment,
IssueProperty,
Label,
IssueLink,
IssueAttachment,
IssueSubscriber,
ProjectMember,
IssueReaction,
CommentReaction,
IssueRelation,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
from collections import defaultdict
from plane.utils.cache import invalidate_cache
class IssueListEndpoint(BaseAPIView):
+26 -5
View File
@@ -3,7 +3,7 @@ import json
# Django Imports
from django.utils import timezone
from django.db.models import Prefetch, F, OuterRef, Exists, Count, Q
from django.db.models import Prefetch, F, OuterRef, Exists, Count, Q, Func
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
@@ -183,7 +183,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"external_id",
# computed fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
@@ -224,8 +223,8 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"external_source",
"external_id",
# computed fields
"is_favorite",
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",
"started_issues",
@@ -237,7 +236,30 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
return Response(modules, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk):
queryset = self.get_queryset().filter(pk=pk)
queryset = (
self.get_queryset()
.filter(pk=pk)
.annotate(
total_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
parent__isnull=True,
issue_module__module_id=pk,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
parent__isnull=False,
issue_module__module_id=pk,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
assignee_distribution = (
Issue.objects.filter(
@@ -380,7 +402,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"external_id",
# computed fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
+73 -3
View File
@@ -46,9 +46,11 @@ from plane.db.models import (
Inbox,
ProjectDeployBoard,
IssueProperty,
Issue,
)
from plane.utils.cache import cache_response
class ProjectViewSet(WebhookMixin, BaseViewSet):
serializer_class = ProjectListSerializer
model = Project
@@ -171,6 +173,73 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
).data
return Response(projects, status=status.HTTP_200_OK)
def retrieve(self, request, slug, pk):
project = (
self.get_queryset()
.filter(pk=pk)
.annotate(
total_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("pk"),
parent__isnull=True,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("pk"),
parent__isnull=False,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
archived_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"),
archived_at__isnull=False,
parent__isnull=True,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
archived_sub_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"),
archived_at__isnull=False,
parent__isnull=False,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
draft_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"),
is_draft=True,
parent__isnull=True,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
draft_sub_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"),
is_draft=True,
parent__isnull=False,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).first()
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
def create(self, request, slug):
try:
workspace = Workspace.objects.get(slug=slug)
@@ -277,12 +346,12 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
{"name": "The project name is already taken"},
status=status.HTTP_410_GONE,
)
except Workspace.DoesNotExist as e:
except Workspace.DoesNotExist:
return Response(
{"error": "Workspace does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
except serializers.ValidationError as e:
except serializers.ValidationError:
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_410_GONE,
@@ -341,7 +410,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
{"error": "Project does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
except serializers.ValidationError as e:
except serializers.ValidationError:
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_410_GONE,
@@ -471,6 +540,7 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
# Cache the below api for 24 hours
@cache_response(60 * 60 * 24, user=False)
def get(self, request):
+18 -20
View File
@@ -1,22 +1,22 @@
# Python imports
import csv
import io
import logging
# Third party imports
from celery import shared_task
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import Issue
from plane.utils.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.analytics_plot import build_graph_plot
from plane.utils.exception_logger import log_exception
from plane.utils.issue_filters import issue_filters
row_mapping = {
"state__name": "State",
@@ -210,9 +210,9 @@ def generate_segmented_rows(
None,
)
if assignee:
generated_row[
0
] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
generated_row[0] = (
f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
)
if x_axis == LABEL_ID:
label = next(
@@ -279,9 +279,9 @@ def generate_segmented_rows(
None,
)
if assignee:
row_zero[
index + 2
] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
row_zero[index + 2] = (
f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
)
if segmented == LABEL_ID:
for index, segm in enumerate(row_zero[2:]):
@@ -366,9 +366,9 @@ def generate_non_segmented_rows(
None,
)
if assignee:
row[
0
] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
row[0] = (
f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
)
if x_axis == LABEL_ID:
label = next(
@@ -504,10 +504,8 @@ def analytic_export_task(email, data, slug):
csv_buffer = generate_csv_from_rows(rows)
send_export_email(email, slug, csv_buffer, rows)
logging.getLogger("plane").info("Email sent succesfully.")
return
except Exception as e:
print(e)
if settings.DEBUG:
print(e)
capture_exception(e)
log_exception(e)
return
@@ -1,21 +1,22 @@
import logging
from datetime import datetime
from bs4 import BeautifulSoup
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
# Django imports
from django.utils import timezone
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Module imports
from plane.db.models import EmailNotificationLog, User, Issue
from plane.db.models import EmailNotificationLog, Issue, User
from plane.license.utils.instance_value import get_email_configuration
from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
# acquire and delete redis lock
@@ -69,7 +70,9 @@ def stack_email_notification():
receiver_notification.get("entity_identifier"), {}
).setdefault(
str(receiver_notification.get("triggered_by_id")), []
).append(receiver_notification.get("data"))
).append(
receiver_notification.get("data")
)
# append processed notifications
processed_notifications.append(receiver_notification.get("id"))
email_notification_ids.append(receiver_notification.get("id"))
@@ -296,7 +299,9 @@ def send_email_notification(
)
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane").info("Email Sent Successfully")
# Update the logs
EmailNotificationLog.objects.filter(
pk__in=email_notification_ids
).update(sent_at=timezone.now())
@@ -305,15 +310,20 @@ def send_email_notification(
release_lock(lock_id=lock_id)
return
except Exception as e:
capture_exception(e)
log_exception(e)
# release the lock
release_lock(lock_id=lock_id)
return
else:
print("Duplicate task recived. Skipping...")
logging.getLogger("plane").info(
"Duplicate email received skipping"
)
return
except (Issue.DoesNotExist, User.DoesNotExist) as e:
if settings.DEBUG:
print(e)
log_exception(e)
release_lock(lock_id=lock_id)
return
except Exception as e:
log_exception(e)
release_lock(lock_id=lock_id)
return
@@ -1,13 +1,13 @@
import uuid
import os
import uuid
# third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from posthog import Posthog
# module imports
from plane.license.utils.instance_value import get_configuration_value
from plane.utils.exception_logger import log_exception
def posthogConfiguration():
@@ -51,7 +51,8 @@ def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
},
)
except Exception as e:
capture_exception(e)
log_exception(e)
return
@shared_task
@@ -77,4 +78,5 @@ def workspace_invite_event(
},
)
except Exception as e:
capture_exception(e)
log_exception(e)
return
+9 -11
View File
@@ -2,21 +2,22 @@
import csv
import io
import json
import boto3
import zipfile
import boto3
from botocore.client import Config
# Third party imports
from celery import shared_task
# Django imports
from django.conf import settings
from django.utils import timezone
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from botocore.client import Config
from openpyxl import Workbook
# Module imports
from plane.db.models import Issue, ExporterHistory
from plane.db.models import ExporterHistory, Issue
from plane.utils.exception_logger import log_exception
def dateTimeConverter(time):
@@ -403,8 +404,5 @@ def issue_export_task(
exporter_instance.status = "failed"
exporter_instance.reason = str(e)
exporter_instance.save(update_fields=["status", "reason"])
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
log_exception(e)
return
@@ -1,17 +1,17 @@
# Python import
# Python imports
import logging
# Third party imports
from celery import shared_task
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@shared_task
@@ -60,10 +60,8 @@ def forgot_password(first_name, email, uidb64, token, current_site):
)
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane").info("Email sent successfully")
return
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
log_exception(e)
return
+18 -19
View File
@@ -1,34 +1,36 @@
# Python imports
import json
import requests
# Third Party imports
from celery import shared_task
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
# Third Party imports
from celery import shared_task
from sentry_sdk import capture_exception
from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
# Module imports
from plane.db.models import (
User,
Issue,
Project,
Label,
IssueActivity,
State,
Cycle,
Module,
IssueReaction,
CommentReaction,
Cycle,
Issue,
IssueActivity,
IssueComment,
IssueReaction,
IssueSubscriber,
Label,
Module,
Project,
State,
User,
)
from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
# Track Changes in name
@@ -1647,7 +1649,7 @@ def issue_activity(
headers=headers,
)
except Exception as e:
capture_exception(e)
log_exception(e)
if notification:
notifications.delay(
@@ -1668,8 +1670,5 @@ def issue_activity(
return
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
log_exception(e)
return
@@ -2,18 +2,17 @@
import json
from datetime import timedelta
# Django imports
from django.utils import timezone
from django.db.models import Q
from django.conf import settings
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from django.db.models import Q
# Django imports
from django.utils import timezone
# Module imports
from plane.db.models import Issue, Project, State
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import Issue, Project, State
from plane.utils.exception_logger import log_exception
@shared_task
@@ -96,9 +95,7 @@ def archive_old_issues():
]
return
except Exception as e:
if settings.DEBUG:
print(e)
capture_exception(e)
log_exception(e)
return
@@ -179,7 +176,5 @@ def close_old_issues():
]
return
except Exception as e:
if settings.DEBUG:
print(e)
capture_exception(e)
log_exception(e)
return
@@ -1,17 +1,17 @@
# Python imports
import logging
# Third party imports
from celery import shared_task
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@shared_task
@@ -52,11 +52,8 @@ def magic_link(email, key, token, current_site):
)
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane").info("Email sent successfully.")
return
except Exception as e:
print(e)
capture_exception(e)
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
log_exception(e)
return
@@ -1,18 +1,18 @@
# Python import
# Python imports
import logging
# Third party imports
from celery import shared_task
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import Project, User, ProjectMemberInvite
from plane.db.models import Project, ProjectMemberInvite, User
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@shared_task
@@ -73,12 +73,10 @@ def project_invitation(email, project_id, token, current_site, invitor):
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane").info("Email sent successfully.")
return
except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist):
return
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
log_exception(e)
return
+36 -35
View File
@@ -1,44 +1,45 @@
import requests
import uuid
import hashlib
import json
import hmac
import json
import logging
import uuid
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
import requests
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from plane.db.models import (
Webhook,
WebhookLog,
Project,
Issue,
Cycle,
Module,
ModuleIssue,
CycleIssue,
IssueComment,
User,
)
from plane.api.serializers import (
ProjectSerializer,
CycleSerializer,
ModuleSerializer,
CycleIssueSerializer,
ModuleIssueSerializer,
IssueCommentSerializer,
IssueExpandSerializer,
)
# Django imports
from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection
from django.core.serializers.json import DjangoJSONEncoder
from django.template.loader import render_to_string
from django.utils.html import strip_tags
# Module imports
from plane.api.serializers import (
CycleIssueSerializer,
CycleSerializer,
IssueCommentSerializer,
IssueExpandSerializer,
ModuleIssueSerializer,
ModuleSerializer,
ProjectSerializer,
)
from plane.db.models import (
Cycle,
CycleIssue,
Issue,
IssueComment,
Module,
ModuleIssue,
Project,
User,
Webhook,
WebhookLog,
)
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
SERIALIZER_MAPPER = {
"project": ProjectSerializer,
@@ -174,7 +175,7 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site):
except Exception as e:
if settings.DEBUG:
print(e)
capture_exception(e)
log_exception(e)
return
@@ -241,7 +242,7 @@ def send_webhook(event, payload, kw, action, slug, bulk, current_site):
except Exception as e:
if settings.DEBUG:
print(e)
capture_exception(e)
log_exception(e)
return
@@ -295,8 +296,8 @@ def send_webhook_deactivation_email(
)
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane").info("Email sent successfully.")
return
except Exception as e:
print(e)
log_exception(e)
return
@@ -1,18 +1,18 @@
# Python imports
import logging
# Third party imports
from celery import shared_task
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import Workspace, WorkspaceMemberInvite, User
from plane.db.models import User, Workspace, WorkspaceMemberInvite
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@shared_task
@@ -76,14 +76,12 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
)
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane").info("Email sent succesfully")
return
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist):
print("Workspace or WorkspaceMember Invite Does not exists")
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e:
log_exception(e)
return
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
log_exception(e)
return
@@ -0,0 +1,17 @@
# Django imports
from django.core.cache import cache
from django.core.management import BaseCommand
class Command(BaseCommand):
help = "Clear Cache before starting the server to remove stale values"
def handle(self, *args, **options):
try:
cache.clear()
self.stdout.write(self.style.SUCCESS("Cache Cleared"))
return
except Exception:
# Another ClientError occurred
self.stdout.write(self.style.ERROR("Failed to clear cache"))
return
@@ -0,0 +1,61 @@
from django.core.mail import EmailMultiAlternatives, get_connection
from django.core.management import BaseCommand, CommandError
from plane.license.utils.instance_value import get_email_configuration
class Command(BaseCommand):
"""Django command to pause execution until db is available"""
def add_arguments(self, parser):
# Positional argument
parser.add_argument("to_email", type=str, help="receiver's email")
def handle(self, *args, **options):
receiver_email = options.get("to_email")
if not receiver_email:
raise CommandError("Reciever email is required")
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
timeout=30,
)
# Prepare email details
subject = "Email Notification from Plane"
message = (
"This is a sample email notification sent from Plane application."
)
self.stdout.write(self.style.SUCCESS("Trying to send test email..."))
# Send the email
try:
msg = EmailMultiAlternatives(
subject=subject,
body=message,
from_email=EMAIL_FROM,
to=[receiver_email],
connection=connection,
)
msg.send()
self.stdout.write(self.style.SUCCESS("Email succesfully sent"))
except Exception as e:
self.stdout.write(
self.style.ERROR(
f"Error: Email could not be delivered due to {e}"
)
)
+8 -7
View File
@@ -1,16 +1,17 @@
# Python imports
import uuid
import string
import random
import string
import uuid
import pytz
from django.contrib.auth.models import (
AbstractBaseUser,
PermissionsMixin,
UserManager,
)
# Django imports
from django.db import models
from django.contrib.auth.models import (
AbstractBaseUser,
UserManager,
PermissionsMixin,
)
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
+6 -5
View File
@@ -3,19 +3,20 @@
# Python imports
import os
import ssl
import certifi
from datetime import timedelta
from urllib.parse import urlparse
# Django imports
from django.core.management.utils import get_random_secret_key
import certifi
# Third party imports
import dj_database_url
import sentry_sdk
# Django imports
from django.core.management.utils import get_random_secret_key
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from sentry_sdk.integrations.celery import CeleryIntegration
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -23,7 +24,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
DEBUG = int(os.environ.get("DEBUG", "0"))
# Allowed Hosts
ALLOWED_HOSTS = ["*"]
+39 -4
View File
@@ -7,8 +7,8 @@ from .common import * # noqa
DEBUG = True
# Debug Toolbar settings
INSTALLED_APPS += ("debug_toolbar",)
MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",)
INSTALLED_APPS += ("debug_toolbar",) # noqa
MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) # noqa
DEBUG_TOOLBAR_PATCH_SETTINGS = False
@@ -18,7 +18,7 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"LOCATION": REDIS_URL, # noqa
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
@@ -28,7 +28,7 @@ CACHES = {
INTERNAL_IPS = ("127.0.0.1",)
MEDIA_URL = "/uploads/"
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads") # noqa
CORS_ALLOWED_ORIGINS = [
"http://localhost:3000",
@@ -36,3 +36,38 @@ CORS_ALLOWED_ORIGINS = [
"http://localhost:4000",
"http://127.0.0.1:4000",
]
LOG_DIR = os.path.join(BASE_DIR, "logs") # noqa
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "verbose",
},
},
"loggers": {
"django.request": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": False,
},
"plane": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": False,
},
},
}
+62 -2
View File
@@ -1,15 +1,16 @@
"""Production settings"""
import os
from .common import * # noqa
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = int(os.environ.get("DEBUG", 0)) == 1
DEBUG = True
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
INSTALLED_APPS += ("scout_apm.django",)
INSTALLED_APPS += ("scout_apm.django",) # noqa
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
@@ -18,3 +19,62 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False)
SCOUT_KEY = os.environ.get("SCOUT_KEY", "")
SCOUT_NAME = "Plane"
LOG_DIR = os.path.join(BASE_DIR, "logs") # noqa
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"style": "{",
},
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
"level": "INFO",
},
"file": {
"class": "plane.utils.logging.SizedTimedRotatingFileHandler",
"filename": (
os.path.join(BASE_DIR, "logs", "plane-debug.log") # noqa
if DEBUG
else os.path.join(BASE_DIR, "logs", "plane-error.log") # noqa
),
"when": "s",
"maxBytes": 1024 * 1024 * 1,
"interval": 1,
"backupCount": 5,
"formatter": "json",
"level": "DEBUG" if DEBUG else "ERROR",
},
},
"loggers": {
"django": {
"handlers": ["console", "file"],
"level": "INFO",
"propagate": True,
},
"django.request": {
"handlers": ["console", "file"],
"level": "INFO",
"propagate": False,
},
"plane": {
"level": "DEBUG" if DEBUG else "ERROR",
"handlers": ["console", "file"],
"propagate": False,
},
},
}
+1 -1
View File
@@ -7,6 +7,6 @@ DEBUG = True
# Send it in a dummy outbox
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
INSTALLED_APPS.append(
INSTALLED_APPS.append( # noqa
"plane.tests",
)
+12 -15
View File
@@ -1,25 +1,25 @@
# Python imports
import zoneinfo
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
# Django imports
from django.urls import resolve
from django.conf import settings
from django.utils import timezone
from django.db import IntegrityError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django_filters.rest_framework import DjangoFilterBackend
# Third part imports
from rest_framework import status
from rest_framework.viewsets import ModelViewSet
from rest_framework.response import Response
from rest_framework.exceptions import APIException
from rest_framework.views import APIView
from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
from sentry_sdk import capture_exception
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
# Module imports
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
@@ -57,7 +57,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
try:
return self.model.objects.all()
except Exception as e:
capture_exception(e)
log_exception(e)
raise APIException(
"Please check the view", status.HTTP_400_BAD_REQUEST
)
@@ -90,14 +90,13 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
)
if isinstance(e, KeyError):
capture_exception(e)
log_exception(e)
return Response(
{"error": "The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
print(e) if settings.DEBUG else print("Server Error")
capture_exception(e)
log_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -185,9 +184,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
status=status.HTTP_400_BAD_REQUEST,
)
if settings.DEBUG:
print(e)
capture_exception(e)
log_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+14 -10
View File
@@ -1,7 +1,11 @@
from django.core.cache import cache
# from django.utils.encoding import force_bytes
# import hashlib
# Python imports
from functools import wraps
# Django imports
from django.conf import settings
from django.core.cache import cache
# Third party imports
from rest_framework.response import Response
@@ -22,21 +26,20 @@ def cache_response(timeout=60 * 60, path=None, user=True):
def _wrapped_view(instance, request, *args, **kwargs):
# Function to generate cache key
auth_header = (
None if request.user.is_anonymous else str(request.user.id) if user else None
None
if request.user.is_anonymous
else str(request.user.id) if user else None
)
custom_path = path if path is not None else request.get_full_path()
key = generate_cache_key(custom_path, auth_header)
cached_result = cache.get(key)
if cached_result is not None:
print("Cache Hit")
return Response(
cached_result["data"], status=cached_result["status"]
)
print("Cache Miss")
response = view_func(instance, request, *args, **kwargs)
if response.status_code == 200:
if response.status_code == 200 and not settings.DEBUG:
cache.set(
key,
{"data": response.data, "status": response.status_code},
@@ -71,11 +74,12 @@ def invalidate_cache(path=None, url_params=False, user=True):
)
auth_header = (
None if request.user.is_anonymous else str(request.user.id) if user else None
None
if request.user.is_anonymous
else str(request.user.id) if user else None
)
key = generate_cache_key(custom_path, auth_header)
cache.delete(key)
print("Invalidating cache")
# Execute the view function
return view_func(instance, request, *args, **kwargs)
+15
View File
@@ -0,0 +1,15 @@
# Python imports
import logging
# Third party imports
from sentry_sdk import capture_exception
def log_exception(e):
# Log the error
logger = logging.getLogger("plane")
logger.error(e)
# Capture in sentry if configured
capture_exception(e)
return
+46
View File
@@ -0,0 +1,46 @@
import logging.handlers as handlers
import time
class SizedTimedRotatingFileHandler(handlers.TimedRotatingFileHandler):
"""
Handler for logging to a set of files, which switches from one file
to the next when the current file reaches a certain size, or at certain
timed intervals
"""
def __init__(
self,
filename,
maxBytes=0,
backupCount=0,
encoding=None,
delay=0,
when="h",
interval=1,
utc=False,
):
handlers.TimedRotatingFileHandler.__init__(
self, filename, when, interval, backupCount, encoding, delay, utc
)
self.maxBytes = maxBytes
def shouldRollover(self, record):
"""
Determine if rollover should occur.
Basically, see if the supplied record would cause the file to exceed
the size limit we have.
"""
if self.stream is None: # delay was set...
self.stream = self._open()
if self.maxBytes > 0: # are we rolling over?
msg = "%s\n" % self.format(record)
# due to non-posix-compliant Windows feature
self.stream.seek(0, 2)
if self.stream.tell() + len(msg) >= self.maxBytes:
return 1
t = int(time.time())
if t >= self.rolloverAt:
return 1
return 0
+1
View File
@@ -27,6 +27,7 @@ psycopg-binary==3.1.12
psycopg-c==3.1.12
scout-apm==2.26.1
openpyxl==3.1.2
python-json-logger==2.0.7
beautifulsoup4==4.12.2
dj-database-url==2.1.0
posthog==3.0.2
+50 -53
View File
@@ -1,82 +1,79 @@
# 1-Click Self-Hosting
# One-click deploy
In this guide, we will walk you through the process of setting up a 1-click self-hosted environment. Self-hosting allows you to have full control over your applications and data. It's a great way to ensure privacy, control, and customization.
Deployment methods for Plane have improved significantly to make self-managing super-easy. One of those is a single-line-command installation of Plane.
Let's get started!
This short guide will guide you through the process, the background tasks that run with the command for the Community, One, and Enterprise editions, and the post-deployment configuration options available to you.
## Installing Plane
### Requirements
Installing Plane is a very easy and minimal step process.
- Operating systems: Debian, Ubuntu, CentOS
- Supported CPU architectures: AMD64, ARM64, x86_64, AArch64
### Prerequisite
### Download the latest stable release
- Operating System (latest): Debian / Ubuntu / Centos
- Supported CPU Architechture: AMD64 / ARM64 / x86_64 / aarch64
### Downloading Latest Stable Release
Run ↓ on any CLI.
```
curl -fsSL https://raw.githubusercontent.com/makeplane/plane/master/deploy/1-click/install.sh | sh -
```
<details>
<summary>Downloading Preview Release</summary>
### Download the Preview release
`Preview` builds do not support ARM64, AArch64 CPU architectures
Run ↓ on any CLI.
```
export BRANCH=preview
curl -fsSL https://raw.githubusercontent.com/makeplane/plane/preview/deploy/1-click/install.sh | sh -
```
NOTE: `Preview` builds do not support ARM64/AARCH64 CPU architecture
</details>
--
Expect this after a successful install
![Install Output](images/install.png)
Access the application on a browser via http://server-ip-address
---
### Get Control of your Plane Server Setup
### Successful installation
Plane App is available via the command `plane-app`. Running the command `plane-app --help` helps you to manage Plane
You should see ↓ if there are no hitches. That output will also list the IP address you can use to access your Plane instance.
![Install Output](images/install.png)
---
### Manage your Plane instance
Use `plane-app` [OPERATOR] to manage your Plane instance easily. Get a list of all operators with `plane-app ---help`.
![Plane Help](images/help.png)
<ins>Basic Operations</ins>:
1. Basic operators
1. Start Server using `plane-app start`
1. Stop Server using `plane-app stop`
1. Restart Server using `plane-app restart`
1. `plane-app start` starts the Plane server.
2. `plane-app restart` restarts the Plane server.
3. `plane-app stop` stops the Plane server.
<ins>Advanced Operations</ins>:
2. Advanced operators
1. Configure Plane using `plane-app --configure`. This will give you options to modify
`plane-app --configure` will show advanced configurators.
- NGINX Port (default 80)
- Domain Name (default is the local server public IP address)
- File Upload Size (default 5MB)
- External Postgres DB Url (optional - default empty)
- External Redis URL (optional - default empty)
- AWS S3 Bucket (optional - to be configured only in case the user wants to use an S3 Bucket)
- Change your proxy or listening port
<br>Default: 80
- Change your domain name
<br>Default: Deployed server's public IP address
- File upload size
<br>Default: 5MB
- Specify external database address when using an external database
<br>Default: `Empty`
<br>`Default folder: /opt/plane/data/postgres`
- Specify external Redis URL when using external Redis
<br>Default: `Empty`
<br>`Default folder: /opt/plane/data/redis`
- Configure AWS S3 bucket
<br>Use only when you or your users want to use S3
<br>`Default folder: /opt/plane/data/minio`
1. Upgrade Plane using `plane-app --upgrade`. This will get the latest stable version of Plane files (docker-compose.yaml, .env, and docker images)
3. Version operators
1. Updating Plane App installer using `plane-app --update-installer` will update the `plane-app` utility.
1. Uninstall Plane using `plane-app --uninstall`. This will uninstall the Plane application from the server and all docker containers but do not remove the data stored in Postgres, Redis, and Minio.
1. Plane App can be reinstalled using `plane-app --install`.
<ins>Application Data is stored in the mentioned folders</ins>:
1. DB Data: /opt/plane/data/postgres
1. Redis Data: /opt/plane/data/redis
1. Minio Data: /opt/plane/data/minio
1. `plane-app --upgrade` gets the latest stable version of `docker-compose.yaml`, `.env`, and Docker images
2. `plane-app --update-installer` updates the installer and the `plane-app` utility.
3. `plane-app --uninstall` uninstalls the Plane application and all Docker containers from the server but leaves the data stored in
Postgres, Redis, and Minio alone.
4. `plane-app --install` installs the Plane app again.
-12
View File
@@ -494,13 +494,6 @@ function install() {
update_env "CORS_ALLOWED_ORIGINS" "http://$MY_IP"
update_config "INSTALLATION_DATE" "$(date '+%Y-%m-%d')"
if command -v crontab &> /dev/null; then
sudo touch /etc/cron.daily/makeplane
sudo chmod +x /etc/cron.daily/makeplane
sudo echo "0 2 * * * root /usr/local/bin/plane-app --upgrade" > /etc/cron.daily/makeplane
sudo crontab /etc/cron.daily/makeplane
fi
show_message "Plane Installed Successfully ✅"
show_message ""
@@ -606,11 +599,6 @@ function uninstall() {
sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null
sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
if command -v crontab &> /dev/null; then
sudo crontab -r &> /dev/null
sudo rm /etc/cron.daily/makeplane &> /dev/null
fi
# rm -rf $PLANE_INSTALL_DIR &> /dev/null
show_message "- Configuration Cleaned ✅"
+16 -24
View File
@@ -1,18 +1,12 @@
version: "3.8"
x-app-env : &app-env
x-app-env: &app-env
environment:
- NGINX_PORT=${NGINX_PORT:-80}
- WEB_URL=${WEB_URL:-http://localhost}
- DEBUG=${DEBUG:-0}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.production} # deprecated
- NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} # deprecated
- SENTRY_DSN=${SENTRY_DSN:-""}
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-""}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-""}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1} # deprecated
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""}
# Gunicorn Workers
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
@@ -28,20 +22,6 @@ x-app-env : &app-env
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
# EMAIL SETTINGS - Deprecated can be configured through admin panel
- EMAIL_HOST=${EMAIL_HOST:-""}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
- EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_FROM=${EMAIL_FROM:-"Team Plane <team@mailer.plane.so>"}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
# LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
# Application secret
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
# DATA STORE SETTINGS
@@ -90,6 +70,8 @@ services:
command: ./bin/takeoff
deploy:
replicas: ${API_REPLICAS:-1}
volumes:
- logs_api:/code/plane/logs
depends_on:
- plane-db
- plane-redis
@@ -100,6 +82,8 @@ services:
pull_policy: ${PULL_POLICY:-always}
restart: unless-stopped
command: ./bin/worker
volumes:
- logs_worker:/code/plane/logs
depends_on:
- api
- plane-db
@@ -111,6 +95,8 @@ services:
pull_policy: ${PULL_POLICY:-always}
restart: unless-stopped
command: ./bin/beat
volumes:
- logs_beat-worker:/code/plane/logs
depends_on:
- api
- plane-db
@@ -122,8 +108,10 @@ services:
pull_policy: ${PULL_POLICY:-always}
restart: no
command: >
sh -c "python manage.py wait_for_db &&
python manage.py migrate"
sh -c "python manage.py wait_for_db &&
python manage.py migrate"
volumes:
- logs_migrator:/code/plane/logs
depends_on:
- plane-db
- plane-redis
@@ -159,7 +147,7 @@ services:
image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-latest}
pull_policy: ${PULL_POLICY:-always}
ports:
- ${NGINX_PORT}:80
- ${NGINX_PORT}:80
depends_on:
- web
- api
@@ -169,3 +157,7 @@ volumes:
pgdata:
redisdata:
uploads:
logs_api:
logs_worker:
logs_beat-worker:
logs_migrator:
+1 -18
View File
@@ -7,13 +7,8 @@ API_REPLICAS=1
NGINX_PORT=80
WEB_URL=http://localhost
DEBUG=0
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
SENTRY_DSN=
SENTRY_ENVIRONMENT=production
GOOGLE_CLIENT_ID=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
DOCKERIZED=1 # deprecated
CORS_ALLOWED_ORIGINS=http://localhost
#DB SETTINGS
@@ -30,19 +25,7 @@ REDIS_HOST=plane-redis
REDIS_PORT=6379
REDIS_URL=redis://${REDIS_HOST}:6379/
# EMAIL SETTINGS
EMAIL_HOST=
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_PORT=587
EMAIL_FROM=Team Plane <team@mailer.plane.so>
EMAIL_USE_TLS=1
EMAIL_USE_SSL=0
# LOGIN/SIGNUP SETTINGS
ENABLE_SIGNUP=1
ENABLE_EMAIL_PASSWORD=1
ENABLE_MAGIC_LINK_LOGIN=0
# Secret Key
SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
# DATA STORE SETTINGS
+14
View File
@@ -1,3 +1,4 @@
import { Selection } from "@tiptap/pm/state";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
interface EditorClassNames {
@@ -18,6 +19,19 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Helper function to find the parent node of a specific type
export function findParentNodeOfType(selection: Selection, typeName: string) {
let depth = selection.$anchor.depth;
while (depth > 0) {
const node = selection.$anchor.node(depth);
if (node.type.name === typeName) {
return { node, pos: selection.$anchor.start(depth) - 1 };
}
depth--;
}
return null;
}
export const findTableAncestor = (node: Node | null): HTMLTableElement | null => {
while (node !== null && node.nodeName !== "TABLE") {
node = node.parentNode;
@@ -1,5 +1,5 @@
import { Editor } from "@tiptap/react";
import { ReactNode } from "react";
import { FC, ReactNode } from "react";
interface EditorContainerProps {
editor: Editor | null;
@@ -8,17 +8,54 @@ interface EditorContainerProps {
hideDragHandle?: () => void;
}
export const EditorContainer = ({ editor, editorClassNames, hideDragHandle, children }: EditorContainerProps) => (
<div
id="editor-container"
onClick={() => {
editor?.chain().focus(undefined, { scrollIntoView: false }).run();
}}
onMouseLeave={() => {
hideDragHandle?.();
}}
className={`cursor-text ${editorClassNames}`}
>
{children}
</div>
);
export const EditorContainer: FC<EditorContainerProps> = (props) => {
const { editor, editorClassNames, hideDragHandle, children } = props;
const handleContainerClick = () => {
if (!editor) return;
if (!editor.isEditable) return;
if (editor.isFocused) return; // If editor is already focused, do nothing
const { selection } = editor.state;
const currentNode = selection.$from.node();
editor?.chain().focus("end", { scrollIntoView: false }).run(); // Focus the editor at the end
if (
currentNode.content.size === 0 && // Check if the current node is empty
!(
editor.isActive("orderedList") ||
editor.isActive("bulletList") ||
editor.isActive("taskItem") ||
editor.isActive("table") ||
editor.isActive("blockquote") ||
editor.isActive("codeBlock")
) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block
) {
return;
}
// Insert a new paragraph at the end of the document
const endPosition = editor?.state.doc.content.size;
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run();
// Focus the newly added paragraph for immediate editing
editor
.chain()
.setTextSelection(endPosition + 1)
.run();
};
return (
<div
id="editor-container"
onClick={handleContainerClick}
onMouseLeave={() => {
hideDragHandle?.();
}}
className={`cursor-text ${editorClassNames}`}
>
{children}
</div>
);
};
@@ -1,17 +1,28 @@
import { Editor, EditorContent } from "@tiptap/react";
import { ReactNode } from "react";
import { FC, ReactNode } from "react";
import { ImageResizer } from "src/ui/extensions/image/image-resize";
interface EditorContentProps {
editor: Editor | null;
editorContentCustomClassNames: string | undefined;
children?: ReactNode;
tabIndex?: number;
}
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = "", children }: EditorContentProps) => (
<div className={`contentEditor ${editorContentCustomClassNames}`}>
<EditorContent editor={editor} />
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
{children}
</div>
);
export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
const { editor, editorContentCustomClassNames = "", tabIndex, children } = props;
return (
<div
className={`contentEditor ${editorContentCustomClassNames}`}
tabIndex={tabIndex}
onFocus={() => {
editor?.chain().focus(undefined, { scrollIntoView: false }).run();
}}
>
<EditorContent editor={editor} />
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
{children}
</div>
);
};
@@ -5,6 +5,8 @@ import ImageExt from "@tiptap/extension-image";
import { onNodeDeleted, onNodeRestored } from "src/ui/plugins/delete-image";
import { DeleteImage } from "src/types/delete-image";
import { RestoreImage } from "src/types/restore-image";
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
interface ImageNode extends ProseMirrorNode {
attrs: {
@@ -18,6 +20,12 @@ const IMAGE_NODE_TYPE = "image";
export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => any) =>
ImageExt.extend({
addKeyboardShortcuts() {
return {
ArrowDown: insertLineBelowImageAction,
ArrowUp: insertLineAboveImageAction,
};
},
addProseMirrorPlugins() {
return [
UploadImagesPlugin(cancelUploadImage),
@@ -0,0 +1,45 @@
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { KeyboardShortcutCommand } from "@tiptap/core";
export const insertLineAboveImageAction: KeyboardShortcutCommand = ({ editor }) => {
const { selection, doc } = editor.state;
const { $from, $to } = selection;
let imageNode: ProseMirrorNode | null = null;
let imagePos: number | null = null;
// Check if the selection itself is an image node
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
if (node.type.name === "image") {
imageNode = node;
imagePos = pos;
return false; // Stop iterating once an image node is found
}
return true;
});
if (imageNode === null || imagePos === null) return false;
// Since we want to insert above the image, we use the imagePos directly
const insertPos = imagePos;
if (insertPos < 0) return false;
// Check for an existing node immediately before the image
if (insertPos === 0) {
// If the previous node doesn't exist or isn't a paragraph, create and insert a new empty node there
editor.chain().insertContentAt(insertPos, { type: "paragraph" }).run();
editor.chain().setTextSelection(insertPos).run();
} else {
const prevNode = doc.nodeAt(insertPos);
if (prevNode && prevNode.type.name === "paragraph") {
// If the previous node is a paragraph, move the cursor there
editor.chain().setTextSelection(insertPos).run();
} else {
return false;
}
}
return true;
};
@@ -0,0 +1,46 @@
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { KeyboardShortcutCommand } from "@tiptap/core";
export const insertLineBelowImageAction: KeyboardShortcutCommand = ({ editor }) => {
const { selection, doc } = editor.state;
const { $from, $to } = selection;
let imageNode: ProseMirrorNode | null = null;
let imagePos: number | null = null;
// Check if the selection itself is an image node
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
if (node.type.name === "image") {
imageNode = node;
imagePos = pos;
return false; // Stop iterating once an image node is found
}
return true;
});
if (imageNode === null || imagePos === null) return false;
const guaranteedImageNode: ProseMirrorNode = imageNode;
const nextNodePos = imagePos + guaranteedImageNode.nodeSize;
// Check for an existing node immediately after the image
const nextNode = doc.nodeAt(nextNodePos);
if (nextNode && nextNode.type.name === "paragraph") {
// If the next node is a paragraph, move the cursor there
const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1;
editor.chain().setTextSelection(endOfParagraphPos).run();
} else if (!nextNode) {
// If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there
editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run();
editor
.chain()
.setTextSelection(nextNodePos + 1)
.run();
} else {
// If the next node is not a paragraph, do not proceed
return false;
}
return true;
};
@@ -27,7 +27,7 @@ import { RestoreImage } from "src/types/restore-image";
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
import { CustomTypographyExtension } from "src/ui/extensions/typography";
import { CustomHorizontalRule } from "./horizontal-rule/horizontal-rule";
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
export const CoreEditorExtensions = (
mentionConfig: {
@@ -66,7 +66,6 @@ export const CoreEditorExtensions = (
CustomQuoteExtension.configure({
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
}),
CustomHorizontalRule.configure({
HTMLAttributes: { class: "mt-4 mb-4" },
}),
@@ -25,6 +25,8 @@ import { tableControls } from "src/ui/extensions/table/table/table-controls";
import { TableView } from "src/ui/extensions/table/table/table-view";
import { createTable } from "src/ui/extensions/table/table/utilities/create-table";
import { deleteTableWhenAllCellsSelected } from "src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected";
import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action";
import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action";
export interface TableOptions {
HTMLAttributes: Record<string, any>;
@@ -231,6 +233,8 @@ export const Table = Node.create({
"Mod-Backspace": deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected,
"Mod-Delete": deleteTableWhenAllCellsSelected,
ArrowDown: insertLineBelowTableAction,
ArrowUp: insertLineAboveTableAction,
};
},
@@ -0,0 +1,50 @@
import { KeyboardShortcutCommand } from "@tiptap/core";
import { findParentNodeOfType } from "src/lib/utils";
export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) => {
// Check if the current selection or the closest node is a table
if (!editor.isActive("table")) return false;
// Get the current selection
const { selection } = editor.state;
// Find the table node and its position
const tableNode = findParentNodeOfType(selection, "table");
if (!tableNode) return false;
const tablePos = tableNode.pos;
// Determine if the selection is in the first row of the table
const firstRow = tableNode.node.child(0);
const selectionPath = (selection.$anchor as any).path;
const selectionInFirstRow = selectionPath.includes(firstRow);
if (!selectionInFirstRow) return false;
// Check if the table is at the very start of the document or its parent node
if (tablePos === 0) {
// The table is at the start, so just insert a paragraph at the current position
editor.chain().insertContentAt(tablePos, { type: "paragraph" }).run();
editor
.chain()
.setTextSelection(tablePos + 1)
.run();
} else {
// The table is not at the start, check for the node immediately before the table
const prevNodePos = tablePos - 1;
if (prevNodePos <= 0) return false;
const prevNode = editor.state.doc.nodeAt(prevNodePos - 1);
if (prevNode && prevNode.type.name === "paragraph") {
// If there's a paragraph before the table, move the cursor to the end of that paragraph
const endOfParagraphPos = tablePos - prevNode.nodeSize;
editor.chain().setTextSelection(endOfParagraphPos).run();
} else {
return false;
}
}
return true;
};
@@ -0,0 +1,48 @@
import { KeyboardShortcutCommand } from "@tiptap/core";
import { findParentNodeOfType } from "src/lib/utils";
export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) => {
// Check if the current selection or the closest node is a table
if (!editor.isActive("table")) return false;
// Get the current selection
const { selection } = editor.state;
// Find the table node and its position
const tableNode = findParentNodeOfType(selection, "table");
if (!tableNode) return false;
const tablePos = tableNode.pos;
const table = tableNode.node;
// Determine if the selection is in the last row of the table
const rowCount = table.childCount;
const lastRow = table.child(rowCount - 1);
const selectionPath = (selection.$anchor as any).path;
const selectionInLastRow = selectionPath.includes(lastRow);
if (!selectionInLastRow) return false;
// Calculate the position immediately after the table
const nextNodePos = tablePos + table.nodeSize;
// Check for an existing node immediately after the table
const nextNode = editor.state.doc.nodeAt(nextNodePos);
if (nextNode && nextNode.type.name === "paragraph") {
// If the next node is an paragraph, move the cursor there
const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1;
editor.chain().setTextSelection(endOfParagraphPos).run();
} else if (!nextNode) {
// If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there
editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run();
editor
.chain()
.setTextSelection(nextNodePos + 1)
.run();
} else {
return false;
}
return true;
};
@@ -5,7 +5,6 @@ import { Color } from "@tiptap/extension-color";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { Markdown } from "tiptap-markdown";
import Gapcursor from "@tiptap/extension-gapcursor";
import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
import { Table } from "src/ui/extensions/table/table";
@@ -17,6 +16,11 @@ import { isValidHttpUrl } from "src/lib/utils";
import { Mentions } from "src/ui/mentions";
import { IMentionSuggestion } from "src/types/mention-suggestion";
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
import { CustomQuoteExtension } from "src/ui/extensions/quote";
import { CustomTypographyExtension } from "src/ui/extensions/typography";
import { CustomCodeBlockExtension } from "src/ui/extensions/code";
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
export const CoreReadOnlyEditorExtensions = (mentionConfig: {
mentionSuggestions: IMentionSuggestion[];
@@ -38,36 +42,31 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
class: "leading-normal -mb-2",
},
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
},
code: {
HTMLAttributes: {
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
},
code: false,
codeBlock: false,
horizontalRule: {
HTMLAttributes: { class: "mt-4 mb-4" },
},
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 2,
},
horizontalRule: false,
blockquote: false,
dropcursor: false,
gapcursor: false,
}),
Gapcursor,
CustomQuoteExtension.configure({
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
}),
CustomHorizontalRule.configure({
HTMLAttributes: { class: "mt-4 mb-4" },
}),
CustomLinkExtension.configure({
openOnClick: true,
autolink: true,
linkOnPaste: true,
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
validate: (url: string) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
CustomTypographyExtension,
ReadOnlyImageExtension.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
@@ -87,6 +86,8 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
},
nested: true,
}),
CustomCodeBlockExtension,
CustomCodeInlineExtension,
Markdown.configure({
html: true,
transformCopiedText: true,
@@ -19,7 +19,7 @@ export const ContentBrowser = (props: ContentBrowserProps) => {
return (
<div className="flex h-full flex-col overflow-hidden">
<h2 className="font-medium">Table of Contents</h2>
<h2 className="font-medium">Outline</h2>
<div className="h-full overflow-y-auto">
{markings.length !== 0 ? (
markings.map((marking) =>
@@ -29,11 +29,13 @@ type IPageRenderer = {
editorContentCustomClassNames?: string;
hideDragHandle?: () => void;
readonly: boolean;
tabIndex?: number;
};
export const PageRenderer = (props: IPageRenderer) => {
const {
documentDetails,
tabIndex,
editor,
editorClassNames,
editorContentCustomClassNames,
@@ -152,7 +154,7 @@ export const PageRenderer = (props: IPageRenderer) => {
);
return (
<div className="w-full pb-64 md:pl-7 pl-3 pt-5 page-renderer">
<div className="w-full h-full pb-20 pl-7 pt-5 page-renderer">
{!readonly ? (
<input
onChange={(e) => handlePageTitleChange(e.target.value)}
@@ -169,7 +171,11 @@ export const PageRenderer = (props: IPageRenderer) => {
)}
<div className="flex relative h-full w-full flex-col pr-5 editor-renderer" onMouseOver={handleLinkHover}>
<EditorContainer hideDragHandle={hideDragHandle} editor={editor} editorClassNames={editorClassNames}>
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
<EditorContentWrapper
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
</EditorContainer>
</div>
{isOpen && linkViewProps && coordinates && (
@@ -47,6 +47,8 @@ interface IDocumentEditor {
duplicationConfig?: IDuplicationConfig;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
tabIndex?: number;
}
interface DocumentEditorProps extends IDocumentEditor {
forwardedRef?: React.Ref<EditorHandle>;
@@ -79,6 +81,7 @@ const DocumentEditor = ({
cancelUploadImage,
onActionCompleteHandler,
rerenderOnPropsChange,
tabIndex,
}: IDocumentEditor) => {
const { markings, updateMarkings } = useEditorMarkings();
const [sidePeekVisible, setSidePeekVisible] = useState(true);
@@ -160,6 +163,7 @@ const DocumentEditor = ({
</div>
<div className="h-full w-full md:w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
<PageRenderer
tabIndex={tabIndex}
onActionCompleteHandler={onActionCompleteHandler}
hideDragHandle={hideDragHandleOnMouseLeave}
readonly={false}
@@ -28,6 +28,7 @@ interface IDocumentReadOnlyEditor {
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
tabIndex?: number;
}
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
@@ -51,6 +52,7 @@ const DocumentReadOnlyEditor = ({
pageArchiveConfig,
rerenderOnPropsChange,
onActionCompleteHandler,
tabIndex,
}: DocumentReadOnlyEditorProps) => {
const router = useRouter();
const [sidePeekVisible, setSidePeekVisible] = useState(true);
@@ -108,9 +110,10 @@ const DocumentReadOnlyEditor = ({
</div>
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
<PageRenderer
tabIndex={tabIndex}
onActionCompleteHandler={onActionCompleteHandler}
updatePageTitle={() => Promise.resolve()}
readonly={true}
readonly
editor={editor}
editorClassNames={editorClassNames}
documentDetails={documentDetails}
@@ -42,6 +42,7 @@ interface ILiteTextEditor {
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
submitButton?: React.ReactNode;
tabIndex?: number;
}
interface LiteTextEditorProps extends ILiteTextEditor {
@@ -74,6 +75,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
mentionHighlights,
mentionSuggestions,
submitButton,
tabIndex,
} = props;
const editor = useEditor({
@@ -103,7 +105,11 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
return (
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
<EditorContentWrapper
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
<div className="mt-4 w-full">
<FixedMenu
editor={editor}
@@ -8,6 +8,7 @@ interface ICoreReadOnlyEditor {
borderOnFocus?: boolean;
customClassName?: string;
mentionHighlights: string[];
tabIndex?: number;
}
interface EditorCoreProps extends ICoreReadOnlyEditor {
@@ -27,6 +28,7 @@ const LiteReadOnlyEditor = ({
value,
forwardedRef,
mentionHighlights,
tabIndex,
}: EditorCoreProps) => {
const editor = useReadOnlyEditor({
value,
@@ -45,7 +47,11 @@ const LiteReadOnlyEditor = ({
return (
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
<EditorContentWrapper
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
</div>
</EditorContainer>
);
@@ -36,6 +36,7 @@ export type IRichTextEditor = {
debouncedUpdatesEnabled?: boolean;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
tabIndex?: number;
};
export interface RichTextEditorProps extends IRichTextEditor {
@@ -68,6 +69,7 @@ const RichTextEditor = ({
mentionHighlights,
rerenderOnPropsChange,
mentionSuggestions,
tabIndex,
}: RichTextEditorProps) => {
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
@@ -100,17 +102,21 @@ const RichTextEditor = ({
customClassName,
});
React.useEffect(() => {
if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue);
}, [editor, initialValue]);
// React.useEffect(() => {
// if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue);
// }, [editor, initialValue]);
//
if (!editor) return null;
return (
<EditorContainer hideDragHandle={hideDragHandleOnMouseLeave} editor={editor} editorClassNames={editorClassNames}>
{editor && <EditorBubbleMenu editor={editor} />}
<div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
<EditorContentWrapper
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
</div>
</EditorContainer>
);
@@ -121,7 +121,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
<button
key={item.name}
type="button"
onClick={item.command}
onClick={(e) => {
item.command();
e.stopPropagation();
}}
className={cn(
"p-2 text-custom-text-300 transition-colors hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5",
{
@@ -33,8 +33,9 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
{ "bg-custom-background-100": isOpen }
)}
onClick={() => {
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
>
<p className="text-base"></p>
@@ -60,6 +61,9 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
ref={inputRef}
type="url"
placeholder="Paste a link"
onClick={(e) => {
e.stopPropagation();
}}
className="flex-1 border-r border-custom-border-300 bg-custom-background-100 p-1 text-sm outline-none placeholder:text-custom-text-400"
defaultValue={editor.getAttributes("link").href || ""}
/>
@@ -67,9 +71,10 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
<button
type="button"
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
onClick={() => {
onClick={(e) => {
unsetLinkEditor(editor);
setIsOpen(false);
e.stopPropagation();
}}
>
<Trash className="h-4 w-4" />
@@ -78,7 +83,8 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
<button
className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90"
type="button"
onClick={() => {
onClick={(e) => {
e.stopPropagation();
onLinkSubmit();
}}
>
@@ -47,7 +47,10 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
<div className="relative h-full">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
>
<span>{activeItem?.name}</span>
@@ -60,9 +63,10 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
<button
key={item.name}
type="button"
onClick={() => {
onClick={(e) => {
item.command();
setIsOpen(false);
e.stopPropagation();
}}
className={cn(
"flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",
@@ -9,6 +9,7 @@ interface IRichTextReadOnlyEditor {
borderOnFocus?: boolean;
customClassName?: string;
mentionHighlights?: string[];
tabIndex?: number;
}
interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor {
@@ -1,11 +1,7 @@
import type { TIssue, IIssueFilterOptions } from "@plane/types";
export type TCycleView = "all" | "active" | "upcoming" | "completed" | "draft";
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
export type TCycleLayout = "list" | "board" | "gantt";
export interface ICycle {
backlog_issues: number;
cancelled_issues: number;
@@ -30,6 +26,7 @@ export interface ICycle {
sort_order: number;
start_date: string | null;
started_issues: number;
sub_issues: number;
total_issues: number;
unstarted_issues: number;
updated_at: Date;
+19
View File
@@ -0,0 +1,19 @@
export type TCycleTabOptions = "active" | "all";
export type TCycleLayoutOptions = "list" | "board" | "gantt";
export type TCycleDisplayFilters = {
active_tab?: TCycleTabOptions;
layout?: TCycleLayoutOptions;
};
export type TCycleFilters = {
end_date?: string[] | null;
start_date?: string[] | null;
status?: string[] | null;
};
export type TCycleStoredFilters = {
display_filters?: TCycleDisplayFilters;
filters?: TCycleFilters;
};
+2
View File
@@ -0,0 +1,2 @@
export * from "./cycle_filters";
export * from "./cycle";
+1 -1
View File
@@ -1,7 +1,7 @@
export * from "./github-importer";
export * from "./jira-importer";
import { IProjectLite } from "../projects";
import { IProjectLite } from "../project";
// types
import { IUserLite } from "../users";
+1 -1
View File
@@ -1,5 +1,5 @@
import { TIssue } from "../issues/base";
import type { IProjectLite } from "../projects";
import type { IProjectLite } from "../project";
export type TInboxIssueExtended = {
completed_at: string | null;
+3 -3
View File
@@ -1,11 +1,11 @@
export * from "./users";
export * from "./workspace";
export * from "./cycles";
export * from "./cycle";
export * from "./dashboard";
export * from "./projects";
export * from "./project";
export * from "./state";
export * from "./issues";
export * from "./modules";
export * from "./module";
export * from "./views";
export * from "./integration";
export * from "./pages";
+2
View File
@@ -0,0 +1,2 @@
export * from "./module_filters";
export * from "./modules";
+32
View File
@@ -0,0 +1,32 @@
export type TModuleOrderByOptions =
| "name"
| "-name"
| "progress"
| "-progress"
| "issues_length"
| "-issues_length"
| "target_date"
| "-target_date"
| "created_at"
| "-created_at";
export type TModuleLayoutOptions = "list" | "board" | "gantt";
export type TModuleDisplayFilters = {
favorites?: boolean;
layout?: TModuleLayoutOptions;
order_by?: TModuleOrderByOptions;
};
export type TModuleFilters = {
lead?: string[] | null;
members?: string[] | null;
start_date?: string[] | null;
status?: string[] | null;
target_date?: string[] | null;
};
export type TModuleStoredFilters = {
display_filters?: TModuleDisplayFilters;
filters?: TModuleFilters;
};
@@ -30,6 +30,7 @@ export interface IModule {
name: string;
project_id: string;
sort_order: number;
sub_issues: number;
start_date: string | null;
started_issues: number;
status: TModuleStatus;
+2
View File
@@ -0,0 +1,2 @@
export * from "./project_filters";
export * from "./projects";
+25
View File
@@ -0,0 +1,25 @@
export type TProjectOrderByOptions =
| "sort_order"
| "name"
| "-name"
| "created_at"
| "-created_at"
| "members_length"
| "-members_length";
export type TProjectDisplayFilters = {
my_projects?: boolean;
order_by?: TProjectOrderByOptions;
};
export type TProjectFilters = {
access?: string[] | null;
lead?: string[] | null;
members?: string[] | null;
created_at?: string[] | null;
};
export type TProjectStoredFilters = {
display_filters?: TProjectDisplayFilters;
filters?: TProjectFilters;
};
@@ -7,7 +7,7 @@ import type {
IWorkspace,
IWorkspaceLite,
TStateGroups,
} from ".";
} from "..";
export type TProjectLogoProps = {
in_use: "emoji" | "icon";
@@ -23,6 +23,8 @@ export type TProjectLogoProps = {
export interface IProject {
archive_in: number;
archived_issues: number;
archived_sub_issues: number;
close_in: number;
created_at: Date;
created_by: string;
@@ -35,6 +37,8 @@ export interface IProject {
default_assignee: IUser | string | null;
default_state: string | null;
description: string;
draft_issues: number;
draft_sub_issues: number;
estimate: string | null;
id: string;
identifier: string;
@@ -48,7 +52,9 @@ export interface IProject {
network: number;
project_lead: IUserLite | string | null;
sort_order: number | null;
sub_issues: number;
total_cycles: number;
total_issues: number;
total_members: number;
total_modules: number;
updated_at: Date;
-4
View File
@@ -1,4 +0,0 @@
module.exports = {
root: true,
extends: ["custom"],
};
-1
View File
@@ -14,7 +14,6 @@
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --external react --minify",
"dev": "tsup src/index.ts --format esm,cjs --watch --dts --external react",
"lint": "eslint src/",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
},
"dependencies": {
+2 -2
View File
@@ -103,7 +103,7 @@ export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
style={styles.popper}
{...attributes.popper}
className={cn(
"h-80 w-80 bg-custom-background-100 rounded-md border-[0.5px] border-custom-border-300 overflow-hidden",
"w-80 bg-custom-background-100 rounded-md border-[0.5px] border-custom-border-300 overflow-hidden",
dropdownClassName
)}
>
@@ -146,7 +146,7 @@ export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
}}
/>
</Tab.Panel>
<Tab.Panel>
<Tab.Panel className="h-80 w-full">
<IconsList
defaultColor={defaultIconColor}
onChange={(val) => {
@@ -4,7 +4,7 @@ import { ISvgIcons } from "../type";
export const CircleDotFullIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg viewBox="0 0 16 16" className={`${className} stroke-1`} fill="none" xmlns="http://www.w3.org/2000/svg" {...rest}>
<circle cx="8.33333" cy="8.33333" r="5.33333" stroke="currentColor" stroke-linecap="round" />
<circle cx="8.33333" cy="8.33333" r="5.33333" stroke="currentColor" strokeLinecap="round" />
<circle cx="8.33333" cy="8.33333" r="4.33333" fill="currentColor" />
</svg>
);
+3 -1
View File
@@ -29,6 +29,7 @@ interface ITooltipProps {
className?: string;
openDelay?: number;
closeDelay?: number;
isMobile?: boolean;
}
export const Tooltip: React.FC<ITooltipProps> = ({
@@ -40,6 +41,7 @@ export const Tooltip: React.FC<ITooltipProps> = ({
className = "",
openDelay = 200,
closeDelay,
isMobile = false,
}) => (
<Tooltip2
disabled={disabled}
@@ -47,7 +49,7 @@ export const Tooltip: React.FC<ITooltipProps> = ({
hoverCloseDelay={closeDelay}
content={
<div
className={`relative z-50 max-w-xs gap-1 overflow-hidden break-words rounded-md bg-custom-background-100 p-2 text-xs text-custom-text-200 shadow-md ${className}`}
className={`relative ${isMobile ? "hidden" : "block"} z-50 max-w-xs gap-1 overflow-hidden break-words rounded-md bg-custom-background-100 p-2 text-xs text-custom-text-200 shadow-md ${className}`}
>
{tooltipHeading && <h5 className="font-medium text-custom-text-100">{tooltipHeading}</h5>}
{tooltipContent}
@@ -6,6 +6,8 @@ import { renderFormattedDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { IApiToken } from "@plane/types";
// hooks
import { usePlatformOS } from "hooks/use-platform-os";
type Props = {
handleClose: () => void;
@@ -14,7 +16,7 @@ type Props = {
export const GeneratedTokenDetails: React.FC<Props> = (props) => {
const { handleClose, tokenDetails } = props;
const { isMobile } = usePlatformOS();
const copyApiToken = (token: string) => {
copyTextToClipboard(token).then(() =>
setToast({
@@ -40,7 +42,7 @@ export const GeneratedTokenDetails: React.FC<Props> = (props) => {
className="mt-4 flex w-full items-center justify-between rounded-md border-[0.5px] border-custom-border-200 px-3 py-2 text-sm font-medium outline-none"
>
{tokenDetails.token}
<Tooltip tooltipContent="Copy secret key">
<Tooltip tooltipContent="Copy secret key" isMobile={isMobile}>
<Copy className="h-4 w-4 text-custom-text-400" />
</Tooltip>
</button>
+6 -4
View File
@@ -3,6 +3,7 @@ import { XCircle } from "lucide-react";
// components
import { Tooltip } from "@plane/ui";
import { DeleteApiTokenModal } from "components/api-token";
import { usePlatformOS } from "hooks/use-platform-os";
// ui
// helpers
import { renderFormattedDate, calculateTimeAgo } from "helpers/date-time.helper";
@@ -17,12 +18,14 @@ export const ApiTokenListItem: React.FC<Props> = (props) => {
const { token } = props;
// states
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
// hooks
const { isMobile } = usePlatformOS();
return (
<>
<DeleteApiTokenModal isOpen={deleteModalOpen} onClose={() => setDeleteModalOpen(false)} tokenId={token.id} />
<div className="group relative flex flex-col justify-center border-b border-custom-border-200 px-4 py-3">
<Tooltip tooltipContent="Delete token">
<Tooltip tooltipContent="Delete token" isMobile={isMobile}>
<button
onClick={() => setDeleteModalOpen(true)}
className="absolute right-4 hidden place-items-center group-hover:grid"
@@ -33,9 +36,8 @@ export const ApiTokenListItem: React.FC<Props> = (props) => {
<div className="flex w-4/5 items-center">
<h5 className="truncate text-sm font-medium">{token.label}</h5>
<span
className={`${
token.is_active ? "bg-green-500/10 text-green-500" : "bg-custom-background-80 text-custom-text-400"
} ml-2 flex h-4 max-h-fit items-center rounded-sm px-2 text-xs font-medium`}
className={`${token.is_active ? "bg-green-500/10 text-green-500" : "bg-custom-background-80 text-custom-text-400"
} ml-2 flex h-4 max-h-fit items-center rounded-sm px-2 text-xs font-medium`}
>
{token.is_active ? "Active" : "Expired"}
</span>
@@ -20,12 +20,11 @@ import {
} from "components/command-palette";
import { ISSUE_DETAILS } from "constants/fetch-keys";
import { useApplication, useEventTracker, useProject } from "hooks/store";
import { usePlatformOS } from "hooks/use-platform-os";
// services
import useDebounce from "hooks/use-debounce";
import { IssueService } from "services/issue";
import { WorkspaceService } from "services/workspace.service";
// hooks
// components
// types
import { IWorkspaceSearchResults } from "@plane/types";
// fetch-keys
@@ -37,6 +36,7 @@ const issueService = new IssueService();
export const CommandModal: React.FC = observer(() => {
// hooks
const { getProjectById } = useProject();
const { isMobile } = usePlatformOS();
// states
const [placeholder, setPlaceholder] = useState("Type a command or search...");
const [resultsCount, setResultsCount] = useState(0);
@@ -197,7 +197,7 @@ export const CommandModal: React.FC = observer(() => {
</div>
)}
{projectId && (
<Tooltip tooltipContent="Toggle workspace level search">
<Tooltip tooltipContent="Toggle workspace level search" isMobile={isMobile}>
<div className="flex flex-shrink-0 cursor-pointer items-center gap-1 self-end text-xs sm:self-center">
<button
type="button"
+3 -1
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { Tooltip } from "@plane/ui";
import { usePlatformOS } from "hooks/use-platform-os";
type Props = {
label?: string;
@@ -9,8 +10,9 @@ type Props = {
export const BreadcrumbLink: React.FC<Props> = (props) => {
const { href, label, icon } = props;
const { isMobile } = usePlatformOS();
return (
<Tooltip tooltipContent={label} position="bottom">
<Tooltip tooltipContent={label} position="bottom" isMobile={isMobile}>
<li className="flex items-center space-x-2" tabIndex={-1}>
<div className="flex flex-wrap items-center gap-2.5">
{href ? (
+18 -13
View File
@@ -1,6 +1,7 @@
import { useEffect } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import { usePlatformOS } from "hooks/use-platform-os";
// store hooks
// icons
import {
@@ -29,22 +30,25 @@ import { IIssueActivity } from "@plane/types";
export const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { isMobile } = usePlatformOS();
return (
<Tooltip tooltipContent={activity?.issue_detail ? activity.issue_detail.name : "This issue has been deleted"}>
<Tooltip
tooltipContent={activity?.issue_detail ? activity.issue_detail.name : "This issue has been deleted"}
isMobile={isMobile}
>
{activity?.issue_detail ? (
<a
aria-disabled={activity.issue === null}
href={`${`/${workspaceSlug ?? activity.workspace_detail?.slug}/projects/${activity.project}/issues/${activity.issue
}`}`}
href={`${`/${workspaceSlug ?? activity.workspace_detail?.slug}/projects/${activity.project}/issues/${
activity.issue
}`}`}
target={activity.issue === null ? "_self" : "_blank"}
rel={activity.issue === null ? "" : "noopener noreferrer"}
className="border border-red-500 relative w-full overflow-hidden"
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
>
<div className="border border-red-500 overflow-hidden relative inline-flex w-full items-center gap-1 font-medium text-custom-text-100 hover:underline">
<div className="whitespace-nowrap break-all">{`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}</div>{" "}
<div className="relative whitespace-nowrap break-all overflow-hidden w-full font-normal truncate line-clamp-1">{activity.issue_detail?.name}</div>
</div>
<span className="whitespace-nowrap">{`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}</span>{" "}
<span className="font-normal">{activity.issue_detail?.name}</span>
</a>
) : (
<span className="inline-flex items-center gap-1 font-medium text-custom-text-100 whitespace-nowrap">
@@ -61,8 +65,9 @@ const UserLink = ({ activity }: { activity: IIssueActivity }) => {
return (
<a
href={`/${workspaceSlug ?? activity.workspace_detail?.slug}/profile/${activity.new_identifier ?? activity.old_identifier
}`}
href={`/${workspaceSlug ?? activity.workspace_detail?.slug}/profile/${
activity.new_identifier ?? activity.old_identifier
}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center font-medium text-custom-text-100 hover:underline"
@@ -591,7 +596,7 @@ const activityDetails: {
state: {
message: (activity, showIssue) => (
<>
{/* set the state to <span className="border border-green-500 font-medium text-custom-text-100">{activity.new_value}</span> */}
set the state to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue && (
<>
{" "}
@@ -678,12 +683,12 @@ export const ActivityMessage = ({ activity, showIssue = false }: ActivityMessage
const { workspaceSlug } = router.query;
return (
<div className="w-full border border-red-500 overflow-hidden">
<>
{activityDetails[activity.field as keyof typeof activityDetails]?.message(
activity,
showIssue,
workspaceSlug ? workspaceSlug.toString() : activity.workspace_detail?.slug ?? ""
)}
</div>
</>
);
};
@@ -5,7 +5,7 @@ import { Rocket, Search, X } from "lucide-react";
import { Button, LayersIcon, Loader, ToggleSwitch, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
import useDebounce from "hooks/use-debounce";
import { usePlatformOS } from "hooks/use-platform-os";
import { ProjectService } from "services/project";
// ui
// types
@@ -40,7 +40,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
const [isSearching, setIsSearching] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const { isMobile } = usePlatformOS();
const debouncedSearchTerm: string = useDebounce(searchTerm, 500);
const handleClose = () => {
@@ -154,7 +154,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
</div>
)}
{workspaceLevelToggle && (
<Tooltip tooltipContent="Toggle workspace level search">
<Tooltip tooltipContent="Toggle workspace level search" isMobile={isMobile}>
<div
className={`flex flex-shrink-0 cursor-pointer items-center gap-1 text-xs ${
isWorkspaceLevel ? "text-custom-text-100" : "text-custom-text-200"
+3 -2
View File
@@ -7,6 +7,7 @@ import { ExternalLinkIcon, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
import { calculateTimeAgo } from "helpers/date-time.helper";
// hooks
import { useMember } from "hooks/store";
import { usePlatformOS } from "hooks/use-platform-os";
// types
import { ILinkDetails, UserAuth } from "@plane/types";
@@ -19,7 +20,7 @@ type Props = {
export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, handleEditLink, userAuth }) => {
const { getUserDetails } = useMember();
const { isMobile } = usePlatformOS();
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
const copyToClipboard = (text: string) => {
@@ -42,7 +43,7 @@ export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, h
<span className="py-1">
<LinkIcon className="h-3 w-3 flex-shrink-0" />
</span>
<Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url}>
<Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url} isMobile={isMobile}>
<span
className="cursor-pointer truncate text-xs"
onClick={() => copyToClipboard(link.title && link.title !== "" ? link.title : link.url)}
@@ -127,7 +127,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
<Tab.Panel
as="div"
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
>
{distribution?.assignees.length > 0 ? (
distribution.assignees.map((assignee, index) => {
@@ -187,7 +187,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab.Panel>
<Tab.Panel
as="div"
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
>
{distribution?.labels.length > 0 ? (
distribution.labels.map((label, index) => (
@@ -230,7 +230,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab.Panel>
<Tab.Panel
as="div"
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
>
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
@@ -0,0 +1,4 @@
export * from "./root";
export * from "./stats";
export * from "./upcoming-cycles-list-item";
export * from "./upcoming-cycles-list";
@@ -3,7 +3,8 @@ import { observer } from "mobx-react-lite";
import Link from "next/link";
import useSWR from "swr";
// hooks
import { useCycle, useIssues, useMember, useProject } from "hooks/store";
import { useCycle, useCycleFilter, useIssues, useMember, useProject } from "hooks/store";
import { usePlatformOS } from "hooks/use-platform-os";
// ui
import { SingleProgressStats } from "components/core";
import {
@@ -17,10 +18,11 @@ import {
Avatar,
CycleGroupIcon,
setPromiseToast,
getButtonStyling,
} from "@plane/ui";
// components
import ProgressChart from "components/core/sidebar/progress-chart";
import { ActiveCycleProgressStats } from "components/cycles";
import { ActiveCycleProgressStats, UpcomingCyclesList } from "components/cycles";
import { StateDropdown } from "components/dropdowns";
import { EmptyState } from "components/empty-state";
// icons
@@ -28,6 +30,7 @@ import { ArrowRight, CalendarCheck, CalendarDays, Star, Target } from "lucide-re
// helpers
import { renderFormattedDate, findHowManyDaysLeft, renderFormattedDateWithoutYear } from "helpers/date-time.helper";
import { truncateText } from "helpers/string.helper";
import { cn } from "helpers/common.helper";
// types
import { ICycle, TCycleGroups } from "@plane/types";
// constants
@@ -41,30 +44,36 @@ interface IActiveCycleDetails {
projectId: string;
}
export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props) => {
export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) => {
// props
const { workspaceSlug, projectId } = props;
// hooks
const { isMobile } = usePlatformOS();
// store hooks
const {
issues: { fetchActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE);
const {
fetchActiveCycle,
currentProjectActiveCycleId,
currentProjectUpcomingCycleIds,
fetchActiveCycle,
getActiveCycleById,
addCycleToFavorites,
removeCycleFromFavorites,
} = useCycle();
const { currentProjectDetails } = useProject();
const { getUserDetails } = useMember();
// cycle filters hook
const { updateDisplayFilters } = useCycleFilter();
// derived values
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
// fetch active cycle details
const { isLoading } = useSWR(
workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null,
workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null
);
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
// fetch active cycle issues
const { data: activeCycleIssues } = useSWR(
workspaceSlug && projectId && currentProjectActiveCycleId
? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" })
@@ -73,7 +82,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
? () => fetchActiveCycleIssues(workspaceSlug, projectId, currentProjectActiveCycleId)
: null
);
// show loader if active cycle is loading
if (!activeCycle && isLoading)
return (
<Loader>
@@ -81,10 +90,44 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</Loader>
);
if (!activeCycle) return <EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />;
if (!activeCycle) {
// show empty state if no active cycle is present
if (currentProjectUpcomingCycleIds?.length === 0)
return <EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />;
// show upcoming cycles list, if present
else
return (
<>
<div className="h-52 w-full grid place-items-center mb-6">
<div className="text-center">
<h5 className="text-xl font-medium mb-1">No active cycle</h5>
<p className="text-custom-text-400 text-base">
Create new cycles to find them here or check
<br />
{"'"}All{"'"} cycles tab to see all cycles or{" "}
<button
type="button"
className="text-custom-primary-100 font-medium"
onClick={() =>
updateDisplayFilters(projectId, {
active_tab: "all",
})
}
>
click here
</button>
</p>
</div>
</div>
<UpcomingCyclesList />
</>
);
}
const endDate = new Date(activeCycle.end_date ?? "");
const startDate = new Date(activeCycle.start_date ?? "");
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups;
const groupedIssues: any = {
backlog: activeCycle.backlog_issues,
@@ -94,8 +137,6 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
cancelled: activeCycle.cancelled_issues,
};
const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups;
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
@@ -148,8 +189,6 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
color: group.color,
}));
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
return (
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow">
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-3 lg:divide-x lg:divide-y-0">
@@ -161,7 +200,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<span className="h-5 w-5">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-4 w-4" />
</span>
<Tooltip tooltipContent={activeCycle.name} position="top-left">
<Tooltip tooltipContent={activeCycle.name} position="top-left" isMobile={isMobile}>
<h3 className="break-words text-lg font-semibold">{truncateText(activeCycle.name, 70)}</h3>
</Tooltip>
</span>
@@ -203,27 +242,15 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<div className="flex items-center gap-4">
<div className="flex items-center gap-2.5 text-custom-text-200">
{cycleOwnerDetails?.avatar && cycleOwnerDetails?.avatar !== "" ? (
<img
src={cycleOwnerDetails?.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycleOwnerDetails?.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-background-100 capitalize">
{cycleOwnerDetails?.display_name.charAt(0)}
</span>
)}
<Avatar src={cycleOwnerDetails?.avatar} name={cycleOwnerDetails?.display_name} />
<span className="text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
</div>
{activeCycle.assignee_ids.length > 0 && (
<div className="flex items-center gap-1 text-custom-text-200">
<AvatarGroup>
{activeCycle.assignee_ids.map((assigne_id) => {
const member = getUserDetails(assigne_id);
{activeCycle.assignee_ids.map((assignee_id) => {
const member = getUserDetails(assignee_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
@@ -233,7 +260,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<div className="flex items-center gap-4 text-custom-text-200">
<div className="flex gap-2">
<LayersIcon className="h-4 w-4 flex-shrink-0" />
<LayersIcon className="h-3.5 w-3.5 flex-shrink-0" />
{activeCycle.total_issues} issues
</div>
<div className="flex items-center gap-2">
@@ -244,9 +271,9 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${activeCycle.id}`}
className="w-min text-nowrap rounded-md bg-custom-primary px-4 py-2 text-center text-sm font-medium text-white hover:bg-custom-primary/90"
className={cn(getButtonStyling("primary", "lg"), "w-min whitespace-nowrap")}
>
View Cycle
View cycle
</Link>
</div>
</div>
@@ -287,11 +314,11 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</div>
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-2 lg:divide-x lg:divide-y-0">
<div className="flex max-h-60 flex-col gap-3 overflow-hidden p-4">
<div className="text-custom-primary">High Priority Issues</div>
<div className="text-custom-primary">High priority issues</div>
<div className="flex h-full flex-col gap-2.5 overflow-y-scroll rounded-md">
{activeCycleIssues ? (
activeCycleIssues.length > 0 ? (
activeCycleIssues.map((issue: any) => (
activeCycleIssues.map((issue) => (
<Link
key={issue.id}
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
@@ -301,6 +328,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<PriorityIcon priority={issue.priority} withContainer size={12} />
<Tooltip
isMobile={isMobile}
tooltipHeading="Issue ID"
tooltipContent={`${currentProjectDetails?.identifier}-${issue.sequence_id}`}
>
@@ -308,20 +336,20 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
{currentProjectDetails?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
<Tooltip position="top-left" tooltipContent={issue.name}>
<Tooltip position="top-left" tooltipContent={issue.name} isMobile={isMobile}>
<span className="text-[0.825rem] text-custom-text-100">{truncateText(issue.name, 30)}</span>
</Tooltip>
</div>
<div className="flex flex-shrink-0 items-center gap-1.5">
<StateDropdown
value={issue.state_id ?? undefined}
value={issue.state_id}
onChange={() => {}}
projectId={projectId?.toString() ?? ""}
projectId={projectId}
disabled
buttonVariant="background-with-text"
/>
{issue.target_date && (
<Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)}>
<Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)} isMobile={isMobile}>
<div className="flex h-full cursor-not-allowed items-center gap-1.5 rounded bg-custom-background-80 px-2 py-0.5 text-xs">
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
<span className="text-xs">{renderFormattedDateWithoutYear(issue.target_date)}</span>
@@ -359,10 +387,10 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</div>
<div className="flex items-center gap-1">
<span>
<LayersIcon className="h-5 w-5 flex-shrink-0 text-custom-text-200" />
<LayersIcon className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-200" />
</span>
<span>
Pending Issues -{" "}
Pending issues-{" "}
{activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)}
</span>
</div>
@@ -134,7 +134,7 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
</Tab.Panels>
) : (
<div className="mt-4 grid place-items-center text-center text-sm text-custom-text-200">
There are no high priority issues present in this cycle.
There are no issues present in this cycle.
</div>
)}
</Tab.Group>
@@ -0,0 +1,135 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react";
import { Star, User2 } from "lucide-react";
// hooks
import { useCycle, useEventTracker, useMember } from "hooks/store";
// components
import { CycleQuickActions } from "components/cycles";
// ui
import { Avatar, AvatarGroup, setPromiseToast } from "@plane/ui";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
// constants
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
type Props = {
cycleId: string;
};
export const UpcomingCycleListItem: React.FC<Props> = observer((props) => {
const { cycleId } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store hooks
const { captureEvent } = useEventTracker();
const { addCycleToFavorites, getCycleById, removeCycleFromFavorites } = useCycle();
const { getUserDetails } = useMember();
// derived values
const cycle = getCycleById(cycleId);
const handleAddToFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then(
() => {
captureEvent(CYCLE_FAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
}
);
setPromiseToast(addToFavoritePromise, {
loading: "Adding cycle to favorites...",
success: {
title: "Success!",
message: () => "Cycle added to favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't add the cycle to favorites. Please try again.",
},
});
};
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const removeFromFavoritePromise = removeCycleFromFavorites(
workspaceSlug?.toString(),
projectId.toString(),
cycleId
).then(() => {
captureEvent(CYCLE_UNFAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
});
setPromiseToast(removeFromFavoritePromise, {
loading: "Removing cycle from favorites...",
success: {
title: "Success!",
message: () => "Cycle removed from favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't remove the cycle from favorites. Please try again.",
},
});
};
if (!cycle) return null;
return (
<Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`}
className="py-5 px-2 flex items-center justify-between gap-2 hover:bg-custom-background-90"
>
<h6 className="font-medium text-base">{cycle.name}</h6>
<div className="flex items-center gap-4">
{cycle.start_date && cycle.end_date && (
<div className="text-xs text-custom-text-300">
{renderFormattedDate(cycle.start_date)} - {renderFormattedDate(cycle.end_date)}
</div>
)}
{cycle.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycle.assignee_ids?.map((assigneeId) => {
const member = getUserDetails(assigneeId);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
{cycle.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
)}
{workspaceSlug && projectId && (
<CycleQuickActions
cycleId={cycleId}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
/>
)}
</div>
</Link>
);
});
@@ -0,0 +1,25 @@
import { observer } from "mobx-react";
// hooks
import { useCycle } from "hooks/store";
// components
import { UpcomingCycleListItem } from "components/cycles";
export const UpcomingCyclesList = observer(() => {
// store hooks
const { currentProjectUpcomingCycleIds } = useCycle();
if (!currentProjectUpcomingCycleIds) return null;
return (
<div>
<div className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded inline-block">
Upcoming cycles
</div>
<div className="mt-2 divide-y-[0.5px] divide-custom-border-200 border-b-[0.5px] border-custom-border-200">
{currentProjectUpcomingCycleIds.map((cycleId) => (
<UpcomingCycleListItem key={cycleId} cycleId={cycleId} />
))}
</div>
</div>
);
});
@@ -0,0 +1,55 @@
import { observer } from "mobx-react-lite";
import { X } from "lucide-react";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper";
// constants
import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters";
type Props = {
editable: boolean | undefined;
handleRemove: (val: string) => void;
values: string[];
};
export const AppliedDateFilters: React.FC<Props> = observer((props) => {
const { editable, handleRemove, values } = props;
const getDateLabel = (value: string): string => {
let dateLabel = "";
const dateDetails = DATE_AFTER_FILTER_OPTIONS.find((d) => d.value === value);
if (dateDetails) dateLabel = dateDetails.name;
else {
const dateParts = value.split(";");
if (dateParts.length === 2) {
const [date, time] = dateParts;
dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`;
}
}
return dateLabel;
};
return (
<>
{values.map((date) => (
<div key={date} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<span className="normal-case">{getDateLabel(date)}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(date)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
))}
</>
);
});
@@ -0,0 +1,3 @@
export * from "./date";
export * from "./root";
export * from "./status";
@@ -0,0 +1,90 @@
import { observer } from "mobx-react-lite";
import { X } from "lucide-react";
// hooks
import { useUser } from "hooks/store";
// components
import { AppliedDateFilters, AppliedStatusFilters } from "components/cycles";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types
import { TCycleFilters } from "@plane/types";
// constants
import { EUserProjectRoles } from "constants/project";
type Props = {
appliedFilters: TCycleFilters;
handleClearAllFilters: () => void;
handleRemoveFilter: (key: keyof TCycleFilters, value: string | null) => void;
alwaysAllowEditing?: boolean;
};
const DATE_FILTERS = ["start_date", "end_date"];
export const CycleAppliedFiltersList: React.FC<Props> = observer((props) => {
const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props;
// store hooks
const {
membership: { currentProjectRole },
} = useUser();
if (!appliedFilters) return null;
if (Object.keys(appliedFilters).length === 0) return null;
const isEditingAllowed = alwaysAllowEditing || (currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER);
return (
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
{Object.entries(appliedFilters).map(([key, value]) => {
const filterKey = key as keyof TCycleFilters;
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
return (
<div
key={filterKey}
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize"
>
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
<div className="flex flex-wrap items-center gap-1">
{filterKey === "status" && (
<AppliedStatusFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("status", val)}
values={value}
/>
)}
{DATE_FILTERS.includes(filterKey) && (
<AppliedDateFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={value}
/>
)}
{isEditingAllowed && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemoveFilter(filterKey, null)}
>
<X size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
);
})}
{isEditingAllowed && (
<button
type="button"
onClick={handleClearAllFilters}
className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 text-xs text-custom-text-300 hover:text-custom-text-200"
>
Clear all
<X size={12} strokeWidth={2} />
</button>
)}
</div>
);
});
@@ -0,0 +1,43 @@
import { observer } from "mobx-react-lite";
import { X } from "lucide-react";
import { CYCLE_STATUS } from "constants/cycle";
import { cn } from "helpers/common.helper";
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedStatusFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
return (
<>
{values.map((status) => {
const statusDetails = CYCLE_STATUS.find((s) => s.value === status);
return (
<div
key={status}
className={cn(
"flex items-center gap-1 rounded p-1 text-xs",
statusDetails?.bgColor,
statusDetails?.textColor
)}
>
{statusDetails?.title}
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(status)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});
@@ -1,22 +1,13 @@
import { FC, MouseEvent, useState } from "react";
import { FC, MouseEvent } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useRouter } from "next/router";
// hooks
import { usePlatformOS } from "hooks/use-platform-os";
// components
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react";
import {
Avatar,
AvatarGroup,
CustomMenu,
Tooltip,
LayersIcon,
CycleGroupIcon,
TOAST_TYPE,
setToast,
setPromiseToast,
} from "@plane/ui";
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
import { Info, Star } from "lucide-react";
import { Avatar, AvatarGroup, Tooltip, LayersIcon, CycleGroupIcon, setPromiseToast } from "@plane/ui";
import { CycleQuickActions } from "components/cycles";
// ui
// icons
// helpers
@@ -24,7 +15,6 @@ import { CYCLE_STATUS } from "constants/cycle";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
import { EUserWorkspaceRoles } from "constants/workspace";
import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// constants
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
//.types
@@ -38,13 +28,10 @@ export interface ICyclesBoardCard {
export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props;
// states
const [updateModal, setUpdateModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// router
const router = useRouter();
// store
const { setTrackElement, captureEvent } = useEventTracker();
const { captureEvent } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
@@ -52,11 +39,12 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
const { getUserDetails } = useMember();
// computed
const cycleDetails = getCycleById(cycleId);
// hooks
const { isMobile } = usePlatformOS();
if (!cycleDetails) return null;
const cycleStatus = cycleDetails.status.toLocaleLowerCase();
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycleDetails.end_date ?? "");
const startDate = new Date(cycleDetails.start_date ?? "");
const isDateValid = cycleDetails.start_date || cycleDetails.end_date;
@@ -78,24 +66,10 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
? cycleTotalIssues === 0
? "0 Issue"
: cycleTotalIssues === cycleDetails.completed_issues
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
: "0 Issue";
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
});
};
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
@@ -152,20 +126,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
});
};
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page grid layout");
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page grid layout");
setDeleteModal(true);
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
const { query } = router;
e.preventDefault();
@@ -181,22 +141,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
return (
<div>
<CycleCreateUpdateModal
data={cycleDetails}
isOpen={updateModal}
handleClose={() => setUpdateModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<CycleDeleteModal
cycle={cycleDetails}
isOpen={deleteModal}
handleClose={() => setDeleteModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
<div className="flex items-center justify-between gap-2">
@@ -204,7 +148,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
<span className="flex-shrink-0">
<CycleGroupIcon cycleGroup={cycleStatus as TCycleGroups} className="h-3.5 w-3.5" />
</span>
<Tooltip tooltipContent={cycleDetails.name} position="top">
<Tooltip tooltipContent={cycleDetails.name} position="top" isMobile={isMobile}>
<span className="truncate text-base font-medium">{cycleDetails.name}</span>
</Tooltip>
</div>
@@ -235,7 +179,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
<span className="text-xs text-custom-text-300">{issueCount}</span>
</div>
{cycleDetails.assignee_ids.length > 0 && (
<Tooltip tooltipContent={`${cycleDetails.assignee_ids.length} Members`}>
<Tooltip tooltipContent={`${cycleDetails.assignee_ids.length} Members`} isMobile={isMobile}>
<div className="flex cursor-default items-center gap-1">
<AvatarGroup showTooltip={false}>
{cycleDetails.assignee_ids.map((assigne_id) => {
@@ -249,6 +193,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
</div>
<Tooltip
isMobile={isMobile}
tooltipContent={isNaN(completionPercentage) ? "0" : `${completionPercentage.toFixed(0)}%`}
position="top-left"
>
@@ -288,30 +233,8 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
))}
<CustomMenu ellipsis className="z-10">
{!isCompleted && isEditingAllowed && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
<CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
</div>
</div>
</div>

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