mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
fix: merge conflicts resolved from develop
This commit is contained in:
commit
aba8b23d92
4
.github/ISSUE_TEMPLATE/--bug-report.yaml
vendored
4
.github/ISSUE_TEMPLATE/--bug-report.yaml
vendored
@ -2,7 +2,7 @@ name: Bug report
|
|||||||
description: Create a bug report to help us improve Plane
|
description: Create a bug report to help us improve Plane
|
||||||
title: "[bug]: "
|
title: "[bug]: "
|
||||||
labels: [🐛bug]
|
labels: [🐛bug]
|
||||||
assignees: [srinivaspendem, pushya-plane]
|
assignees: [srinivaspendem, pushya22]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
@ -45,7 +45,7 @@ body:
|
|||||||
- Deploy preview
|
- Deploy preview
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
type: dropdown
|
- type: dropdown
|
||||||
id: browser
|
id: browser
|
||||||
attributes:
|
attributes:
|
||||||
label: Browser
|
label: Browser
|
||||||
|
@ -2,7 +2,7 @@ name: Feature request
|
|||||||
description: Suggest a feature to improve Plane
|
description: Suggest a feature to improve Plane
|
||||||
title: "[feature]: "
|
title: "[feature]: "
|
||||||
labels: [✨feature]
|
labels: [✨feature]
|
||||||
assignees: [srinivaspendem, pushya-plane]
|
assignees: [srinivaspendem, pushya22]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
|
97
.github/workflows/auto-merge.yml
vendored
Normal file
97
.github/workflows/auto-merge.yml
vendored
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
name: Auto Merge or Create PR on Push
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "sync/**"
|
||||||
|
|
||||||
|
env:
|
||||||
|
CURRENT_BRANCH: ${{ github.ref_name }}
|
||||||
|
SOURCE_BRANCH: ${{ secrets.SYNC_TARGET_BRANCH_NAME }} # The sync branch such as "sync/ce"
|
||||||
|
TARGET_BRANCH: ${{ secrets.TARGET_BRANCH }} # The target branch that you would like to merge changes like develop
|
||||||
|
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
|
||||||
|
REVIEWER: ${{ secrets.REVIEWER }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
Check_Branch:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }}
|
||||||
|
steps:
|
||||||
|
- name: Check if current branch matches the secret
|
||||||
|
id: check-branch
|
||||||
|
run: |
|
||||||
|
if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then
|
||||||
|
echo "MATCH=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "MATCH=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
Auto_Merge:
|
||||||
|
if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }}
|
||||||
|
needs: [Check_Branch]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4.1.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Fetch all history for all branches and tags
|
||||||
|
|
||||||
|
- name: Setup GH CLI and Git Config
|
||||||
|
run: |
|
||||||
|
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||||
|
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||||
|
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install gh -y
|
||||||
|
|
||||||
|
- id: git-author
|
||||||
|
name: Setup Git CLI from Github Token
|
||||||
|
run: |
|
||||||
|
VIEWER_JSON=$(gh api graphql -f query='query { viewer { name login databaseId }}' --jq '.data.viewer')
|
||||||
|
VIEWER_NAME=$(jq --raw-output '.name | values' <<< "${VIEWER_JSON}")
|
||||||
|
VIEWER_LOGIN=$(jq --raw-output '.login' <<< "${VIEWER_JSON}")
|
||||||
|
VIEWER_DATABASE_ID=$(jq --raw-output '.databaseId' <<< "${VIEWER_JSON}")
|
||||||
|
|
||||||
|
USER_NAME="${VIEWER_NAME:-${VIEWER_LOGIN}}"
|
||||||
|
USER_EMAIL="${VIEWER_DATABASE_ID}+${VIEWER_LOGIN}@users.noreply.github.com"
|
||||||
|
|
||||||
|
git config --global user.name ${USER_NAME}
|
||||||
|
git config --global user.email ${USER_EMAIL}
|
||||||
|
|
||||||
|
- name: Check for merge conflicts
|
||||||
|
id: conflicts
|
||||||
|
run: |
|
||||||
|
git fetch origin $TARGET_BRANCH
|
||||||
|
git checkout $TARGET_BRANCH
|
||||||
|
# Attempt to merge the main branch into the current branch
|
||||||
|
if $(git merge --no-commit --no-ff $SOURCE_BRANCH); then
|
||||||
|
echo "No merge conflicts detected."
|
||||||
|
echo "HAS_CONFLICTS=false" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "Merge conflicts detected."
|
||||||
|
echo "HAS_CONFLICTS=true" >> $GITHUB_ENV
|
||||||
|
git merge --abort
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Merge Change to Target Branch
|
||||||
|
if: env.HAS_CONFLICTS == 'false'
|
||||||
|
run: |
|
||||||
|
git commit -m "Merge branch '$SOURCE_BRANCH' into $TARGET_BRANCH"
|
||||||
|
git push origin $TARGET_BRANCH
|
||||||
|
|
||||||
|
- name: Create PR to Target Branch
|
||||||
|
if: env.HAS_CONFLICTS == 'true'
|
||||||
|
run: |
|
||||||
|
# Use GitHub CLI to create PR and specify author and committer
|
||||||
|
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH \
|
||||||
|
--title "sync: merge conflicts need to be resolved" \
|
||||||
|
--body "" \
|
||||||
|
--reviewer $REVIEWER )
|
||||||
|
echo "Pull Request created: $PR_URL"
|
||||||
|
|
61
.github/workflows/build-branch.yml
vendored
61
.github/workflows/build-branch.yml
vendored
@ -23,6 +23,10 @@ jobs:
|
|||||||
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
|
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
|
||||||
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
|
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
|
||||||
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
|
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
|
||||||
|
build_frontend: ${{ steps.changed_files.outputs.frontend_any_changed }}
|
||||||
|
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
|
||||||
|
build_backend: ${{ steps.changed_files.outputs.backend_any_changed }}
|
||||||
|
build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- id: set_env_variables
|
- id: set_env_variables
|
||||||
@ -41,7 +45,36 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
|
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- id: checkout_files
|
||||||
|
name: Checkout Files
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed_files
|
||||||
|
uses: tj-actions/changed-files@v42
|
||||||
|
with:
|
||||||
|
files_yaml: |
|
||||||
|
frontend:
|
||||||
|
- web/**
|
||||||
|
- packages/**
|
||||||
|
- 'package.json'
|
||||||
|
- 'yarn.lock'
|
||||||
|
- 'tsconfig.json'
|
||||||
|
- 'turbo.json'
|
||||||
|
space:
|
||||||
|
- space/**
|
||||||
|
- packages/**
|
||||||
|
- 'package.json'
|
||||||
|
- 'yarn.lock'
|
||||||
|
- 'tsconfig.json'
|
||||||
|
- 'turbo.json'
|
||||||
|
backend:
|
||||||
|
- apiserver/**
|
||||||
|
proxy:
|
||||||
|
- nginx/**
|
||||||
|
|
||||||
branch_build_push_frontend:
|
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' }}
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
@ -55,9 +88,9 @@ jobs:
|
|||||||
- name: Set Frontend Docker Tag
|
- name: Set Frontend Docker Tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" == "release" ]; then
|
if [ "${{ github.event_name }}" == "release" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
|
||||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest
|
||||||
else
|
else
|
||||||
TAG=${{ env.FRONTEND_TAG }}
|
TAG=${{ env.FRONTEND_TAG }}
|
||||||
fi
|
fi
|
||||||
@ -77,7 +110,7 @@ jobs:
|
|||||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||||
|
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build and Push Frontend to Docker Container Registry
|
- name: Build and Push Frontend to Docker Container Registry
|
||||||
uses: docker/build-push-action@v5.1.0
|
uses: docker/build-push-action@v5.1.0
|
||||||
@ -93,6 +126,7 @@ jobs:
|
|||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
branch_build_push_space:
|
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' }}
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
@ -106,9 +140,9 @@ jobs:
|
|||||||
- name: Set Space Docker Tag
|
- name: Set Space Docker Tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" == "release" ]; then
|
if [ "${{ github.event_name }}" == "release" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
|
||||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest
|
||||||
else
|
else
|
||||||
TAG=${{ env.SPACE_TAG }}
|
TAG=${{ env.SPACE_TAG }}
|
||||||
fi
|
fi
|
||||||
@ -128,7 +162,7 @@ jobs:
|
|||||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||||
|
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build and Push Space to Docker Hub
|
- name: Build and Push Space to Docker Hub
|
||||||
uses: docker/build-push-action@v5.1.0
|
uses: docker/build-push-action@v5.1.0
|
||||||
@ -144,6 +178,7 @@ jobs:
|
|||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
branch_build_push_backend:
|
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' }}
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
@ -157,9 +192,9 @@ jobs:
|
|||||||
- name: Set Backend Docker Tag
|
- name: Set Backend Docker Tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" == "release" ]; then
|
if [ "${{ github.event_name }}" == "release" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
|
||||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest
|
||||||
else
|
else
|
||||||
TAG=${{ env.BACKEND_TAG }}
|
TAG=${{ env.BACKEND_TAG }}
|
||||||
fi
|
fi
|
||||||
@ -179,7 +214,7 @@ jobs:
|
|||||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||||
|
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build and Push Backend to Docker Hub
|
- name: Build and Push Backend to Docker Hub
|
||||||
uses: docker/build-push-action@v5.1.0
|
uses: docker/build-push-action@v5.1.0
|
||||||
@ -194,8 +229,8 @@ jobs:
|
|||||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
|
||||||
branch_build_push_proxy:
|
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' }}
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
needs: [branch_build_setup]
|
needs: [branch_build_setup]
|
||||||
env:
|
env:
|
||||||
@ -209,9 +244,9 @@ jobs:
|
|||||||
- name: Set Proxy Docker Tag
|
- name: Set Proxy Docker Tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" == "release" ]; then
|
if [ "${{ github.event_name }}" == "release" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
|
||||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest
|
||||||
else
|
else
|
||||||
TAG=${{ env.PROXY_TAG }}
|
TAG=${{ env.PROXY_TAG }}
|
||||||
fi
|
fi
|
||||||
@ -231,7 +266,7 @@ jobs:
|
|||||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||||
|
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build and Push Plane-Proxy to Docker Hub
|
- name: Build and Push Plane-Proxy to Docker Hub
|
||||||
uses: docker/build-push-action@v5.1.0
|
uses: docker/build-push-action@v5.1.0
|
||||||
|
112
.github/workflows/build-test-pull-request.yml
vendored
112
.github/workflows/build-test-pull-request.yml
vendored
@ -1,28 +1,19 @@
|
|||||||
name: Build Pull Request Contents
|
name: Build and Lint on Pull Request
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: ["opened", "synchronize"]
|
types: ["opened", "synchronize"]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-pull-request-contents:
|
get-changed-files:
|
||||||
name: Build Pull Request Contents
|
runs-on: ubuntu-latest
|
||||||
runs-on: ubuntu-20.04
|
outputs:
|
||||||
permissions:
|
apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }}
|
||||||
pull-requests: read
|
web_changed: ${{ steps.changed-files.outputs.web_any_changed }}
|
||||||
|
space_changed: ${{ steps.changed-files.outputs.deploy_any_changed }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository to Actions
|
- uses: actions/checkout@v3
|
||||||
uses: actions/checkout@v3.3.0
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.ACCESS_TOKEN }}
|
|
||||||
|
|
||||||
- name: Setup Node.js 18.x
|
|
||||||
uses: actions/setup-node@v2
|
|
||||||
with:
|
|
||||||
node-version: 18.x
|
|
||||||
cache: "yarn"
|
|
||||||
|
|
||||||
- name: Get changed files
|
- name: Get changed files
|
||||||
id: changed-files
|
id: changed-files
|
||||||
uses: tj-actions/changed-files@v41
|
uses: tj-actions/changed-files@v41
|
||||||
@ -32,17 +23,82 @@ jobs:
|
|||||||
- apiserver/**
|
- apiserver/**
|
||||||
web:
|
web:
|
||||||
- web/**
|
- web/**
|
||||||
|
- packages/**
|
||||||
|
- 'package.json'
|
||||||
|
- 'yarn.lock'
|
||||||
|
- 'tsconfig.json'
|
||||||
|
- 'turbo.json'
|
||||||
deploy:
|
deploy:
|
||||||
- space/**
|
- space/**
|
||||||
|
- packages/**
|
||||||
|
- 'package.json'
|
||||||
|
- 'yarn.lock'
|
||||||
|
- 'tsconfig.json'
|
||||||
|
- 'turbo.json'
|
||||||
|
|
||||||
- name: Build Plane's Main App
|
lint-apiserver:
|
||||||
if: steps.changed-files.outputs.web_any_changed == 'true'
|
needs: get-changed-files
|
||||||
run: |
|
runs-on: ubuntu-latest
|
||||||
yarn
|
if: needs.get-changed-files.outputs.apiserver_changed == 'true'
|
||||||
yarn build --filter=web
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: '3.x' # Specify the Python version you need
|
||||||
|
- name: Install Pylint
|
||||||
|
run: python -m pip install ruff
|
||||||
|
- name: Install Apiserver Dependencies
|
||||||
|
run: cd apiserver && pip install -r requirements.txt
|
||||||
|
- name: Lint apiserver
|
||||||
|
run: ruff check --fix apiserver
|
||||||
|
|
||||||
- name: Build Plane's Deploy App
|
lint-web:
|
||||||
if: steps.changed-files.outputs.deploy_any_changed == 'true'
|
needs: get-changed-files
|
||||||
run: |
|
if: needs.get-changed-files.outputs.web_changed == 'true'
|
||||||
yarn
|
runs-on: ubuntu-latest
|
||||||
yarn build --filter=space
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: 18.x
|
||||||
|
- run: yarn install
|
||||||
|
- run: yarn lint --filter=web
|
||||||
|
|
||||||
|
lint-space:
|
||||||
|
needs: get-changed-files
|
||||||
|
if: needs.get-changed-files.outputs.space_changed == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: 18.x
|
||||||
|
- run: yarn install
|
||||||
|
- run: yarn lint --filter=space
|
||||||
|
|
||||||
|
build-web:
|
||||||
|
needs: lint-web
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: 18.x
|
||||||
|
- run: yarn install
|
||||||
|
- run: yarn build --filter=web
|
||||||
|
|
||||||
|
build-space:
|
||||||
|
needs: lint-space
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: 18.x
|
||||||
|
- run: yarn install
|
||||||
|
- run: yarn build --filter=space
|
||||||
|
45
.github/workflows/check-version.yml
vendored
Normal file
45
.github/workflows/check-version.yml
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
name: Version Change Before Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.head_ref }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Get PR Branch version
|
||||||
|
run: echo "PR_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Fetch base branch
|
||||||
|
run: git fetch origin master:master
|
||||||
|
|
||||||
|
- name: Get Master Branch version
|
||||||
|
run: |
|
||||||
|
git checkout master
|
||||||
|
echo "MASTER_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Get master branch version and compare
|
||||||
|
run: |
|
||||||
|
echo "Comparing versions: PR version is $PR_VERSION, Master version is $MASTER_VERSION"
|
||||||
|
if [ "$PR_VERSION" == "$MASTER_VERSION" ]; then
|
||||||
|
echo "Version in PR branch is the same as in master. Failing the CI."
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "Version check passed. Versions are different."
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
PR_VERSION: ${{ env.PR_VERSION }}
|
||||||
|
MASTER_VERSION: ${{ env.MASTER_VERSION }}
|
25
.github/workflows/create-sync-pr.yml
vendored
25
.github/workflows/create-sync-pr.yml
vendored
@ -2,7 +2,7 @@ name: Create Sync Action
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- preview
|
- preview
|
||||||
|
|
||||||
@ -17,7 +17,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4.1.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@ -31,14 +31,25 @@ jobs:
|
|||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install gh -y
|
sudo apt install gh -y
|
||||||
|
|
||||||
- name: Push Changes to Target Repo
|
- name: Push Changes to Target Repo A
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
TARGET_REPO="${{ secrets.SYNC_TARGET_REPO_NAME }}"
|
TARGET_REPO="${{ secrets.TARGET_REPO_A }}"
|
||||||
TARGET_BRANCH="${{ secrets.SYNC_TARGET_BRANCH_NAME }}"
|
TARGET_BRANCH="${{ secrets.TARGET_REPO_A_BRANCH_NAME }}"
|
||||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||||
|
|
||||||
git checkout $SOURCE_BRANCH
|
git checkout $SOURCE_BRANCH
|
||||||
git remote add target-origin "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||||
git push target-origin $SOURCE_BRANCH:$TARGET_BRANCH
|
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
|
||||||
|
|
||||||
|
- name: Push Changes to Target Repo B
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||||
|
run: |
|
||||||
|
TARGET_REPO="${{ secrets.TARGET_REPO_B }}"
|
||||||
|
TARGET_BRANCH="${{ secrets.TARGET_REPO_B_BRANCH_NAME }}"
|
||||||
|
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||||
|
|
||||||
|
git remote add target-origin-b "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||||
|
git push target-origin-b $SOURCE_BRANCH:$TARGET_BRANCH
|
||||||
|
73
.github/workflows/feature-deployment.yml
vendored
Normal file
73
.github/workflows/feature-deployment.yml
vendored
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
name: Feature Preview
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
web-build:
|
||||||
|
required: true
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
space-build:
|
||||||
|
required: true
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
feature-deploy:
|
||||||
|
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 }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- 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"
|
||||||
|
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 }}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 "****************************************"
|
@ -50,7 +50,6 @@ chmod +x setup.sh
|
|||||||
docker compose -f docker-compose-local.yml up
|
docker compose -f docker-compose-local.yml up
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Missing a Feature?
|
## Missing a Feature?
|
||||||
|
|
||||||
If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository.
|
If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository.
|
||||||
|
@ -53,7 +53,6 @@ NGINX_PORT=80
|
|||||||
NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"
|
NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## {PROJECT_FOLDER}/apiserver/.env
|
## {PROJECT_FOLDER}/apiserver/.env
|
||||||
|
|
||||||
|
|
||||||
|
153
README.md
153
README.md
@ -7,7 +7,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 align="center"><b>Plane</b></h3>
|
<h3 align="center"><b>Plane</b></h3>
|
||||||
<p align="center"><b>Flexible, extensible open-source project management</b></p>
|
<p align="center"><b>Open-source project management that unlocks customer value.</b></p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://discord.com/invite/A92xrEGCge">
|
<a href="https://discord.com/invite/A92xrEGCge">
|
||||||
@ -16,6 +16,13 @@
|
|||||||
<img alt="Commit activity per month" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
|
<img alt="Commit activity per month" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="http://www.plane.so"><b>Website</b></a> •
|
||||||
|
<a href="https://github.com/makeplane/plane/releases"><b>Releases</b></a> •
|
||||||
|
<a href="https://twitter.com/planepowers"><b>Twitter</b></a> •
|
||||||
|
<a href="https://docs.plane.so/"><b>Documentation</b></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
|
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
|
||||||
<img
|
<img
|
||||||
@ -33,56 +40,90 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Meet [Plane](https://plane.so). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘♀️.
|
Meet [Plane](https://plane.so). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind. 🧘♀️
|
||||||
|
|
||||||
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
|
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
|
||||||
|
|
||||||
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
|
## ⚡ Installation
|
||||||
|
|
||||||
## ⚡️ Contributors Quick Start
|
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account where we offer a hosted solution for users.
|
||||||
|
|
||||||
### Prerequisite
|
If you want more control over your data prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
|
||||||
|
|
||||||
Development system must have docker engine installed and running.
|
| Installation Methods | Documentation Link |
|
||||||
|
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://docs.plane.so/docker-compose) |
|
||||||
|
| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://docs.plane.so/kubernetes) |
|
||||||
|
|
||||||
### Steps
|
`Instance admin` can configure instance settings using our [God-mode](https://docs.plane.so/instance-admin) feature.
|
||||||
|
|
||||||
Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute
|
|
||||||
|
|
||||||
1. Clone the code locally using `git clone https://github.com/makeplane/plane.git`
|
|
||||||
1. Switch to the code folder `cd plane`
|
|
||||||
1. Create your feature or fix branch you plan to work on using `git checkout -b <feature-branch-name>`
|
|
||||||
1. Open terminal and run `./setup.sh`
|
|
||||||
1. Open the code on VSCode or similar equivalent IDE
|
|
||||||
1. Review the `.env` files available in various folders. Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system
|
|
||||||
1. Run the docker command to initiate various services `docker compose -f docker-compose-local.yml up -d`
|
|
||||||
|
|
||||||
You are ready to make changes to the code. Do not forget to refresh the browser (in case id does not auto-reload)
|
|
||||||
|
|
||||||
Thats it!
|
|
||||||
|
|
||||||
## 🍙 Self Hosting
|
|
||||||
|
|
||||||
For self hosting environment setup, visit the [Self Hosting](https://docs.plane.so/docker-compose) documentation page
|
|
||||||
|
|
||||||
## 🚀 Features
|
## 🚀 Features
|
||||||
|
|
||||||
- **Issue Planning and Tracking**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to issues for better organization and tracking.
|
- **Issues**: Quickly create issues and add details using a powerful, rich text editor that supports file uploads. Add sub-properties and references to problems for better organization and tracking.
|
||||||
- **Issue Attachments**: Collaborate effectively by attaching files to issues, making it easy for your team to find and share important project-related documents.
|
|
||||||
- **Layouts**: Customize your project view with your preferred layout - choose from List, Kanban, or Calendar to visualize your project in a way that makes sense to you.
|
- **Cycles**
|
||||||
- **Cycles**: Plan sprints with Cycles to keep your team on track and productive. Gain insights into your project's progress with burn-down charts and other useful features.
|
Keep up your team's momentum with Cycles. Gain insights into your project's progress with burn-down charts and other valuable features.
|
||||||
- **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to easily track and plan your project's progress.
|
|
||||||
|
- **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to track and plan your project's progress easily.
|
||||||
|
|
||||||
- **Views**: Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks.
|
- **Views**: Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks.
|
||||||
- **Pages**: Plane pages function as an AI-powered notepad, allowing you to easily document issues, cycle plans, and module details, and then synchronize them with your issues.
|
|
||||||
- **Command K**: Enjoy a better user experience with the new Command + K menu. Easily manage and navigate through your projects from one convenient location.
|
- **Pages**: Plane pages, equipped with AI and a rich text editor, let you jot down your thoughts on the fly. Format your text, upload images, hyperlink, or sync your existing ideas into an actionable item or issue.
|
||||||
- **GitHub Sync**: Streamline your planning process by syncing your GitHub issues with Plane. Keep all your issues in one place for better tracking and collaboration.
|
|
||||||
|
- **Analytics**: Get insights into all your Plane data in real-time. Visualize issue data to spot trends, remove blockers, and progress your work.
|
||||||
|
|
||||||
|
- **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution.
|
||||||
|
|
||||||
|
## 🛠️ Contributors Quick Start
|
||||||
|
|
||||||
|
> Development system must have docker engine installed and running.
|
||||||
|
|
||||||
|
Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute
|
||||||
|
|
||||||
|
1. Clone the code locally using:
|
||||||
|
```
|
||||||
|
git clone https://github.com/makeplane/plane.git
|
||||||
|
```
|
||||||
|
2. Switch to the code folder:
|
||||||
|
```
|
||||||
|
cd plane
|
||||||
|
```
|
||||||
|
3. Create your feature or fix branch you plan to work on using:
|
||||||
|
```
|
||||||
|
git checkout -b <feature-branch-name>
|
||||||
|
```
|
||||||
|
4. Open terminal and run:
|
||||||
|
```
|
||||||
|
./setup.sh
|
||||||
|
```
|
||||||
|
5. Open the code on VSCode or similar equivalent IDE.
|
||||||
|
6. Review the `.env` files available in various folders.
|
||||||
|
Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system.
|
||||||
|
7. Run the docker command to initiate services:
|
||||||
|
```
|
||||||
|
docker compose -f docker-compose-local.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
You are ready to make changes to the code. Do not forget to refresh the browser (in case it does not auto-reload).
|
||||||
|
|
||||||
|
Thats it!
|
||||||
|
|
||||||
|
## ❤️ Community
|
||||||
|
|
||||||
|
The Plane community can be found on [GitHub Discussions](https://github.com/orgs/makeplane/discussions), and our [Discord server](https://discord.com/invite/A92xrEGCge). Our [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community chanels.
|
||||||
|
|
||||||
|
Ask questions, report bugs, join discussions, voice ideas, make feature requests, or share your projects.
|
||||||
|
|
||||||
|
### Repo Activity
|
||||||
|
|
||||||
|
![Plane Repo Activity](https://repobeats.axiom.co/api/embed/2523c6ed2f77c082b7908c33e2ab208981d76c39.svg "Repobeats analytics image")
|
||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://plane.so" target="_blank">
|
<a href="https://plane.so" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_views_dark_mode.webp"
|
src="https://ik.imagekit.io/w2okwbtu2/Issues_rNZjrGgFl.png?updatedAt=1709298765880"
|
||||||
alt="Plane Views"
|
alt="Plane Views"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
@ -91,8 +132,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
|
|||||||
<p>
|
<p>
|
||||||
<a href="https://plane.so" target="_blank">
|
<a href="https://plane.so" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_issue_detail_dark_mode.webp"
|
src="https://ik.imagekit.io/w2okwbtu2/Cycles_jCDhqmTl9.png?updatedAt=1709298780697"
|
||||||
alt="Plane Issue Details"
|
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
@ -100,7 +140,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
|
|||||||
<p>
|
<p>
|
||||||
<a href="https://plane.so" target="_blank">
|
<a href="https://plane.so" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_cycles_modules_dark_mode.webp"
|
src="https://ik.imagekit.io/w2okwbtu2/Modules_PSCVsbSfI.png?updatedAt=1709298796783"
|
||||||
alt="Plane Cycles and Modules"
|
alt="Plane Cycles and Modules"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
@ -109,7 +149,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
|
|||||||
<p>
|
<p>
|
||||||
<a href="https://plane.so" target="_blank">
|
<a href="https://plane.so" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_analytics_dark_mode.webp"
|
src="https://ik.imagekit.io/w2okwbtu2/Views_uxXsRatS4.png?updatedAt=1709298834522"
|
||||||
alt="Plane Analytics"
|
alt="Plane Analytics"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
@ -118,7 +158,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
|
|||||||
<p>
|
<p>
|
||||||
<a href="https://plane.so" target="_blank">
|
<a href="https://plane.so" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_pages_dark_mode.webp"
|
src="https://ik.imagekit.io/w2okwbtu2/Analytics_0o22gLRtp.png?updatedAt=1709298834389"
|
||||||
alt="Plane Pages"
|
alt="Plane Pages"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
@ -128,7 +168,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
|
|||||||
<p>
|
<p>
|
||||||
<a href="https://plane.so" target="_blank">
|
<a href="https://plane.so" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_commad_k_dark_mode.webp"
|
src="https://ik.imagekit.io/w2okwbtu2/Drive_LlfeY4xn3.png?updatedAt=1709298837917"
|
||||||
alt="Plane Command Menu"
|
alt="Plane Command Menu"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
@ -136,20 +176,23 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
|
|||||||
</p>
|
</p>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 📚Documentation
|
|
||||||
|
|
||||||
For full documentation, visit [docs.plane.so](https://docs.plane.so/)
|
|
||||||
|
|
||||||
To see how to Contribute, visit [here](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md).
|
|
||||||
|
|
||||||
## ❤️ Community
|
|
||||||
|
|
||||||
The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects.
|
|
||||||
|
|
||||||
To chat with other community members you can join the [Plane Discord](https://discord.com/invite/A92xrEGCge).
|
|
||||||
|
|
||||||
Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community channels.
|
|
||||||
|
|
||||||
## ⛓️ Security
|
## ⛓️ Security
|
||||||
|
|
||||||
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email engineering@plane.so to disclose any security vulnerabilities.
|
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports.
|
||||||
|
|
||||||
|
Email squawk@plane.so to disclose any security vulnerabilities.
|
||||||
|
|
||||||
|
## ❤️ Contribute
|
||||||
|
|
||||||
|
There are many ways to contribute to Plane, including:
|
||||||
|
|
||||||
|
- Submitting [bugs](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%F0%9F%90%9Bbug&projects=&template=--bug-report.yaml&title=%5Bbug%5D%3A+) and [feature requests](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%E2%9C%A8feature&projects=&template=--feature-request.yaml&title=%5Bfeature%5D%3A+) for various components.
|
||||||
|
- Reviewing [the documentation](https://docs.plane.so/) and submitting [pull requests](https://github.com/makeplane/plane), from fixing typos to adding new features.
|
||||||
|
- Speaking or writing about Plane or any other ecosystem integration and [letting us know](https://discord.com/invite/A92xrEGCge)!
|
||||||
|
- Upvoting [popular feature requests](https://github.com/makeplane/plane/issues) to show your support.
|
||||||
|
|
||||||
|
### We couldn't have done this without you.
|
||||||
|
|
||||||
|
<a href="https://github.com/makeplane/plane/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=makeplane/plane" />
|
||||||
|
</a>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"name": "plane-api",
|
"name": "plane-api",
|
||||||
"version": "0.15.1"
|
"version": "0.16.0"
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
from lxml import html
|
from lxml import html
|
||||||
|
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.core.validators import URLValidator
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
@ -284,6 +285,20 @@ class IssueLinkSerializer(BaseSerializer):
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def validate_url(self, value):
|
||||||
|
# Check URL format
|
||||||
|
validate_url = URLValidator()
|
||||||
|
try:
|
||||||
|
validate_url(value)
|
||||||
|
except ValidationError:
|
||||||
|
raise serializers.ValidationError("Invalid URL format.")
|
||||||
|
|
||||||
|
# Check URL scheme
|
||||||
|
if not value.startswith(('http://', 'https://')):
|
||||||
|
raise serializers.ValidationError("Invalid URL scheme.")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
# Validation if url already exists
|
# Validation if url already exists
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
if IssueLink.objects.filter(
|
if IssueLink.objects.filter(
|
||||||
@ -295,6 +310,17 @@ class IssueLinkSerializer(BaseSerializer):
|
|||||||
)
|
)
|
||||||
return IssueLink.objects.create(**validated_data)
|
return IssueLink.objects.create(**validated_data)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
if IssueLink.objects.filter(
|
||||||
|
url=validated_data.get("url"),
|
||||||
|
issue_id=instance.issue_id,
|
||||||
|
).exists():
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"error": "URL already exists for this Issue"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
class IssueAttachmentSerializer(BaseSerializer):
|
class IssueAttachmentSerializer(BaseSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -45,7 +45,10 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
return (
|
return (
|
||||||
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("owned_by")
|
.select_related("owned_by")
|
||||||
@ -390,7 +393,10 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
)
|
)
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.filter(cycle_id=self.kwargs.get("cycle_id"))
|
.filter(cycle_id=self.kwargs.get("cycle_id"))
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
|
@ -352,7 +352,10 @@ class LabelAPIEndpoint(BaseAPIView):
|
|||||||
return (
|
return (
|
||||||
Label.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
Label.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("parent")
|
.select_related("parent")
|
||||||
@ -481,7 +484,10 @@ class IssueLinkAPIEndpoint(BaseAPIView):
|
|||||||
IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.order_by(self.kwargs.get("order_by", "-created_at"))
|
.order_by(self.kwargs.get("order_by", "-created_at"))
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
@ -607,11 +613,11 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
)
|
)
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
.select_related("project")
|
project__project_projectmember__member=self.request.user,
|
||||||
.select_related("workspace")
|
project__project_projectmember__is_active=True,
|
||||||
.select_related("issue")
|
)
|
||||||
.select_related("actor")
|
.select_related("workspace", "project", "issue", "actor")
|
||||||
.annotate(
|
.annotate(
|
||||||
is_member=Exists(
|
is_member=Exists(
|
||||||
ProjectMember.objects.filter(
|
ProjectMember.objects.filter(
|
||||||
@ -647,6 +653,33 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def post(self, request, slug, project_id, issue_id):
|
def post(self, request, slug, project_id, issue_id):
|
||||||
|
|
||||||
|
# Validation check if the issue already exists
|
||||||
|
if (
|
||||||
|
request.data.get("external_id")
|
||||||
|
and request.data.get("external_source")
|
||||||
|
and IssueComment.objects.filter(
|
||||||
|
project_id=project_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
external_source=request.data.get("external_source"),
|
||||||
|
external_id=request.data.get("external_id"),
|
||||||
|
).exists()
|
||||||
|
):
|
||||||
|
issue_comment = IssueComment.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
external_id=request.data.get("external_id"),
|
||||||
|
external_source=request.data.get("external_source"),
|
||||||
|
).first()
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "Issue Comment with the same external id and external source already exists",
|
||||||
|
"id": str(issue_comment.id),
|
||||||
|
},
|
||||||
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
serializer = IssueCommentSerializer(data=request.data)
|
serializer = IssueCommentSerializer(data=request.data)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save(
|
serializer.save(
|
||||||
@ -680,6 +713,29 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
IssueCommentSerializer(issue_comment).data,
|
IssueCommentSerializer(issue_comment).data,
|
||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validation check if the issue already exists
|
||||||
|
if (
|
||||||
|
request.data.get("external_id")
|
||||||
|
and (issue_comment.external_id != str(request.data.get("external_id")))
|
||||||
|
and IssueComment.objects.filter(
|
||||||
|
project_id=project_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
external_source=request.data.get(
|
||||||
|
"external_source", issue_comment.external_source
|
||||||
|
),
|
||||||
|
external_id=request.data.get("external_id"),
|
||||||
|
).exists()
|
||||||
|
):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "Issue Comment with the same external id and external source already exists",
|
||||||
|
"id": str(issue_comment.id),
|
||||||
|
},
|
||||||
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
serializer = IssueCommentSerializer(
|
serializer = IssueCommentSerializer(
|
||||||
issue_comment, data=request.data, partial=True
|
issue_comment, data=request.data, partial=True
|
||||||
)
|
)
|
||||||
@ -734,6 +790,7 @@ class IssueActivityAPIEndpoint(BaseAPIView):
|
|||||||
.filter(
|
.filter(
|
||||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
.select_related("actor", "workspace", "issue", "project")
|
.select_related("actor", "workspace", "issue", "project")
|
||||||
).order_by(request.GET.get("order_by", "created_at"))
|
).order_by(request.GET.get("order_by", "created_at"))
|
||||||
|
@ -273,7 +273,10 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(module_id=self.kwargs.get("module_id"))
|
.filter(module_id=self.kwargs.get("module_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("module")
|
.select_related("module")
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
# Python imports
|
|
||||||
from itertools import groupby
|
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
|
from django.db import IntegrityError
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
@ -26,7 +24,10 @@ class StateAPIEndpoint(BaseAPIView):
|
|||||||
return (
|
return (
|
||||||
State.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
State.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.filter(~Q(name="Triage"))
|
.filter(~Q(name="Triage"))
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
@ -34,37 +35,51 @@ class StateAPIEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def post(self, request, slug, project_id):
|
def post(self, request, slug, project_id):
|
||||||
serializer = StateSerializer(
|
try:
|
||||||
data=request.data, context={"project_id": project_id}
|
serializer = StateSerializer(
|
||||||
)
|
data=request.data, context={"project_id": project_id}
|
||||||
if serializer.is_valid():
|
)
|
||||||
if (
|
if serializer.is_valid():
|
||||||
request.data.get("external_id")
|
if (
|
||||||
and request.data.get("external_source")
|
request.data.get("external_id")
|
||||||
and State.objects.filter(
|
and request.data.get("external_source")
|
||||||
project_id=project_id,
|
and State.objects.filter(
|
||||||
workspace__slug=slug,
|
project_id=project_id,
|
||||||
external_source=request.data.get("external_source"),
|
workspace__slug=slug,
|
||||||
external_id=request.data.get("external_id"),
|
external_source=request.data.get("external_source"),
|
||||||
).exists()
|
external_id=request.data.get("external_id"),
|
||||||
):
|
).exists()
|
||||||
state = State.objects.filter(
|
):
|
||||||
workspace__slug=slug,
|
state = State.objects.filter(
|
||||||
project_id=project_id,
|
workspace__slug=slug,
|
||||||
external_id=request.data.get("external_id"),
|
project_id=project_id,
|
||||||
external_source=request.data.get("external_source"),
|
external_id=request.data.get("external_id"),
|
||||||
).first()
|
external_source=request.data.get("external_source"),
|
||||||
return Response(
|
).first()
|
||||||
{
|
return Response(
|
||||||
"error": "State with the same external id and external source already exists",
|
{
|
||||||
"id": str(state.id),
|
"error": "State with the same external id and external source already exists",
|
||||||
},
|
"id": str(state.id),
|
||||||
status=status.HTTP_409_CONFLICT,
|
},
|
||||||
)
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
|
||||||
serializer.save(project_id=project_id)
|
serializer.save(project_id=project_id)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except IntegrityError as e:
|
||||||
|
state = State.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
name=request.data.get("name"),
|
||||||
|
).first()
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "State with the same name already exists in the project",
|
||||||
|
"id": str(state.id),
|
||||||
|
},
|
||||||
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
|
||||||
def get(self, request, slug, project_id, state_id=None):
|
def get(self, request, slug, project_id, state_id=None):
|
||||||
if state_id:
|
if state_id:
|
||||||
|
@ -69,9 +69,13 @@ from .issue import (
|
|||||||
RelatedIssueSerializer,
|
RelatedIssueSerializer,
|
||||||
IssuePublicSerializer,
|
IssuePublicSerializer,
|
||||||
IssueDetailSerializer,
|
IssueDetailSerializer,
|
||||||
|
IssueReactionLiteSerializer,
|
||||||
|
IssueAttachmentLiteSerializer,
|
||||||
|
IssueLinkLiteSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .module import (
|
from .module import (
|
||||||
|
ModuleDetailSerializer,
|
||||||
ModuleWriteSerializer,
|
ModuleWriteSerializer,
|
||||||
ModuleSerializer,
|
ModuleSerializer,
|
||||||
ModuleIssueSerializer,
|
ModuleIssueSerializer,
|
||||||
|
@ -58,9 +58,12 @@ class DynamicBaseSerializer(BaseSerializer):
|
|||||||
IssueSerializer,
|
IssueSerializer,
|
||||||
LabelSerializer,
|
LabelSerializer,
|
||||||
CycleIssueSerializer,
|
CycleIssueSerializer,
|
||||||
IssueFlatSerializer,
|
IssueLiteSerializer,
|
||||||
IssueRelationSerializer,
|
IssueRelationSerializer,
|
||||||
InboxIssueLiteSerializer
|
InboxIssueLiteSerializer,
|
||||||
|
IssueReactionLiteSerializer,
|
||||||
|
IssueAttachmentLiteSerializer,
|
||||||
|
IssueLinkLiteSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Expansion mapper
|
# Expansion mapper
|
||||||
@ -79,12 +82,34 @@ class DynamicBaseSerializer(BaseSerializer):
|
|||||||
"assignees": UserLiteSerializer,
|
"assignees": UserLiteSerializer,
|
||||||
"labels": LabelSerializer,
|
"labels": LabelSerializer,
|
||||||
"issue_cycle": CycleIssueSerializer,
|
"issue_cycle": CycleIssueSerializer,
|
||||||
"parent": IssueSerializer,
|
"parent": IssueLiteSerializer,
|
||||||
"issue_relation": IssueRelationSerializer,
|
"issue_relation": IssueRelationSerializer,
|
||||||
"issue_inbox" : InboxIssueLiteSerializer,
|
"issue_inbox": InboxIssueLiteSerializer,
|
||||||
|
"issue_reactions": IssueReactionLiteSerializer,
|
||||||
|
"issue_attachment": IssueAttachmentLiteSerializer,
|
||||||
|
"issue_link": IssueLinkLiteSerializer,
|
||||||
|
"sub_issues": IssueLiteSerializer,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation", "issue_inbox"] else False)
|
self.fields[field] = expansion[field](
|
||||||
|
many=(
|
||||||
|
True
|
||||||
|
if field
|
||||||
|
in [
|
||||||
|
"members",
|
||||||
|
"assignees",
|
||||||
|
"labels",
|
||||||
|
"issue_cycle",
|
||||||
|
"issue_relation",
|
||||||
|
"issue_inbox",
|
||||||
|
"issue_reactions",
|
||||||
|
"issue_attachment",
|
||||||
|
"issue_link",
|
||||||
|
"sub_issues",
|
||||||
|
]
|
||||||
|
else False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return self.fields
|
return self.fields
|
||||||
|
|
||||||
@ -105,7 +130,11 @@ class DynamicBaseSerializer(BaseSerializer):
|
|||||||
LabelSerializer,
|
LabelSerializer,
|
||||||
CycleIssueSerializer,
|
CycleIssueSerializer,
|
||||||
IssueRelationSerializer,
|
IssueRelationSerializer,
|
||||||
InboxIssueLiteSerializer
|
InboxIssueLiteSerializer,
|
||||||
|
IssueLiteSerializer,
|
||||||
|
IssueReactionLiteSerializer,
|
||||||
|
IssueAttachmentLiteSerializer,
|
||||||
|
IssueLinkLiteSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Expansion mapper
|
# Expansion mapper
|
||||||
@ -124,9 +153,13 @@ class DynamicBaseSerializer(BaseSerializer):
|
|||||||
"assignees": UserLiteSerializer,
|
"assignees": UserLiteSerializer,
|
||||||
"labels": LabelSerializer,
|
"labels": LabelSerializer,
|
||||||
"issue_cycle": CycleIssueSerializer,
|
"issue_cycle": CycleIssueSerializer,
|
||||||
"parent": IssueSerializer,
|
"parent": IssueLiteSerializer,
|
||||||
"issue_relation": IssueRelationSerializer,
|
"issue_relation": IssueRelationSerializer,
|
||||||
"issue_inbox" : InboxIssueLiteSerializer,
|
"issue_inbox": InboxIssueLiteSerializer,
|
||||||
|
"issue_reactions": IssueReactionLiteSerializer,
|
||||||
|
"issue_attachment": IssueAttachmentLiteSerializer,
|
||||||
|
"issue_link": IssueLinkLiteSerializer,
|
||||||
|
"sub_issues": IssueLiteSerializer,
|
||||||
}
|
}
|
||||||
# Check if field in expansion then expand the field
|
# Check if field in expansion then expand the field
|
||||||
if expand in expansion:
|
if expand in expansion:
|
||||||
|
@ -3,10 +3,7 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
from .user import UserLiteSerializer
|
|
||||||
from .issue import IssueStateSerializer
|
from .issue import IssueStateSerializer
|
||||||
from .workspace import WorkspaceLiteSerializer
|
|
||||||
from .project import ProjectLiteSerializer
|
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
Cycle,
|
Cycle,
|
||||||
CycleIssue,
|
CycleIssue,
|
||||||
@ -14,7 +11,6 @@ from plane.db.models import (
|
|||||||
CycleUserProperties,
|
CycleUserProperties,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CycleWriteSerializer(BaseSerializer):
|
class CycleWriteSerializer(BaseSerializer):
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
if (
|
if (
|
||||||
@ -30,60 +26,6 @@ class CycleWriteSerializer(BaseSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Cycle
|
model = Cycle
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
class CycleSerializer(BaseSerializer):
|
|
||||||
is_favorite = serializers.BooleanField(read_only=True)
|
|
||||||
total_issues = serializers.IntegerField(read_only=True)
|
|
||||||
cancelled_issues = serializers.IntegerField(read_only=True)
|
|
||||||
completed_issues = serializers.IntegerField(read_only=True)
|
|
||||||
started_issues = serializers.IntegerField(read_only=True)
|
|
||||||
unstarted_issues = serializers.IntegerField(read_only=True)
|
|
||||||
backlog_issues = serializers.IntegerField(read_only=True)
|
|
||||||
assignees = serializers.SerializerMethodField(read_only=True)
|
|
||||||
total_estimates = serializers.IntegerField(read_only=True)
|
|
||||||
completed_estimates = serializers.IntegerField(read_only=True)
|
|
||||||
started_estimates = serializers.IntegerField(read_only=True)
|
|
||||||
workspace_detail = WorkspaceLiteSerializer(
|
|
||||||
read_only=True, source="workspace"
|
|
||||||
)
|
|
||||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
|
||||||
status = serializers.CharField(read_only=True)
|
|
||||||
|
|
||||||
def validate(self, data):
|
|
||||||
if (
|
|
||||||
data.get("start_date", None) is not None
|
|
||||||
and data.get("end_date", None) is not None
|
|
||||||
and data.get("start_date", None) > data.get("end_date", None)
|
|
||||||
):
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
"Start date cannot exceed end date"
|
|
||||||
)
|
|
||||||
return data
|
|
||||||
|
|
||||||
def get_assignees(self, obj):
|
|
||||||
members = [
|
|
||||||
{
|
|
||||||
"avatar": assignee.avatar,
|
|
||||||
"display_name": assignee.display_name,
|
|
||||||
"id": assignee.id,
|
|
||||||
}
|
|
||||||
for issue_cycle in obj.issue_cycle.prefetch_related(
|
|
||||||
"issue__assignees"
|
|
||||||
).all()
|
|
||||||
for assignee in issue_cycle.issue.assignees.all()
|
|
||||||
]
|
|
||||||
# Use a set comprehension to return only the unique objects
|
|
||||||
unique_objects = {frozenset(item.items()) for item in members}
|
|
||||||
|
|
||||||
# Convert the set back to a list of dictionaries
|
|
||||||
unique_list = [dict(item) for item in unique_objects]
|
|
||||||
|
|
||||||
return unique_list
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Cycle
|
|
||||||
fields = "__all__"
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
"workspace",
|
"workspace",
|
||||||
"project",
|
"project",
|
||||||
@ -91,6 +33,52 @@ class CycleSerializer(BaseSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CycleSerializer(BaseSerializer):
|
||||||
|
# favorite
|
||||||
|
is_favorite = serializers.BooleanField(read_only=True)
|
||||||
|
total_issues = serializers.IntegerField(read_only=True)
|
||||||
|
# state group wise distribution
|
||||||
|
cancelled_issues = serializers.IntegerField(read_only=True)
|
||||||
|
completed_issues = serializers.IntegerField(read_only=True)
|
||||||
|
started_issues = serializers.IntegerField(read_only=True)
|
||||||
|
unstarted_issues = serializers.IntegerField(read_only=True)
|
||||||
|
backlog_issues = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
|
# active | draft | upcoming | completed
|
||||||
|
status = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Cycle
|
||||||
|
fields = [
|
||||||
|
# necessary fields
|
||||||
|
"id",
|
||||||
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"owned_by_id",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
"progress_snapshot",
|
||||||
|
# meta fields
|
||||||
|
"is_favorite",
|
||||||
|
"total_issues",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"status",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
class CycleIssueSerializer(BaseSerializer):
|
class CycleIssueSerializer(BaseSerializer):
|
||||||
issue_detail = IssueStateSerializer(read_only=True, source="issue")
|
issue_detail = IssueStateSerializer(read_only=True, source="issue")
|
||||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
# Django imports
|
# Django imports
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.core.validators import URLValidator
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
# Third Party imports
|
# Third Party imports
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
@ -432,6 +434,20 @@ class IssueLinkSerializer(BaseSerializer):
|
|||||||
"issue",
|
"issue",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def validate_url(self, value):
|
||||||
|
# Check URL format
|
||||||
|
validate_url = URLValidator()
|
||||||
|
try:
|
||||||
|
validate_url(value)
|
||||||
|
except ValidationError:
|
||||||
|
raise serializers.ValidationError("Invalid URL format.")
|
||||||
|
|
||||||
|
# Check URL scheme
|
||||||
|
if not value.startswith(('http://', 'https://')):
|
||||||
|
raise serializers.ValidationError("Invalid URL scheme.")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
# Validation if url already exists
|
# Validation if url already exists
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
if IssueLink.objects.filter(
|
if IssueLink.objects.filter(
|
||||||
@ -443,6 +459,33 @@ class IssueLinkSerializer(BaseSerializer):
|
|||||||
)
|
)
|
||||||
return IssueLink.objects.create(**validated_data)
|
return IssueLink.objects.create(**validated_data)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
if IssueLink.objects.filter(
|
||||||
|
url=validated_data.get("url"),
|
||||||
|
issue_id=instance.issue_id,
|
||||||
|
).exists():
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"error": "URL already exists for this Issue"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class IssueLinkLiteSerializer(BaseSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = IssueLink
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"issue_id",
|
||||||
|
"title",
|
||||||
|
"url",
|
||||||
|
"metadata",
|
||||||
|
"created_by_id",
|
||||||
|
"created_at",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
class IssueAttachmentSerializer(BaseSerializer):
|
class IssueAttachmentSerializer(BaseSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -459,6 +502,21 @@ class IssueAttachmentSerializer(BaseSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = IssueAttachment
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"asset",
|
||||||
|
"attributes",
|
||||||
|
"issue_id",
|
||||||
|
"updated_at",
|
||||||
|
"updated_by_id",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
class IssueReactionSerializer(BaseSerializer):
|
class IssueReactionSerializer(BaseSerializer):
|
||||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||||
|
|
||||||
@ -473,6 +531,18 @@ class IssueReactionSerializer(BaseSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class IssueReactionLiteSerializer(DynamicBaseSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = IssueReaction
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"actor_id",
|
||||||
|
"issue_id",
|
||||||
|
"reaction",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class CommentReactionSerializer(BaseSerializer):
|
class CommentReactionSerializer(BaseSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CommentReaction
|
model = CommentReaction
|
||||||
@ -503,9 +573,7 @@ class IssueCommentSerializer(BaseSerializer):
|
|||||||
workspace_detail = WorkspaceLiteSerializer(
|
workspace_detail = WorkspaceLiteSerializer(
|
||||||
read_only=True, source="workspace"
|
read_only=True, source="workspace"
|
||||||
)
|
)
|
||||||
comment_reactions = CommentReactionSerializer(
|
comment_reactions = CommentReactionSerializer(read_only=True, many=True)
|
||||||
read_only=True, many=True
|
|
||||||
)
|
|
||||||
is_member = serializers.BooleanField(read_only=True)
|
is_member = serializers.BooleanField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -558,18 +626,17 @@ class IssueStateSerializer(DynamicBaseSerializer):
|
|||||||
|
|
||||||
class IssueSerializer(DynamicBaseSerializer):
|
class IssueSerializer(DynamicBaseSerializer):
|
||||||
# ids
|
# ids
|
||||||
project_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
|
||||||
state_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
|
||||||
parent_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
|
||||||
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||||
module_ids = serializers.SerializerMethodField()
|
module_ids = serializers.ListField(
|
||||||
|
child=serializers.UUIDField(), required=False,
|
||||||
|
)
|
||||||
|
|
||||||
# Many to many
|
# Many to many
|
||||||
label_ids = serializers.PrimaryKeyRelatedField(
|
label_ids = serializers.ListField(
|
||||||
read_only=True, many=True, source="labels"
|
child=serializers.UUIDField(), required=False,
|
||||||
)
|
)
|
||||||
assignee_ids = serializers.PrimaryKeyRelatedField(
|
assignee_ids = serializers.ListField(
|
||||||
read_only=True, many=True, source="assignees"
|
child=serializers.UUIDField(), required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Count items
|
# Count items
|
||||||
@ -577,9 +644,6 @@ class IssueSerializer(DynamicBaseSerializer):
|
|||||||
attachment_count = serializers.IntegerField(read_only=True)
|
attachment_count = serializers.IntegerField(read_only=True)
|
||||||
link_count = serializers.IntegerField(read_only=True)
|
link_count = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
# is_subscribed
|
|
||||||
is_subscribed = serializers.BooleanField(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Issue
|
model = Issue
|
||||||
fields = [
|
fields = [
|
||||||
@ -606,57 +670,45 @@ class IssueSerializer(DynamicBaseSerializer):
|
|||||||
"updated_by",
|
"updated_by",
|
||||||
"attachment_count",
|
"attachment_count",
|
||||||
"link_count",
|
"link_count",
|
||||||
"is_subscribed",
|
|
||||||
"is_draft",
|
"is_draft",
|
||||||
"archived_at",
|
"archived_at",
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
def get_module_ids(self, obj):
|
|
||||||
# Access the prefetched modules and extract module IDs
|
|
||||||
return [module for module in obj.issue_module.values_list("module_id", flat=True)]
|
|
||||||
|
|
||||||
|
|
||||||
class IssueDetailSerializer(IssueSerializer):
|
class IssueDetailSerializer(IssueSerializer):
|
||||||
description_html = serializers.CharField()
|
description_html = serializers.CharField()
|
||||||
|
is_subscribed = serializers.BooleanField(read_only=True)
|
||||||
|
|
||||||
class Meta(IssueSerializer.Meta):
|
class Meta(IssueSerializer.Meta):
|
||||||
fields = IssueSerializer.Meta.fields + ['description_html']
|
fields = IssueSerializer.Meta.fields + [
|
||||||
|
"description_html",
|
||||||
|
"is_subscribed",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class IssueLiteSerializer(DynamicBaseSerializer):
|
class IssueLiteSerializer(DynamicBaseSerializer):
|
||||||
workspace_detail = WorkspaceLiteSerializer(
|
|
||||||
read_only=True, source="workspace"
|
|
||||||
)
|
|
||||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
|
||||||
state_detail = StateLiteSerializer(read_only=True, source="state")
|
|
||||||
label_details = LabelLiteSerializer(
|
|
||||||
read_only=True, source="labels", many=True
|
|
||||||
)
|
|
||||||
assignee_details = UserLiteSerializer(
|
|
||||||
read_only=True, source="assignees", many=True
|
|
||||||
)
|
|
||||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
|
||||||
cycle_id = serializers.UUIDField(read_only=True)
|
|
||||||
module_id = serializers.UUIDField(read_only=True)
|
|
||||||
attachment_count = serializers.IntegerField(read_only=True)
|
|
||||||
link_count = serializers.IntegerField(read_only=True)
|
|
||||||
issue_reactions = IssueReactionSerializer(read_only=True, many=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Issue
|
model = Issue
|
||||||
fields = "__all__"
|
fields = [
|
||||||
read_only_fields = [
|
"id",
|
||||||
"start_date",
|
"sequence_id",
|
||||||
"target_date",
|
"project_id",
|
||||||
"completed_at",
|
|
||||||
"workspace",
|
|
||||||
"project",
|
|
||||||
"created_by",
|
|
||||||
"updated_by",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
]
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
class IssueDetailSerializer(IssueSerializer):
|
||||||
|
description_html = serializers.CharField()
|
||||||
|
is_subscribed = serializers.BooleanField()
|
||||||
|
|
||||||
|
class Meta(IssueSerializer.Meta):
|
||||||
|
fields = IssueSerializer.Meta.fields + [
|
||||||
|
"description_html",
|
||||||
|
"is_subscribed",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
class IssuePublicSerializer(BaseSerializer):
|
class IssuePublicSerializer(BaseSerializer):
|
||||||
|
@ -5,7 +5,6 @@ from rest_framework import serializers
|
|||||||
from .base import BaseSerializer, DynamicBaseSerializer
|
from .base import BaseSerializer, DynamicBaseSerializer
|
||||||
from .user import UserLiteSerializer
|
from .user import UserLiteSerializer
|
||||||
from .project import ProjectLiteSerializer
|
from .project import ProjectLiteSerializer
|
||||||
from .workspace import WorkspaceLiteSerializer
|
|
||||||
|
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
User,
|
User,
|
||||||
@ -19,17 +18,18 @@ from plane.db.models import (
|
|||||||
|
|
||||||
|
|
||||||
class ModuleWriteSerializer(BaseSerializer):
|
class ModuleWriteSerializer(BaseSerializer):
|
||||||
members = serializers.ListField(
|
lead_id = serializers.PrimaryKeyRelatedField(
|
||||||
|
source="lead",
|
||||||
|
queryset=User.objects.all(),
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
)
|
||||||
|
member_ids = serializers.ListField(
|
||||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
|
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
|
||||||
write_only=True,
|
write_only=True,
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
|
||||||
workspace_detail = WorkspaceLiteSerializer(
|
|
||||||
source="workspace", read_only=True
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Module
|
model = Module
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
@ -44,7 +44,9 @@ class ModuleWriteSerializer(BaseSerializer):
|
|||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
data = super().to_representation(instance)
|
data = super().to_representation(instance)
|
||||||
data["members"] = [str(member.id) for member in instance.members.all()]
|
data["member_ids"] = [
|
||||||
|
str(member.id) for member in instance.members.all()
|
||||||
|
]
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
@ -59,12 +61,10 @@ class ModuleWriteSerializer(BaseSerializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
members = validated_data.pop("members", None)
|
members = validated_data.pop("member_ids", None)
|
||||||
|
|
||||||
project = self.context["project"]
|
project = self.context["project"]
|
||||||
|
|
||||||
module = Module.objects.create(**validated_data, project=project)
|
module = Module.objects.create(**validated_data, project=project)
|
||||||
|
|
||||||
if members is not None:
|
if members is not None:
|
||||||
ModuleMember.objects.bulk_create(
|
ModuleMember.objects.bulk_create(
|
||||||
[
|
[
|
||||||
@ -85,7 +85,7 @@ class ModuleWriteSerializer(BaseSerializer):
|
|||||||
return module
|
return module
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
members = validated_data.pop("members", None)
|
members = validated_data.pop("member_ids", None)
|
||||||
|
|
||||||
if members is not None:
|
if members is not None:
|
||||||
ModuleMember.objects.filter(module=instance).delete()
|
ModuleMember.objects.filter(module=instance).delete()
|
||||||
@ -142,7 +142,6 @@ class ModuleIssueSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class ModuleLinkSerializer(BaseSerializer):
|
class ModuleLinkSerializer(BaseSerializer):
|
||||||
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ModuleLink
|
model = ModuleLink
|
||||||
@ -170,12 +169,9 @@ class ModuleLinkSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class ModuleSerializer(DynamicBaseSerializer):
|
class ModuleSerializer(DynamicBaseSerializer):
|
||||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
member_ids = serializers.ListField(
|
||||||
lead_detail = UserLiteSerializer(read_only=True, source="lead")
|
child=serializers.UUIDField(), required=False, allow_null=True
|
||||||
members_detail = UserLiteSerializer(
|
|
||||||
read_only=True, many=True, source="members"
|
|
||||||
)
|
)
|
||||||
link_module = ModuleLinkSerializer(read_only=True, many=True)
|
|
||||||
is_favorite = serializers.BooleanField(read_only=True)
|
is_favorite = serializers.BooleanField(read_only=True)
|
||||||
total_issues = serializers.IntegerField(read_only=True)
|
total_issues = serializers.IntegerField(read_only=True)
|
||||||
cancelled_issues = serializers.IntegerField(read_only=True)
|
cancelled_issues = serializers.IntegerField(read_only=True)
|
||||||
@ -186,15 +182,46 @@ class ModuleSerializer(DynamicBaseSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Module
|
model = Module
|
||||||
fields = "__all__"
|
fields = [
|
||||||
read_only_fields = [
|
# Required fields
|
||||||
"workspace",
|
"id",
|
||||||
"project",
|
"workspace_id",
|
||||||
"created_by",
|
"project_id",
|
||||||
"updated_by",
|
# Model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"description_text",
|
||||||
|
"description_html",
|
||||||
|
"start_date",
|
||||||
|
"target_date",
|
||||||
|
"status",
|
||||||
|
"lead_id",
|
||||||
|
"member_ids",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
# computed fields
|
||||||
|
"is_favorite",
|
||||||
|
"total_issues",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleDetailSerializer(ModuleSerializer):
|
||||||
|
|
||||||
|
link_module = ModuleLinkSerializer(read_only=True, many=True)
|
||||||
|
|
||||||
|
class Meta(ModuleSerializer.Meta):
|
||||||
|
fields = ModuleSerializer.Meta.fields + ['link_module']
|
||||||
|
|
||||||
|
|
||||||
class ModuleFavoriteSerializer(BaseSerializer):
|
class ModuleFavoriteSerializer(BaseSerializer):
|
||||||
|
@ -95,8 +95,7 @@ class ProjectLiteSerializer(BaseSerializer):
|
|||||||
"identifier",
|
"identifier",
|
||||||
"name",
|
"name",
|
||||||
"cover_image",
|
"cover_image",
|
||||||
"icon_prop",
|
"logo_props",
|
||||||
"emoji",
|
|
||||||
"description",
|
"description",
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
@ -2,6 +2,7 @@ from django.urls import path
|
|||||||
|
|
||||||
|
|
||||||
from plane.app.views import (
|
from plane.app.views import (
|
||||||
|
IssueListEndpoint,
|
||||||
IssueViewSet,
|
IssueViewSet,
|
||||||
LabelViewSet,
|
LabelViewSet,
|
||||||
BulkCreateIssueLabelsEndpoint,
|
BulkCreateIssueLabelsEndpoint,
|
||||||
@ -25,6 +26,11 @@ from plane.app.views import (
|
|||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/list/",
|
||||||
|
IssueListEndpoint.as_view(),
|
||||||
|
name="project-issue",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
|
||||||
IssueViewSet.as_view(
|
IssueViewSet.as_view(
|
||||||
@ -84,11 +90,13 @@ urlpatterns = [
|
|||||||
BulkImportIssuesEndpoint.as_view(),
|
BulkImportIssuesEndpoint.as_view(),
|
||||||
name="project-issues-bulk",
|
name="project-issues-bulk",
|
||||||
),
|
),
|
||||||
|
# deprecated endpoint TODO: remove once confirmed
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/my-issues/",
|
"workspaces/<str:slug>/my-issues/",
|
||||||
UserWorkSpaceIssues.as_view(),
|
UserWorkSpaceIssues.as_view(),
|
||||||
name="workspace-issues",
|
name="workspace-issues",
|
||||||
),
|
),
|
||||||
|
##
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/",
|
||||||
SubIssuesEndpoint.as_view(),
|
SubIssuesEndpoint.as_view(),
|
||||||
@ -251,23 +259,15 @@ urlpatterns = [
|
|||||||
name="project-issue-archive",
|
name="project-issue-archive",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/<uuid:pk>/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/archive/",
|
||||||
IssueArchiveViewSet.as_view(
|
IssueArchiveViewSet.as_view(
|
||||||
{
|
{
|
||||||
"get": "retrieve",
|
"get": "retrieve",
|
||||||
"delete": "destroy",
|
"post": "archive",
|
||||||
|
"delete": "unarchive",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
name="project-issue-archive",
|
name="project-issue-archive-unarchive",
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/unarchive/<uuid:pk>/",
|
|
||||||
IssueArchiveViewSet.as_view(
|
|
||||||
{
|
|
||||||
"post": "unarchive",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
name="project-issue-archive",
|
|
||||||
),
|
),
|
||||||
## End Issue Archives
|
## End Issue Archives
|
||||||
## Issue Relation
|
## Issue Relation
|
||||||
|
@ -22,6 +22,9 @@ from plane.app.views import (
|
|||||||
WorkspaceUserPropertiesEndpoint,
|
WorkspaceUserPropertiesEndpoint,
|
||||||
WorkspaceStatesEndpoint,
|
WorkspaceStatesEndpoint,
|
||||||
WorkspaceEstimatesEndpoint,
|
WorkspaceEstimatesEndpoint,
|
||||||
|
ExportWorkspaceUserActivityEndpoint,
|
||||||
|
WorkspaceModulesEndpoint,
|
||||||
|
WorkspaceCyclesEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -189,6 +192,11 @@ urlpatterns = [
|
|||||||
WorkspaceUserActivityEndpoint.as_view(),
|
WorkspaceUserActivityEndpoint.as_view(),
|
||||||
name="workspace-user-activity",
|
name="workspace-user-activity",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/user-activity/<uuid:user_id>/export/",
|
||||||
|
ExportWorkspaceUserActivityEndpoint.as_view(),
|
||||||
|
name="export-workspace-user-activity",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/user-profile/<uuid:user_id>/",
|
"workspaces/<str:slug>/user-profile/<uuid:user_id>/",
|
||||||
WorkspaceUserProfileEndpoint.as_view(),
|
WorkspaceUserProfileEndpoint.as_view(),
|
||||||
@ -219,4 +227,14 @@ urlpatterns = [
|
|||||||
WorkspaceEstimatesEndpoint.as_view(),
|
WorkspaceEstimatesEndpoint.as_view(),
|
||||||
name="workspace-estimate",
|
name="workspace-estimate",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/modules/",
|
||||||
|
WorkspaceModulesEndpoint.as_view(),
|
||||||
|
name="workspace-modules",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/cycles/",
|
||||||
|
WorkspaceCyclesEndpoint.as_view(),
|
||||||
|
name="workspace-cycles",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -49,6 +49,9 @@ from .workspace import (
|
|||||||
WorkspaceUserPropertiesEndpoint,
|
WorkspaceUserPropertiesEndpoint,
|
||||||
WorkspaceStatesEndpoint,
|
WorkspaceStatesEndpoint,
|
||||||
WorkspaceEstimatesEndpoint,
|
WorkspaceEstimatesEndpoint,
|
||||||
|
ExportWorkspaceUserActivityEndpoint,
|
||||||
|
WorkspaceModulesEndpoint,
|
||||||
|
WorkspaceCyclesEndpoint,
|
||||||
)
|
)
|
||||||
from .state import StateViewSet
|
from .state import StateViewSet
|
||||||
from .view import (
|
from .view import (
|
||||||
@ -67,6 +70,7 @@ from .cycle import (
|
|||||||
)
|
)
|
||||||
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
||||||
from .issue import (
|
from .issue import (
|
||||||
|
IssueListEndpoint,
|
||||||
IssueViewSet,
|
IssueViewSet,
|
||||||
WorkSpaceIssuesEndpoint,
|
WorkSpaceIssuesEndpoint,
|
||||||
IssueActivityEndpoint,
|
IssueActivityEndpoint,
|
||||||
@ -183,4 +187,6 @@ from .webhook import (
|
|||||||
from .dashboard import (
|
from .dashboard import (
|
||||||
DashboardEndpoint,
|
DashboardEndpoint,
|
||||||
WidgetsEndpoint
|
WidgetsEndpoint
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .error_404 import custom_404_view
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
# Django imports
|
# Django imports
|
||||||
from django.db.models import Count, Sum, F, Q
|
from django.db.models import Count, Sum, F, Q
|
||||||
from django.db.models.functions import ExtractMonth
|
from django.db.models.functions import ExtractMonth
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@ -331,8 +332,9 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
|
|||||||
.order_by("state_group")
|
.order_by("state_group")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
current_year = timezone.now().year
|
||||||
issue_completed_month_wise = (
|
issue_completed_month_wise = (
|
||||||
base_issues.filter(completed_at__isnull=False)
|
base_issues.filter(completed_at__year=current_year)
|
||||||
.annotate(month=ExtractMonth("completed_at"))
|
.annotate(month=ExtractMonth("completed_at"))
|
||||||
.values("month")
|
.values("month")
|
||||||
.annotate(count=Count("*"))
|
.annotate(count=Count("*"))
|
||||||
|
@ -66,15 +66,15 @@ class ConfigurationEndpoint(BaseAPIView):
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "SLACK_CLIENT_ID",
|
"key": "SLACK_CLIENT_ID",
|
||||||
"default": os.environ.get("SLACK_CLIENT_ID", "1"),
|
"default": os.environ.get("SLACK_CLIENT_ID", None),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "POSTHOG_API_KEY",
|
"key": "POSTHOG_API_KEY",
|
||||||
"default": os.environ.get("POSTHOG_API_KEY", "1"),
|
"default": os.environ.get("POSTHOG_API_KEY", None),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "POSTHOG_HOST",
|
"key": "POSTHOG_HOST",
|
||||||
"default": os.environ.get("POSTHOG_HOST", "1"),
|
"default": os.environ.get("POSTHOG_HOST", None),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "UNSPLASH_ACCESS_KEY",
|
"key": "UNSPLASH_ACCESS_KEY",
|
||||||
@ -181,11 +181,11 @@ class MobileConfigurationEndpoint(BaseAPIView):
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "POSTHOG_API_KEY",
|
"key": "POSTHOG_API_KEY",
|
||||||
"default": os.environ.get("POSTHOG_API_KEY", "1"),
|
"default": os.environ.get("POSTHOG_API_KEY", None),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "POSTHOG_HOST",
|
"key": "POSTHOG_HOST",
|
||||||
"default": os.environ.get("POSTHOG_HOST", "1"),
|
"default": os.environ.get("POSTHOG_HOST", None),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "UNSPLASH_ACCESS_KEY",
|
"key": "UNSPLASH_ACCESS_KEY",
|
||||||
|
@ -20,7 +20,10 @@ from django.core import serializers
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.gzip import gzip_page
|
from django.views.decorators.gzip import gzip_page
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
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.functions import Coalesce
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -33,7 +36,6 @@ from plane.app.serializers import (
|
|||||||
CycleIssueSerializer,
|
CycleIssueSerializer,
|
||||||
CycleFavoriteSerializer,
|
CycleFavoriteSerializer,
|
||||||
IssueSerializer,
|
IssueSerializer,
|
||||||
IssueStateSerializer,
|
|
||||||
CycleWriteSerializer,
|
CycleWriteSerializer,
|
||||||
CycleUserPropertiesSerializer,
|
CycleUserPropertiesSerializer,
|
||||||
)
|
)
|
||||||
@ -51,7 +53,6 @@ from plane.db.models import (
|
|||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
Label,
|
Label,
|
||||||
CycleUserProperties,
|
CycleUserProperties,
|
||||||
IssueSubscriber,
|
|
||||||
)
|
)
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
@ -73,7 +74,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
subquery = CycleFavorite.objects.filter(
|
favorite_subquery = CycleFavorite.objects.filter(
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
cycle_id=OuterRef("pk"),
|
cycle_id=OuterRef("pk"),
|
||||||
project_id=self.kwargs.get("project_id"),
|
project_id=self.kwargs.get("project_id"),
|
||||||
@ -84,11 +85,28 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
.get_queryset()
|
.get_queryset()
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
.select_related("project")
|
project__project_projectmember__member=self.request.user,
|
||||||
.select_related("workspace")
|
project__project_projectmember__is_active=True,
|
||||||
.select_related("owned_by")
|
)
|
||||||
.annotate(is_favorite=Exists(subquery))
|
.select_related("project", "workspace", "owned_by")
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"issue_cycle__issue__assignees",
|
||||||
|
queryset=User.objects.only(
|
||||||
|
"avatar", "first_name", "id"
|
||||||
|
).distinct(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"issue_cycle__issue__labels",
|
||||||
|
queryset=Label.objects.only(
|
||||||
|
"name", "color", "id"
|
||||||
|
).distinct(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(is_favorite=Exists(favorite_subquery))
|
||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Count(
|
total_issues=Count(
|
||||||
"issue_cycle",
|
"issue_cycle",
|
||||||
@ -148,29 +166,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
|
||||||
total_estimates=Sum("issue_cycle__issue__estimate_point")
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
completed_estimates=Sum(
|
|
||||||
"issue_cycle__issue__estimate_point",
|
|
||||||
filter=Q(
|
|
||||||
issue_cycle__issue__state__group="completed",
|
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
|
||||||
issue_cycle__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
started_estimates=Sum(
|
|
||||||
"issue_cycle__issue__estimate_point",
|
|
||||||
filter=Q(
|
|
||||||
issue_cycle__issue__state__group="started",
|
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
|
||||||
issue_cycle__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
.annotate(
|
||||||
status=Case(
|
status=Case(
|
||||||
When(
|
When(
|
||||||
@ -190,20 +185,16 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
output_field=CharField(),
|
output_field=CharField(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.prefetch_related(
|
.annotate(
|
||||||
Prefetch(
|
assignee_ids=Coalesce(
|
||||||
"issue_cycle__issue__assignees",
|
ArrayAgg(
|
||||||
queryset=User.objects.only(
|
"issue_cycle__issue__assignees__id",
|
||||||
"avatar", "first_name", "id"
|
distinct=True,
|
||||||
).distinct(),
|
filter=~Q(
|
||||||
)
|
issue_cycle__issue__assignees__id__isnull=True
|
||||||
)
|
),
|
||||||
.prefetch_related(
|
),
|
||||||
Prefetch(
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
"issue_cycle__issue__labels",
|
|
||||||
queryset=Label.objects.only(
|
|
||||||
"name", "color", "id"
|
|
||||||
).distinct(),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.order_by("-is_favorite", "name")
|
.order_by("-is_favorite", "name")
|
||||||
@ -213,12 +204,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
def list(self, request, slug, project_id):
|
def list(self, request, slug, project_id):
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
cycle_view = request.GET.get("cycle_view", "all")
|
cycle_view = request.GET.get("cycle_view", "all")
|
||||||
fields = [
|
|
||||||
field
|
|
||||||
for field in request.GET.get("fields", "").split(",")
|
|
||||||
if field
|
|
||||||
]
|
|
||||||
|
|
||||||
|
# Update the order by
|
||||||
queryset = queryset.order_by("-is_favorite", "-created_at")
|
queryset = queryset.order_by("-is_favorite", "-created_at")
|
||||||
|
|
||||||
# Current Cycle
|
# Current Cycle
|
||||||
@ -228,9 +215,35 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
end_date__gte=timezone.now(),
|
end_date__gte=timezone.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data = CycleSerializer(queryset, many=True).data
|
data = queryset.values(
|
||||||
|
# necessary fields
|
||||||
|
"id",
|
||||||
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"owned_by_id",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
"progress_snapshot",
|
||||||
|
# meta fields
|
||||||
|
"is_favorite",
|
||||||
|
"total_issues",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"assignee_ids",
|
||||||
|
"status",
|
||||||
|
)
|
||||||
|
|
||||||
if len(data):
|
if data:
|
||||||
assignee_distribution = (
|
assignee_distribution = (
|
||||||
Issue.objects.filter(
|
Issue.objects.filter(
|
||||||
issue_cycle__cycle_id=data[0]["id"],
|
issue_cycle__cycle_id=data[0]["id"],
|
||||||
@ -315,19 +328,45 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if data[0]["start_date"] and data[0]["end_date"]:
|
if data[0]["start_date"] and data[0]["end_date"]:
|
||||||
data[0]["distribution"][
|
data[0]["distribution"]["completion_chart"] = (
|
||||||
"completion_chart"
|
burndown_plot(
|
||||||
] = burndown_plot(
|
queryset=queryset.first(),
|
||||||
queryset=queryset.first(),
|
slug=slug,
|
||||||
slug=slug,
|
project_id=project_id,
|
||||||
project_id=project_id,
|
cycle_id=data[0]["id"],
|
||||||
cycle_id=data[0]["id"],
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(data, status=status.HTTP_200_OK)
|
return Response(data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
cycles = CycleSerializer(queryset, many=True).data
|
data = queryset.values(
|
||||||
return Response(cycles, status=status.HTTP_200_OK)
|
# necessary fields
|
||||||
|
"id",
|
||||||
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"owned_by_id",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
"progress_snapshot",
|
||||||
|
# meta fields
|
||||||
|
"is_favorite",
|
||||||
|
"total_issues",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"assignee_ids",
|
||||||
|
"status",
|
||||||
|
)
|
||||||
|
return Response(data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def create(self, request, slug, project_id):
|
def create(self, request, slug, project_id):
|
||||||
if (
|
if (
|
||||||
@ -337,7 +376,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
request.data.get("start_date", None) is not None
|
request.data.get("start_date", None) is not None
|
||||||
and request.data.get("end_date", None) is not None
|
and request.data.get("end_date", None) is not None
|
||||||
):
|
):
|
||||||
serializer = CycleSerializer(data=request.data)
|
serializer = CycleWriteSerializer(data=request.data)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save(
|
serializer.save(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
@ -346,12 +385,36 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
cycle = (
|
cycle = (
|
||||||
self.get_queryset()
|
self.get_queryset()
|
||||||
.filter(pk=serializer.data["id"])
|
.filter(pk=serializer.data["id"])
|
||||||
|
.values(
|
||||||
|
# necessary fields
|
||||||
|
"id",
|
||||||
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"owned_by_id",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
"progress_snapshot",
|
||||||
|
# meta fields
|
||||||
|
"is_favorite",
|
||||||
|
"total_issues",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"assignee_ids",
|
||||||
|
"status",
|
||||||
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
serializer = CycleSerializer(cycle)
|
return Response(cycle, status=status.HTTP_201_CREATED)
|
||||||
return Response(
|
|
||||||
serializer.data, status=status.HTTP_201_CREATED
|
|
||||||
)
|
|
||||||
return Response(
|
return Response(
|
||||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
@ -364,10 +427,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def partial_update(self, request, slug, project_id, pk):
|
def partial_update(self, request, slug, project_id, pk):
|
||||||
cycle = Cycle.objects.get(
|
queryset = (
|
||||||
workspace__slug=slug, project_id=project_id, pk=pk
|
self.get_queryset()
|
||||||
|
.filter(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||||
)
|
)
|
||||||
|
cycle = queryset.first()
|
||||||
request_data = request.data
|
request_data = request.data
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -375,7 +439,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
and cycle.end_date < timezone.now().date()
|
and cycle.end_date < timezone.now().date()
|
||||||
):
|
):
|
||||||
if "sort_order" in request_data:
|
if "sort_order" in request_data:
|
||||||
# Can only change sort order
|
# Can only change sort order for a completed cycle``
|
||||||
request_data = {
|
request_data = {
|
||||||
"sort_order": request_data.get(
|
"sort_order": request_data.get(
|
||||||
"sort_order", cycle.sort_order
|
"sort_order", cycle.sort_order
|
||||||
@ -394,12 +458,71 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save()
|
serializer.save()
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
cycle = queryset.values(
|
||||||
|
# necessary fields
|
||||||
|
"id",
|
||||||
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"owned_by_id",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
"progress_snapshot",
|
||||||
|
# meta fields
|
||||||
|
"is_favorite",
|
||||||
|
"total_issues",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"assignee_ids",
|
||||||
|
"status",
|
||||||
|
).first()
|
||||||
|
return Response(cycle, status=status.HTTP_200_OK)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, pk):
|
def retrieve(self, request, slug, project_id, pk):
|
||||||
queryset = self.get_queryset().get(pk=pk)
|
queryset = self.get_queryset().filter(pk=pk)
|
||||||
|
data = (
|
||||||
|
self.get_queryset()
|
||||||
|
.filter(pk=pk)
|
||||||
|
.values(
|
||||||
|
# necessary fields
|
||||||
|
"id",
|
||||||
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"owned_by_id",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
"progress_snapshot",
|
||||||
|
# meta fields
|
||||||
|
"is_favorite",
|
||||||
|
"total_issues",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"assignee_ids",
|
||||||
|
"status",
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
queryset = queryset.first()
|
||||||
# Assignee Distribution
|
# Assignee Distribution
|
||||||
assignee_distribution = (
|
assignee_distribution = (
|
||||||
Issue.objects.filter(
|
Issue.objects.filter(
|
||||||
@ -488,7 +611,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
.order_by("label_name")
|
.order_by("label_name")
|
||||||
)
|
)
|
||||||
|
|
||||||
data = CycleSerializer(queryset).data
|
|
||||||
data["distribution"] = {
|
data["distribution"] = {
|
||||||
"assignees": assignee_distribution,
|
"assignees": assignee_distribution,
|
||||||
"labels": label_distribution,
|
"labels": label_distribution,
|
||||||
@ -570,7 +692,10 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.filter(cycle_id=self.kwargs.get("cycle_id"))
|
.filter(cycle_id=self.kwargs.get("cycle_id"))
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
@ -589,20 +714,18 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
]
|
]
|
||||||
order_by = request.GET.get("order_by", "created_at")
|
order_by = request.GET.get("order_by", "created_at")
|
||||||
filters = issue_filters(request.query_params, "GET")
|
filters = issue_filters(request.query_params, "GET")
|
||||||
issues = (
|
queryset = (
|
||||||
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
|
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
|
||||||
.annotate(
|
|
||||||
sub_issues_count=Issue.issue_objects.filter(
|
|
||||||
parent=OuterRef("id")
|
|
||||||
)
|
|
||||||
.order_by()
|
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
|
||||||
.values("count")
|
|
||||||
)
|
|
||||||
.filter(project_id=project_id)
|
.filter(project_id=project_id)
|
||||||
.filter(workspace__slug=slug)
|
.filter(workspace__slug=slug)
|
||||||
|
.filter(**filters)
|
||||||
.select_related("workspace", "project", "state", "parent")
|
.select_related("workspace", "project", "state", "parent")
|
||||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
.prefetch_related(
|
||||||
|
"assignees",
|
||||||
|
"labels",
|
||||||
|
"issue_module__module",
|
||||||
|
"issue_cycle__cycle",
|
||||||
|
)
|
||||||
.order_by(order_by)
|
.order_by(order_by)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||||
@ -621,22 +744,79 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
.values("count")
|
.values("count")
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
is_subscribed=Exists(
|
sub_issues_count=Issue.issue_objects.filter(
|
||||||
IssueSubscriber.objects.filter(
|
parent=OuterRef("id")
|
||||||
subscriber=self.request.user, issue_id=OuterRef("id")
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
)
|
)
|
||||||
|
.annotate(
|
||||||
|
label_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"labels__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(labels__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
assignee_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"assignees__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(assignees__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
module_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue_module__module_id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(issue_module__module_id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(order_by)
|
||||||
)
|
)
|
||||||
serializer = IssueSerializer(
|
if self.fields:
|
||||||
issues, many=True, fields=fields if fields else None
|
issues = IssueSerializer(
|
||||||
)
|
queryset, many=True, fields=fields if fields else None
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
).data
|
||||||
|
else:
|
||||||
|
issues = queryset.values(
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"state_id",
|
||||||
|
"sort_order",
|
||||||
|
"completed_at",
|
||||||
|
"estimate_point",
|
||||||
|
"priority",
|
||||||
|
"start_date",
|
||||||
|
"target_date",
|
||||||
|
"sequence_id",
|
||||||
|
"project_id",
|
||||||
|
"parent_id",
|
||||||
|
"cycle_id",
|
||||||
|
"module_ids",
|
||||||
|
"label_ids",
|
||||||
|
"assignee_ids",
|
||||||
|
"sub_issues_count",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
"attachment_count",
|
||||||
|
"link_count",
|
||||||
|
"is_draft",
|
||||||
|
"archived_at",
|
||||||
|
)
|
||||||
|
return Response(issues, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def create(self, request, slug, project_id, cycle_id):
|
def create(self, request, slug, project_id, cycle_id):
|
||||||
issues = request.data.get("issues", [])
|
issues = request.data.get("issues", [])
|
||||||
|
|
||||||
if not len(issues):
|
if not issues:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Issues are required"},
|
{"error": "Issues are required"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
@ -658,52 +838,52 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get all CycleIssues already created
|
# Get all CycleIssues already created
|
||||||
cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues))
|
cycle_issues = list(
|
||||||
update_cycle_issue_activity = []
|
CycleIssue.objects.filter(
|
||||||
record_to_create = []
|
~Q(cycle_id=cycle_id), issue_id__in=issues
|
||||||
records_to_update = []
|
)
|
||||||
|
)
|
||||||
|
existing_issues = [
|
||||||
|
str(cycle_issue.issue_id) for cycle_issue in cycle_issues
|
||||||
|
]
|
||||||
|
new_issues = list(set(issues) - set(existing_issues))
|
||||||
|
|
||||||
for issue in issues:
|
# New issues to create
|
||||||
cycle_issue = [
|
created_records = CycleIssue.objects.bulk_create(
|
||||||
cycle_issue
|
[
|
||||||
for cycle_issue in cycle_issues
|
CycleIssue(
|
||||||
if str(cycle_issue.issue_id) in issues
|
project_id=project_id,
|
||||||
]
|
workspace_id=cycle.workspace_id,
|
||||||
# Update only when cycle changes
|
created_by_id=request.user.id,
|
||||||
if len(cycle_issue):
|
updated_by_id=request.user.id,
|
||||||
if cycle_issue[0].cycle_id != cycle_id:
|
cycle_id=cycle_id,
|
||||||
update_cycle_issue_activity.append(
|
issue_id=issue,
|
||||||
{
|
|
||||||
"old_cycle_id": str(cycle_issue[0].cycle_id),
|
|
||||||
"new_cycle_id": str(cycle_id),
|
|
||||||
"issue_id": str(cycle_issue[0].issue_id),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
cycle_issue[0].cycle_id = cycle_id
|
|
||||||
records_to_update.append(cycle_issue[0])
|
|
||||||
else:
|
|
||||||
record_to_create.append(
|
|
||||||
CycleIssue(
|
|
||||||
project_id=project_id,
|
|
||||||
workspace=cycle.workspace,
|
|
||||||
created_by=request.user,
|
|
||||||
updated_by=request.user,
|
|
||||||
cycle=cycle,
|
|
||||||
issue_id=issue,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
for issue in new_issues
|
||||||
CycleIssue.objects.bulk_create(
|
],
|
||||||
record_to_create,
|
|
||||||
batch_size=10,
|
|
||||||
ignore_conflicts=True,
|
|
||||||
)
|
|
||||||
CycleIssue.objects.bulk_update(
|
|
||||||
records_to_update,
|
|
||||||
["cycle"],
|
|
||||||
batch_size=10,
|
batch_size=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Updated Issues
|
||||||
|
updated_records = []
|
||||||
|
update_cycle_issue_activity = []
|
||||||
|
# Iterate over each cycle_issue in cycle_issues
|
||||||
|
for cycle_issue in cycle_issues:
|
||||||
|
# Update the cycle_issue's cycle_id
|
||||||
|
cycle_issue.cycle_id = cycle_id
|
||||||
|
# Add the modified cycle_issue to the records_to_update list
|
||||||
|
updated_records.append(cycle_issue)
|
||||||
|
# Record the update activity
|
||||||
|
update_cycle_issue_activity.append(
|
||||||
|
{
|
||||||
|
"old_cycle_id": str(cycle_issue.cycle_id),
|
||||||
|
"new_cycle_id": str(cycle_id),
|
||||||
|
"issue_id": str(cycle_issue.issue_id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the cycle issues
|
||||||
|
CycleIssue.objects.bulk_update(updated_records, ["cycle_id"], batch_size=100)
|
||||||
# Capture Issue Activity
|
# Capture Issue Activity
|
||||||
issue_activity.delay(
|
issue_activity.delay(
|
||||||
type="cycle.activity.created",
|
type="cycle.activity.created",
|
||||||
@ -715,7 +895,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
{
|
{
|
||||||
"updated_cycle_issues": update_cycle_issue_activity,
|
"updated_cycle_issues": update_cycle_issue_activity,
|
||||||
"created_cycle_issues": serializers.serialize(
|
"created_cycle_issues": serializers.serialize(
|
||||||
"json", record_to_create
|
"json", created_records
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@ -723,16 +903,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
notification=True,
|
notification=True,
|
||||||
origin=request.META.get("HTTP_ORIGIN"),
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
|
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
|
||||||
# Return all Cycle Issues
|
|
||||||
issues = self.get_queryset().values_list("issue_id", flat=True)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
IssueSerializer(
|
|
||||||
Issue.objects.filter(pk__in=issues), many=True
|
|
||||||
).data,
|
|
||||||
status=status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
def destroy(self, request, slug, project_id, cycle_id, issue_id):
|
def destroy(self, request, slug, project_id, cycle_id, issue_id):
|
||||||
cycle_issue = CycleIssue.objects.get(
|
cycle_issue = CycleIssue.objects.get(
|
||||||
@ -776,6 +947,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if any cycle intersects in the given interval
|
||||||
cycles = Cycle.objects.filter(
|
cycles = Cycle.objects.filter(
|
||||||
Q(workspace__slug=slug)
|
Q(workspace__slug=slug)
|
||||||
& Q(project_id=project_id)
|
& Q(project_id=project_id)
|
||||||
@ -785,7 +957,6 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
|||||||
| Q(start_date__gte=start_date, end_date__lte=end_date)
|
| Q(start_date__gte=start_date, end_date__lte=end_date)
|
||||||
)
|
)
|
||||||
).exclude(pk=cycle_id)
|
).exclude(pk=cycle_id)
|
||||||
|
|
||||||
if cycles.exists():
|
if cycles.exists():
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@ -909,29 +1080,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.annotate(
|
|
||||||
total_estimates=Sum("issue_cycle__issue__estimate_point")
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
completed_estimates=Sum(
|
|
||||||
"issue_cycle__issue__estimate_point",
|
|
||||||
filter=Q(
|
|
||||||
issue_cycle__issue__state__group="completed",
|
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
|
||||||
issue_cycle__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
started_estimates=Sum(
|
|
||||||
"issue_cycle__issue__estimate_point",
|
|
||||||
filter=Q(
|
|
||||||
issue_cycle__issue__state__group="started",
|
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
|
||||||
issue_cycle__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pass the new_cycle queryset to burndown_plot
|
# Pass the new_cycle queryset to burndown_plot
|
||||||
@ -942,6 +1090,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||||||
cycle_id=cycle_id,
|
cycle_id=cycle_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get the assignee distribution
|
||||||
assignee_distribution = (
|
assignee_distribution = (
|
||||||
Issue.objects.filter(
|
Issue.objects.filter(
|
||||||
issue_cycle__cycle_id=cycle_id,
|
issue_cycle__cycle_id=cycle_id,
|
||||||
@ -980,7 +1129,22 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
.order_by("display_name")
|
.order_by("display_name")
|
||||||
)
|
)
|
||||||
|
# assignee distribution serialized
|
||||||
|
assignee_distribution_data = [
|
||||||
|
{
|
||||||
|
"display_name": item["display_name"],
|
||||||
|
"assignee_id": (
|
||||||
|
str(item["assignee_id"]) if item["assignee_id"] else None
|
||||||
|
),
|
||||||
|
"avatar": item["avatar"],
|
||||||
|
"total_issues": item["total_issues"],
|
||||||
|
"completed_issues": item["completed_issues"],
|
||||||
|
"pending_issues": item["pending_issues"],
|
||||||
|
}
|
||||||
|
for item in assignee_distribution
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get the label distribution
|
||||||
label_distribution = (
|
label_distribution = (
|
||||||
Issue.objects.filter(
|
Issue.objects.filter(
|
||||||
issue_cycle__cycle_id=cycle_id,
|
issue_cycle__cycle_id=cycle_id,
|
||||||
@ -1023,7 +1187,9 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||||||
assignee_distribution_data = [
|
assignee_distribution_data = [
|
||||||
{
|
{
|
||||||
"display_name": item["display_name"],
|
"display_name": item["display_name"],
|
||||||
"assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None,
|
"assignee_id": (
|
||||||
|
str(item["assignee_id"]) if item["assignee_id"] else None
|
||||||
|
),
|
||||||
"avatar": item["avatar"],
|
"avatar": item["avatar"],
|
||||||
"total_issues": item["total_issues"],
|
"total_issues": item["total_issues"],
|
||||||
"completed_issues": item["completed_issues"],
|
"completed_issues": item["completed_issues"],
|
||||||
@ -1032,11 +1198,14 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||||||
for item in assignee_distribution
|
for item in assignee_distribution
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Label distribution serilization
|
||||||
label_distribution_data = [
|
label_distribution_data = [
|
||||||
{
|
{
|
||||||
"label_name": item["label_name"],
|
"label_name": item["label_name"],
|
||||||
"color": item["color"],
|
"color": item["color"],
|
||||||
"label_id": str(item["label_id"]) if item["label_id"] else None,
|
"label_id": (
|
||||||
|
str(item["label_id"]) if item["label_id"] else None
|
||||||
|
),
|
||||||
"total_issues": item["total_issues"],
|
"total_issues": item["total_issues"],
|
||||||
"completed_issues": item["completed_issues"],
|
"completed_issues": item["completed_issues"],
|
||||||
"pending_issues": item["pending_issues"],
|
"pending_issues": item["pending_issues"],
|
||||||
@ -1055,10 +1224,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||||||
"started_issues": old_cycle.first().started_issues,
|
"started_issues": old_cycle.first().started_issues,
|
||||||
"unstarted_issues": old_cycle.first().unstarted_issues,
|
"unstarted_issues": old_cycle.first().unstarted_issues,
|
||||||
"backlog_issues": old_cycle.first().backlog_issues,
|
"backlog_issues": old_cycle.first().backlog_issues,
|
||||||
"total_estimates": old_cycle.first().total_estimates,
|
"distribution": {
|
||||||
"completed_estimates": old_cycle.first().completed_estimates,
|
|
||||||
"started_estimates": old_cycle.first().started_estimates,
|
|
||||||
"distribution":{
|
|
||||||
"labels": label_distribution_data,
|
"labels": label_distribution_data,
|
||||||
"assignees": assignee_distribution_data,
|
"assignees": assignee_distribution_data,
|
||||||
"completion_chart": completion_chart,
|
"completion_chart": completion_chart,
|
||||||
|
@ -14,7 +14,12 @@ from django.db.models import (
|
|||||||
JSONField,
|
JSONField,
|
||||||
Func,
|
Func,
|
||||||
Prefetch,
|
Prefetch,
|
||||||
|
IntegerField,
|
||||||
)
|
)
|
||||||
|
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.functions import Coalesce
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
# Third Party imports
|
# Third Party imports
|
||||||
@ -34,6 +39,8 @@ from plane.db.models import (
|
|||||||
IssueLink,
|
IssueLink,
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
IssueRelation,
|
IssueRelation,
|
||||||
|
IssueAssignee,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
from plane.app.serializers import (
|
from plane.app.serializers import (
|
||||||
IssueActivitySerializer,
|
IssueActivitySerializer,
|
||||||
@ -54,6 +61,7 @@ def dashboard_overview_stats(self, request, slug):
|
|||||||
|
|
||||||
pending_issues_count = Issue.issue_objects.filter(
|
pending_issues_count = Issue.issue_objects.filter(
|
||||||
~Q(state__group__in=["completed", "cancelled"]),
|
~Q(state__group__in=["completed", "cancelled"]),
|
||||||
|
target_date__lt=timezone.now().date(),
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
@ -130,7 +138,32 @@ def dashboard_assigned_issues(self, request, slug):
|
|||||||
.annotate(count=Func(F("id"), function="Count"))
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
.values("count")
|
.values("count")
|
||||||
)
|
)
|
||||||
.order_by("created_at")
|
.annotate(
|
||||||
|
label_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"labels__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(labels__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
assignee_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"assignees__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(assignees__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
module_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue_module__module_id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(issue_module__module_id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Priority Ordering
|
# Priority Ordering
|
||||||
@ -182,11 +215,11 @@ def dashboard_assigned_issues(self, request, slug):
|
|||||||
if issue_type == "overdue":
|
if issue_type == "overdue":
|
||||||
overdue_issues_count = assigned_issues.filter(
|
overdue_issues_count = assigned_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__lt=timezone.now()
|
target_date__lt=timezone.now(),
|
||||||
).count()
|
).count()
|
||||||
overdue_issues = assigned_issues.filter(
|
overdue_issues = assigned_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__lt=timezone.now()
|
target_date__lt=timezone.now(),
|
||||||
)[:5]
|
)[:5]
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@ -201,11 +234,11 @@ def dashboard_assigned_issues(self, request, slug):
|
|||||||
if issue_type == "upcoming":
|
if issue_type == "upcoming":
|
||||||
upcoming_issues_count = assigned_issues.filter(
|
upcoming_issues_count = assigned_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__gte=timezone.now()
|
target_date__gte=timezone.now(),
|
||||||
).count()
|
).count()
|
||||||
upcoming_issues = assigned_issues.filter(
|
upcoming_issues = assigned_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__gte=timezone.now()
|
target_date__gte=timezone.now(),
|
||||||
)[:5]
|
)[:5]
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@ -259,6 +292,32 @@ def dashboard_created_issues(self, request, slug):
|
|||||||
.annotate(count=Func(F("id"), function="Count"))
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
.values("count")
|
.values("count")
|
||||||
)
|
)
|
||||||
|
.annotate(
|
||||||
|
label_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"labels__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(labels__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
assignee_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"assignees__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(assignees__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
module_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue_module__module_id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(issue_module__module_id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
)
|
||||||
.order_by("created_at")
|
.order_by("created_at")
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -309,11 +368,11 @@ def dashboard_created_issues(self, request, slug):
|
|||||||
if issue_type == "overdue":
|
if issue_type == "overdue":
|
||||||
overdue_issues_count = created_issues.filter(
|
overdue_issues_count = created_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__lt=timezone.now()
|
target_date__lt=timezone.now(),
|
||||||
).count()
|
).count()
|
||||||
overdue_issues = created_issues.filter(
|
overdue_issues = created_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__lt=timezone.now()
|
target_date__lt=timezone.now(),
|
||||||
)[:5]
|
)[:5]
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@ -326,11 +385,11 @@ def dashboard_created_issues(self, request, slug):
|
|||||||
if issue_type == "upcoming":
|
if issue_type == "upcoming":
|
||||||
upcoming_issues_count = created_issues.filter(
|
upcoming_issues_count = created_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__gte=timezone.now()
|
target_date__gte=timezone.now(),
|
||||||
).count()
|
).count()
|
||||||
upcoming_issues = created_issues.filter(
|
upcoming_issues = created_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__gte=timezone.now()
|
target_date__gte=timezone.now(),
|
||||||
)[:5]
|
)[:5]
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@ -447,7 +506,9 @@ def dashboard_recent_projects(self, request, slug):
|
|||||||
).exclude(id__in=unique_project_ids)
|
).exclude(id__in=unique_project_ids)
|
||||||
|
|
||||||
# Append additional project IDs to the existing list
|
# Append additional project IDs to the existing list
|
||||||
unique_project_ids.update(additional_projects.values_list("id", flat=True))
|
unique_project_ids.update(
|
||||||
|
additional_projects.values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
list(unique_project_ids)[:4],
|
list(unique_project_ids)[:4],
|
||||||
@ -456,90 +517,97 @@ def dashboard_recent_projects(self, request, slug):
|
|||||||
|
|
||||||
|
|
||||||
def dashboard_recent_collaborators(self, request, slug):
|
def dashboard_recent_collaborators(self, request, slug):
|
||||||
# Fetch all project IDs where the user belongs to
|
# Subquery to count activities for each project member
|
||||||
user_projects = Project.objects.filter(
|
activity_count_subquery = (
|
||||||
project_projectmember__member=request.user,
|
|
||||||
project_projectmember__is_active=True,
|
|
||||||
workspace__slug=slug,
|
|
||||||
).values_list("id", flat=True)
|
|
||||||
|
|
||||||
# Fetch all users who have performed an activity in the projects where the user exists
|
|
||||||
users_with_activities = (
|
|
||||||
IssueActivity.objects.filter(
|
IssueActivity.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project_id__in=user_projects,
|
actor=OuterRef("member"),
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
.values("actor")
|
.values("actor")
|
||||||
.exclude(actor=request.user)
|
.annotate(num_activities=Count("pk"))
|
||||||
.annotate(num_activities=Count("actor"))
|
.values("num_activities")
|
||||||
.order_by("-num_activities")
|
|
||||||
)[:7]
|
|
||||||
|
|
||||||
# Get the count of active issues for each user in users_with_activities
|
|
||||||
users_with_active_issues = []
|
|
||||||
for user_activity in users_with_activities:
|
|
||||||
user_id = user_activity["actor"]
|
|
||||||
active_issue_count = Issue.objects.filter(
|
|
||||||
assignees__in=[user_id],
|
|
||||||
state__group__in=["unstarted", "started"],
|
|
||||||
).count()
|
|
||||||
users_with_active_issues.append(
|
|
||||||
{"user_id": user_id, "active_issue_count": active_issue_count}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert the logged-in user's ID and their active issue count at the beginning
|
|
||||||
active_issue_count = Issue.objects.filter(
|
|
||||||
assignees__in=[request.user],
|
|
||||||
state__group__in=["unstarted", "started"],
|
|
||||||
).count()
|
|
||||||
|
|
||||||
if users_with_activities.count() < 7:
|
|
||||||
# Calculate the additional collaborators needed
|
|
||||||
additional_collaborators_needed = 7 - users_with_activities.count()
|
|
||||||
|
|
||||||
# Fetch additional collaborators from the project_member table
|
|
||||||
additional_collaborators = list(
|
|
||||||
set(
|
|
||||||
ProjectMember.objects.filter(
|
|
||||||
~Q(member=request.user),
|
|
||||||
project_id__in=user_projects,
|
|
||||||
workspace__slug=slug,
|
|
||||||
)
|
|
||||||
.exclude(
|
|
||||||
member__in=[
|
|
||||||
user["actor"] for user in users_with_activities
|
|
||||||
]
|
|
||||||
)
|
|
||||||
.values_list("member", flat=True)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
additional_collaborators = additional_collaborators[
|
|
||||||
:additional_collaborators_needed
|
|
||||||
]
|
|
||||||
|
|
||||||
# Append additional collaborators to the list
|
|
||||||
for collaborator_id in additional_collaborators:
|
|
||||||
active_issue_count = Issue.objects.filter(
|
|
||||||
assignees__in=[collaborator_id],
|
|
||||||
state__group__in=["unstarted", "started"],
|
|
||||||
).count()
|
|
||||||
users_with_active_issues.append(
|
|
||||||
{
|
|
||||||
"user_id": str(collaborator_id),
|
|
||||||
"active_issue_count": active_issue_count,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
users_with_active_issues.insert(
|
|
||||||
0,
|
|
||||||
{"user_id": request.user.id, "active_issue_count": active_issue_count},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(users_with_active_issues, status=status.HTTP_200_OK)
|
# Get all project members and annotate them with activity counts
|
||||||
|
project_members_with_activities = (
|
||||||
|
ProjectMember.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
num_activities=Coalesce(
|
||||||
|
Subquery(activity_count_subquery),
|
||||||
|
Value(0),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
is_current_user=Case(
|
||||||
|
When(member=request.user, then=Value(0)),
|
||||||
|
default=Value(1),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.values_list("member", flat=True)
|
||||||
|
.order_by("is_current_user", "-num_activities")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
search = request.query_params.get("search", None)
|
||||||
|
if search:
|
||||||
|
project_members_with_activities = (
|
||||||
|
project_members_with_activities.filter(
|
||||||
|
Q(member__display_name__icontains=search)
|
||||||
|
| Q(member__first_name__icontains=search)
|
||||||
|
| Q(member__last_name__icontains=search)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.paginate(
|
||||||
|
request=request,
|
||||||
|
queryset=project_members_with_activities,
|
||||||
|
controller=self.get_results_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DashboardEndpoint(BaseAPIView):
|
class DashboardEndpoint(BaseAPIView):
|
||||||
|
def get_results_controller(self, project_members_with_activities):
|
||||||
|
user_active_issue_counts = (
|
||||||
|
User.objects.filter(id__in=project_members_with_activities)
|
||||||
|
.annotate(
|
||||||
|
active_issue_count=Count(
|
||||||
|
Case(
|
||||||
|
When(
|
||||||
|
issue_assignee__issue__state__group__in=[
|
||||||
|
"unstarted",
|
||||||
|
"started",
|
||||||
|
],
|
||||||
|
then=1,
|
||||||
|
),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values("active_issue_count", user_id=F("id"))
|
||||||
|
)
|
||||||
|
# Create a dictionary to store the active issue counts by user ID
|
||||||
|
active_issue_counts_dict = {
|
||||||
|
user["user_id"]: user["active_issue_count"]
|
||||||
|
for user in user_active_issue_counts
|
||||||
|
}
|
||||||
|
|
||||||
|
# Preserve the sequence of project members with activities
|
||||||
|
paginated_results = [
|
||||||
|
{
|
||||||
|
"user_id": member_id,
|
||||||
|
"active_issue_count": active_issue_counts_dict.get(
|
||||||
|
member_id, 0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for member_id in project_members_with_activities
|
||||||
|
]
|
||||||
|
return paginated_results
|
||||||
|
|
||||||
def create(self, request, slug):
|
def create(self, request, slug):
|
||||||
serializer = DashboardSerializer(data=request.data)
|
serializer = DashboardSerializer(data=request.data)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
@ -566,7 +634,9 @@ class DashboardEndpoint(BaseAPIView):
|
|||||||
dashboard_type = request.GET.get("dashboard_type", None)
|
dashboard_type = request.GET.get("dashboard_type", None)
|
||||||
if dashboard_type == "home":
|
if dashboard_type == "home":
|
||||||
dashboard, created = Dashboard.objects.get_or_create(
|
dashboard, created = Dashboard.objects.get_or_create(
|
||||||
type_identifier=dashboard_type, owned_by=request.user, is_default=True
|
type_identifier=dashboard_type,
|
||||||
|
owned_by=request.user,
|
||||||
|
is_default=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@ -583,7 +653,9 @@ class DashboardEndpoint(BaseAPIView):
|
|||||||
|
|
||||||
updated_dashboard_widgets = []
|
updated_dashboard_widgets = []
|
||||||
for widget_key in widgets_to_fetch:
|
for widget_key in widgets_to_fetch:
|
||||||
widget = Widget.objects.filter(key=widget_key).values_list("id", flat=True)
|
widget = Widget.objects.filter(
|
||||||
|
key=widget_key
|
||||||
|
).values_list("id", flat=True)
|
||||||
if widget:
|
if widget:
|
||||||
updated_dashboard_widgets.append(
|
updated_dashboard_widgets.append(
|
||||||
DashboardWidget(
|
DashboardWidget(
|
||||||
|
5
apiserver/plane/app/views/error_404.py
Normal file
5
apiserver/plane/app/views/error_404.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# views.py
|
||||||
|
from django.http import JsonResponse
|
||||||
|
|
||||||
|
def custom_404_view(request, exception=None):
|
||||||
|
return JsonResponse({"error": "Page not found."}, status=404)
|
@ -3,8 +3,12 @@ import json
|
|||||||
|
|
||||||
# Django import
|
# Django import
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch
|
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch, Exists
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
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.functions import Coalesce
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@ -21,12 +25,14 @@ from plane.db.models import (
|
|||||||
IssueLink,
|
IssueLink,
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
ProjectMember,
|
ProjectMember,
|
||||||
|
IssueReaction,
|
||||||
|
IssueSubscriber,
|
||||||
)
|
)
|
||||||
from plane.app.serializers import (
|
from plane.app.serializers import (
|
||||||
|
IssueCreateSerializer,
|
||||||
IssueSerializer,
|
IssueSerializer,
|
||||||
InboxSerializer,
|
InboxSerializer,
|
||||||
InboxIssueSerializer,
|
InboxIssueSerializer,
|
||||||
IssueCreateSerializer,
|
|
||||||
IssueDetailSerializer,
|
IssueDetailSerializer,
|
||||||
)
|
)
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
@ -92,7 +98,7 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
Issue.objects.filter(
|
Issue.objects.filter(
|
||||||
project_id=self.kwargs.get("project_id"),
|
project_id=self.kwargs.get("project_id"),
|
||||||
workspace__slug=self.kwargs.get("slug"),
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
issue_inbox__inbox_id=self.kwargs.get("inbox_id")
|
issue_inbox__inbox_id=self.kwargs.get("inbox_id"),
|
||||||
)
|
)
|
||||||
.select_related("workspace", "project", "state", "parent")
|
.select_related("workspace", "project", "state", "parent")
|
||||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||||
@ -127,14 +133,75 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
.annotate(count=Func(F("id"), function="Count"))
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
.values("count")
|
.values("count")
|
||||||
)
|
)
|
||||||
|
.annotate(
|
||||||
|
label_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"labels__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(labels__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
assignee_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"assignees__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(assignees__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
module_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue_module__module_id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(issue_module__module_id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
)
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
def list(self, request, slug, project_id, inbox_id):
|
def list(self, request, slug, project_id, inbox_id):
|
||||||
filters = issue_filters(request.query_params, "GET")
|
filters = issue_filters(request.query_params, "GET")
|
||||||
issue_queryset = self.get_queryset().filter(**filters).order_by("issue_inbox__snoozed_till", "issue_inbox__status")
|
issue_queryset = (
|
||||||
issues_data = IssueSerializer(issue_queryset, expand=self.expand, many=True).data
|
self.get_queryset()
|
||||||
|
.filter(**filters)
|
||||||
|
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
|
||||||
|
)
|
||||||
|
if self.expand:
|
||||||
|
issues = IssueSerializer(
|
||||||
|
issue_queryset, expand=self.expand, many=True
|
||||||
|
).data
|
||||||
|
else:
|
||||||
|
issues = issue_queryset.values(
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"state_id",
|
||||||
|
"sort_order",
|
||||||
|
"completed_at",
|
||||||
|
"estimate_point",
|
||||||
|
"priority",
|
||||||
|
"start_date",
|
||||||
|
"target_date",
|
||||||
|
"sequence_id",
|
||||||
|
"project_id",
|
||||||
|
"parent_id",
|
||||||
|
"cycle_id",
|
||||||
|
"module_ids",
|
||||||
|
"label_ids",
|
||||||
|
"assignee_ids",
|
||||||
|
"sub_issues_count",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
"attachment_count",
|
||||||
|
"link_count",
|
||||||
|
"is_draft",
|
||||||
|
"archived_at",
|
||||||
|
)
|
||||||
return Response(
|
return Response(
|
||||||
issues_data,
|
issues,
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -199,8 +266,8 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
source=request.data.get("source", "in-app"),
|
source=request.data.get("source", "in-app"),
|
||||||
)
|
)
|
||||||
|
|
||||||
issue = (self.get_queryset().filter(pk=issue.id).first())
|
issue = self.get_queryset().filter(pk=issue.id).first()
|
||||||
serializer = IssueSerializer(issue ,expand=self.expand)
|
serializer = IssueSerializer(issue, expand=self.expand)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def partial_update(self, request, slug, project_id, inbox_id, issue_id):
|
def partial_update(self, request, slug, project_id, inbox_id, issue_id):
|
||||||
@ -230,11 +297,7 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
issue_data = request.data.pop("issue", False)
|
issue_data = request.data.pop("issue", False)
|
||||||
|
|
||||||
if bool(issue_data):
|
if bool(issue_data):
|
||||||
issue = Issue.objects.get(
|
issue = self.get_queryset().filter(pk=inbox_issue.issue_id).first()
|
||||||
pk=inbox_issue.issue_id,
|
|
||||||
workspace__slug=slug,
|
|
||||||
project_id=project_id,
|
|
||||||
)
|
|
||||||
# Only allow guests and viewers to edit name and description
|
# Only allow guests and viewers to edit name and description
|
||||||
if project_member.role <= 10:
|
if project_member.role <= 10:
|
||||||
# viewers and guests since only viewers and guests
|
# viewers and guests since only viewers and guests
|
||||||
@ -320,20 +383,54 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
if state is not None:
|
if state is not None:
|
||||||
issue.state = state
|
issue.state = state
|
||||||
issue.save()
|
issue.save()
|
||||||
issue = (self.get_queryset().filter(pk=issue_id).first())
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
serializer = IssueSerializer(issue, expand=self.expand)
|
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
||||||
return Response(
|
return Response(
|
||||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
issue = (self.get_queryset().filter(pk=issue_id).first())
|
issue = self.get_queryset().filter(pk=issue_id).first()
|
||||||
serializer = IssueSerializer(issue ,expand=self.expand)
|
serializer = IssueSerializer(issue, expand=self.expand)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
|
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
|
||||||
issue = self.get_queryset().filter(pk=issue_id).first()
|
issue = (
|
||||||
serializer = IssueDetailSerializer(issue, expand=self.expand,)
|
self.get_queryset()
|
||||||
|
.filter(pk=issue_id)
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"issue_reactions",
|
||||||
|
queryset=IssueReaction.objects.select_related(
|
||||||
|
"issue", "actor"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"issue_attachment",
|
||||||
|
queryset=IssueAttachment.objects.select_related("issue"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"issue_link",
|
||||||
|
queryset=IssueLink.objects.select_related("created_by"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
is_subscribed=Exists(
|
||||||
|
IssueSubscriber.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
issue_id=OuterRef("pk"),
|
||||||
|
subscriber=request.user,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if issue is None:
|
||||||
|
return Response({"error": "Requested object was not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
serializer = IssueDetailSerializer(issue)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def destroy(self, request, slug, project_id, inbox_id, issue_id):
|
def destroy(self, request, slug, project_id, inbox_id, issue_id):
|
||||||
|
@ -36,7 +36,10 @@ class SlackProjectSyncViewSet(BaseViewSet):
|
|||||||
workspace__slug=self.kwargs.get("slug"),
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
project_id=self.kwargs.get("project_id"),
|
project_id=self.kwargs.get("project_id"),
|
||||||
)
|
)
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def create(self, request, slug, project_id, workspace_integration_id):
|
def create(self, request, slug, project_id, workspace_integration_id):
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -4,11 +4,12 @@ import json
|
|||||||
# Django Imports
|
# Django Imports
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q
|
from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q
|
||||||
from django.core import serializers
|
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.gzip import gzip_page
|
from django.views.decorators.gzip import gzip_page
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
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.functions import Coalesce
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -24,6 +25,7 @@ from plane.app.serializers import (
|
|||||||
ModuleFavoriteSerializer,
|
ModuleFavoriteSerializer,
|
||||||
IssueSerializer,
|
IssueSerializer,
|
||||||
ModuleUserPropertiesSerializer,
|
ModuleUserPropertiesSerializer,
|
||||||
|
ModuleDetailSerializer,
|
||||||
)
|
)
|
||||||
from plane.app.permissions import (
|
from plane.app.permissions import (
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
@ -38,11 +40,9 @@ from plane.db.models import (
|
|||||||
ModuleFavorite,
|
ModuleFavorite,
|
||||||
IssueLink,
|
IssueLink,
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
IssueSubscriber,
|
|
||||||
ModuleUserProperties,
|
ModuleUserProperties,
|
||||||
)
|
)
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
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 plane.utils.issue_filters import issue_filters
|
||||||
from plane.utils.analytics_plot import burndown_plot
|
from plane.utils.analytics_plot import burndown_plot
|
||||||
|
|
||||||
@ -62,7 +62,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
subquery = ModuleFavorite.objects.filter(
|
favorite_subquery = ModuleFavorite.objects.filter(
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
module_id=OuterRef("pk"),
|
module_id=OuterRef("pk"),
|
||||||
project_id=self.kwargs.get("project_id"),
|
project_id=self.kwargs.get("project_id"),
|
||||||
@ -73,7 +73,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
.get_queryset()
|
.get_queryset()
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.annotate(is_favorite=Exists(subquery))
|
.annotate(is_favorite=Exists(favorite_subquery))
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("lead")
|
.select_related("lead")
|
||||||
@ -145,6 +145,16 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.annotate(
|
||||||
|
member_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"members__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(members__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
)
|
||||||
|
)
|
||||||
.order_by("-is_favorite", "-created_at")
|
.order_by("-is_favorite", "-created_at")
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -157,25 +167,84 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
|
||||||
module = Module.objects.get(pk=serializer.data["id"])
|
module = (
|
||||||
serializer = ModuleSerializer(module)
|
self.get_queryset()
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
.filter(pk=serializer.data["id"])
|
||||||
|
.values( # Required fields
|
||||||
|
"id",
|
||||||
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# Model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"description_text",
|
||||||
|
"description_html",
|
||||||
|
"start_date",
|
||||||
|
"target_date",
|
||||||
|
"status",
|
||||||
|
"lead_id",
|
||||||
|
"member_ids",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
# computed fields
|
||||||
|
"is_favorite",
|
||||||
|
"total_issues",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
return Response(module, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def list(self, request, slug, project_id):
|
def list(self, request, slug, project_id):
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
fields = [
|
if self.fields:
|
||||||
field
|
modules = ModuleSerializer(
|
||||||
for field in request.GET.get("fields", "").split(",")
|
queryset,
|
||||||
if field
|
many=True,
|
||||||
]
|
fields=self.fields,
|
||||||
modules = ModuleSerializer(
|
).data
|
||||||
queryset, many=True, fields=fields if fields else None
|
else:
|
||||||
).data
|
modules = queryset.values( # Required fields
|
||||||
|
"id",
|
||||||
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# Model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"description_text",
|
||||||
|
"description_html",
|
||||||
|
"start_date",
|
||||||
|
"target_date",
|
||||||
|
"status",
|
||||||
|
"lead_id",
|
||||||
|
"member_ids",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
# computed fields
|
||||||
|
"is_favorite",
|
||||||
|
"total_issues",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
return Response(modules, status=status.HTTP_200_OK)
|
return Response(modules, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, pk):
|
def retrieve(self, request, slug, project_id, pk):
|
||||||
queryset = self.get_queryset().get(pk=pk)
|
queryset = self.get_queryset().filter(pk=pk)
|
||||||
|
|
||||||
assignee_distribution = (
|
assignee_distribution = (
|
||||||
Issue.objects.filter(
|
Issue.objects.filter(
|
||||||
@ -269,16 +338,16 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
.order_by("label_name")
|
.order_by("label_name")
|
||||||
)
|
)
|
||||||
|
|
||||||
data = ModuleSerializer(queryset).data
|
data = ModuleDetailSerializer(queryset.first()).data
|
||||||
data["distribution"] = {
|
data["distribution"] = {
|
||||||
"assignees": assignee_distribution,
|
"assignees": assignee_distribution,
|
||||||
"labels": label_distribution,
|
"labels": label_distribution,
|
||||||
"completion_chart": {},
|
"completion_chart": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
if queryset.start_date and queryset.target_date:
|
if queryset.first().start_date and queryset.first().target_date:
|
||||||
data["distribution"]["completion_chart"] = burndown_plot(
|
data["distribution"]["completion_chart"] = burndown_plot(
|
||||||
queryset=queryset,
|
queryset=queryset.first(),
|
||||||
slug=slug,
|
slug=slug,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
module_id=pk,
|
module_id=pk,
|
||||||
@ -289,6 +358,47 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def partial_update(self, request, slug, project_id, pk):
|
||||||
|
queryset = self.get_queryset().filter(pk=pk)
|
||||||
|
serializer = ModuleWriteSerializer(
|
||||||
|
queryset.first(), data=request.data, partial=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
module = queryset.values(
|
||||||
|
# Required fields
|
||||||
|
"id",
|
||||||
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# Model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"description_text",
|
||||||
|
"description_html",
|
||||||
|
"start_date",
|
||||||
|
"target_date",
|
||||||
|
"status",
|
||||||
|
"lead_id",
|
||||||
|
"member_ids",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
# computed fields
|
||||||
|
"is_favorite",
|
||||||
|
"total_issues",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
).first()
|
||||||
|
return Response(module, status=status.HTTP_200_OK)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def destroy(self, request, slug, project_id, pk):
|
def destroy(self, request, slug, project_id, pk):
|
||||||
module = Module.objects.get(
|
module = Module.objects.get(
|
||||||
workspace__slug=slug, project_id=project_id, pk=pk
|
workspace__slug=slug, project_id=project_id, pk=pk
|
||||||
@ -331,17 +441,15 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return (
|
return (
|
||||||
Issue.issue_objects.filter(
|
Issue.issue_objects.filter(
|
||||||
project_id=self.kwargs.get("project_id"),
|
project_id=self.kwargs.get("project_id"),
|
||||||
workspace__slug=self.kwargs.get("slug"),
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
issue_module__module_id=self.kwargs.get("module_id")
|
issue_module__module_id=self.kwargs.get("module_id"),
|
||||||
)
|
)
|
||||||
.select_related("workspace", "project", "state", "parent")
|
.select_related("workspace", "project", "state", "parent")
|
||||||
.prefetch_related("labels", "assignees")
|
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||||
.prefetch_related('issue_module__module')
|
|
||||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||||
.annotate(
|
.annotate(
|
||||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||||
@ -365,6 +473,32 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
.annotate(count=Func(F("id"), function="Count"))
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
.values("count")
|
.values("count")
|
||||||
)
|
)
|
||||||
|
.annotate(
|
||||||
|
label_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"labels__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(labels__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
assignee_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"assignees__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(assignees__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
module_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue_module__module_id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(issue_module__module_id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
)
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
@method_decorator(gzip_page)
|
@method_decorator(gzip_page)
|
||||||
@ -376,15 +510,44 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
]
|
]
|
||||||
filters = issue_filters(request.query_params, "GET")
|
filters = issue_filters(request.query_params, "GET")
|
||||||
issue_queryset = self.get_queryset().filter(**filters)
|
issue_queryset = self.get_queryset().filter(**filters)
|
||||||
serializer = IssueSerializer(
|
if self.fields or self.expand:
|
||||||
issue_queryset, many=True, fields=fields if fields else None
|
issues = IssueSerializer(
|
||||||
)
|
issue_queryset, many=True, fields=fields if fields else None
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
).data
|
||||||
|
else:
|
||||||
|
issues = issue_queryset.values(
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"state_id",
|
||||||
|
"sort_order",
|
||||||
|
"completed_at",
|
||||||
|
"estimate_point",
|
||||||
|
"priority",
|
||||||
|
"start_date",
|
||||||
|
"target_date",
|
||||||
|
"sequence_id",
|
||||||
|
"project_id",
|
||||||
|
"parent_id",
|
||||||
|
"cycle_id",
|
||||||
|
"module_ids",
|
||||||
|
"label_ids",
|
||||||
|
"assignee_ids",
|
||||||
|
"sub_issues_count",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
"attachment_count",
|
||||||
|
"link_count",
|
||||||
|
"is_draft",
|
||||||
|
"archived_at",
|
||||||
|
)
|
||||||
|
return Response(issues, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
# create multiple issues inside a module
|
# create multiple issues inside a module
|
||||||
def create_module_issues(self, request, slug, project_id, module_id):
|
def create_module_issues(self, request, slug, project_id, module_id):
|
||||||
issues = request.data.get("issues", [])
|
issues = request.data.get("issues", [])
|
||||||
if not len(issues):
|
if not issues:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Issues are required"},
|
{"error": "Issues are required"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
@ -420,15 +583,12 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
for issue in issues
|
for issue in issues
|
||||||
]
|
]
|
||||||
issues = (self.get_queryset().filter(pk__in=issues))
|
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
|
||||||
serializer = IssueSerializer(issues , many=True)
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
|
|
||||||
# create multiple module inside an issue
|
# create multiple module inside an issue
|
||||||
def create_issue_modules(self, request, slug, project_id, issue_id):
|
def create_issue_modules(self, request, slug, project_id, issue_id):
|
||||||
modules = request.data.get("modules", [])
|
modules = request.data.get("modules", [])
|
||||||
if not len(modules):
|
if not modules:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Modules are required"},
|
{"error": "Modules are required"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
@ -466,10 +626,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
for module in modules
|
for module in modules
|
||||||
]
|
]
|
||||||
|
|
||||||
issue = (self.get_queryset().filter(pk=issue_id).first())
|
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
|
||||||
serializer = IssueSerializer(issue)
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
|
|
||||||
def destroy(self, request, slug, project_id, module_id, issue_id):
|
def destroy(self, request, slug, project_id, module_id, issue_id):
|
||||||
module_issue = ModuleIssue.objects.get(
|
module_issue = ModuleIssue.objects.get(
|
||||||
@ -484,7 +641,9 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
actor_id=str(request.user.id),
|
actor_id=str(request.user.id),
|
||||||
issue_id=str(issue_id),
|
issue_id=str(issue_id),
|
||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=json.dumps({"module_name": module_issue.module.name}),
|
current_instance=json.dumps(
|
||||||
|
{"module_name": module_issue.module.name}
|
||||||
|
),
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
notification=True,
|
notification=True,
|
||||||
origin=request.META.get("HTTP_ORIGIN"),
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
@ -514,7 +673,10 @@ class ModuleLinkViewSet(BaseViewSet):
|
|||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(module_id=self.kwargs.get("module_id"))
|
.filter(module_id=self.kwargs.get("module_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.order_by("-created_at")
|
.order_by("-created_at")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
@ -60,7 +60,10 @@ class PageViewSet(BaseViewSet):
|
|||||||
.get_queryset()
|
.get_queryset()
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.filter(parent__isnull=True)
|
.filter(parent__isnull=True)
|
||||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
|
@ -77,6 +77,12 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
sort_order = ProjectMember.objects.filter(
|
||||||
|
member=self.request.user,
|
||||||
|
project_id=OuterRef("pk"),
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
is_active=True,
|
||||||
|
).values("sort_order")
|
||||||
return self.filter_queryset(
|
return self.filter_queryset(
|
||||||
super()
|
super()
|
||||||
.get_queryset()
|
.get_queryset()
|
||||||
@ -147,6 +153,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.annotate(sort_order=Subquery(sort_order))
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
Prefetch(
|
Prefetch(
|
||||||
"project_projectmember",
|
"project_projectmember",
|
||||||
@ -166,16 +173,8 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
for field in request.GET.get("fields", "").split(",")
|
for field in request.GET.get("fields", "").split(",")
|
||||||
if field
|
if field
|
||||||
]
|
]
|
||||||
|
|
||||||
sort_order_query = ProjectMember.objects.filter(
|
|
||||||
member=request.user,
|
|
||||||
project_id=OuterRef("pk"),
|
|
||||||
workspace__slug=self.kwargs.get("slug"),
|
|
||||||
is_active=True,
|
|
||||||
).values("sort_order")
|
|
||||||
projects = (
|
projects = (
|
||||||
self.get_queryset()
|
self.get_queryset()
|
||||||
.annotate(sort_order=Subquery(sort_order_query))
|
|
||||||
.order_by("sort_order", "name")
|
.order_by("sort_order", "name")
|
||||||
)
|
)
|
||||||
if request.GET.get("per_page", False) and request.GET.get(
|
if request.GET.get("per_page", False) and request.GET.get(
|
||||||
@ -204,7 +203,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
serializer.save()
|
serializer.save()
|
||||||
|
|
||||||
# Add the user as Administrator to the project
|
# Add the user as Administrator to the project
|
||||||
project_member = ProjectMember.objects.create(
|
_ = ProjectMember.objects.create(
|
||||||
project_id=serializer.data["id"],
|
project_id=serializer.data["id"],
|
||||||
member=request.user,
|
member=request.user,
|
||||||
role=20,
|
role=20,
|
||||||
|
@ -48,8 +48,8 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
return (
|
return (
|
||||||
Project.objects.filter(
|
Project.objects.filter(
|
||||||
q,
|
q,
|
||||||
Q(project_projectmember__member=self.request.user)
|
project_projectmember__member=self.request.user,
|
||||||
| Q(network=2),
|
project_projectmember__is_active=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
.distinct()
|
.distinct()
|
||||||
@ -71,6 +71,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
issues = Issue.issue_objects.filter(
|
issues = Issue.issue_objects.filter(
|
||||||
q,
|
q,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -95,6 +96,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
cycles = Cycle.objects.filter(
|
cycles = Cycle.objects.filter(
|
||||||
q,
|
q,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -118,6 +120,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
modules = Module.objects.filter(
|
modules = Module.objects.filter(
|
||||||
q,
|
q,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -141,6 +144,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
pages = Page.objects.filter(
|
pages = Page.objects.filter(
|
||||||
q,
|
q,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -164,6 +168,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
|||||||
issue_views = IssueView.objects.filter(
|
issue_views = IssueView.objects.filter(
|
||||||
q,
|
q,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -236,6 +241,7 @@ class IssueSearchEndpoint(BaseAPIView):
|
|||||||
issues = Issue.issue_objects.filter(
|
issues = Issue.issue_objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if workspace_search == "false":
|
if workspace_search == "false":
|
||||||
|
@ -31,7 +31,10 @@ class StateViewSet(BaseViewSet):
|
|||||||
.get_queryset()
|
.get_queryset()
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.filter(~Q(name="Triage"))
|
.filter(~Q(name="Triage"))
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# Django imports
|
# Django imports
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
Prefetch,
|
Q,
|
||||||
OuterRef,
|
OuterRef,
|
||||||
Func,
|
Func,
|
||||||
F,
|
F,
|
||||||
@ -13,16 +13,21 @@ from django.db.models import (
|
|||||||
)
|
)
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.gzip import gzip_page
|
from django.views.decorators.gzip import gzip_page
|
||||||
from django.db.models import Prefetch, OuterRef, Exists
|
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.functions import Coalesce
|
||||||
|
from django.contrib.postgres.aggregates import ArrayAgg
|
||||||
|
from django.contrib.postgres.fields import ArrayField
|
||||||
|
from django.db.models import Value, UUIDField
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from . import BaseViewSet, BaseAPIView
|
from . import BaseViewSet
|
||||||
from plane.app.serializers import (
|
from plane.app.serializers import (
|
||||||
GlobalViewSerializer,
|
|
||||||
IssueViewSerializer,
|
IssueViewSerializer,
|
||||||
IssueSerializer,
|
IssueSerializer,
|
||||||
IssueViewFavoriteSerializer,
|
IssueViewFavoriteSerializer,
|
||||||
@ -30,22 +35,16 @@ from plane.app.serializers import (
|
|||||||
from plane.app.permissions import (
|
from plane.app.permissions import (
|
||||||
WorkspaceEntityPermission,
|
WorkspaceEntityPermission,
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
WorkspaceViewerPermission,
|
|
||||||
ProjectLitePermission,
|
|
||||||
)
|
)
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
Workspace,
|
Workspace,
|
||||||
GlobalView,
|
|
||||||
IssueView,
|
IssueView,
|
||||||
Issue,
|
Issue,
|
||||||
IssueViewFavorite,
|
IssueViewFavorite,
|
||||||
IssueReaction,
|
|
||||||
IssueLink,
|
IssueLink,
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
IssueSubscriber,
|
|
||||||
)
|
)
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
from plane.utils.grouper import group_results
|
|
||||||
|
|
||||||
|
|
||||||
class GlobalViewViewSet(BaseViewSet):
|
class GlobalViewViewSet(BaseViewSet):
|
||||||
@ -87,13 +86,60 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
|||||||
.values("count")
|
.values("count")
|
||||||
)
|
)
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.select_related("workspace", "project", "state", "parent")
|
.select_related("workspace", "project", "state", "parent")
|
||||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||||
.prefetch_related(
|
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||||
Prefetch(
|
.annotate(
|
||||||
"issue_reactions",
|
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||||
queryset=IssueReaction.objects.select_related("actor"),
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
attachment_count=IssueAttachment.objects.filter(
|
||||||
|
issue=OuterRef("id")
|
||||||
)
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
sub_issues_count=Issue.issue_objects.filter(
|
||||||
|
parent=OuterRef("id")
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
label_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"labels__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(labels__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
assignee_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"assignees__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(assignees__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
module_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue_module__module_id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(issue_module__module_id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -121,30 +167,7 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
|||||||
issue_queryset = (
|
issue_queryset = (
|
||||||
self.get_queryset()
|
self.get_queryset()
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
|
||||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||||
.annotate(
|
|
||||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
|
||||||
.order_by()
|
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
|
||||||
.values("count")
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
attachment_count=IssueAttachment.objects.filter(
|
|
||||||
issue=OuterRef("id")
|
|
||||||
)
|
|
||||||
.order_by()
|
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
|
||||||
.values("count")
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
sub_issues_count=Issue.issue_objects.filter(
|
|
||||||
parent=OuterRef("id")
|
|
||||||
)
|
|
||||||
.order_by()
|
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
|
||||||
.values("count")
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Priority Ordering
|
# Priority Ordering
|
||||||
@ -207,10 +230,39 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
|||||||
else:
|
else:
|
||||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||||
|
|
||||||
serializer = IssueSerializer(
|
if self.fields:
|
||||||
issue_queryset, many=True, fields=fields if fields else None
|
issues = IssueSerializer(
|
||||||
)
|
issue_queryset, many=True, fields=self.fields
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
).data
|
||||||
|
else:
|
||||||
|
issues = issue_queryset.values(
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"state_id",
|
||||||
|
"sort_order",
|
||||||
|
"completed_at",
|
||||||
|
"estimate_point",
|
||||||
|
"priority",
|
||||||
|
"start_date",
|
||||||
|
"target_date",
|
||||||
|
"sequence_id",
|
||||||
|
"project_id",
|
||||||
|
"parent_id",
|
||||||
|
"cycle_id",
|
||||||
|
"module_ids",
|
||||||
|
"label_ids",
|
||||||
|
"assignee_ids",
|
||||||
|
"sub_issues_count",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
"attachment_count",
|
||||||
|
"link_count",
|
||||||
|
"is_draft",
|
||||||
|
"archived_at",
|
||||||
|
)
|
||||||
|
return Response(issues, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class IssueViewViewSet(BaseViewSet):
|
class IssueViewViewSet(BaseViewSet):
|
||||||
@ -235,7 +287,10 @@ class IssueViewViewSet(BaseViewSet):
|
|||||||
.get_queryset()
|
.get_queryset()
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.annotate(is_favorite=Exists(subquery))
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import jwt
|
import jwt
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
|
from django.http import HttpResponse
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -22,9 +25,14 @@ from django.db.models import (
|
|||||||
When,
|
When,
|
||||||
Max,
|
Max,
|
||||||
IntegerField,
|
IntegerField,
|
||||||
|
Sum,
|
||||||
)
|
)
|
||||||
from django.db.models.functions import ExtractWeek, Cast, ExtractDay
|
from django.db.models.functions import ExtractWeek, Cast, ExtractDay
|
||||||
from django.db.models.fields import DateField
|
from django.db.models.fields import DateField
|
||||||
|
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.functions import Coalesce
|
||||||
|
|
||||||
# Third party modules
|
# Third party modules
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@ -73,6 +81,9 @@ from plane.db.models import (
|
|||||||
WorkspaceUserProperties,
|
WorkspaceUserProperties,
|
||||||
Estimate,
|
Estimate,
|
||||||
EstimatePoint,
|
EstimatePoint,
|
||||||
|
Module,
|
||||||
|
ModuleLink,
|
||||||
|
Cycle,
|
||||||
)
|
)
|
||||||
from plane.app.permissions import (
|
from plane.app.permissions import (
|
||||||
WorkSpaceBasePermission,
|
WorkSpaceBasePermission,
|
||||||
@ -85,6 +96,12 @@ from plane.app.permissions import (
|
|||||||
from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
from plane.bgtasks.event_tracking_task import workspace_invite_event
|
from plane.bgtasks.event_tracking_task import workspace_invite_event
|
||||||
|
from plane.app.serializers.module import (
|
||||||
|
ModuleSerializer,
|
||||||
|
)
|
||||||
|
from plane.app.serializers.cycle import (
|
||||||
|
CycleSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WorkSpaceViewSet(BaseViewSet):
|
class WorkSpaceViewSet(BaseViewSet):
|
||||||
@ -546,7 +563,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
|||||||
.get_queryset()
|
.get_queryset()
|
||||||
.filter(
|
.filter(
|
||||||
workspace__slug=self.kwargs.get("slug"),
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
member__is_bot=False,
|
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
.select_related("workspace", "workspace__owner")
|
.select_related("workspace", "workspace__owner")
|
||||||
@ -754,7 +770,6 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
|
|||||||
project_ids = (
|
project_ids = (
|
||||||
ProjectMember.objects.filter(
|
ProjectMember.objects.filter(
|
||||||
member=request.user,
|
member=request.user,
|
||||||
member__is_bot=False,
|
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
.values_list("project_id", flat=True)
|
.values_list("project_id", flat=True)
|
||||||
@ -764,7 +779,6 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
|
|||||||
# Get all the project members in which the user is involved
|
# Get all the project members in which the user is involved
|
||||||
project_members = ProjectMember.objects.filter(
|
project_members = ProjectMember.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
member__is_bot=False,
|
|
||||||
project_id__in=project_ids,
|
project_id__in=project_ids,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
).select_related("project", "member", "workspace")
|
).select_related("project", "member", "workspace")
|
||||||
@ -1075,6 +1089,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
|||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
assignees__in=[user_id],
|
assignees__in=[user_id],
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.annotate(state_group=F("state__group"))
|
.annotate(state_group=F("state__group"))
|
||||||
@ -1090,6 +1105,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
|||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
assignees__in=[user_id],
|
assignees__in=[user_id],
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.values("priority")
|
.values("priority")
|
||||||
@ -1112,6 +1128,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
|||||||
Issue.issue_objects.filter(
|
Issue.issue_objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
created_by_id=user_id,
|
created_by_id=user_id,
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
@ -1123,6 +1140,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
|||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
assignees__in=[user_id],
|
assignees__in=[user_id],
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.count()
|
.count()
|
||||||
@ -1134,6 +1152,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
|||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
assignees__in=[user_id],
|
assignees__in=[user_id],
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.count()
|
.count()
|
||||||
@ -1145,6 +1164,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
|||||||
assignees__in=[user_id],
|
assignees__in=[user_id],
|
||||||
state__group="completed",
|
state__group="completed",
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.count()
|
.count()
|
||||||
@ -1155,6 +1175,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
|||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
subscriber_id=user_id,
|
subscriber_id=user_id,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.count()
|
.count()
|
||||||
@ -1204,6 +1225,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
|
|||||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
actor=user_id,
|
actor=user_id,
|
||||||
).select_related("actor", "workspace", "issue", "project")
|
).select_related("actor", "workspace", "issue", "project")
|
||||||
|
|
||||||
@ -1219,6 +1241,66 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExportWorkspaceUserActivityEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
WorkspaceEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def generate_csv_from_rows(self, rows):
|
||||||
|
"""Generate CSV buffer from rows."""
|
||||||
|
csv_buffer = io.StringIO()
|
||||||
|
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||||
|
[writer.writerow(row) for row in rows]
|
||||||
|
csv_buffer.seek(0)
|
||||||
|
return csv_buffer
|
||||||
|
|
||||||
|
def post(self, request, slug, user_id):
|
||||||
|
|
||||||
|
if not request.data.get("date"):
|
||||||
|
return Response(
|
||||||
|
{"error": "Date is required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_activities = IssueActivity.objects.filter(
|
||||||
|
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||||
|
workspace__slug=slug,
|
||||||
|
created_at__date=request.data.get("date"),
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
actor_id=user_id,
|
||||||
|
).select_related("actor", "workspace", "issue", "project")[:10000]
|
||||||
|
|
||||||
|
header = [
|
||||||
|
"Actor name",
|
||||||
|
"Issue ID",
|
||||||
|
"Project",
|
||||||
|
"Created at",
|
||||||
|
"Updated at",
|
||||||
|
"Action",
|
||||||
|
"Field",
|
||||||
|
"Old value",
|
||||||
|
"New value",
|
||||||
|
]
|
||||||
|
rows = [
|
||||||
|
(
|
||||||
|
activity.actor.display_name,
|
||||||
|
f"{activity.project.identifier} - {activity.issue.sequence_id if activity.issue else ''}",
|
||||||
|
activity.project.name,
|
||||||
|
activity.created_at,
|
||||||
|
activity.updated_at,
|
||||||
|
activity.verb,
|
||||||
|
activity.field,
|
||||||
|
activity.old_value,
|
||||||
|
activity.new_value,
|
||||||
|
)
|
||||||
|
for activity in user_activities
|
||||||
|
]
|
||||||
|
csv_buffer = self.generate_csv_from_rows([header] + rows)
|
||||||
|
response = HttpResponse(csv_buffer.getvalue(), content_type="text/csv")
|
||||||
|
response["Content-Disposition"] = 'attachment; filename="workspace-user-activity.csv"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceUserProfileEndpoint(BaseAPIView):
|
class WorkspaceUserProfileEndpoint(BaseAPIView):
|
||||||
def get(self, request, slug, user_id):
|
def get(self, request, slug, user_id):
|
||||||
user_data = User.objects.get(pk=user_id)
|
user_data = User.objects.get(pk=user_id)
|
||||||
@ -1234,6 +1316,7 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
|
|||||||
Project.objects.filter(
|
Project.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project_projectmember__member=request.user,
|
project_projectmember__member=request.user,
|
||||||
|
project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
created_issues=Count(
|
created_issues=Count(
|
||||||
@ -1283,10 +1366,6 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
.values(
|
.values(
|
||||||
"id",
|
"id",
|
||||||
"name",
|
|
||||||
"identifier",
|
|
||||||
"emoji",
|
|
||||||
"icon_prop",
|
|
||||||
"created_issues",
|
"created_issues",
|
||||||
"assigned_issues",
|
"assigned_issues",
|
||||||
"completed_issues",
|
"completed_issues",
|
||||||
@ -1343,6 +1422,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
|||||||
| Q(issue_subscribers__subscriber_id=user_id),
|
| Q(issue_subscribers__subscriber_id=user_id),
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True
|
||||||
)
|
)
|
||||||
.filter(**filters)
|
.filter(**filters)
|
||||||
.select_related("workspace", "project", "state", "parent")
|
.select_related("workspace", "project", "state", "parent")
|
||||||
@ -1370,6 +1450,32 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
|||||||
.annotate(count=Func(F("id"), function="Count"))
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
.values("count")
|
.values("count")
|
||||||
)
|
)
|
||||||
|
.annotate(
|
||||||
|
label_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"labels__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(labels__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
assignee_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"assignees__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(assignees__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
module_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue_module__module_id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(issue_module__module_id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
)
|
||||||
.order_by("created_at")
|
.order_by("created_at")
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
@ -1448,6 +1554,7 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
|
|||||||
labels = Label.objects.filter(
|
labels = Label.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True
|
||||||
)
|
)
|
||||||
serializer = LabelSerializer(labels, many=True).data
|
serializer = LabelSerializer(labels, many=True).data
|
||||||
return Response(serializer, status=status.HTTP_200_OK)
|
return Response(serializer, status=status.HTTP_200_OK)
|
||||||
@ -1462,6 +1569,7 @@ class WorkspaceStatesEndpoint(BaseAPIView):
|
|||||||
states = State.objects.filter(
|
states = State.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project__project_projectmember__member=request.user,
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True
|
||||||
)
|
)
|
||||||
serializer = StateSerializer(states, many=True).data
|
serializer = StateSerializer(states, many=True).data
|
||||||
return Response(serializer, status=status.HTTP_200_OK)
|
return Response(serializer, status=status.HTTP_200_OK)
|
||||||
@ -1490,6 +1598,192 @@ class WorkspaceEstimatesEndpoint(BaseAPIView):
|
|||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceModulesEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
WorkspaceViewerPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get(self, request, slug):
|
||||||
|
modules = (
|
||||||
|
Module.objects.filter(workspace__slug=slug)
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("lead")
|
||||||
|
.prefetch_related("members")
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"link_module",
|
||||||
|
queryset=ModuleLink.objects.select_related(
|
||||||
|
"module", "created_by"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"issue_module",
|
||||||
|
filter=Q(
|
||||||
|
issue_module__issue__archived_at__isnull=True,
|
||||||
|
issue_module__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"issue_module__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_module__issue__state__group="completed",
|
||||||
|
issue_module__issue__archived_at__isnull=True,
|
||||||
|
issue_module__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_issues=Count(
|
||||||
|
"issue_module__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_module__issue__state__group="cancelled",
|
||||||
|
issue_module__issue__archived_at__isnull=True,
|
||||||
|
issue_module__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_issues=Count(
|
||||||
|
"issue_module__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_module__issue__state__group="started",
|
||||||
|
issue_module__issue__archived_at__isnull=True,
|
||||||
|
issue_module__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_issues=Count(
|
||||||
|
"issue_module__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_module__issue__state__group="unstarted",
|
||||||
|
issue_module__issue__archived_at__isnull=True,
|
||||||
|
issue_module__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_issues=Count(
|
||||||
|
"issue_module__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_module__issue__state__group="backlog",
|
||||||
|
issue_module__issue__archived_at__isnull=True,
|
||||||
|
issue_module__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(self.kwargs.get("order_by", "-created_at"))
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = ModuleSerializer(modules, many=True).data
|
||||||
|
return Response(serializer, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceCyclesEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
WorkspaceViewerPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get(self, request, slug):
|
||||||
|
cycles = (
|
||||||
|
Cycle.objects.filter(workspace__slug=slug)
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("owned_by")
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"issue_cycle",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="completed",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="cancelled",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="started",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="unstarted",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="backlog",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
total_estimates=Sum("issue_cycle__issue__estimate_point")
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
"issue_cycle__issue__estimate_point",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="completed",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_estimates=Sum(
|
||||||
|
"issue_cycle__issue__estimate_point",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="started",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(self.kwargs.get("order_by", "-created_at"))
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
serializer = CycleSerializer(cycles, many=True).data
|
||||||
|
return Response(serializer, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceUserPropertiesEndpoint(BaseAPIView):
|
class WorkspaceUserPropertiesEndpoint(BaseAPIView):
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
WorkspaceViewerPermission,
|
WorkspaceViewerPermission,
|
||||||
|
@ -1,21 +1,33 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.utils.html import strip_tags
|
from django.utils.html import strip_tags
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.db.models import EmailNotificationLog, User, Issue
|
from plane.db.models import EmailNotificationLog, User, Issue
|
||||||
from plane.license.utils.instance_value import get_email_configuration
|
from plane.license.utils.instance_value import get_email_configuration
|
||||||
from plane.settings.redis import redis_instance
|
from plane.settings.redis import redis_instance
|
||||||
|
|
||||||
|
# acquire and delete redis lock
|
||||||
|
def acquire_lock(lock_id, expire_time=300):
|
||||||
|
redis_client = redis_instance()
|
||||||
|
"""Attempt to acquire a lock with a specified expiration time."""
|
||||||
|
return redis_client.set(lock_id, 'true', nx=True, ex=expire_time)
|
||||||
|
|
||||||
|
def release_lock(lock_id):
|
||||||
|
"""Release a lock."""
|
||||||
|
redis_client = redis_instance()
|
||||||
|
redis_client.delete(lock_id)
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def stack_email_notification():
|
def stack_email_notification():
|
||||||
# get all email notifications
|
# get all email notifications
|
||||||
@ -142,135 +154,155 @@ def process_html_content(content):
|
|||||||
processed_content_list.append(processed_content)
|
processed_content_list.append(processed_content)
|
||||||
return processed_content_list
|
return processed_content_list
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def send_email_notification(
|
def send_email_notification(
|
||||||
issue_id, notification_data, receiver_id, email_notification_ids
|
issue_id, notification_data, receiver_id, email_notification_ids
|
||||||
):
|
):
|
||||||
|
# Convert UUIDs to a sorted, concatenated string
|
||||||
|
sorted_ids = sorted(email_notification_ids)
|
||||||
|
ids_str = "_".join(str(id) for id in sorted_ids)
|
||||||
|
lock_id = f"send_email_notif_{issue_id}_{receiver_id}_{ids_str}"
|
||||||
|
|
||||||
|
# acquire the lock for sending emails
|
||||||
try:
|
try:
|
||||||
ri = redis_instance()
|
if acquire_lock(lock_id=lock_id):
|
||||||
base_api = (ri.get(str(issue_id)).decode())
|
# get the redis instance
|
||||||
data = create_payload(notification_data=notification_data)
|
ri = redis_instance()
|
||||||
|
base_api = (ri.get(str(issue_id)).decode())
|
||||||
|
data = create_payload(notification_data=notification_data)
|
||||||
|
|
||||||
# Get email configurations
|
# Get email configurations
|
||||||
(
|
(
|
||||||
EMAIL_HOST,
|
EMAIL_HOST,
|
||||||
EMAIL_HOST_USER,
|
EMAIL_HOST_USER,
|
||||||
EMAIL_HOST_PASSWORD,
|
EMAIL_HOST_PASSWORD,
|
||||||
EMAIL_PORT,
|
EMAIL_PORT,
|
||||||
EMAIL_USE_TLS,
|
EMAIL_USE_TLS,
|
||||||
EMAIL_FROM,
|
EMAIL_FROM,
|
||||||
) = get_email_configuration()
|
) = get_email_configuration()
|
||||||
|
|
||||||
receiver = User.objects.get(pk=receiver_id)
|
receiver = User.objects.get(pk=receiver_id)
|
||||||
issue = Issue.objects.get(pk=issue_id)
|
issue = Issue.objects.get(pk=issue_id)
|
||||||
template_data = []
|
template_data = []
|
||||||
total_changes = 0
|
total_changes = 0
|
||||||
comments = []
|
comments = []
|
||||||
actors_involved = []
|
actors_involved = []
|
||||||
for actor_id, changes in data.items():
|
for actor_id, changes in data.items():
|
||||||
actor = User.objects.get(pk=actor_id)
|
actor = User.objects.get(pk=actor_id)
|
||||||
total_changes = total_changes + len(changes)
|
total_changes = total_changes + len(changes)
|
||||||
comment = changes.pop("comment", False)
|
comment = changes.pop("comment", False)
|
||||||
mention = changes.pop("mention", False)
|
mention = changes.pop("mention", False)
|
||||||
actors_involved.append(actor_id)
|
actors_involved.append(actor_id)
|
||||||
if comment:
|
if comment:
|
||||||
comments.append(
|
comments.append(
|
||||||
{
|
{
|
||||||
"actor_comments": comment,
|
"actor_comments": comment,
|
||||||
"actor_detail": {
|
"actor_detail": {
|
||||||
"avatar_url": actor.avatar,
|
"avatar_url": actor.avatar,
|
||||||
"first_name": actor.first_name,
|
"first_name": actor.first_name,
|
||||||
"last_name": actor.last_name,
|
"last_name": actor.last_name,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
if mention:
|
||||||
|
mention["new_value"] = process_html_content(mention.get("new_value"))
|
||||||
|
mention["old_value"] = process_html_content(mention.get("old_value"))
|
||||||
|
comments.append(
|
||||||
|
{
|
||||||
|
"actor_comments": mention,
|
||||||
|
"actor_detail": {
|
||||||
|
"avatar_url": actor.avatar,
|
||||||
|
"first_name": actor.first_name,
|
||||||
|
"last_name": actor.last_name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
activity_time = changes.pop("activity_time")
|
||||||
|
# Parse the input string into a datetime object
|
||||||
|
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
template_data.append(
|
||||||
|
{
|
||||||
|
"actor_detail": {
|
||||||
|
"avatar_url": actor.avatar,
|
||||||
|
"first_name": actor.first_name,
|
||||||
|
"last_name": actor.last_name,
|
||||||
|
},
|
||||||
|
"changes": changes,
|
||||||
|
"issue_details": {
|
||||||
|
"name": issue.name,
|
||||||
|
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
|
||||||
|
},
|
||||||
|
"activity_time": str(formatted_time),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
if mention:
|
|
||||||
mention["new_value"] = process_html_content(mention.get("new_value"))
|
|
||||||
mention["old_value"] = process_html_content(mention.get("old_value"))
|
|
||||||
comments.append(
|
|
||||||
{
|
|
||||||
"actor_comments": mention,
|
|
||||||
"actor_detail": {
|
|
||||||
"avatar_url": actor.avatar,
|
|
||||||
"first_name": actor.first_name,
|
|
||||||
"last_name": actor.last_name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
activity_time = changes.pop("activity_time")
|
|
||||||
# Parse the input string into a datetime object
|
|
||||||
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
|
|
||||||
|
|
||||||
if changes:
|
summary = "Updates were made to the issue by"
|
||||||
template_data.append(
|
|
||||||
{
|
|
||||||
"actor_detail": {
|
|
||||||
"avatar_url": actor.avatar,
|
|
||||||
"first_name": actor.first_name,
|
|
||||||
"last_name": actor.last_name,
|
|
||||||
},
|
|
||||||
"changes": changes,
|
|
||||||
"issue_details": {
|
|
||||||
"name": issue.name,
|
|
||||||
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
|
|
||||||
},
|
|
||||||
"activity_time": str(formatted_time),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
summary = "Updates were made to the issue by"
|
# Send the mail
|
||||||
|
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
|
||||||
# Send the mail
|
context = {
|
||||||
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
|
"data": template_data,
|
||||||
context = {
|
"summary": summary,
|
||||||
"data": template_data,
|
"actors_involved": len(set(actors_involved)),
|
||||||
"summary": summary,
|
"issue": {
|
||||||
"actors_involved": len(set(actors_involved)),
|
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
|
||||||
"issue": {
|
"name": issue.name,
|
||||||
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
|
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
||||||
"name": issue.name,
|
},
|
||||||
|
"receiver": {
|
||||||
|
"email": receiver.email,
|
||||||
|
},
|
||||||
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
||||||
},
|
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
|
||||||
"receiver": {
|
"workspace":str(issue.project.workspace.slug),
|
||||||
"email": receiver.email,
|
"project": str(issue.project.name),
|
||||||
},
|
"user_preference": f"{base_api}/profile/preferences/email",
|
||||||
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
"comments": comments,
|
||||||
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
|
}
|
||||||
"workspace":str(issue.project.workspace.slug),
|
html_content = render_to_string(
|
||||||
"project": str(issue.project.name),
|
"emails/notifications/issue-updates.html", context
|
||||||
"user_preference": f"{base_api}/profile/preferences/email",
|
|
||||||
"comments": comments,
|
|
||||||
}
|
|
||||||
html_content = render_to_string(
|
|
||||||
"emails/notifications/issue-updates.html", context
|
|
||||||
)
|
|
||||||
text_content = strip_tags(html_content)
|
|
||||||
|
|
||||||
try:
|
|
||||||
connection = get_connection(
|
|
||||||
host=EMAIL_HOST,
|
|
||||||
port=int(EMAIL_PORT),
|
|
||||||
username=EMAIL_HOST_USER,
|
|
||||||
password=EMAIL_HOST_PASSWORD,
|
|
||||||
use_tls=EMAIL_USE_TLS == "1",
|
|
||||||
)
|
)
|
||||||
|
text_content = strip_tags(html_content)
|
||||||
|
|
||||||
msg = EmailMultiAlternatives(
|
try:
|
||||||
subject=subject,
|
connection = get_connection(
|
||||||
body=text_content,
|
host=EMAIL_HOST,
|
||||||
from_email=EMAIL_FROM,
|
port=int(EMAIL_PORT),
|
||||||
to=[receiver.email],
|
username=EMAIL_HOST_USER,
|
||||||
connection=connection,
|
password=EMAIL_HOST_PASSWORD,
|
||||||
)
|
use_tls=EMAIL_USE_TLS == "1",
|
||||||
msg.attach_alternative(html_content, "text/html")
|
)
|
||||||
msg.send()
|
|
||||||
|
|
||||||
EmailNotificationLog.objects.filter(
|
msg = EmailMultiAlternatives(
|
||||||
pk__in=email_notification_ids
|
subject=subject,
|
||||||
).update(sent_at=timezone.now())
|
body=text_content,
|
||||||
|
from_email=EMAIL_FROM,
|
||||||
|
to=[receiver.email],
|
||||||
|
connection=connection,
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html_content, "text/html")
|
||||||
|
msg.send()
|
||||||
|
|
||||||
|
EmailNotificationLog.objects.filter(
|
||||||
|
pk__in=email_notification_ids
|
||||||
|
).update(sent_at=timezone.now())
|
||||||
|
|
||||||
|
# release the lock
|
||||||
|
release_lock(lock_id=lock_id)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
# release the lock
|
||||||
|
release_lock(lock_id=lock_id)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
print("Duplicate task recived. Skipping...")
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except (Issue.DoesNotExist, User.DoesNotExist) as e:
|
||||||
|
if settings.DEBUG:
|
||||||
print(e)
|
print(e)
|
||||||
return
|
release_lock(lock_id=lock_id)
|
||||||
except Issue.DoesNotExist:
|
|
||||||
return
|
return
|
||||||
|
@ -292,6 +292,7 @@ def issue_export_task(
|
|||||||
workspace__id=workspace_id,
|
workspace__id=workspace_id,
|
||||||
project_id__in=project_ids,
|
project_id__in=project_ids,
|
||||||
project__project_projectmember__member=exporter_instance.initiated_by_id,
|
project__project_projectmember__member=exporter_instance.initiated_by_id,
|
||||||
|
project__project_projectmember__is_active=True
|
||||||
)
|
)
|
||||||
.select_related(
|
.select_related(
|
||||||
"project", "workspace", "state", "parent", "created_by"
|
"project", "workspace", "state", "parent", "created_by"
|
||||||
|
@ -60,15 +60,6 @@ def service_importer(service, importer_id):
|
|||||||
batch_size=100,
|
batch_size=100,
|
||||||
)
|
)
|
||||||
|
|
||||||
_ = [
|
|
||||||
send_welcome_slack.delay(
|
|
||||||
str(user.id),
|
|
||||||
True,
|
|
||||||
f"{user.email} was imported to Plane from {service}",
|
|
||||||
)
|
|
||||||
for user in new_users
|
|
||||||
]
|
|
||||||
|
|
||||||
workspace_users = User.objects.filter(
|
workspace_users = User.objects.filter(
|
||||||
email__in=[
|
email__in=[
|
||||||
user.get("email").strip().lower()
|
user.get("email").strip().lower()
|
||||||
|
@ -483,17 +483,23 @@ def track_archive_at(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
if requested_data.get("automation"):
|
||||||
|
comment = "Plane has archived the issue"
|
||||||
|
new_value = "archive"
|
||||||
|
else:
|
||||||
|
comment = "Actor has archived the issue"
|
||||||
|
new_value = "manual_archive"
|
||||||
issue_activities.append(
|
issue_activities.append(
|
||||||
IssueActivity(
|
IssueActivity(
|
||||||
issue_id=issue_id,
|
issue_id=issue_id,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
comment="Plane has archived the issue",
|
comment=comment,
|
||||||
verb="updated",
|
verb="updated",
|
||||||
actor_id=actor_id,
|
actor_id=actor_id,
|
||||||
field="archived_at",
|
field="archived_at",
|
||||||
old_value=None,
|
old_value=None,
|
||||||
new_value="archive",
|
new_value=new_value,
|
||||||
epoch=epoch,
|
epoch=epoch,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -79,7 +79,7 @@ def archive_old_issues():
|
|||||||
issue_activity.delay(
|
issue_activity.delay(
|
||||||
type="issue.activity.updated",
|
type="issue.activity.updated",
|
||||||
requested_data=json.dumps(
|
requested_data=json.dumps(
|
||||||
{"archived_at": str(archive_at)}
|
{"archived_at": str(archive_at), "automation": True}
|
||||||
),
|
),
|
||||||
actor_id=str(project.created_by_id),
|
actor_id=str(project.created_by_id),
|
||||||
issue_id=issue.id,
|
issue_id=issue.id,
|
||||||
|
54
apiserver/plane/db/migrations/0061_project_logo_props.py
Normal file
54
apiserver/plane/db/migrations/0061_project_logo_props.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2024-03-03 16:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
def update_project_logo_props(apps, schema_editor):
|
||||||
|
Project = apps.get_model("db", "Project")
|
||||||
|
|
||||||
|
bulk_update_project_logo = []
|
||||||
|
# Iterate through projects and update logo_props
|
||||||
|
for project in Project.objects.all():
|
||||||
|
project.logo_props["in_use"] = "emoji" if project.emoji else "icon"
|
||||||
|
project.logo_props["emoji"] = {
|
||||||
|
"value": project.emoji if project.emoji else "",
|
||||||
|
"url": "",
|
||||||
|
}
|
||||||
|
project.logo_props["icon"] = {
|
||||||
|
"name": (
|
||||||
|
project.icon_prop.get("name", "")
|
||||||
|
if project.icon_prop
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"color": (
|
||||||
|
project.icon_prop.get("color", "")
|
||||||
|
if project.icon_prop
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
}
|
||||||
|
bulk_update_project_logo.append(project)
|
||||||
|
|
||||||
|
# Bulk update logo_props for all projects
|
||||||
|
Project.objects.bulk_update(
|
||||||
|
bulk_update_project_logo, ["logo_props"], batch_size=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("db", "0060_cycle_progress_snapshot"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="issuelink",
|
||||||
|
name="url",
|
||||||
|
field=models.TextField(),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="project",
|
||||||
|
name="logo_props",
|
||||||
|
field=models.JSONField(default=dict),
|
||||||
|
),
|
||||||
|
migrations.RunPython(update_project_logo_props),
|
||||||
|
]
|
@ -320,7 +320,7 @@ class IssueAssignee(ProjectBaseModel):
|
|||||||
|
|
||||||
class IssueLink(ProjectBaseModel):
|
class IssueLink(ProjectBaseModel):
|
||||||
title = models.CharField(max_length=255, null=True, blank=True)
|
title = models.CharField(max_length=255, null=True, blank=True)
|
||||||
url = models.URLField()
|
url = models.TextField()
|
||||||
issue = models.ForeignKey(
|
issue = models.ForeignKey(
|
||||||
"db.Issue", on_delete=models.CASCADE, related_name="issue_link"
|
"db.Issue", on_delete=models.CASCADE, related_name="issue_link"
|
||||||
)
|
)
|
||||||
|
@ -107,6 +107,7 @@ class Project(BaseModel):
|
|||||||
close_in = models.IntegerField(
|
close_in = models.IntegerField(
|
||||||
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
|
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
|
||||||
)
|
)
|
||||||
|
logo_props = models.JSONField(default=dict)
|
||||||
default_state = models.ForeignKey(
|
default_state = models.ForeignKey(
|
||||||
"db.State",
|
"db.State",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
|
@ -12,15 +12,9 @@ from django.contrib.auth.models import (
|
|||||||
PermissionsMixin,
|
PermissionsMixin,
|
||||||
)
|
)
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.conf import settings
|
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
# Third party imports
|
|
||||||
from sentry_sdk import capture_exception
|
|
||||||
from slack_sdk import WebClient
|
|
||||||
from slack_sdk.errors import SlackApiError
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_onboarding():
|
def get_default_onboarding():
|
||||||
return {
|
return {
|
||||||
@ -144,25 +138,6 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||||||
super(User, self).save(*args, **kwargs)
|
super(User, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=User)
|
|
||||||
def send_welcome_slack(sender, instance, created, **kwargs):
|
|
||||||
try:
|
|
||||||
if created and not instance.is_bot:
|
|
||||||
# Send message on slack as well
|
|
||||||
if settings.SLACK_BOT_TOKEN:
|
|
||||||
client = WebClient(token=settings.SLACK_BOT_TOKEN)
|
|
||||||
try:
|
|
||||||
_ = client.chat_postMessage(
|
|
||||||
channel="#trackers",
|
|
||||||
text=f"New user {instance.email} has signed up and begun the onboarding journey.",
|
|
||||||
)
|
|
||||||
except SlackApiError as e:
|
|
||||||
print(f"Got an error: {e.response['error']}")
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
capture_exception(e)
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=User)
|
@receiver(post_save, sender=User)
|
||||||
def create_user_notification(sender, instance, created, **kwargs):
|
def create_user_notification(sender, instance, created, **kwargs):
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
"""Global Settings"""
|
"""Global Settings"""
|
||||||
|
|
||||||
# Python imports
|
# Python imports
|
||||||
import os
|
import os
|
||||||
import ssl
|
import ssl
|
||||||
@ -307,7 +308,9 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get(
|
|||||||
traces_sample_rate=1,
|
traces_sample_rate=1,
|
||||||
send_default_pii=True,
|
send_default_pii=True,
|
||||||
environment=os.environ.get("SENTRY_ENVIRONMENT", "development"),
|
environment=os.environ.get("SENTRY_ENVIRONMENT", "development"),
|
||||||
profiles_sample_rate=1.0,
|
profiles_sample_rate=float(
|
||||||
|
os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0.5)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ from django.views.generic import TemplateView
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
handler404 = "plane.app.views.error_404.custom_404_view"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", TemplateView.as_view(template_name="index.html")),
|
path("", TemplateView.as_view(template_name="index.html")),
|
||||||
|
@ -9,11 +9,11 @@ from plane.db.models import Issue
|
|||||||
|
|
||||||
|
|
||||||
def search_issues(query, queryset):
|
def search_issues(query, queryset):
|
||||||
fields = ["name", "sequence_id"]
|
fields = ["name", "sequence_id", "project__identifier"]
|
||||||
q = Q()
|
q = Q()
|
||||||
for field in fields:
|
for field in fields:
|
||||||
if field == "sequence_id" and len(query) <= 20:
|
if field == "sequence_id" and len(query) <= 20:
|
||||||
sequences = re.findall(r"[A-Za-z0-9]{1,12}-\d+", query)
|
sequences = re.findall(r"\b\d+\b", query)
|
||||||
for sequence_id in sequences:
|
for sequence_id in sequences:
|
||||||
q |= Q(**{"sequence_id": sequence_id})
|
q |= Q(**{"sequence_id": sequence_id})
|
||||||
else:
|
else:
|
||||||
|
@ -30,7 +30,7 @@ openpyxl==3.1.2
|
|||||||
beautifulsoup4==4.12.2
|
beautifulsoup4==4.12.2
|
||||||
dj-database-url==2.1.0
|
dj-database-url==2.1.0
|
||||||
posthog==3.0.2
|
posthog==3.0.2
|
||||||
cryptography==42.0.0
|
cryptography==42.0.4
|
||||||
lxml==4.9.3
|
lxml==4.9.3
|
||||||
boto3==1.28.40
|
boto3==1.28.40
|
||||||
|
|
||||||
|
@ -1 +1 @@
|
|||||||
python-3.11.7
|
python-3.11.8
|
82
deploy/1-click/README.md
Normal file
82
deploy/1-click/README.md
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# 1-Click Self-Hosting
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Let's get started!
|
||||||
|
|
||||||
|
## Installing Plane
|
||||||
|
|
||||||
|
Installing Plane is a very easy and minimal step process.
|
||||||
|
|
||||||
|
### Prerequisite
|
||||||
|
|
||||||
|
- Operating System (latest): Debian / Ubuntu / Centos
|
||||||
|
- Supported CPU Architechture: AMD64 / ARM64 / x86_64 / aarch64
|
||||||
|
|
||||||
|
### Downloading Latest Stable Release
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/makeplane/plane/master/deploy/1-click/install.sh | sh -
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Downloading Preview Release</summary>
|
||||||
|
|
||||||
|
```
|
||||||
|
export BRANCH=preview
|
||||||
|
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/makeplane/plane/preview/deploy/1-click/install.sh | sh -
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE: `Preview` builds do not support ARM64/AARCH64 CPU architecture
|
||||||
|
|
||||||
|
</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
|
||||||
|
|
||||||
|
Plane App is available via the command `plane-app`. Running the command `plane-app --help` helps you to manage Plane
|
||||||
|
|
||||||
|
![Plane Help](images/help.png)
|
||||||
|
|
||||||
|
<ins>Basic Operations</ins>:
|
||||||
|
|
||||||
|
1. Start Server using `plane-app start`
|
||||||
|
1. Stop Server using `plane-app stop`
|
||||||
|
1. Restart Server using `plane-app restart`
|
||||||
|
|
||||||
|
<ins>Advanced Operations</ins>:
|
||||||
|
|
||||||
|
1. Configure Plane using `plane-app --configure`. This will give you options to modify
|
||||||
|
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
1. Upgrade Plane using `plane-app --upgrade`. This will get the latest stable version of Plane files (docker-compose.yaml, .env, and docker images)
|
||||||
|
|
||||||
|
1. 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
|
BIN
deploy/1-click/images/help.png
Normal file
BIN
deploy/1-click/images/help.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 109 KiB |
BIN
deploy/1-click/images/install.png
Normal file
BIN
deploy/1-click/images/install.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 173 KiB |
@ -1,17 +1,20 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
export GIT_REPO=makeplane/plane
|
||||||
|
|
||||||
# Check if the user has sudo access
|
# Check if the user has sudo access
|
||||||
if command -v curl &> /dev/null; then
|
if command -v curl &> /dev/null; then
|
||||||
sudo curl -sSL \
|
sudo curl -sSL \
|
||||||
-o /usr/local/bin/plane-app \
|
-o /usr/local/bin/plane-app \
|
||||||
https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
|
https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
|
||||||
else
|
else
|
||||||
sudo wget -q \
|
sudo wget -q \
|
||||||
-O /usr/local/bin/plane-app \
|
-O /usr/local/bin/plane-app \
|
||||||
https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
|
https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sudo chmod +x /usr/local/bin/plane-app
|
sudo chmod +x /usr/local/bin/plane-app
|
||||||
sudo sed -i 's/export DEPLOY_BRANCH=${BRANCH:-master}/export DEPLOY_BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app
|
sudo sed -i 's@export DEPLOY_BRANCH=${BRANCH:-master}@export DEPLOY_BRANCH='${BRANCH:-master}'@' /usr/local/bin/plane-app
|
||||||
|
sudo sed -i 's@CODE_REPO=${GIT_REPO:-makeplane/plane}@CODE_REPO='$GIT_REPO'@' /usr/local/bin/plane-app
|
||||||
|
|
||||||
plane-app --help
|
plane-app -i #--help
|
||||||
|
@ -90,9 +90,9 @@ function prepare_environment() {
|
|||||||
|
|
||||||
show_message "- Updating OS with required tools ✋" >&2
|
show_message "- Updating OS with required tools ✋" >&2
|
||||||
sudo "$PACKAGE_MANAGER" update -y
|
sudo "$PACKAGE_MANAGER" update -y
|
||||||
sudo "$PACKAGE_MANAGER" upgrade -y
|
# sudo "$PACKAGE_MANAGER" upgrade -y
|
||||||
|
|
||||||
local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap")
|
local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap" "jq")
|
||||||
|
|
||||||
for tool in "${required_tools[@]}"; do
|
for tool in "${required_tools[@]}"; do
|
||||||
if ! command -v $tool &> /dev/null; then
|
if ! command -v $tool &> /dev/null; then
|
||||||
@ -150,11 +150,11 @@ function download_plane() {
|
|||||||
show_message "Downloading Plane Setup Files ✋" >&2
|
show_message "Downloading Plane Setup Files ✋" >&2
|
||||||
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
||||||
-s -o $PLANE_INSTALL_DIR/docker-compose.yaml \
|
-s -o $PLANE_INSTALL_DIR/docker-compose.yaml \
|
||||||
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s)
|
https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s)
|
||||||
|
|
||||||
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
||||||
-s -o $PLANE_INSTALL_DIR/variables-upgrade.env \
|
-s -o $PLANE_INSTALL_DIR/variables-upgrade.env \
|
||||||
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s)
|
https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s)
|
||||||
|
|
||||||
# if .env does not exists rename variables-upgrade.env to .env
|
# if .env does not exists rename variables-upgrade.env to .env
|
||||||
if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then
|
if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then
|
||||||
@ -202,7 +202,7 @@ function printUsageInstructions() {
|
|||||||
}
|
}
|
||||||
function build_local_image() {
|
function build_local_image() {
|
||||||
show_message "- Downloading Plane Source Code ✋" >&2
|
show_message "- Downloading Plane Source Code ✋" >&2
|
||||||
REPO=https://github.com/makeplane/plane.git
|
REPO=https://github.com/$CODE_REPO.git
|
||||||
CURR_DIR=$PWD
|
CURR_DIR=$PWD
|
||||||
PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp
|
PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp
|
||||||
sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null
|
sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null
|
||||||
@ -290,40 +290,40 @@ function configure_plane() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
smtp_host=$(read_env "EMAIL_HOST")
|
# smtp_host=$(read_env "EMAIL_HOST")
|
||||||
smtp_user=$(read_env "EMAIL_HOST_USER")
|
# smtp_user=$(read_env "EMAIL_HOST_USER")
|
||||||
smtp_password=$(read_env "EMAIL_HOST_PASSWORD")
|
# smtp_password=$(read_env "EMAIL_HOST_PASSWORD")
|
||||||
smtp_port=$(read_env "EMAIL_PORT")
|
# smtp_port=$(read_env "EMAIL_PORT")
|
||||||
smtp_from=$(read_env "EMAIL_FROM")
|
# smtp_from=$(read_env "EMAIL_FROM")
|
||||||
smtp_tls=$(read_env "EMAIL_USE_TLS")
|
# smtp_tls=$(read_env "EMAIL_USE_TLS")
|
||||||
smtp_ssl=$(read_env "EMAIL_USE_SSL")
|
# smtp_ssl=$(read_env "EMAIL_USE_SSL")
|
||||||
|
|
||||||
SMTP_SETTINGS=$(dialog \
|
# SMTP_SETTINGS=$(dialog \
|
||||||
--ok-label "Next" \
|
# --ok-label "Next" \
|
||||||
--cancel-label "Skip" \
|
# --cancel-label "Skip" \
|
||||||
--backtitle "Plane Configuration" \
|
# --backtitle "Plane Configuration" \
|
||||||
--title "SMTP Settings" \
|
# --title "SMTP Settings" \
|
||||||
--form "" \
|
# --form "" \
|
||||||
0 0 0 \
|
# 0 0 0 \
|
||||||
"Host:" 1 1 "$smtp_host" 1 10 80 0 \
|
# "Host:" 1 1 "$smtp_host" 1 10 80 0 \
|
||||||
"User:" 2 1 "$smtp_user" 2 10 80 0 \
|
# "User:" 2 1 "$smtp_user" 2 10 80 0 \
|
||||||
"Password:" 3 1 "$smtp_password" 3 10 80 0 \
|
# "Password:" 3 1 "$smtp_password" 3 10 80 0 \
|
||||||
"Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \
|
# "Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \
|
||||||
"From:" 5 1 "${smtp_from:-Mailer <mailer@example.com>}" 5 10 80 0 \
|
# "From:" 5 1 "${smtp_from:-Mailer <mailer@example.com>}" 5 10 80 0 \
|
||||||
"TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \
|
# "TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \
|
||||||
"SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \
|
# "SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \
|
||||||
2>&1 1>&3)
|
# 2>&1 1>&3)
|
||||||
|
|
||||||
save_smtp_settings=0
|
# save_smtp_settings=0
|
||||||
if [ $? -eq 0 ]; then
|
# if [ $? -eq 0 ]; then
|
||||||
save_smtp_settings=1
|
# save_smtp_settings=1
|
||||||
smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p)
|
# smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p)
|
||||||
smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p)
|
# smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p)
|
||||||
smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p)
|
# smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p)
|
||||||
smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p)
|
# smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p)
|
||||||
smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p)
|
# smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p)
|
||||||
smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p)
|
# smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p)
|
||||||
fi
|
# fi
|
||||||
external_pgdb_url=$(dialog \
|
external_pgdb_url=$(dialog \
|
||||||
--backtitle "Plane Configuration" \
|
--backtitle "Plane Configuration" \
|
||||||
--title "Using External Postgres Database ?" \
|
--title "Using External Postgres Database ?" \
|
||||||
@ -383,15 +383,6 @@ function configure_plane() {
|
|||||||
domain_name: $domain_name
|
domain_name: $domain_name
|
||||||
upload_limit: $upload_limit
|
upload_limit: $upload_limit
|
||||||
|
|
||||||
save_smtp_settings: $save_smtp_settings
|
|
||||||
smtp_host: $smtp_host
|
|
||||||
smtp_user: $smtp_user
|
|
||||||
smtp_password: $smtp_password
|
|
||||||
smtp_port: $smtp_port
|
|
||||||
smtp_from: $smtp_from
|
|
||||||
smtp_tls: $smtp_tls
|
|
||||||
smtp_ssl: $smtp_ssl
|
|
||||||
|
|
||||||
save_aws_settings: $save_aws_settings
|
save_aws_settings: $save_aws_settings
|
||||||
aws_region: $aws_region
|
aws_region: $aws_region
|
||||||
aws_access_key: $aws_access_key
|
aws_access_key: $aws_access_key
|
||||||
@ -413,15 +404,15 @@ function configure_plane() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# check enable smpt settings value
|
# check enable smpt settings value
|
||||||
if [ $save_smtp_settings == 1 ]; then
|
# if [ $save_smtp_settings == 1 ]; then
|
||||||
update_env "EMAIL_HOST" "$smtp_host"
|
# update_env "EMAIL_HOST" "$smtp_host"
|
||||||
update_env "EMAIL_HOST_USER" "$smtp_user"
|
# update_env "EMAIL_HOST_USER" "$smtp_user"
|
||||||
update_env "EMAIL_HOST_PASSWORD" "$smtp_password"
|
# update_env "EMAIL_HOST_PASSWORD" "$smtp_password"
|
||||||
update_env "EMAIL_PORT" "$smtp_port"
|
# update_env "EMAIL_PORT" "$smtp_port"
|
||||||
update_env "EMAIL_FROM" "$smtp_from"
|
# update_env "EMAIL_FROM" "$smtp_from"
|
||||||
update_env "EMAIL_USE_TLS" "$smtp_tls"
|
# update_env "EMAIL_USE_TLS" "$smtp_tls"
|
||||||
update_env "EMAIL_USE_SSL" "$smtp_ssl"
|
# update_env "EMAIL_USE_SSL" "$smtp_ssl"
|
||||||
fi
|
# fi
|
||||||
|
|
||||||
# check enable aws settings value
|
# check enable aws settings value
|
||||||
if [[ $save_aws_settings == 1 && $aws_access_key != "" && $aws_secret_key != "" ]] ; then
|
if [[ $save_aws_settings == 1 && $aws_access_key != "" && $aws_secret_key != "" ]] ; then
|
||||||
@ -493,13 +484,24 @@ function install() {
|
|||||||
check_for_docker_images
|
check_for_docker_images
|
||||||
|
|
||||||
last_installed_on=$(read_config "INSTALLATION_DATE")
|
last_installed_on=$(read_config "INSTALLATION_DATE")
|
||||||
if [ "$last_installed_on" == "" ]; then
|
# if [ "$last_installed_on" == "" ]; then
|
||||||
configure_plane
|
# configure_plane
|
||||||
fi
|
# fi
|
||||||
printUsageInstructions
|
|
||||||
|
|
||||||
update_config "INSTALLATION_DATE" "$(date)"
|
|
||||||
|
|
||||||
|
update_env "NGINX_PORT" "80"
|
||||||
|
update_env "DOMAIN_NAME" "$MY_IP"
|
||||||
|
update_env "WEB_URL" "http://$MY_IP"
|
||||||
|
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 "Plane Installed Successfully ✅"
|
||||||
show_message ""
|
show_message ""
|
||||||
else
|
else
|
||||||
@ -539,12 +541,15 @@ function upgrade() {
|
|||||||
prepare_environment
|
prepare_environment
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
|
stop_server
|
||||||
download_plane
|
download_plane
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
check_for_docker_images
|
check_for_docker_images
|
||||||
upgrade_configuration
|
upgrade_configuration
|
||||||
update_config "UPGRADE_DATE" "$(date)"
|
update_config "UPGRADE_DATE" "$(date)"
|
||||||
|
|
||||||
|
start_server
|
||||||
|
|
||||||
show_message ""
|
show_message ""
|
||||||
show_message "Plane Upgraded Successfully ✅"
|
show_message "Plane Upgraded Successfully ✅"
|
||||||
show_message ""
|
show_message ""
|
||||||
@ -601,6 +606,11 @@ function uninstall() {
|
|||||||
sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
|
sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
|
||||||
sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null
|
sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null
|
||||||
sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /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
|
# rm -rf $PLANE_INSTALL_DIR &> /dev/null
|
||||||
show_message "- Configuration Cleaned ✅"
|
show_message "- Configuration Cleaned ✅"
|
||||||
@ -642,7 +652,39 @@ function start_server() {
|
|||||||
while ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do
|
while ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
# wait for migrator container to exit with status 0 before starting the application
|
||||||
|
migrator_container_id=$(sudo docker container ls -aq -f "name=plane-migrator")
|
||||||
|
|
||||||
|
# if migrator container is running, wait for it to exit
|
||||||
|
if [ -n "$migrator_container_id" ]; then
|
||||||
|
while sudo docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do
|
||||||
|
show_message "Waiting for Plane Server ($APP_RELEASE) to start...✋ (Migrator in progress)" "replace_last_line" >&2
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# if migrator exit status is not 0, show error message and exit
|
||||||
|
if [ -n "$migrator_container_id" ]; then
|
||||||
|
migrator_exit_code=$(sudo docker inspect --format='{{.State.ExitCode}}' $migrator_container_id)
|
||||||
|
if [ $migrator_exit_code -ne 0 ]; then
|
||||||
|
# show_message "Migrator failed with exit code $migrator_exit_code ❌" "replace_last_line" >&2
|
||||||
|
show_message "Plane Server failed to start ❌" "replace_last_line" >&2
|
||||||
|
stop_server
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_container_id=$(sudo docker container ls -q -f "name=plane-api")
|
||||||
|
while ! sudo docker logs $api_container_id 2>&1 | grep -i "Application startup complete";
|
||||||
|
do
|
||||||
|
show_message "Waiting for Plane Server ($APP_RELEASE) to start...✋ (API starting)" "replace_last_line" >&2
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2
|
show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2
|
||||||
|
show_message "---------------------------------------------------------------" >&2
|
||||||
|
show_message "Access the Plane application at http://$MY_IP" >&2
|
||||||
|
show_message "---------------------------------------------------------------" >&2
|
||||||
|
|
||||||
else
|
else
|
||||||
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
|
||||||
fi
|
fi
|
||||||
@ -694,7 +736,7 @@ function update_installer() {
|
|||||||
show_message "Updating Plane Installer ✋" >&2
|
show_message "Updating Plane Installer ✋" >&2
|
||||||
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
sudo curl -H 'Cache-Control: no-cache, no-store' \
|
||||||
-s -o /usr/local/bin/plane-app \
|
-s -o /usr/local/bin/plane-app \
|
||||||
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s)
|
https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s)
|
||||||
|
|
||||||
sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null
|
sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null
|
||||||
show_message "Plane Installer Updated ✅" "replace_last_line" >&2
|
show_message "Plane Installer Updated ✅" "replace_last_line" >&2
|
||||||
@ -711,12 +753,14 @@ fi
|
|||||||
|
|
||||||
PLANE_INSTALL_DIR=/opt/plane
|
PLANE_INSTALL_DIR=/opt/plane
|
||||||
DATA_DIR=$PLANE_INSTALL_DIR/data
|
DATA_DIR=$PLANE_INSTALL_DIR/data
|
||||||
LOG_DIR=$PLANE_INSTALL_DIR/log
|
LOG_DIR=$PLANE_INSTALL_DIR/logs
|
||||||
|
CODE_REPO=${GIT_REPO:-makeplane/plane}
|
||||||
OS_SUPPORTED=false
|
OS_SUPPORTED=false
|
||||||
CPU_ARCH=$(uname -m)
|
CPU_ARCH=$(uname -m)
|
||||||
PROGRESS_MSG=""
|
PROGRESS_MSG=""
|
||||||
USE_GLOBAL_IMAGES=0
|
USE_GLOBAL_IMAGES=0
|
||||||
PACKAGE_MANAGER=""
|
PACKAGE_MANAGER=""
|
||||||
|
MY_IP=$(curl -s ifconfig.me)
|
||||||
|
|
||||||
if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then
|
if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then
|
||||||
USE_GLOBAL_IMAGES=1
|
USE_GLOBAL_IMAGES=1
|
||||||
@ -740,6 +784,9 @@ elif [ "$1" == "restart" ]; then
|
|||||||
restart_server
|
restart_server
|
||||||
elif [ "$1" == "--install" ] || [ "$1" == "-i" ]; then
|
elif [ "$1" == "--install" ] || [ "$1" == "-i" ]; then
|
||||||
install
|
install
|
||||||
|
start_server
|
||||||
|
show_message "" >&2
|
||||||
|
show_message "To view help, use plane-app --help " >&2
|
||||||
elif [ "$1" == "--configure" ] || [ "$1" == "-c" ]; then
|
elif [ "$1" == "--configure" ] || [ "$1" == "-c" ]; then
|
||||||
configure_plane
|
configure_plane
|
||||||
printUsageInstructions
|
printUsageInstructions
|
||||||
|
@ -56,8 +56,6 @@ x-app-env : &app-env
|
|||||||
- BUCKET_NAME=${BUCKET_NAME:-uploads}
|
- BUCKET_NAME=${BUCKET_NAME:-uploads}
|
||||||
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
|
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
<<: *app-env
|
<<: *app-env
|
||||||
@ -138,7 +136,6 @@ services:
|
|||||||
command: postgres -c 'max_connections=1000'
|
command: postgres -c 'max_connections=1000'
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
plane-redis:
|
plane-redis:
|
||||||
<<: *app-env
|
<<: *app-env
|
||||||
image: redis:6.2.7-alpine
|
image: redis:6.2.7-alpine
|
||||||
|
@ -13,6 +13,23 @@ YELLOW='\033[1;33m'
|
|||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
function print_header() {
|
||||||
|
clear
|
||||||
|
|
||||||
|
cat <<"EOF"
|
||||||
|
---------------------------------------
|
||||||
|
____ _
|
||||||
|
| _ \| | __ _ _ __ ___
|
||||||
|
| |_) | |/ _` | '_ \ / _ \
|
||||||
|
| __/| | (_| | | | | __/
|
||||||
|
|_| |_|\__,_|_| |_|\___|
|
||||||
|
|
||||||
|
---------------------------------------
|
||||||
|
Project management tool from the future
|
||||||
|
---------------------------------------
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
function buildLocalImage() {
|
function buildLocalImage() {
|
||||||
if [ "$1" == "--force-build" ]; then
|
if [ "$1" == "--force-build" ]; then
|
||||||
DO_BUILD="1"
|
DO_BUILD="1"
|
||||||
@ -110,7 +127,7 @@ function download() {
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml pull
|
docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH pull
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@ -121,19 +138,48 @@ function download() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
function startServices() {
|
function startServices() {
|
||||||
cd $PLANE_INSTALL_DIR
|
docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH up -d --quiet-pull
|
||||||
docker compose up -d --quiet-pull
|
|
||||||
cd $SCRIPT_DIR
|
local migrator_container_id=$(docker container ls -aq -f "name=plane-app-migrator")
|
||||||
|
if [ -n "$migrator_container_id" ]; then
|
||||||
|
local idx=0
|
||||||
|
while docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do
|
||||||
|
local message=">>> Waiting for Data Migration to finish"
|
||||||
|
local dots=$(printf '%*s' $idx | tr ' ' '.')
|
||||||
|
echo -ne "\r$message$dots"
|
||||||
|
((idx++))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
printf "\r\033[K"
|
||||||
|
|
||||||
|
# if migrator exit status is not 0, show error message and exit
|
||||||
|
if [ -n "$migrator_container_id" ]; then
|
||||||
|
local migrator_exit_code=$(docker inspect --format='{{.State.ExitCode}}' $migrator_container_id)
|
||||||
|
if [ $migrator_exit_code -ne 0 ]; then
|
||||||
|
echo "Plane Server failed to start ❌"
|
||||||
|
stopServices
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local api_container_id=$(docker container ls -q -f "name=plane-app-api")
|
||||||
|
local idx2=0
|
||||||
|
while ! docker logs $api_container_id 2>&1 | grep -m 1 -i "Application startup complete" | grep -q ".";
|
||||||
|
do
|
||||||
|
local message=">>> Waiting for API Service to Start"
|
||||||
|
local dots=$(printf '%*s' $idx2 | tr ' ' '.')
|
||||||
|
echo -ne "\r$message$dots"
|
||||||
|
((idx2++))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
printf "\r\033[K"
|
||||||
}
|
}
|
||||||
function stopServices() {
|
function stopServices() {
|
||||||
cd $PLANE_INSTALL_DIR
|
docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH down
|
||||||
docker compose down
|
|
||||||
cd $SCRIPT_DIR
|
|
||||||
}
|
}
|
||||||
function restartServices() {
|
function restartServices() {
|
||||||
cd $PLANE_INSTALL_DIR
|
docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH restart
|
||||||
docker compose restart
|
|
||||||
cd $SCRIPT_DIR
|
|
||||||
}
|
}
|
||||||
function upgrade() {
|
function upgrade() {
|
||||||
echo "***** STOPPING SERVICES ****"
|
echo "***** STOPPING SERVICES ****"
|
||||||
@ -144,47 +190,137 @@ function upgrade() {
|
|||||||
download
|
download
|
||||||
|
|
||||||
echo "***** PLEASE VALIDATE AND START SERVICES ****"
|
echo "***** PLEASE VALIDATE AND START SERVICES ****"
|
||||||
|
}
|
||||||
|
function viewSpecificLogs(){
|
||||||
|
local SERVICE_NAME=$1
|
||||||
|
|
||||||
|
if docker-compose -f $DOCKER_FILE_PATH ps | grep -q "$SERVICE_NAME"; then
|
||||||
|
echo "Service '$SERVICE_NAME' is running."
|
||||||
|
else
|
||||||
|
echo "Service '$SERVICE_NAME' is not running."
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker compose -f $DOCKER_FILE_PATH logs -f $SERVICE_NAME
|
||||||
|
}
|
||||||
|
function viewLogs(){
|
||||||
|
|
||||||
|
ARG_SERVICE_NAME=$2
|
||||||
|
|
||||||
|
if [ -z "$ARG_SERVICE_NAME" ];
|
||||||
|
then
|
||||||
|
echo
|
||||||
|
echo "Select a Service you want to view the logs for:"
|
||||||
|
echo " 1) Web"
|
||||||
|
echo " 2) Space"
|
||||||
|
echo " 3) API"
|
||||||
|
echo " 4) Worker"
|
||||||
|
echo " 5) Beat-Worker"
|
||||||
|
echo " 6) Migrator"
|
||||||
|
echo " 7) Proxy"
|
||||||
|
echo " 8) Redis"
|
||||||
|
echo " 9) Postgres"
|
||||||
|
echo " 10) Minio"
|
||||||
|
echo " 0) Back to Main Menu"
|
||||||
|
echo
|
||||||
|
read -p "Service: " DOCKER_SERVICE_NAME
|
||||||
|
|
||||||
|
until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 10 )); do
|
||||||
|
echo "Invalid selection. Please enter a number between 1 and 11."
|
||||||
|
read -p "Service: " DOCKER_SERVICE_NAME
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$DOCKER_SERVICE_NAME" ];
|
||||||
|
then
|
||||||
|
echo "INVALID SERVICE NAME SUPPLIED"
|
||||||
|
else
|
||||||
|
case $DOCKER_SERVICE_NAME in
|
||||||
|
1) viewSpecificLogs "web";;
|
||||||
|
2) viewSpecificLogs "space";;
|
||||||
|
3) viewSpecificLogs "api";;
|
||||||
|
4) viewSpecificLogs "worker";;
|
||||||
|
5) viewSpecificLogs "beat-worker";;
|
||||||
|
6) viewSpecificLogs "migrator";;
|
||||||
|
7) viewSpecificLogs "proxy";;
|
||||||
|
8) viewSpecificLogs "plane-redis";;
|
||||||
|
9) viewSpecificLogs "plane-db";;
|
||||||
|
10) viewSpecificLogs "plane-minio";;
|
||||||
|
0) askForAction;;
|
||||||
|
*) echo "INVALID SERVICE NAME SUPPLIED";;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
elif [ -n "$ARG_SERVICE_NAME" ];
|
||||||
|
then
|
||||||
|
ARG_SERVICE_NAME=$(echo "$ARG_SERVICE_NAME" | tr '[:upper:]' '[:lower:]')
|
||||||
|
case $ARG_SERVICE_NAME in
|
||||||
|
web) viewSpecificLogs "web";;
|
||||||
|
space) viewSpecificLogs "space";;
|
||||||
|
api) viewSpecificLogs "api";;
|
||||||
|
worker) viewSpecificLogs "worker";;
|
||||||
|
beat-worker) viewSpecificLogs "beat-worker";;
|
||||||
|
migrator) viewSpecificLogs "migrator";;
|
||||||
|
proxy) viewSpecificLogs "proxy";;
|
||||||
|
redis) viewSpecificLogs "plane-redis";;
|
||||||
|
postgres) viewSpecificLogs "plane-db";;
|
||||||
|
minio) viewSpecificLogs "plane-minio";;
|
||||||
|
*) echo "INVALID SERVICE NAME SUPPLIED";;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
echo "INVALID SERVICE NAME SUPPLIED"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
function askForAction() {
|
function askForAction() {
|
||||||
echo
|
local DEFAULT_ACTION=$1
|
||||||
echo "Select a Action you want to perform:"
|
|
||||||
echo " 1) Install (${CPU_ARCH})"
|
if [ -z "$DEFAULT_ACTION" ];
|
||||||
echo " 2) Start"
|
then
|
||||||
echo " 3) Stop"
|
echo
|
||||||
echo " 4) Restart"
|
echo "Select a Action you want to perform:"
|
||||||
echo " 5) Upgrade"
|
echo " 1) Install (${CPU_ARCH})"
|
||||||
echo " 6) Exit"
|
echo " 2) Start"
|
||||||
echo
|
echo " 3) Stop"
|
||||||
read -p "Action [2]: " ACTION
|
echo " 4) Restart"
|
||||||
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do
|
echo " 5) Upgrade"
|
||||||
echo "$ACTION: invalid selection."
|
echo " 6) View Logs"
|
||||||
|
echo " 7) Exit"
|
||||||
|
echo
|
||||||
read -p "Action [2]: " ACTION
|
read -p "Action [2]: " ACTION
|
||||||
done
|
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-7]$ ]]; do
|
||||||
echo
|
echo "$ACTION: invalid selection."
|
||||||
|
read -p "Action [2]: " ACTION
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$ACTION" ];
|
||||||
|
then
|
||||||
|
ACTION=2
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$ACTION" == "1" ]
|
if [ "$ACTION" == "1" ] || [ "$DEFAULT_ACTION" == "install" ]
|
||||||
then
|
then
|
||||||
install
|
install
|
||||||
askForAction
|
askForAction
|
||||||
elif [ "$ACTION" == "2" ] || [ "$ACTION" == "" ]
|
elif [ "$ACTION" == "2" ] || [ "$DEFAULT_ACTION" == "start" ]
|
||||||
then
|
then
|
||||||
startServices
|
startServices
|
||||||
askForAction
|
askForAction
|
||||||
elif [ "$ACTION" == "3" ]
|
elif [ "$ACTION" == "3" ] || [ "$DEFAULT_ACTION" == "stop" ]
|
||||||
then
|
then
|
||||||
stopServices
|
stopServices
|
||||||
askForAction
|
askForAction
|
||||||
elif [ "$ACTION" == "4" ]
|
elif [ "$ACTION" == "4" ] || [ "$DEFAULT_ACTION" == "restart" ]
|
||||||
then
|
then
|
||||||
restartServices
|
restartServices
|
||||||
askForAction
|
askForAction
|
||||||
elif [ "$ACTION" == "5" ]
|
elif [ "$ACTION" == "5" ] || [ "$DEFAULT_ACTION" == "upgrade" ]
|
||||||
then
|
then
|
||||||
upgrade
|
upgrade
|
||||||
askForAction
|
askForAction
|
||||||
elif [ "$ACTION" == "6" ]
|
elif [ "$ACTION" == "6" ] || [ "$DEFAULT_ACTION" == "logs" ]
|
||||||
|
then
|
||||||
|
viewLogs $@
|
||||||
|
askForAction
|
||||||
|
elif [ "$ACTION" == "7" ]
|
||||||
then
|
then
|
||||||
exit 0
|
exit 0
|
||||||
else
|
else
|
||||||
@ -217,4 +353,8 @@ then
|
|||||||
fi
|
fi
|
||||||
mkdir -p $PLANE_INSTALL_DIR/archive
|
mkdir -p $PLANE_INSTALL_DIR/archive
|
||||||
|
|
||||||
askForAction
|
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml
|
||||||
|
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/.env
|
||||||
|
|
||||||
|
print_header
|
||||||
|
askForAction $@
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"repository": "https://github.com/makeplane/plane.git",
|
"repository": "https://github.com/makeplane/plane.git",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/editor-core",
|
"name": "@plane/editor-core",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"description": "Core Editor that powers Plane",
|
"description": "Core Editor that powers Plane",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
@ -59,8 +59,7 @@
|
|||||||
"@types/node": "18.15.3",
|
"@types/node": "18.15.3",
|
||||||
"@types/react": "^18.2.42",
|
"@types/react": "^18.2.42",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
"eslint": "^7.32.0",
|
"eslint-config-custom": "*",
|
||||||
"eslint-config-next": "13.2.4",
|
|
||||||
"postcss": "^8.4.29",
|
"postcss": "^8.4.29",
|
||||||
"tailwind-config-custom": "*",
|
"tailwind-config-custom": "*",
|
||||||
"tsconfig": "*",
|
"tsconfig": "*",
|
||||||
|
@ -97,8 +97,8 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3 }).run();
|
||||||
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
else editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unsetLinkEditor = (editor: Editor) => {
|
export const unsetLinkEditor = (editor: Editor) => {
|
||||||
|
@ -170,68 +170,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#editor-container {
|
|
||||||
table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
table-layout: fixed;
|
|
||||||
margin: 0.5em 0 0.5em 0;
|
|
||||||
|
|
||||||
border: 1px solid rgb(var(--color-border-200));
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
td,
|
|
||||||
th {
|
|
||||||
min-width: 1em;
|
|
||||||
border: 1px solid rgb(var(--color-border-200));
|
|
||||||
padding: 10px 15px;
|
|
||||||
vertical-align: top;
|
|
||||||
box-sizing: border-box;
|
|
||||||
position: relative;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
|
|
||||||
> * {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
font-weight: bold;
|
|
||||||
text-align: left;
|
|
||||||
background-color: rgb(var(--color-primary-100));
|
|
||||||
}
|
|
||||||
|
|
||||||
td:hover {
|
|
||||||
background-color: rgba(var(--color-primary-300), 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selectedCell:after {
|
|
||||||
z-index: 2;
|
|
||||||
position: absolute;
|
|
||||||
content: "";
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(var(--color-primary-300), 0.1);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-resize-handle {
|
|
||||||
position: absolute;
|
|
||||||
right: -2px;
|
|
||||||
top: 0;
|
|
||||||
bottom: -2px;
|
|
||||||
width: 2px;
|
|
||||||
background-color: rgb(var(--color-primary-400));
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableWrapper {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-cursor {
|
.resize-cursor {
|
||||||
cursor: ew-resize;
|
cursor: ew-resize;
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
|
@ -9,15 +9,15 @@
|
|||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 1rem;
|
||||||
border: 1px solid rgba(var(--color-border-200));
|
border: 2px solid rgba(var(--color-border-300));
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper table td,
|
.tableWrapper table td,
|
||||||
.tableWrapper table th {
|
.tableWrapper table th {
|
||||||
min-width: 1em;
|
min-width: 1em;
|
||||||
border: 1px solid rgba(var(--color-border-200));
|
border: 1px solid rgba(var(--color-border-300));
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -43,7 +43,8 @@
|
|||||||
.tableWrapper table th {
|
.tableWrapper table th {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background-color: rgba(var(--color-primary-100));
|
background-color: #d9e4ff;
|
||||||
|
color: #171717;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper table th * {
|
.tableWrapper table th * {
|
||||||
@ -62,14 +63,43 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.colorPicker {
|
||||||
|
display: grid;
|
||||||
|
padding: 8px 8px;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorPickerLabel {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
padding: 8px 8px;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorPickerItem {
|
||||||
|
margin: 2px 0px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
height: 1px;
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.tableWrapper table .column-resize-handle {
|
.tableWrapper table .column-resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -2px;
|
right: -2px;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: -2px;
|
bottom: -2px;
|
||||||
width: 4px;
|
width: 4px;
|
||||||
z-index: 99;
|
z-index: 5;
|
||||||
background-color: rgba(var(--color-primary-400));
|
background-color: #d9e4ff;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,7 +111,7 @@
|
|||||||
.tableWrapper .tableControls .rowsControl {
|
.tableWrapper .tableControls .rowsControl {
|
||||||
transition: opacity ease-in 100ms;
|
transition: opacity ease-in 100ms;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 99;
|
z-index: 5;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -112,7 +142,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper .tableControls .rowsControlDiv {
|
.tableWrapper .tableControls .rowsControlDiv {
|
||||||
background-color: rgba(var(--color-primary-100));
|
background-color: #d9e4ff;
|
||||||
border: 1px solid rgba(var(--color-border-200));
|
border: 1px solid rgba(var(--color-border-200));
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background-size: 1.25rem;
|
background-size: 1.25rem;
|
||||||
@ -127,7 +157,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper .tableControls .columnsControlDiv {
|
.tableWrapper .tableControls .columnsControlDiv {
|
||||||
background-color: rgba(var(--color-primary-100));
|
background-color: #d9e4ff;
|
||||||
border: 1px solid rgba(var(--color-border-200));
|
border: 1px solid rgba(var(--color-border-200));
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background-size: 1.25rem;
|
background-size: 1.25rem;
|
||||||
@ -144,10 +174,12 @@
|
|||||||
.tableWrapper .tableControls .tableColorPickerToolbox {
|
.tableWrapper .tableControls .tableColorPickerToolbox {
|
||||||
border: 1px solid rgba(var(--color-border-300));
|
border: 1px solid rgba(var(--color-border-300));
|
||||||
background-color: rgba(var(--color-background-100));
|
background-color: rgba(var(--color-background-100));
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 200px;
|
width: max-content;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,7 +190,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.1rem;
|
padding: 0.3rem 0.5rem 0.1rem 0.1rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
@ -166,16 +198,14 @@
|
|||||||
|
|
||||||
.tableWrapper .tableControls .tableToolbox .toolboxItem:hover,
|
.tableWrapper .tableControls .tableToolbox .toolboxItem:hover,
|
||||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem:hover {
|
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem:hover {
|
||||||
background-color: rgba(var(--color-background-100), 0.5);
|
background-color: rgba(var(--color-background-80), 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer,
|
.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer,
|
||||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer,
|
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer,
|
||||||
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer,
|
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer,
|
||||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer {
|
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer {
|
||||||
border: 1px solid rgba(var(--color-border-300));
|
padding: 4px 0px;
|
||||||
border-radius: 3px;
|
|
||||||
padding: 4px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -187,8 +217,8 @@
|
|||||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg,
|
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg,
|
||||||
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg,
|
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg,
|
||||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg {
|
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg {
|
||||||
width: 2rem;
|
width: 1rem;
|
||||||
height: 2rem;
|
height: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableToolbox {
|
.tableToolbox {
|
||||||
|
@ -15,9 +15,15 @@ export function clickHandler(options: ClickHandlerOptions): Plugin {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventTarget = event.target as HTMLElement;
|
let a = event.target as HTMLElement;
|
||||||
|
const els = [];
|
||||||
|
|
||||||
if (eventTarget.nodeName !== "A") {
|
while (a.nodeName !== "DIV") {
|
||||||
|
els.push(a);
|
||||||
|
a = a.parentNode as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!els.find((value) => value.nodeName === "A")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,9 +34,7 @@ export function clickHandler(options: ClickHandlerOptions): Plugin {
|
|||||||
const target = link?.target ?? attrs.target;
|
const target = link?.target ?? attrs.target;
|
||||||
|
|
||||||
if (link && href) {
|
if (link && href) {
|
||||||
if (view.editable) {
|
window.open(href, target);
|
||||||
window.open(href, target);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -33,16 +33,8 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = event.clipboardData?.getData("text/html");
|
|
||||||
|
|
||||||
const hrefRegex = /href="([^"]*)"/;
|
|
||||||
|
|
||||||
const existingLink = html?.match(hrefRegex);
|
|
||||||
|
|
||||||
const url = existingLink ? existingLink[1] : link.href;
|
|
||||||
|
|
||||||
options.editor.commands.setMark(options.type, {
|
options.editor.commands.setMark(options.type, {
|
||||||
href: url,
|
href: link.href,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
@ -1,41 +1,76 @@
|
|||||||
import { Mark, markPasteRule, mergeAttributes } from "@tiptap/core";
|
import { Mark, markPasteRule, mergeAttributes, PasteRuleMatch } from "@tiptap/core";
|
||||||
import { Plugin } from "@tiptap/pm/state";
|
import { Plugin } from "@tiptap/pm/state";
|
||||||
import { find, registerCustomProtocol, reset } from "linkifyjs";
|
import { find, registerCustomProtocol, reset } from "linkifyjs";
|
||||||
|
import { autolink } from "./helpers/autolink";
|
||||||
import { autolink } from "src/ui/extensions/custom-link/helpers/autolink";
|
import { clickHandler } from "./helpers/clickHandler";
|
||||||
import { clickHandler } from "src/ui/extensions/custom-link/helpers/clickHandler";
|
import { pasteHandler } from "./helpers/pasteHandler";
|
||||||
import { pasteHandler } from "src/ui/extensions/custom-link/helpers/pasteHandler";
|
|
||||||
|
|
||||||
export interface LinkProtocolOptions {
|
export interface LinkProtocolOptions {
|
||||||
scheme: string;
|
scheme: string;
|
||||||
optionalSlashes?: boolean;
|
optionalSlashes?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const pasteRegex =
|
||||||
|
/https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)/gi;
|
||||||
|
|
||||||
export interface LinkOptions {
|
export interface LinkOptions {
|
||||||
|
/**
|
||||||
|
* If enabled, it adds links as you type.
|
||||||
|
*/
|
||||||
autolink: boolean;
|
autolink: boolean;
|
||||||
inclusive: boolean;
|
/**
|
||||||
|
* An array of custom protocols to be registered with linkifyjs.
|
||||||
|
*/
|
||||||
protocols: Array<LinkProtocolOptions | string>;
|
protocols: Array<LinkProtocolOptions | string>;
|
||||||
|
/**
|
||||||
|
* If enabled, links will be opened on click.
|
||||||
|
*/
|
||||||
openOnClick: boolean;
|
openOnClick: boolean;
|
||||||
|
/**
|
||||||
|
* If enabled, links will be inclusive i.e. if you move your cursor to the
|
||||||
|
* link text, and start typing, it'll be a part of the link itself.
|
||||||
|
*/
|
||||||
|
inclusive: boolean;
|
||||||
|
/**
|
||||||
|
* Adds a link to the current selection if the pasted content only contains an url.
|
||||||
|
*/
|
||||||
linkOnPaste: boolean;
|
linkOnPaste: boolean;
|
||||||
|
/**
|
||||||
|
* A list of HTML attributes to be rendered.
|
||||||
|
*/
|
||||||
HTMLAttributes: Record<string, any>;
|
HTMLAttributes: Record<string, any>;
|
||||||
|
/**
|
||||||
|
* A validation function that modifies link verification for the auto linker.
|
||||||
|
* @param url - The url to be validated.
|
||||||
|
* @returns - True if the url is valid, false otherwise.
|
||||||
|
*/
|
||||||
validate?: (url: string) => boolean;
|
validate?: (url: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
link: {
|
link: {
|
||||||
|
/**
|
||||||
|
* Set a link mark
|
||||||
|
*/
|
||||||
setLink: (attributes: {
|
setLink: (attributes: {
|
||||||
href: string;
|
href: string;
|
||||||
target?: string | null;
|
target?: string | null;
|
||||||
rel?: string | null;
|
rel?: string | null;
|
||||||
class?: string | null;
|
class?: string | null;
|
||||||
}) => ReturnType;
|
}) => ReturnType;
|
||||||
|
/**
|
||||||
|
* Toggle a link mark
|
||||||
|
*/
|
||||||
toggleLink: (attributes: {
|
toggleLink: (attributes: {
|
||||||
href: string;
|
href: string;
|
||||||
target?: string | null;
|
target?: string | null;
|
||||||
rel?: string | null;
|
rel?: string | null;
|
||||||
class?: string | null;
|
class?: string | null;
|
||||||
}) => ReturnType;
|
}) => ReturnType;
|
||||||
|
/**
|
||||||
|
* Unset a link mark
|
||||||
|
*/
|
||||||
unsetLink: () => ReturnType;
|
unsetLink: () => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -150,37 +185,31 @@ export const CustomLinkExtension = Mark.create<LinkOptions>({
|
|||||||
addPasteRules() {
|
addPasteRules() {
|
||||||
return [
|
return [
|
||||||
markPasteRule({
|
markPasteRule({
|
||||||
find: (text) =>
|
find: (text) => {
|
||||||
find(text)
|
const foundLinks: PasteRuleMatch[] = [];
|
||||||
.filter((link) => {
|
|
||||||
if (this.options.validate) {
|
|
||||||
return this.options.validate(link.value);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.filter((link) => link.isLink)
|
|
||||||
.map((link) => ({
|
|
||||||
text: link.value,
|
|
||||||
index: link.start,
|
|
||||||
data: link,
|
|
||||||
})),
|
|
||||||
type: this.type,
|
|
||||||
getAttributes: (match, pasteEvent) => {
|
|
||||||
const html = pasteEvent?.clipboardData?.getData("text/html");
|
|
||||||
const hrefRegex = /href="([^"]*)"/;
|
|
||||||
|
|
||||||
const existingLink = html?.match(hrefRegex);
|
if (text) {
|
||||||
|
const links = find(text).filter((item) => item.isLink);
|
||||||
|
|
||||||
if (existingLink) {
|
if (links.length) {
|
||||||
return {
|
links.forEach((link) =>
|
||||||
href: existingLink[1],
|
foundLinks.push({
|
||||||
};
|
text: link.value,
|
||||||
|
data: {
|
||||||
|
href: link.href,
|
||||||
|
},
|
||||||
|
index: link.start,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return foundLinks;
|
||||||
href: match.data?.href,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
type: this.type,
|
||||||
|
getAttributes: (match) => ({
|
||||||
|
href: match.data?.href,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
},
|
},
|
@ -0,0 +1,111 @@
|
|||||||
|
import { isNodeSelection, mergeAttributes, Node, nodeInputRule } from "@tiptap/core";
|
||||||
|
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
|
||||||
|
|
||||||
|
export interface HorizontalRuleOptions {
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
horizontalRule: {
|
||||||
|
/**
|
||||||
|
* Add a horizontal rule
|
||||||
|
*/
|
||||||
|
setHorizontalRule: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomHorizontalRule = Node.create<HorizontalRuleOptions>({
|
||||||
|
name: "horizontalRule",
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
group: "block",
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: "hr" }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ["hr", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setHorizontalRule:
|
||||||
|
() =>
|
||||||
|
({ chain, state }) => {
|
||||||
|
const { selection } = state;
|
||||||
|
const { $from: $originFrom, $to: $originTo } = selection;
|
||||||
|
|
||||||
|
const currentChain = chain();
|
||||||
|
|
||||||
|
if ($originFrom.parentOffset === 0) {
|
||||||
|
currentChain.insertContentAt(
|
||||||
|
{
|
||||||
|
from: Math.max($originFrom.pos - 1, 0),
|
||||||
|
to: $originTo.pos,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: this.name,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else if (isNodeSelection(selection)) {
|
||||||
|
currentChain.insertContentAt($originTo.pos, {
|
||||||
|
type: this.name,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
currentChain.insertContent({ type: this.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
currentChain
|
||||||
|
// set cursor after horizontal rule
|
||||||
|
.command(({ tr, dispatch }) => {
|
||||||
|
if (dispatch) {
|
||||||
|
const { $to } = tr.selection;
|
||||||
|
const posAfter = $to.end();
|
||||||
|
|
||||||
|
if ($to.nodeAfter) {
|
||||||
|
if ($to.nodeAfter.isTextblock) {
|
||||||
|
tr.setSelection(TextSelection.create(tr.doc, $to.pos + 1));
|
||||||
|
} else if ($to.nodeAfter.isBlock) {
|
||||||
|
tr.setSelection(NodeSelection.create(tr.doc, $to.pos));
|
||||||
|
} else {
|
||||||
|
tr.setSelection(TextSelection.create(tr.doc, $to.pos));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// add node after horizontal rule if it’s the end of the document
|
||||||
|
const node = $to.parent.type.contentMatch.defaultType?.create();
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
tr.insert(posAfter, node);
|
||||||
|
tr.setSelection(TextSelection.create(tr.doc, posAfter + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.scrollIntoView();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addInputRules() {
|
||||||
|
return [
|
||||||
|
nodeInputRule({
|
||||||
|
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
|
||||||
|
type: this.type,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
@ -25,7 +25,9 @@ import { DeleteImage } from "src/types/delete-image";
|
|||||||
import { IMentionSuggestion } from "src/types/mention-suggestion";
|
import { IMentionSuggestion } from "src/types/mention-suggestion";
|
||||||
import { RestoreImage } from "src/types/restore-image";
|
import { RestoreImage } from "src/types/restore-image";
|
||||||
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
||||||
import { CustomCodeInlineExtension } from "./code-inline";
|
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
|
||||||
|
import { CustomTypographyExtension } from "src/ui/extensions/typography";
|
||||||
|
import { CustomHorizontalRule } from "./horizontal-rule/horizontal-rule";
|
||||||
|
|
||||||
export const CoreEditorExtensions = (
|
export const CoreEditorExtensions = (
|
||||||
mentionConfig: {
|
mentionConfig: {
|
||||||
@ -54,9 +56,7 @@ export const CoreEditorExtensions = (
|
|||||||
},
|
},
|
||||||
code: false,
|
code: false,
|
||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
horizontalRule: {
|
horizontalRule: false,
|
||||||
HTMLAttributes: { class: "mt-4 mb-4" },
|
|
||||||
},
|
|
||||||
blockquote: false,
|
blockquote: false,
|
||||||
dropcursor: {
|
dropcursor: {
|
||||||
color: "rgba(var(--color-text-100))",
|
color: "rgba(var(--color-text-100))",
|
||||||
@ -66,6 +66,10 @@ export const CoreEditorExtensions = (
|
|||||||
CustomQuoteExtension.configure({
|
CustomQuoteExtension.configure({
|
||||||
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
|
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
CustomHorizontalRule.configure({
|
||||||
|
HTMLAttributes: { class: "mt-4 mb-4" },
|
||||||
|
}),
|
||||||
CustomKeymap,
|
CustomKeymap,
|
||||||
ListKeymap,
|
ListKeymap,
|
||||||
CustomLinkExtension.configure({
|
CustomLinkExtension.configure({
|
||||||
@ -79,6 +83,7 @@ export const CoreEditorExtensions = (
|
|||||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
CustomTypographyExtension,
|
||||||
ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({
|
ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "rounded-lg border border-custom-border-300",
|
class: "rounded-lg border border-custom-border-300",
|
||||||
|
@ -13,7 +13,7 @@ export const TableCell = Node.create<TableCellOptions>({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
content: "paragraph+",
|
content: "block+",
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
@ -33,7 +33,10 @@ export const TableCell = Node.create<TableCellOptions>({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
default: "none",
|
default: null,
|
||||||
|
},
|
||||||
|
textColor: {
|
||||||
|
default: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -50,7 +53,7 @@ export const TableCell = Node.create<TableCellOptions>({
|
|||||||
return [
|
return [
|
||||||
"td",
|
"td",
|
||||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||||
style: `background-color: ${node.attrs.background}`,
|
style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor}`,
|
||||||
}),
|
}),
|
||||||
0,
|
0,
|
||||||
];
|
];
|
||||||
|
@ -33,7 +33,7 @@ export const TableHeader = Node.create<TableHeaderOptions>({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
default: "rgb(var(--color-primary-100))",
|
default: "none",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -13,6 +13,17 @@ export const TableRow = Node.create<TableRowOptions>({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
background: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
textColor: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
content: "(tableCell | tableHeader)*",
|
content: "(tableCell | tableHeader)*",
|
||||||
|
|
||||||
tableRole: "row",
|
tableRole: "row",
|
||||||
@ -22,6 +33,12 @@ export const TableRow = Node.create<TableRowOptions>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ["tr", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
const style = HTMLAttributes.background
|
||||||
|
? `background-color: ${HTMLAttributes.background}; color: ${HTMLAttributes.textColor}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { style });
|
||||||
|
|
||||||
|
return ["tr", attributes, 0];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
export const icons = {
|
export const icons = {
|
||||||
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" length="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path fill="rgb(var(--color-text-300))" d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
|
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" length="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path fill="rgb(var(--color-text-300))" d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
|
||||||
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" length="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M12 3c.552 0 1 .448 1 1v8c.835-.628 1.874-1 3-1 2.761 0 5 2.239 5 5s-2.239 5-5 5c-1.032 0-1.99-.313-2.787-.848L13 20c0 .552-.448 1-1 1H6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2H7v14h4V5zm8 10h-6v2h6v-2z"/></svg>`,
|
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
|
||||||
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" length="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M20 5c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1 .628.835 1 1.874 1 3 0 2.761-2.239 5-5 5s-5-2.239-5-5c0-1.126.372-2.165 1-3H4c-.552 0-1-.448-1-1V6c0-.552.448-1 1-1h16zm-7 10v2h6v-2h-6zm6-8H5v4h14V7z"/></svg>`,
|
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
|
||||||
insertLeftTableIcon: `<svg
|
insertLeftTableIcon: `<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
length={24}
|
length={24}
|
||||||
@ -35,6 +35,8 @@ export const icons = {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
`,
|
`,
|
||||||
|
toggleColumnHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
|
||||||
|
toggleRowHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
|
||||||
insertBottomTableIcon: `<svg
|
insertBottomTableIcon: `<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
length={24}
|
length={24}
|
||||||
|
@ -81,53 +81,75 @@ const defaultTippyOptions: Partial<Props> = {
|
|||||||
placement: "right",
|
placement: "right",
|
||||||
};
|
};
|
||||||
|
|
||||||
function setCellsBackgroundColor(editor: Editor, backgroundColor: string) {
|
function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) {
|
||||||
return editor
|
return editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.updateAttributes("tableCell", {
|
.updateAttributes("tableCell", {
|
||||||
background: backgroundColor,
|
background: color.backgroundColor,
|
||||||
})
|
textColor: color.textColor,
|
||||||
.updateAttributes("tableHeader", {
|
|
||||||
background: backgroundColor,
|
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) {
|
||||||
|
const { state, dispatch } = editor.view;
|
||||||
|
const { selection } = state;
|
||||||
|
if (!(selection instanceof CellSelection)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the position of the hovered cell in the selection to determine the row.
|
||||||
|
const hoveredCell = selection.$headCell || selection.$anchorCell;
|
||||||
|
|
||||||
|
// Find the depth of the table row node
|
||||||
|
let rowDepth = hoveredCell.depth;
|
||||||
|
while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== "tableRow") {
|
||||||
|
rowDepth--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we couldn't find a tableRow node, we can't set the background color
|
||||||
|
if (hoveredCell.node(rowDepth).type.name !== "tableRow") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the position where the table row starts
|
||||||
|
const rowStartPos = hoveredCell.start(rowDepth);
|
||||||
|
|
||||||
|
// Create a transaction that sets the background color on the tableRow node.
|
||||||
|
const tr = state.tr.setNodeMarkup(rowStartPos - 1, null, {
|
||||||
|
...hoveredCell.node(rowDepth).attrs,
|
||||||
|
background: color.backgroundColor,
|
||||||
|
textColor: color.textColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(tr);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const columnsToolboxItems: ToolboxItem[] = [
|
const columnsToolboxItems: ToolboxItem[] = [
|
||||||
{
|
{
|
||||||
label: "Add Column Before",
|
label: "Toggle column header",
|
||||||
|
icon: icons.toggleColumnHeader,
|
||||||
|
action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderColumn().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Add column before",
|
||||||
icon: icons.insertLeftTableIcon,
|
icon: icons.insertLeftTableIcon,
|
||||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run(),
|
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Add Column After",
|
label: "Add column after",
|
||||||
icon: icons.insertRightTableIcon,
|
icon: icons.insertRightTableIcon,
|
||||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run(),
|
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Pick Column Color",
|
label: "Pick color",
|
||||||
icon: icons.colorPicker,
|
icon: "", // No icon needed for color picker
|
||||||
action: ({
|
action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
|
||||||
editor,
|
|
||||||
triggerButton,
|
|
||||||
controlsContainer,
|
|
||||||
}: {
|
|
||||||
editor: Editor;
|
|
||||||
triggerButton: HTMLElement;
|
|
||||||
controlsContainer: Element;
|
|
||||||
}) => {
|
|
||||||
createColorPickerToolbox({
|
|
||||||
triggerButton,
|
|
||||||
tippyOptions: {
|
|
||||||
appendTo: controlsContainer,
|
|
||||||
},
|
|
||||||
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Delete Column",
|
label: "Delete column",
|
||||||
icon: icons.deleteColumn,
|
icon: icons.deleteColumn,
|
||||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run(),
|
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run(),
|
||||||
},
|
},
|
||||||
@ -135,35 +157,24 @@ const columnsToolboxItems: ToolboxItem[] = [
|
|||||||
|
|
||||||
const rowsToolboxItems: ToolboxItem[] = [
|
const rowsToolboxItems: ToolboxItem[] = [
|
||||||
{
|
{
|
||||||
label: "Add Row Above",
|
label: "Toggle row header",
|
||||||
|
icon: icons.toggleRowHeader,
|
||||||
|
action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderRow().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Add row above",
|
||||||
icon: icons.insertTopTableIcon,
|
icon: icons.insertTopTableIcon,
|
||||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run(),
|
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Add Row Below",
|
label: "Add row below",
|
||||||
icon: icons.insertBottomTableIcon,
|
icon: icons.insertBottomTableIcon,
|
||||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run(),
|
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Pick Row Color",
|
label: "Pick color",
|
||||||
icon: icons.colorPicker,
|
icon: "",
|
||||||
action: ({
|
action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
|
||||||
editor,
|
|
||||||
triggerButton,
|
|
||||||
controlsContainer,
|
|
||||||
}: {
|
|
||||||
editor: Editor;
|
|
||||||
triggerButton: HTMLButtonElement;
|
|
||||||
controlsContainer: Element | "parent" | ((ref: Element) => Element) | undefined;
|
|
||||||
}) => {
|
|
||||||
createColorPickerToolbox({
|
|
||||||
triggerButton,
|
|
||||||
tippyOptions: {
|
|
||||||
appendTo: controlsContainer,
|
|
||||||
},
|
|
||||||
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Delete Row",
|
label: "Delete Row",
|
||||||
@ -176,37 +187,58 @@ function createToolbox({
|
|||||||
triggerButton,
|
triggerButton,
|
||||||
items,
|
items,
|
||||||
tippyOptions,
|
tippyOptions,
|
||||||
|
onSelectColor,
|
||||||
onClickItem,
|
onClickItem,
|
||||||
|
colors,
|
||||||
}: {
|
}: {
|
||||||
triggerButton: Element | null;
|
triggerButton: Element | null;
|
||||||
items: ToolboxItem[];
|
items: ToolboxItem[];
|
||||||
tippyOptions: any;
|
tippyOptions: any;
|
||||||
onClickItem: (item: ToolboxItem) => void;
|
onClickItem: (item: ToolboxItem) => void;
|
||||||
|
onSelectColor: (color: { backgroundColor: string; textColor: string }) => void;
|
||||||
|
colors: { [key: string]: { backgroundColor: string; textColor: string; icon?: string } };
|
||||||
}): Instance<Props> {
|
}): Instance<Props> {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
const toolbox = tippy(triggerButton, {
|
const toolbox = tippy(triggerButton, {
|
||||||
content: h(
|
content: h(
|
||||||
"div",
|
"div",
|
||||||
{ className: "tableToolbox" },
|
{ className: "tableToolbox" },
|
||||||
items.map((item) =>
|
items.map((item, index) => {
|
||||||
h(
|
if (item.label === "Pick color") {
|
||||||
"div",
|
return h("div", { className: "flex flex-col" }, [
|
||||||
{
|
h("div", { className: "divider" }),
|
||||||
className: "toolboxItem",
|
h("div", { className: "colorPickerLabel" }, item.label),
|
||||||
itemType: "button",
|
h(
|
||||||
onClick() {
|
"div",
|
||||||
onClickItem(item);
|
{ className: "colorPicker grid" },
|
||||||
|
Object.entries(colors).map(([colorName, colorValue]) =>
|
||||||
|
h("div", {
|
||||||
|
className: "colorPickerItem flex items-center justify-center",
|
||||||
|
style: `background-color: ${colorValue.backgroundColor};
|
||||||
|
color: ${colorValue.textColor || "inherit"};`,
|
||||||
|
innerHTML:
|
||||||
|
colorValue.icon ?? `<span class="text-md" style:"color: ${colorValue.backgroundColor}>A</span>`,
|
||||||
|
onClick: () => onSelectColor(colorValue),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
h("div", { className: "divider" }),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
return h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
className: "toolboxItem",
|
||||||
|
itemType: "div",
|
||||||
|
onClick: () => onClickItem(item),
|
||||||
},
|
},
|
||||||
},
|
[
|
||||||
[
|
h("div", { className: "iconContainer", innerHTML: item.icon }),
|
||||||
h("div", {
|
h("div", { className: "label" }, item.label),
|
||||||
className: "iconContainer",
|
]
|
||||||
innerHTML: item.icon,
|
);
|
||||||
}),
|
}
|
||||||
h("div", { className: "label" }, item.label),
|
})
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
...tippyOptions,
|
...tippyOptions,
|
||||||
});
|
});
|
||||||
@ -214,71 +246,6 @@ function createToolbox({
|
|||||||
return Array.isArray(toolbox) ? toolbox[0] : toolbox;
|
return Array.isArray(toolbox) ? toolbox[0] : toolbox;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createColorPickerToolbox({
|
|
||||||
triggerButton,
|
|
||||||
tippyOptions,
|
|
||||||
onSelectColor = () => {},
|
|
||||||
}: {
|
|
||||||
triggerButton: HTMLElement;
|
|
||||||
tippyOptions: Partial<Props>;
|
|
||||||
onSelectColor?: (color: string) => void;
|
|
||||||
}) {
|
|
||||||
const items = {
|
|
||||||
Default: "rgb(var(--color-primary-100))",
|
|
||||||
Orange: "#FFE5D1",
|
|
||||||
Grey: "#F1F1F1",
|
|
||||||
Yellow: "#FEF3C7",
|
|
||||||
Green: "#DCFCE7",
|
|
||||||
Red: "#FFDDDD",
|
|
||||||
Blue: "#D9E4FF",
|
|
||||||
Pink: "#FFE8FA",
|
|
||||||
Purple: "#E8DAFB",
|
|
||||||
};
|
|
||||||
|
|
||||||
const colorPicker = tippy(triggerButton, {
|
|
||||||
...defaultTippyOptions,
|
|
||||||
content: h(
|
|
||||||
"div",
|
|
||||||
{ className: "tableColorPickerToolbox" },
|
|
||||||
Object.entries(items).map(([key, value]) =>
|
|
||||||
h(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
className: "toolboxItem",
|
|
||||||
itemType: "button",
|
|
||||||
onClick: () => {
|
|
||||||
onSelectColor(value);
|
|
||||||
colorPicker.hide();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[
|
|
||||||
h("div", {
|
|
||||||
className: "colorContainer",
|
|
||||||
style: {
|
|
||||||
backgroundColor: value,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
h(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
className: "label",
|
|
||||||
},
|
|
||||||
key
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
onHidden: (instance) => {
|
|
||||||
instance.destroy();
|
|
||||||
},
|
|
||||||
showOnCreate: true,
|
|
||||||
...tippyOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
return colorPicker;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TableView implements NodeView {
|
export class TableView implements NodeView {
|
||||||
node: ProseMirrorNode;
|
node: ProseMirrorNode;
|
||||||
cellMinWidth: number;
|
cellMinWidth: number;
|
||||||
@ -347,10 +314,27 @@ export class TableView implements NodeView {
|
|||||||
this.rowsControl,
|
this.rowsControl,
|
||||||
this.columnsControl
|
this.columnsControl
|
||||||
);
|
);
|
||||||
|
const columnColors = {
|
||||||
|
Blue: { backgroundColor: "#D9E4FF", textColor: "#171717" },
|
||||||
|
Orange: { backgroundColor: "#FFEDD5", textColor: "#171717" },
|
||||||
|
Grey: { backgroundColor: "#F1F1F1", textColor: "#171717" },
|
||||||
|
Yellow: { backgroundColor: "#FEF3C7", textColor: "#171717" },
|
||||||
|
Green: { backgroundColor: "#DCFCE7", textColor: "#171717" },
|
||||||
|
Red: { backgroundColor: "#FFDDDD", textColor: "#171717" },
|
||||||
|
Pink: { backgroundColor: "#FFE8FA", textColor: "#171717" },
|
||||||
|
Purple: { backgroundColor: "#E8DAFB", textColor: "#171717" },
|
||||||
|
None: {
|
||||||
|
backgroundColor: "none",
|
||||||
|
textColor: "none",
|
||||||
|
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="gray" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban"><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></svg>`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
this.columnsToolbox = createToolbox({
|
this.columnsToolbox = createToolbox({
|
||||||
triggerButton: this.columnsControl.querySelector(".columnsControlDiv"),
|
triggerButton: this.columnsControl.querySelector(".columnsControlDiv"),
|
||||||
items: columnsToolboxItems,
|
items: columnsToolboxItems,
|
||||||
|
colors: columnColors,
|
||||||
|
onSelectColor: (color) => setCellsBackgroundColor(this.editor, color),
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
...defaultTippyOptions,
|
...defaultTippyOptions,
|
||||||
appendTo: this.controls,
|
appendTo: this.controls,
|
||||||
@ -368,10 +352,12 @@ export class TableView implements NodeView {
|
|||||||
this.rowsToolbox = createToolbox({
|
this.rowsToolbox = createToolbox({
|
||||||
triggerButton: this.rowsControl.firstElementChild,
|
triggerButton: this.rowsControl.firstElementChild,
|
||||||
items: rowsToolboxItems,
|
items: rowsToolboxItems,
|
||||||
|
colors: columnColors,
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
...defaultTippyOptions,
|
...defaultTippyOptions,
|
||||||
appendTo: this.controls,
|
appendTo: this.controls,
|
||||||
},
|
},
|
||||||
|
onSelectColor: (color) => setTableRowBackgroundColor(editor, color),
|
||||||
onClickItem: (item) => {
|
onClickItem: (item) => {
|
||||||
item.action({
|
item.action({
|
||||||
editor: this.editor,
|
editor: this.editor,
|
||||||
@ -383,8 +369,6 @@ export class TableView implements NodeView {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Table
|
|
||||||
|
|
||||||
this.colgroup = h(
|
this.colgroup = h(
|
||||||
"colgroup",
|
"colgroup",
|
||||||
null,
|
null,
|
||||||
@ -437,16 +421,19 @@ export class TableView implements NodeView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateControls() {
|
updateControls() {
|
||||||
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce((acc, curr) => {
|
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce(
|
||||||
if (curr.spec.hoveredCell !== undefined) {
|
(acc, curr) => {
|
||||||
acc["hoveredCell"] = curr.spec.hoveredCell;
|
if (curr.spec.hoveredCell !== undefined) {
|
||||||
}
|
acc["hoveredCell"] = curr.spec.hoveredCell;
|
||||||
|
}
|
||||||
|
|
||||||
if (curr.spec.hoveredTable !== undefined) {
|
if (curr.spec.hoveredTable !== undefined) {
|
||||||
acc["hoveredTable"] = curr.spec.hoveredTable;
|
acc["hoveredTable"] = curr.spec.hoveredTable;
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, HTMLElement>) as any;
|
},
|
||||||
|
{} as Record<string, HTMLElement>
|
||||||
|
) as any;
|
||||||
|
|
||||||
if (table === undefined || cell === undefined) {
|
if (table === undefined || cell === undefined) {
|
||||||
return this.root.classList.add("controls--disabled");
|
return this.root.classList.add("controls--disabled");
|
||||||
@ -457,12 +444,12 @@ export class TableView implements NodeView {
|
|||||||
|
|
||||||
const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement;
|
const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement;
|
||||||
|
|
||||||
if (!this.table) {
|
if (!this.table || !cellDom) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableRect = this.table.getBoundingClientRect();
|
const tableRect = this.table?.getBoundingClientRect();
|
||||||
const cellRect = cellDom.getBoundingClientRect();
|
const cellRect = cellDom?.getBoundingClientRect();
|
||||||
|
|
||||||
if (this.columnsControl) {
|
if (this.columnsControl) {
|
||||||
this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`;
|
this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`;
|
||||||
|
@ -107,10 +107,9 @@ export const Table = Node.create({
|
|||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
insertTable:
|
insertTable:
|
||||||
({ rows = 3, cols = 3, withHeaderRow = true } = {}) =>
|
({ rows = 3, cols = 3, withHeaderRow = false } = {}) =>
|
||||||
({ tr, dispatch, editor }) => {
|
({ tr, dispatch, editor }) => {
|
||||||
const node = createTable(editor.schema, rows, cols, withHeaderRow);
|
const node = createTable(editor.schema, rows, cols, withHeaderRow);
|
||||||
|
|
||||||
if (dispatch) {
|
if (dispatch) {
|
||||||
const offset = tr.selection.anchor + 1;
|
const offset = tr.selection.anchor + 1;
|
||||||
|
|
||||||
|
109
packages/editor/core/src/ui/extensions/typography/index.ts
Normal file
109
packages/editor/core/src/ui/extensions/typography/index.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { Extension } from "@tiptap/core";
|
||||||
|
import {
|
||||||
|
TypographyOptions,
|
||||||
|
emDash,
|
||||||
|
ellipsis,
|
||||||
|
leftArrow,
|
||||||
|
rightArrow,
|
||||||
|
copyright,
|
||||||
|
trademark,
|
||||||
|
servicemark,
|
||||||
|
registeredTrademark,
|
||||||
|
oneHalf,
|
||||||
|
plusMinus,
|
||||||
|
notEqual,
|
||||||
|
laquo,
|
||||||
|
raquo,
|
||||||
|
multiplication,
|
||||||
|
superscriptTwo,
|
||||||
|
superscriptThree,
|
||||||
|
oneQuarter,
|
||||||
|
threeQuarters,
|
||||||
|
impliesArrowRight,
|
||||||
|
} from "src/ui/extensions/typography/inputRules";
|
||||||
|
|
||||||
|
export const CustomTypographyExtension = Extension.create<TypographyOptions>({
|
||||||
|
name: "typography",
|
||||||
|
|
||||||
|
addInputRules() {
|
||||||
|
const rules = [];
|
||||||
|
|
||||||
|
if (this.options.emDash !== false) {
|
||||||
|
rules.push(emDash(this.options.emDash));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.impliesArrowRight !== false) {
|
||||||
|
rules.push(impliesArrowRight(this.options.impliesArrowRight));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.ellipsis !== false) {
|
||||||
|
rules.push(ellipsis(this.options.ellipsis));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.leftArrow !== false) {
|
||||||
|
rules.push(leftArrow(this.options.leftArrow));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.rightArrow !== false) {
|
||||||
|
rules.push(rightArrow(this.options.rightArrow));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.copyright !== false) {
|
||||||
|
rules.push(copyright(this.options.copyright));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.trademark !== false) {
|
||||||
|
rules.push(trademark(this.options.trademark));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.servicemark !== false) {
|
||||||
|
rules.push(servicemark(this.options.servicemark));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.registeredTrademark !== false) {
|
||||||
|
rules.push(registeredTrademark(this.options.registeredTrademark));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.oneHalf !== false) {
|
||||||
|
rules.push(oneHalf(this.options.oneHalf));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.plusMinus !== false) {
|
||||||
|
rules.push(plusMinus(this.options.plusMinus));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.notEqual !== false) {
|
||||||
|
rules.push(notEqual(this.options.notEqual));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.laquo !== false) {
|
||||||
|
rules.push(laquo(this.options.laquo));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.raquo !== false) {
|
||||||
|
rules.push(raquo(this.options.raquo));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.multiplication !== false) {
|
||||||
|
rules.push(multiplication(this.options.multiplication));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.superscriptTwo !== false) {
|
||||||
|
rules.push(superscriptTwo(this.options.superscriptTwo));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.superscriptThree !== false) {
|
||||||
|
rules.push(superscriptThree(this.options.superscriptThree));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.oneQuarter !== false) {
|
||||||
|
rules.push(oneQuarter(this.options.oneQuarter));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.threeQuarters !== false) {
|
||||||
|
rules.push(threeQuarters(this.options.threeQuarters));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules;
|
||||||
|
},
|
||||||
|
});
|
137
packages/editor/core/src/ui/extensions/typography/inputRules.ts
Normal file
137
packages/editor/core/src/ui/extensions/typography/inputRules.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { textInputRule } from "@tiptap/core";
|
||||||
|
|
||||||
|
export interface TypographyOptions {
|
||||||
|
emDash: false | string;
|
||||||
|
ellipsis: false | string;
|
||||||
|
leftArrow: false | string;
|
||||||
|
rightArrow: false | string;
|
||||||
|
copyright: false | string;
|
||||||
|
trademark: false | string;
|
||||||
|
servicemark: false | string;
|
||||||
|
registeredTrademark: false | string;
|
||||||
|
oneHalf: false | string;
|
||||||
|
plusMinus: false | string;
|
||||||
|
notEqual: false | string;
|
||||||
|
laquo: false | string;
|
||||||
|
raquo: false | string;
|
||||||
|
multiplication: false | string;
|
||||||
|
superscriptTwo: false | string;
|
||||||
|
superscriptThree: false | string;
|
||||||
|
oneQuarter: false | string;
|
||||||
|
threeQuarters: false | string;
|
||||||
|
impliesArrowRight: false | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emDash = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /--$/,
|
||||||
|
replace: override ?? "—",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const impliesArrowRight = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /=>$/,
|
||||||
|
replace: override ?? "⇒",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const leftArrow = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /<-$/,
|
||||||
|
replace: override ?? "←",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rightArrow = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /->$/,
|
||||||
|
replace: override ?? "→",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ellipsis = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /\.\.\.$/,
|
||||||
|
replace: override ?? "…",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const copyright = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /\(c\)$/,
|
||||||
|
replace: override ?? "©",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const trademark = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /\(tm\)$/,
|
||||||
|
replace: override ?? "™",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const servicemark = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /\(sm\)$/,
|
||||||
|
replace: override ?? "℠",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const registeredTrademark = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /\(r\)$/,
|
||||||
|
replace: override ?? "®",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const oneHalf = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /(?:^|\s)(1\/2)\s$/,
|
||||||
|
replace: override ?? "½",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const plusMinus = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /\+\/-$/,
|
||||||
|
replace: override ?? "±",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const notEqual = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /!=$/,
|
||||||
|
replace: override ?? "≠",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const laquo = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /<<$/,
|
||||||
|
replace: override ?? "«",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const raquo = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: />>$/,
|
||||||
|
replace: override ?? "»",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const multiplication = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /\d+\s?([*x])\s?\d+$/,
|
||||||
|
replace: override ?? "×",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const superscriptTwo = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /\^2$/,
|
||||||
|
replace: override ?? "²",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const superscriptThree = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /\^3$/,
|
||||||
|
replace: override ?? "³",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const oneQuarter = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /(?:^|\s)(1\/4)\s$/,
|
||||||
|
replace: override ?? "¼",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const threeQuarters = (override?: string) =>
|
||||||
|
textInputRule({
|
||||||
|
find: /(?:^|\s)(3\/4)\s$/,
|
||||||
|
replace: override ?? "¾",
|
||||||
|
});
|
@ -42,15 +42,6 @@ export function CoreEditorProps(
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
handleDrop: (view, event, _slice, moved) => {
|
handleDrop: (view, event, _slice, moved) => {
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const selection: any = window?.getSelection();
|
|
||||||
if (selection.rangeCount !== 0) {
|
|
||||||
const range = selection.getRangeAt(0);
|
|
||||||
if (findTableAncestor(range.startContainer)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
|
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const file = event.dataTransfer.files[0];
|
const file = event.dataTransfer.files[0];
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/document-editor",
|
"name": "@plane/document-editor",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"description": "Package that powers Plane's Pages Editor",
|
"description": "Package that powers Plane's Pages Editor",
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
@ -37,7 +37,6 @@
|
|||||||
"@tiptap/extension-placeholder": "^2.1.13",
|
"@tiptap/extension-placeholder": "^2.1.13",
|
||||||
"@tiptap/pm": "^2.1.13",
|
"@tiptap/pm": "^2.1.13",
|
||||||
"@tiptap/suggestion": "^2.1.13",
|
"@tiptap/suggestion": "^2.1.13",
|
||||||
"eslint-config-next": "13.2.4",
|
|
||||||
"lucide-react": "^0.309.0",
|
"lucide-react": "^0.309.0",
|
||||||
"react-popper": "^2.3.0",
|
"react-popper": "^2.3.0",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
@ -47,7 +46,7 @@
|
|||||||
"@types/node": "18.15.3",
|
"@types/node": "18.15.3",
|
||||||
"@types/react": "^18.2.42",
|
"@types/react": "^18.2.42",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
"eslint": "8.36.0",
|
"eslint-config-custom": "*",
|
||||||
"postcss": "^8.4.29",
|
"postcss": "^8.4.29",
|
||||||
"tailwind-config-custom": "*",
|
"tailwind-config-custom": "*",
|
||||||
"tsconfig": "*",
|
"tsconfig": "*",
|
||||||
|
@ -15,7 +15,7 @@ export const ContentBrowser = (props: ContentBrowserProps) => {
|
|||||||
const handleOnClick = (marking: IMarking) => {
|
const handleOnClick = (marking: IMarking) => {
|
||||||
scrollSummary(editor, marking);
|
scrollSummary(editor, marking);
|
||||||
if (setSidePeekVisible) setSidePeekVisible(false);
|
if (setSidePeekVisible) setSidePeekVisible(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
@ -40,9 +40,11 @@ export const LinkEditView = ({
|
|||||||
const [positionRef, setPositionRef] = useState({ from: from, to: to });
|
const [positionRef, setPositionRef] = useState({ from: from, to: to });
|
||||||
const [localUrl, setLocalUrl] = useState(viewProps.url);
|
const [localUrl, setLocalUrl] = useState(viewProps.url);
|
||||||
|
|
||||||
const linkRemoved = useRef<Boolean>();
|
const linkRemoved = useRef<boolean>();
|
||||||
|
|
||||||
const getText = (from: number, to: number) => {
|
const getText = (from: number, to: number) => {
|
||||||
|
if (to >= editor.state.doc.content.size) return "";
|
||||||
|
|
||||||
const text = editor.state.doc.textBetween(from, to, "\n");
|
const text = editor.state.doc.textBetween(from, to, "\n");
|
||||||
return text;
|
return text;
|
||||||
};
|
};
|
||||||
@ -72,10 +74,12 @@ export const LinkEditView = ({
|
|||||||
|
|
||||||
const url = isValidUrl(localUrl) ? localUrl : viewProps.url;
|
const url = isValidUrl(localUrl) ? localUrl : viewProps.url;
|
||||||
|
|
||||||
|
if (to >= editor.state.doc.content.size) return;
|
||||||
|
|
||||||
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
|
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
|
||||||
editor.view.dispatch(editor.state.tr.addMark(from, to, editor.schema.marks.link.create({ href: url })));
|
editor.view.dispatch(editor.state.tr.addMark(from, to, editor.schema.marks.link.create({ href: url })));
|
||||||
},
|
},
|
||||||
[localUrl]
|
[localUrl, editor, from, to, viewProps.url]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpdateText = (text: string) => {
|
const handleUpdateText = (text: string) => {
|
||||||
|
@ -33,8 +33,9 @@ export const SummaryPopover: React.FC<Props> = (props) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={setReferenceElement}
|
ref={setReferenceElement}
|
||||||
className={`grid h-7 w-7 place-items-center rounded ${sidePeekVisible ? "bg-custom-primary-100/20 text-custom-primary-100" : "text-custom-text-300"
|
className={`grid h-7 w-7 place-items-center rounded ${
|
||||||
}`}
|
sidePeekVisible ? "bg-custom-primary-100/20 text-custom-primary-100" : "text-custom-text-300"
|
||||||
|
}`}
|
||||||
onClick={() => setSidePeekVisible(!sidePeekVisible)}
|
onClick={() => setSidePeekVisible(!sidePeekVisible)}
|
||||||
>
|
>
|
||||||
<List className="h-4 w-4" />
|
<List className="h-4 w-4" />
|
||||||
|
@ -26,4 +26,3 @@ export const DocumentEditorExtensions = (
|
|||||||
}),
|
}),
|
||||||
IssueWidgetPlaceholder(),
|
IssueWidgetPlaceholder(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -145,7 +145,7 @@ const IssueSuggestionList = ({
|
|||||||
<div
|
<div
|
||||||
id="issue-list-container"
|
id="issue-list-container"
|
||||||
ref={commandListContainer}
|
ref={commandListContainer}
|
||||||
className=" fixed z-[10] max-h-80 w-60 overflow-y-auto overflow-x-hidden rounded-md border border-custom-border-100 bg-custom-background-100 px-1 shadow-custom-shadow-xs transition-all"
|
className=" fixed z-[10] max-h-80 w-96 overflow-y-auto overflow-x-hidden rounded-md border border-custom-border-100 bg-custom-background-100 px-1 shadow-custom-shadow-xs transition-all"
|
||||||
>
|
>
|
||||||
{sections.map((section) => {
|
{sections.map((section) => {
|
||||||
const sectionItems = displayedItems[section];
|
const sectionItems = displayedItems[section];
|
||||||
@ -175,8 +175,8 @@ const IssueSuggestionList = ({
|
|||||||
>
|
>
|
||||||
<h5 className="whitespace-nowrap text-xs text-custom-text-300">{item.identifier}</h5>
|
<h5 className="whitespace-nowrap text-xs text-custom-text-300">{item.identifier}</h5>
|
||||||
<PriorityIcon priority={item.priority} />
|
<PriorityIcon priority={item.priority} />
|
||||||
<div>
|
<div className="w-full truncate">
|
||||||
<p className="flex-grow truncate text-xs">{item.title}</p>
|
<p className="flex-grow w-full truncate text-xs">{item.title}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
@ -48,34 +48,12 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
|||||||
function getComplexItems(): BubbleMenuItem[] {
|
function getComplexItems(): BubbleMenuItem[] {
|
||||||
const items: BubbleMenuItem[] = [TableItem(editor)];
|
const items: BubbleMenuItem[] = [TableItem(editor)];
|
||||||
|
|
||||||
if (shouldShowImageItem()) {
|
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
|
||||||
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
const complexItems: BubbleMenuItem[] = getComplexItems();
|
const complexItems: BubbleMenuItem[] = getComplexItems();
|
||||||
|
|
||||||
function shouldShowImageItem(): boolean {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const selectionRange: any = window?.getSelection();
|
|
||||||
const { selection } = props.editor.state;
|
|
||||||
|
|
||||||
if (selectionRange.rangeCount !== 0) {
|
|
||||||
const range = selectionRange.getRangeAt(0);
|
|
||||||
if (findTableAncestor(range.startContainer)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (isCellSelection(selection)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
|
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
|
||||||
<div className="flex items-center gap-0.5 pr-2">
|
<div className="flex items-center gap-0.5 pr-2">
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/editor-extensions",
|
"name": "@plane/editor-extensions",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"description": "Package that powers Plane's Editor with extensions",
|
"description": "Package that powers Plane's Editor with extensions",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
@ -33,7 +33,6 @@
|
|||||||
"@tiptap/pm": "^2.1.13",
|
"@tiptap/pm": "^2.1.13",
|
||||||
"@tiptap/react": "^2.1.13",
|
"@tiptap/react": "^2.1.13",
|
||||||
"@tiptap/suggestion": "^2.1.13",
|
"@tiptap/suggestion": "^2.1.13",
|
||||||
"eslint-config-next": "13.2.4",
|
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"tippy.js": "^6.3.7"
|
"tippy.js": "^6.3.7"
|
||||||
},
|
},
|
||||||
@ -41,7 +40,7 @@
|
|||||||
"@types/node": "18.15.3",
|
"@types/node": "18.15.3",
|
||||||
"@types/react": "^18.2.42",
|
"@types/react": "^18.2.42",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
"eslint": "8.36.0",
|
"eslint-config-custom": "*",
|
||||||
"postcss": "^8.4.29",
|
"postcss": "^8.4.29",
|
||||||
"tailwind-config-custom": "*",
|
"tailwind-config-custom": "*",
|
||||||
"tsconfig": "*",
|
"tsconfig": "*",
|
||||||
|
@ -35,7 +35,7 @@ export interface DragHandleOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function absoluteRect(node: Element) {
|
function absoluteRect(node: Element) {
|
||||||
const data = node.getBoundingClientRect();
|
const data = node?.getBoundingClientRect();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top: data.top,
|
top: data.top,
|
||||||
@ -65,7 +65,7 @@ function nodeDOMAtCoords(coords: { x: number; y: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function nodePosAtDOM(node: Element, view: EditorView) {
|
function nodePosAtDOM(node: Element, view: EditorView) {
|
||||||
const boundingRect = node.getBoundingClientRect();
|
const boundingRect = node?.getBoundingClientRect();
|
||||||
|
|
||||||
if (node.nodeName === "IMG") {
|
if (node.nodeName === "IMG") {
|
||||||
return view.posAtCoords({
|
return view.posAtCoords({
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/lite-text-editor",
|
"name": "@plane/lite-text-editor",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"description": "Package that powers Plane's Comment Editor",
|
"description": "Package that powers Plane's Comment Editor",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
@ -36,10 +36,9 @@
|
|||||||
"@types/node": "18.15.3",
|
"@types/node": "18.15.3",
|
||||||
"@types/react": "^18.2.42",
|
"@types/react": "^18.2.42",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
"eslint": "^7.32.0",
|
"eslint-config-custom": "*",
|
||||||
"postcss": "^8.4.29",
|
"postcss": "^8.4.29",
|
||||||
"tailwind-config-custom": "*",
|
"tailwind-config-custom": "*",
|
||||||
"eslint-config-custom": "*",
|
|
||||||
"tsconfig": "*",
|
"tsconfig": "*",
|
||||||
"tsup": "^7.2.0",
|
"tsup": "^7.2.0",
|
||||||
"typescript": "4.9.5"
|
"typescript": "4.9.5"
|
||||||
|
@ -60,34 +60,13 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
|||||||
function getComplexItems(): BubbleMenuItem[] {
|
function getComplexItems(): BubbleMenuItem[] {
|
||||||
const items: BubbleMenuItem[] = [TableItem(props.editor)];
|
const items: BubbleMenuItem[] = [TableItem(props.editor)];
|
||||||
|
|
||||||
if (shouldShowImageItem()) {
|
items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting));
|
||||||
items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting));
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
const complexItems: BubbleMenuItem[] = getComplexItems();
|
const complexItems: BubbleMenuItem[] = getComplexItems();
|
||||||
|
|
||||||
function shouldShowImageItem(): boolean {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const selectionRange: any = window?.getSelection();
|
|
||||||
const { selection } = props.editor.state;
|
|
||||||
|
|
||||||
if (selectionRange.rangeCount !== 0) {
|
|
||||||
const range = selectionRange.getRangeAt(0);
|
|
||||||
if (findTableAncestor(range.startContainer)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (isCellSelection(selection)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAccessChange = (accessKey: string) => {
|
const handleAccessChange = (accessKey: string) => {
|
||||||
props.commentAccessSpecifier?.onAccessChange(accessKey);
|
props.commentAccessSpecifier?.onAccessChange(accessKey);
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/rich-text-editor",
|
"name": "@plane/rich-text-editor",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"description": "Rich Text Editor that powers Plane",
|
"description": "Rich Text Editor that powers Plane",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
@ -39,7 +39,7 @@
|
|||||||
"@types/node": "18.15.3",
|
"@types/node": "18.15.3",
|
||||||
"@types/react": "^18.2.42",
|
"@types/react": "^18.2.42",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
"eslint": "^7.32.0",
|
"eslint-config-custom": "*",
|
||||||
"postcss": "^8.4.29",
|
"postcss": "^8.4.29",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"tailwind-config-custom": "*",
|
"tailwind-config-custom": "*",
|
||||||
|
@ -15,6 +15,7 @@ import { EditorBubbleMenu } from "src/ui/menus/bubble-menu";
|
|||||||
|
|
||||||
export type IRichTextEditor = {
|
export type IRichTextEditor = {
|
||||||
value: string;
|
value: string;
|
||||||
|
initialValue?: string;
|
||||||
dragDropEnabled?: boolean;
|
dragDropEnabled?: boolean;
|
||||||
uploadFile: UploadImage;
|
uploadFile: UploadImage;
|
||||||
restoreFile: RestoreImage;
|
restoreFile: RestoreImage;
|
||||||
@ -54,6 +55,7 @@ const RichTextEditor = ({
|
|||||||
setShouldShowAlert,
|
setShouldShowAlert,
|
||||||
editorContentCustomClassNames,
|
editorContentCustomClassNames,
|
||||||
value,
|
value,
|
||||||
|
initialValue,
|
||||||
uploadFile,
|
uploadFile,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
noBorder,
|
noBorder,
|
||||||
@ -97,6 +99,10 @@ const RichTextEditor = ({
|
|||||||
customClassName,
|
customClassName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue);
|
||||||
|
}, [editor, initialValue]);
|
||||||
|
|
||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,22 +1,43 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ["next", "turbo", "prettier"],
|
extends: [
|
||||||
|
"next",
|
||||||
|
"turbo",
|
||||||
|
"prettier",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
],
|
||||||
parser: "@typescript-eslint/parser",
|
parser: "@typescript-eslint/parser",
|
||||||
plugins: ["react", "@typescript-eslint"],
|
parserOptions: {
|
||||||
|
ecmaVersion: 2021, // Or the ECMAScript version you are using
|
||||||
|
sourceType: "module", // Or 'script' if you're using CommonJS or other modules
|
||||||
|
},
|
||||||
|
plugins: ["react", "@typescript-eslint", "import"],
|
||||||
settings: {
|
settings: {
|
||||||
next: {
|
next: {
|
||||||
rootDir: ["web/", "space/", "packages/*/"],
|
rootDir: ["web/", "space/", "packages/*/"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"@next/next/no-html-link-for-pages": "off",
|
|
||||||
"react/jsx-key": "off",
|
|
||||||
"prefer-const": "error",
|
"prefer-const": "error",
|
||||||
"no-irregular-whitespace": "error",
|
"no-irregular-whitespace": "error",
|
||||||
"no-trailing-spaces": "error",
|
"no-trailing-spaces": "error",
|
||||||
"no-duplicate-imports": "error",
|
"no-duplicate-imports": "error",
|
||||||
"arrow-body-style": ["error", "as-needed"],
|
"arrow-body-style": ["error", "as-needed"],
|
||||||
"react/self-closing-comp": ["error", { component: true, html: true }],
|
"@next/next/no-html-link-for-pages": "off",
|
||||||
"@next/next/no-img-element": "off",
|
"@next/next/no-img-element": "off",
|
||||||
"@typescript-eslint/no-unused-vars": ["warn"],
|
"react/jsx-key": "error",
|
||||||
|
"react/self-closing-comp": ["error", { component: true, html: true }],
|
||||||
|
"react/jsx-boolean-value": "error",
|
||||||
|
"react/jsx-no-duplicate-props": "error",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error"],
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"@typescript-eslint/no-useless-empty-export": "error",
|
||||||
|
"@typescript-eslint/prefer-ts-expect-error": "error",
|
||||||
|
"@typescript-eslint/naming-convention": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
selector: ["function", "variable"],
|
||||||
|
format: ["camelCase", "snake_case", "UPPER_CASE", "PascalCase"],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -1,21 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "eslint-config-custom",
|
"name": "eslint-config-custom",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"devDependencies": {},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eslint": "^7.23.0",
|
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||||
"eslint-config-next": "13.0.0",
|
"@typescript-eslint/parser": "^7.1.1",
|
||||||
"eslint-config-prettier": "^8.3.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-turbo": "latest",
|
"eslint-config-next": "^14.1.0",
|
||||||
"eslint-plugin-react": "7.31.8"
|
"eslint-config-prettier": "^9.1.0",
|
||||||
},
|
"eslint-config-turbo": "^1.12.4",
|
||||||
"devDependencies": {
|
"eslint-plugin-import": "^2.29.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.13.2",
|
"eslint-plugin-react": "^7.33.2",
|
||||||
"typescript": "^4.7.4"
|
"typescript": "^5.3.3"
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tailwind-config-custom",
|
"name": "tailwind-config-custom",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"description": "common tailwind configuration across monorepo",
|
"description": "common tailwind configuration across monorepo",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
@ -198,6 +198,31 @@ module.exports = {
|
|||||||
300: convertToRGB("--color-onboarding-border-300"),
|
300: convertToRGB("--color-onboarding-border-300"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
toast: {
|
||||||
|
text: {
|
||||||
|
success: convertToRGB("--color-toast-success-text"),
|
||||||
|
error: convertToRGB("--color-toast-error-text"),
|
||||||
|
warning: convertToRGB("--color-toast-warning-text"),
|
||||||
|
info: convertToRGB("--color-toast-info-text"),
|
||||||
|
loading: convertToRGB("--color-toast-loading-text"),
|
||||||
|
secondary: convertToRGB("--color-toast-secondary-text"),
|
||||||
|
tertiary: convertToRGB("--color-toast-tertiary-text"),
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
success: convertToRGB("--color-toast-success-background"),
|
||||||
|
error: convertToRGB("--color-toast-error-background"),
|
||||||
|
warning: convertToRGB("--color-toast-warning-background"),
|
||||||
|
info: convertToRGB("--color-toast-info-background"),
|
||||||
|
loading: convertToRGB("--color-toast-loading-background"),
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
success: convertToRGB("--color-toast-success-border"),
|
||||||
|
error: convertToRGB("--color-toast-error-border"),
|
||||||
|
warning: convertToRGB("--color-toast-warning-border"),
|
||||||
|
info: convertToRGB("--color-toast-info-border"),
|
||||||
|
loading: convertToRGB("--color-toast-loading-border"),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
leftToaster: {
|
leftToaster: {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tsconfig",
|
"name": "tsconfig",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"files": [
|
"files": [
|
||||||
"base.json",
|
"base.json",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/types",
|
"name": "@plane/types",
|
||||||
"version": "0.15.1",
|
"version": "0.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./src/index.d.ts"
|
"main": "./src/index.d.ts"
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user