Merge pull request #3878 from makeplane/preview

release: v0.16-dev
This commit is contained in:
sriram veeraghanta 2024-03-05 20:04:08 +05:30 committed by GitHub
commit c1b1ba35c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
484 changed files with 13098 additions and 9446 deletions

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -21,7 +21,6 @@ jobs:
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: 18.x node-version: 18.x
cache: "yarn"
- name: Get changed files - name: Get changed files
id: changed-files id: changed-files

View File

@ -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

151
README.md
View File

@ -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,93 @@
</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).
## ⚡️ Contributors Quick Start
### Prerequisite ## ⚡ Installation
Development system must have docker engine installed and running. 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.
### Steps 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).
Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute | 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) |
1. Clone the code locally using `git clone https://github.com/makeplane/plane.git` `Instance admin` can configure instance settings using our [God-mode](https://docs.plane.so/instance-admin) feature.
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 +135,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 +143,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 +152,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 +161,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 +171,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 +179,22 @@ 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>

View File

@ -1,4 +1,4 @@
{ {
"name": "plane-api", "name": "plane-api",
"version": "0.15.1" "version": "0.16.0"
} }

View File

@ -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")

View File

@ -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"))

View File

@ -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")

View File

@ -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,6 +35,7 @@ class StateAPIEndpoint(BaseAPIView):
) )
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
try:
serializer = StateSerializer( serializer = StateSerializer(
data=request.data, context={"project_id": project_id} data=request.data, context={"project_id": project_id}
) )
@ -65,6 +67,19 @@ class StateAPIEndpoint(BaseAPIView):
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:

View File

@ -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,

View File

@ -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:

View File

@ -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)

View File

@ -444,6 +444,22 @@ class IssueLinkSerializer(BaseSerializer):
return IssueLink.objects.create(**validated_data) return IssueLink.objects.create(**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:
model = IssueAttachment model = IssueAttachment
@ -459,6 +475,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 +504,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 +546,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 +599,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 +617,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 +643,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 class IssueDetailSerializer(IssueSerializer):
return [module for module in obj.issue_module.values_list("module_id", flat=True)] description_html = serializers.CharField()
is_subscribed = serializers.BooleanField(read_only=True)
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + [
"description_html",
"is_subscribed",
]
class IssueLiteSerializer(DynamicBaseSerializer):
class Meta:
model = Issue
fields = [
"id",
"sequence_id",
"project_id",
]
read_only_fields = fields
class IssueDetailSerializer(IssueSerializer): class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField() description_html = serializers.CharField()
is_subscribed = serializers.BooleanField()
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):
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:
model = Issue
fields = "__all__"
read_only_fields = [
"start_date",
"target_date",
"completed_at",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
] ]
read_only_fields = fields
class IssuePublicSerializer(BaseSerializer): class IssuePublicSerializer(BaseSerializer):

View File

@ -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):

View File

@ -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

View File

@ -22,6 +22,8 @@ from plane.app.views import (
WorkspaceUserPropertiesEndpoint, WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint, WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint, WorkspaceEstimatesEndpoint,
WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint,
) )
@ -219,4 +221,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",
),
] ]

View File

@ -49,6 +49,8 @@ from .workspace import (
WorkspaceUserPropertiesEndpoint, WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint, WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint, WorkspaceEstimatesEndpoint,
WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint,
) )
from .state import StateViewSet from .state import StateViewSet
from .view import ( from .view import (
@ -67,6 +69,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,

View File

@ -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("*"))

View File

@ -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",

View File

@ -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
).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(serializer.data, status=status.HTTP_200_OK) 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 = []
for issue in issues:
cycle_issue = [
cycle_issue
for cycle_issue in cycle_issues
if str(cycle_issue.issue_id) in issues
]
# Update only when cycle changes
if len(cycle_issue):
if cycle_issue[0].cycle_id != cycle_id:
update_cycle_issue_activity.append(
{
"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]) existing_issues = [
else: str(cycle_issue.issue_id) for cycle_issue in cycle_issues
record_to_create.append( ]
new_issues = list(set(issues) - set(existing_issues))
# New issues to create
created_records = CycleIssue.objects.bulk_create(
[
CycleIssue( CycleIssue(
project_id=project_id, project_id=project_id,
workspace=cycle.workspace, workspace_id=cycle.workspace_id,
created_by=request.user, created_by_id=request.user.id,
updated_by=request.user, updated_by_id=request.user.id,
cycle=cycle, cycle_id=cycle_id,
issue_id=issue, 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,

View File

@ -15,6 +15,10 @@ from django.db.models import (
Func, Func,
Prefetch, Prefetch,
) )
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
@ -54,6 +58,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 +135,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
@ -259,6 +289,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")
) )

View File

@ -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):

View File

@ -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

View File

@ -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
for field in request.GET.get("fields", "").split(",")
if field
]
modules = ModuleSerializer( modules = ModuleSerializer(
queryset, many=True, fields=fields if fields else None queryset,
many=True,
fields=self.fields,
).data ).data
else:
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:
issues = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None issue_queryset, many=True, fields=fields if fields else None
).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(serializer.data, status=status.HTTP_200_OK) 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()
) )

View File

@ -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")

View File

@ -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,

View File

@ -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":

View File

@ -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")

View File

@ -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
).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(serializer.data, status=status.HTTP_200_OK) 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))

View File

@ -22,9 +22,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 +78,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 +93,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 +560,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 +767,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 +776,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 +1086,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 +1102,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 +1125,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 +1137,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 +1149,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 +1161,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 +1172,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 +1222,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")
@ -1234,6 +1253,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(
@ -1343,6 +1363,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 +1391,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 +1495,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 +1510,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 +1539,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,

View File

@ -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,11 +154,20 @@ 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:
if acquire_lock(lock_id=lock_id):
# get the redis instance
ri = redis_instance() ri = redis_instance()
base_api = (ri.get(str(issue_id)).decode()) base_api = (ri.get(str(issue_id)).decode())
data = create_payload(notification_data=notification_data) data = create_payload(notification_data=notification_data)
@ -268,9 +289,20 @@ def send_email_notification(
EmailNotificationLog.objects.filter( EmailNotificationLog.objects.filter(
pk__in=email_notification_ids pk__in=email_notification_ids
).update(sent_at=timezone.now()) ).update(sent_at=timezone.now())
# release the lock
release_lock(lock_id=lock_id)
return return
except Exception as e: except Exception as e:
capture_exception(e)
# release the lock
release_lock(lock_id=lock_id)
return
else:
print("Duplicate task recived. Skipping...")
return
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

View File

@ -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"

View File

@ -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()

View File

@ -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,
) )
) )

View File

@ -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,

View File

@ -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):

View File

@ -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)
),
) )

View File

@ -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:

View File

@ -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

View File

@ -1 +1 @@
python-3.11.7 python-3.11.8

78
deploy/1-click/README.md Normal file
View File

@ -0,0 +1,78 @@
# 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View File

@ -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

View File

@ -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,12 +484,23 @@ 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 ""
@ -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 ""
@ -602,6 +607,11 @@ function uninstall() {
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

View File

@ -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

View File

@ -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,9 +190,89 @@ 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() {
local DEFAULT_ACTION=$1
if [ -z "$DEFAULT_ACTION" ];
then
echo echo
echo "Select a Action you want to perform:" echo "Select a Action you want to perform:"
echo " 1) Install (${CPU_ARCH})" echo " 1) Install (${CPU_ARCH})"
@ -154,37 +280,47 @@ function askForAction() {
echo " 3) Stop" echo " 3) Stop"
echo " 4) Restart" echo " 4) Restart"
echo " 5) Upgrade" echo " 5) Upgrade"
echo " 6) Exit" echo " 6) View Logs"
echo " 7) Exit"
echo echo
read -p "Action [2]: " ACTION read -p "Action [2]: " ACTION
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do until [[ -z "$ACTION" || "$ACTION" =~ ^[1-7]$ ]]; do
echo "$ACTION: invalid selection." echo "$ACTION: invalid selection."
read -p "Action [2]: " ACTION read -p "Action [2]: " ACTION
done done
if [ -z "$ACTION" ];
then
ACTION=2
fi
echo echo
fi
if [ "$ACTION" == "1" ] || [ "$DEFAULT_ACTION" == "install" ]
if [ "$ACTION" == "1" ]
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 $@

View File

@ -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": [

View File

@ -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",

View File

@ -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) => {

View File

@ -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;

View File

@ -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,6 +63,35 @@
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;
@ -69,7 +99,7 @@
bottom: -2px; bottom: -2px;
width: 4px; width: 4px;
z-index: 99; z-index: 99;
background-color: rgba(var(--color-primary-400)); background-color: #d9e4ff;
pointer-events: none; pointer-events: none;
} }
@ -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;
@ -173,9 +205,7 @@
.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 {

View File

@ -25,7 +25,8 @@ 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";
export const CoreEditorExtensions = ( export const CoreEditorExtensions = (
mentionConfig: { mentionConfig: {
@ -79,6 +80,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",

View File

@ -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,
]; ];

View File

@ -33,7 +33,7 @@ export const TableHeader = Node.create<TableHeaderOptions>({
}, },
}, },
background: { background: {
default: "rgb(var(--color-primary-100))", default: "none",
}, },
}; };
}, },

View File

@ -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];
}, },
}); });

View File

@ -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}

View File

@ -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,57 @@ 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) => {
if (item.label === "Pick color") {
return h("div", { className: "flex flex-col" }, [
h("div", { className: "divider" }),
h("div", { className: "colorPickerLabel" }, item.label),
h( h(
"div",
{ className: "colorPicker grid" },
Object.entries(colors).map(([colorName, colorValue]) =>
h("div", {
className: "colorPickerItem",
style: `background-color: ${colorValue.backgroundColor};
color: ${colorValue.textColor || "inherit"};`,
innerHTML: colorValue?.icon || "",
onClick: () => onSelectColor(colorValue),
})
)
),
h("div", { className: "divider" }),
]);
} else {
return h(
"div", "div",
{ {
className: "toolboxItem", className: "toolboxItem",
itemType: "button", itemType: "div",
onClick() { onClick: () => onClickItem(item),
onClickItem(item);
},
}, },
[ [
h("div", { h("div", { className: "iconContainer", innerHTML: item.icon }),
className: "iconContainer",
innerHTML: item.icon,
}),
h("div", { className: "label" }, item.label), h("div", { className: "label" }, item.label),
] ]
) );
) }
})
), ),
...tippyOptions, ...tippyOptions,
}); });
@ -214,71 +245,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 +313,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 +351,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 +368,6 @@ export class TableView implements NodeView {
}); });
} }
// Table
this.colgroup = h( this.colgroup = h(
"colgroup", "colgroup",
null, null,
@ -437,7 +420,8 @@ 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(
(acc, curr) => {
if (curr.spec.hoveredCell !== undefined) { if (curr.spec.hoveredCell !== undefined) {
acc["hoveredCell"] = curr.spec.hoveredCell; acc["hoveredCell"] = curr.spec.hoveredCell;
} }
@ -446,7 +430,9 @@ export class TableView implements NodeView {
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 +443,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`;

View File

@ -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;

View 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;
},
});

View 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 ?? "¾",
});

View File

@ -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];

View File

@ -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",

View File

@ -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) => {

View File

@ -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>
))} ))}

View File

@ -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">

View File

@ -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",

View File

@ -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({

View File

@ -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",

View File

@ -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);
}; };

View File

@ -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",

View File

@ -1,7 +1,7 @@
{ {
"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",
"dependencies": { "dependencies": {

View File

@ -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,

View File

@ -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",

View File

@ -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"
} }

View File

@ -30,10 +30,9 @@ export interface ICycle {
is_favorite: boolean; is_favorite: boolean;
issue: string; issue: string;
name: string; name: string;
owned_by: string; owned_by_id: string;
progress_snapshot: TProgressSnapshot; progress_snapshot: TProgressSnapshot;
project: string; project_id: string;
project_detail: IProjectLite;
status: TCycleGroups; status: TCycleGroups;
sort_order: number; sort_order: number;
start_date: string | null; start_date: string | null;
@ -42,12 +41,11 @@ export interface ICycle {
unstarted_issues: number; unstarted_issues: number;
updated_at: Date; updated_at: Date;
updated_by: string; updated_by: string;
assignees: IUserLite[]; assignee_ids: string[];
view_props: { view_props: {
filters: IIssueFilterOptions; filters: IIssueFilterOptions;
}; };
workspace: string; workspace_id: string;
workspace_detail: IWorkspaceLite;
} }
export type TProgressSnapshot = { export type TProgressSnapshot = {

View File

@ -58,7 +58,6 @@ export interface IIssueLink {
export interface ILinkDetails { export interface ILinkDetails {
created_at: Date; created_at: Date;
created_by: string; created_by: string;
created_by_detail: IUserLite;
id: string; id: string;
metadata: any; metadata: any;
title: string; title: string;
@ -204,6 +203,8 @@ export interface ViewFlags {
export type GroupByColumnTypes = export type GroupByColumnTypes =
| "project" | "project"
| "cycle"
| "module"
| "state" | "state"
| "state_detail.group" | "state_detail.group"
| "priority" | "priority"

View File

@ -1,4 +1,7 @@
import { TIssuePriorities } from "../issues"; import { TIssuePriorities } from "../issues";
import { TIssueAttachment } from "./issue_attachment";
import { TIssueLink } from "./issue_link";
import { TIssueReaction } from "./issue_reaction";
// new issue structure types // new issue structure types
export type TIssue = { export type TIssue = {
@ -34,7 +37,12 @@ export type TIssue = {
updated_by: string; updated_by: string;
is_draft: boolean; is_draft: boolean;
is_subscribed: boolean; is_subscribed?: boolean;
parent?: partial<TIssue>;
issue_reactions?: TIssueReaction[];
issue_attachment?: TIssueAttachment[];
issue_link?: TIssueLink[];
// tempId is used for optimistic updates. It is not a part of the API response. // tempId is used for optimistic updates. It is not a part of the API response.
tempId?: string; tempId?: string;

View File

@ -1,17 +1,15 @@
export type TIssueAttachment = { export type TIssueAttachment = {
id: string; id: string;
created_at: string;
updated_at: string;
attributes: { attributes: {
name: string; name: string;
size: number; size: number;
}; };
asset: string; asset: string;
created_by: string; issue_id: string;
//need
updated_at: string;
updated_by: string; updated_by: string;
project: string;
workspace: string;
issue: string;
}; };
export type TIssueAttachmentMap = { export type TIssueAttachmentMap = {

View File

@ -4,11 +4,13 @@ export type TIssueLinkEditableFields = {
}; };
export type TIssueLink = TIssueLinkEditableFields & { export type TIssueLink = TIssueLinkEditableFields & {
created_at: Date; created_by_id: string;
created_by: string;
created_by_detail: IUserLite;
id: string; id: string;
metadata: any; metadata: any;
issue_id: string;
//need
created_at: Date;
}; };
export type TIssueLinkMap = { export type TIssueLinkMap = {

View File

@ -1,15 +1,8 @@
export type TIssueReaction = { export type TIssueReaction = {
actor: string; actor_id: string;
actor_detail: IUserLite;
created_at: Date;
created_by: string;
id: string; id: string;
issue: string; issue_id: string;
project: string;
reaction: string; reaction: string;
updated_at: Date;
updated_by: string;
workspace: string;
}; };
export type TIssueReactionMap = { export type TIssueReactionMap = {

View File

@ -27,16 +27,12 @@ export interface IModule {
labels: TLabelsDistribution[]; labels: TLabelsDistribution[];
}; };
id: string; id: string;
lead: string | null; lead_id: string | null;
lead_detail: IUserLite | null;
link_module: ILinkDetails[]; link_module: ILinkDetails[];
links_list: ModuleLink[]; member_ids: string[];
members: string[];
members_detail: IUserLite[];
is_favorite: boolean; is_favorite: boolean;
name: string; name: string;
project: string; project_id: string;
project_detail: IProjectLite;
sort_order: number; sort_order: number;
start_date: string | null; start_date: string | null;
started_issues: number; started_issues: number;
@ -49,8 +45,7 @@ export interface IModule {
view_props: { view_props: {
filters: IIssueFilterOptions; filters: IIssueFilterOptions;
}; };
workspace: string; workspace_id: string;
workspace_detail: IWorkspaceLite;
} }
export interface ModuleIssueResponse { export interface ModuleIssueResponse {

View File

@ -12,27 +12,27 @@ export interface PaginatedUserNotification {
} }
export interface IUserNotification { export interface IUserNotification {
id: string; archived_at: string | null;
created_at: Date; created_at: string;
updated_at: Date; created_by: null;
data: Data; data: Data;
entity_identifier: string; entity_identifier: string;
entity_name: string; entity_name: string;
title: string; id: string;
message: null; message: null;
message_html: string; message_html: string;
message_stripped: null; message_stripped: null;
sender: string;
read_at: Date | null;
archived_at: Date | null;
snoozed_till: Date | null;
created_by: null;
updated_by: null;
workspace: string;
project: string; project: string;
read_at: Date | null;
receiver: string;
sender: string;
snoozed_till: Date | null;
title: string;
triggered_by: string; triggered_by: string;
triggered_by_details: IUserLite; triggered_by_details: IUserLite;
receiver: string; updated_at: Date;
updated_by: null;
workspace: string;
} }
export interface Data { export interface Data {

View File

@ -14,6 +14,8 @@ export type TIssueGroupByOptions =
| "project" | "project"
| "assignees" | "assignees"
| "mentions" | "mentions"
| "cycle"
| "module"
| null; | null;
export type TIssueOrderByOptions = export type TIssueOrderByOptions =
@ -30,6 +32,10 @@ export type TIssueOrderByOptions =
| "-assignees__first_name" | "-assignees__first_name"
| "labels__name" | "labels__name"
| "-labels__name" | "-labels__name"
| "modules__name"
| "-modules__name"
| "cycle__name"
| "-cycle__name"
| "target_date" | "target_date"
| "-target_date" | "-target_date"
| "estimate_point" | "estimate_point"
@ -56,6 +62,8 @@ export type TIssueParams =
| "created_by" | "created_by"
| "subscriber" | "subscriber"
| "labels" | "labels"
| "cycle"
| "module"
| "start_date" | "start_date"
| "target_date" | "target_date"
| "project" | "project"
@ -75,6 +83,8 @@ export interface IIssueFilterOptions {
labels?: string[] | null; labels?: string[] | null;
priority?: string[] | null; priority?: string[] | null;
project?: string[] | null; project?: string[] | null;
cycle?: string[] | null;
module?: string[] | null;
start_date?: string[] | null; start_date?: string[] | null;
state?: string[] | null; state?: string[] | null;
state_group?: string[] | null; state_group?: string[] | null;
@ -109,6 +119,8 @@ export interface IIssueDisplayProperties {
estimate?: boolean; estimate?: boolean;
created_on?: boolean; created_on?: boolean;
updated_on?: boolean; updated_on?: boolean;
modules?: boolean;
cycle?: boolean;
} }
export type TIssueKanbanFilters = { export type TIssueKanbanFilters = {

View File

@ -2,7 +2,7 @@
"name": "@plane/ui", "name": "@plane/ui",
"description": "UI components shared across multiple apps internally", "description": "UI components shared across multiple apps internally",
"private": true, "private": true,
"version": "0.15.1", "version": "0.16.0",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",

View File

@ -5,10 +5,11 @@ export type TControlLink = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
onClick: () => void; onClick: () => void;
children: React.ReactNode; children: React.ReactNode;
target?: string; target?: string;
disabled?: boolean;
}; };
export const ControlLink: React.FC<TControlLink> = (props) => { export const ControlLink: React.FC<TControlLink> = (props) => {
const { href, onClick, children, target = "_self", ...rest } = props; const { href, onClick, children, target = "_self", disabled = false, ...rest } = props;
const LEFT_CLICK_EVENT_CODE = 0; const LEFT_CLICK_EVENT_CODE = 0;
const _onClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => { const _onClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
@ -19,6 +20,8 @@ export const ControlLink: React.FC<TControlLink> = (props) => {
} }
}; };
if (disabled) return <>{children}</>;
return ( return (
<a href={href} target={target} onClick={_onClick} {...rest}> <a href={href} target={target} onClick={_onClick} {...rest}>
{children} {children}

View File

@ -27,6 +27,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
noBorder = false, noBorder = false,
noChevron = false, noChevron = false,
optionsClassName = "", optionsClassName = "",
menuItemsClassName = "",
verticalEllipsis = false, verticalEllipsis = false,
portalElement, portalElement,
menuButtonOnClick, menuButtonOnClick,
@ -70,7 +71,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
useOutsideClickDetector(dropdownRef, closeDropdown); useOutsideClickDetector(dropdownRef, closeDropdown);
let menuItems = ( let menuItems = (
<Menu.Items className="fixed z-10" static> <Menu.Items className={cn("fixed z-10", menuItemsClassName)} static>
<div <div
className={cn( className={cn(
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap", "my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap",
@ -177,17 +178,18 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
}; };
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => { const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
const { children, onClick, className = "" } = props; const { children, disabled = false, onClick, className } = props;
return ( return (
<Menu.Item as="div"> <Menu.Item as="div" disabled={disabled}>
{({ active, close }) => ( {({ active, close }) => (
<button <button
type="button" type="button"
className={cn( className={cn(
"w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200", "w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200",
{ {
"bg-custom-background-80": active, "bg-custom-background-80": active && !disabled,
"text-custom-text-400": disabled,
}, },
className className
)} )}
@ -195,6 +197,7 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
close(); close();
onClick && onClick(e); onClick && onClick(e);
}} }}
disabled={disabled}
> >
{children} {children}
</button> </button>

View File

@ -122,7 +122,7 @@ const Option = (props: ICustomSelectItemProps) => {
value={value} value={value}
className={({ active }) => className={({ active }) =>
cn( cn(
"cursor-pointer select-none truncate rounded px-1 py-1.5 text-custom-text-200", "cursor-pointer select-none truncate rounded px-1 py-1.5 text-custom-text-200 flex items-center justify-between gap-2",
{ {
"bg-custom-background-80": active, "bg-custom-background-80": active,
}, },
@ -131,10 +131,10 @@ const Option = (props: ICustomSelectItemProps) => {
} }
> >
{({ selected }) => ( {({ selected }) => (
<div className="flex items-center justify-between gap-2"> <>
<div className="flex items-center gap-2">{children}</div> {children}
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />} {selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</div> </>
)} )}
</Listbox.Option> </Listbox.Option>
); );

View File

@ -24,6 +24,7 @@ export interface ICustomMenuDropdownProps extends IDropdownProps {
noBorder?: boolean; noBorder?: boolean;
verticalEllipsis?: boolean; verticalEllipsis?: boolean;
menuButtonOnClick?: (...args: any) => void; menuButtonOnClick?: (...args: any) => void;
menuItemsClassName?: string;
onMenuClose?: () => void; onMenuClose?: () => void;
closeOnSelect?: boolean; closeOnSelect?: boolean;
portalElement?: Element | null; portalElement?: Element | null;
@ -64,6 +65,7 @@ export type ICustomSearchSelectProps = IDropdownProps &
export interface ICustomMenuItemProps { export interface ICustomMenuItemProps {
children: React.ReactNode; children: React.ReactNode;
disabled?: boolean;
onClick?: (args?: any) => void; onClick?: (args?: any) => void;
className?: string; className?: string;
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "space", "name": "space",
"version": "0.15.1", "version": "0.16.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "turbo run develop", "dev": "turbo run develop",

View File

@ -20,7 +20,8 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
const projectDetails = projectId ? getProjectById(projectId.toString()) : undefined; const projectDetails = projectId ? getProjectById(projectId.toString()) : undefined;
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined; const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined;
const moduleLeadDetails = moduleDetails && moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined;
return ( return (
<> <>
@ -57,7 +58,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6> <h6 className="text-custom-text-200">Lead</h6>
<span>{moduleDetails.lead_detail?.display_name}</span> {moduleLeadDetails && <span>{moduleLeadDetails?.display_name}</span>}
</div> </div>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6> <h6 className="text-custom-text-200">Start Date</h6>

View File

@ -5,7 +5,7 @@ import { mutate } from "swr";
// services // services
import { AnalyticsService } from "services/analytics.service"; import { AnalyticsService } from "services/analytics.service";
// hooks // hooks
import { useCycle, useModule, useProject, useUser } from "hooks/store"; import { useCycle, useModule, useProject, useUser, useWorkspace } from "hooks/store";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { CustomAnalyticsSidebarHeader, CustomAnalyticsSidebarProjectsList } from "components/analytics"; import { CustomAnalyticsSidebarHeader, CustomAnalyticsSidebarProjectsList } from "components/analytics";
@ -39,6 +39,8 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
// store hooks // store hooks
const { currentUser } = useUser(); const { currentUser } = useUser();
const { workspaceProjectIds, getProjectById } = useProject(); const { workspaceProjectIds, getProjectById } = useProject();
const { getWorkspaceById } = useWorkspace();
const { fetchCycleDetails, getCycleById } = useCycle(); const { fetchCycleDetails, getCycleById } = useCycle();
const { fetchModuleDetails, getModuleById } = useModule(); const { fetchModuleDetails, getModuleById } = useModule();
@ -70,11 +72,14 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
if (cycleDetails || moduleDetails) { if (cycleDetails || moduleDetails) {
const details = cycleDetails || moduleDetails; const details = cycleDetails || moduleDetails;
eventPayload.workspaceId = details?.workspace_detail?.id; const currentProjectDetails = getProjectById(details?.project_id || "");
eventPayload.workspaceName = details?.workspace_detail?.name; const currentWorkspaceDetails = getWorkspaceById(details?.workspace_id || "");
eventPayload.projectId = details?.project_detail.id;
eventPayload.projectIdentifier = details?.project_detail.identifier; eventPayload.workspaceId = details?.workspace_id;
eventPayload.projectName = details?.project_detail.name; eventPayload.workspaceName = currentWorkspaceDetails?.name;
eventPayload.projectId = details?.project_id;
eventPayload.projectIdentifier = currentProjectDetails?.identifier;
eventPayload.projectName = currentProjectDetails?.name;
} }
if (cycleDetails) { if (cycleDetails) {
@ -138,14 +143,18 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds; const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds;
return ( return (
<div className={cn("relative h-full flex w-full gap-2 justify-between items-start px-5 py-4 bg-custom-sidebar-background-100", !isProjectLevel ? "flex-col" : "")} <div
className={cn(
"relative h-full flex w-full gap-2 justify-between items-start px-5 py-4 bg-custom-sidebar-background-100",
!isProjectLevel ? "flex-col" : ""
)}
> >
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200"> <div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
<LayersIcon height={14} width={14} /> <LayersIcon height={14} width={14} />
{analytics ? analytics.total : "..."} <div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div> {analytics ? analytics.total : "..."}
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div>
</div> </div>
{isProjectLevel && ( {isProjectLevel && (
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200"> <div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">

View File

@ -47,7 +47,7 @@ export const ScopeAndDemand: React.FC<Props> = (props) => {
<> <>
{!defaultAnalyticsError ? ( {!defaultAnalyticsError ? (
defaultAnalytics ? ( defaultAnalytics ? (
<div className="h-full overflow-y-auto p-5 text-sm"> <div className="h-full overflow-y-auto p-5 text-sm vertical-scrollbar scrollbar-lg">
<div className={`grid grid-cols-1 gap-5 ${fullScreen ? "md:grid-cols-2" : ""}`}> <div className={`grid grid-cols-1 gap-5 ${fullScreen ? "md:grid-cols-2" : ""}`}>
<AnalyticsDemand defaultAnalytics={defaultAnalytics} /> <AnalyticsDemand defaultAnalytics={defaultAnalytics} />
<AnalyticsScope defaultAnalytics={defaultAnalytics} /> <AnalyticsScope defaultAnalytics={defaultAnalytics} />

View File

@ -1,11 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { add } from "date-fns"; import { add } from "date-fns";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { DateDropdown } from "components/dropdowns";
import { Calendar } from "lucide-react"; import { Calendar } from "lucide-react";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components
import { CustomDatePicker } from "components/ui";
// ui // ui
import { Button, CustomSelect, Input, TextArea, ToggleSwitch } from "@plane/ui"; import { Button, CustomSelect, Input, TextArea, ToggleSwitch } from "@plane/ui";
// helpers // helpers
@ -167,7 +166,7 @@ export const CreateApiTokenForm: React.FC<Props> = (props) => {
<CustomSelect <CustomSelect
customButton={ customButton={
<div <div
className={`flex items-center gap-2 rounded border-[0.5px] border-custom-border-200 px-2 py-1 ${ className={`flex items-center gap-2 rounded border-[0.5px] border-custom-border-300 px-2 py-0.5 ${
neverExpires ? "text-custom-text-400" : "" neverExpires ? "text-custom-text-400" : ""
}`} }`}
> >
@ -194,20 +193,13 @@ export const CreateApiTokenForm: React.FC<Props> = (props) => {
}} }}
/> />
{watch("expired_at") === "custom" && ( {watch("expired_at") === "custom" && (
<CustomDatePicker <DateDropdown
value={customDate} value={customDate}
onChange={(date) => setCustomDate(date ? new Date(date) : null)} onChange={(date) => setCustomDate(date)}
minDate={tomorrow} minDate={tomorrow}
customInput={ icon={<Calendar className="h-3 w-3" />}
<div buttonVariant="border-with-text"
className={`flex cursor-pointer items-center gap-2 !rounded border-[0.5px] border-custom-border-200 px-2 py-1 text-xs !shadow-none !duration-0 ${ placeholder="Set date"
customDate ? "w-[7.5rem]" : ""
} ${neverExpires ? "!cursor-not-allowed text-custom-text-400" : "hover:bg-custom-background-80"}`}
>
<Calendar className="h-3 w-3" />
{customDate ? renderFormattedDate(customDate) : "Set date"}
</div>
}
disabled={neverExpires} disabled={neverExpires}
/> />
)} )}

View File

@ -48,7 +48,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
<div className=""> <div className="">
<h4 className="text-sm font-medium">Auto-archive closed issues</h4> <h4 className="text-sm font-medium">Auto-archive closed issues</h4>
<p className="text-sm tracking-tight text-custom-text-200"> <p className="text-sm tracking-tight text-custom-text-200">
Plane will auto archive issues that have been completed or cancelled. Plane will auto archive issues that have been completed or canceled.
</p> </p>
</div> </div>
</div> </div>
@ -73,7 +73,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
<CustomSelect <CustomSelect
value={currentProjectDetails?.archive_in} value={currentProjectDetails?.archive_in}
label={`${currentProjectDetails?.archive_in} ${ label={`${currentProjectDetails?.archive_in} ${
currentProjectDetails?.archive_in === 1 ? "Month" : "Months" currentProjectDetails?.archive_in === 1 ? "month" : "months"
}`} }`}
onChange={(val: number) => { onChange={(val: number) => {
handleChange({ archive_in: val }); handleChange({ archive_in: val });
@ -93,7 +93,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
className="flex w-full select-none items-center rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80" className="flex w-full select-none items-center rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)} onClick={() => setmonthModal(true)}
> >
Customise Time Range Customize time range
</button> </button>
</> </>
</CustomSelect> </CustomSelect>

View File

@ -74,7 +74,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
<div className=""> <div className="">
<h4 className="text-sm font-medium">Auto-close issues</h4> <h4 className="text-sm font-medium">Auto-close issues</h4>
<p className="text-sm tracking-tight text-custom-text-200"> <p className="text-sm tracking-tight text-custom-text-200">
Plane will automatically close issue that haven{"'"}t been completed or cancelled. Plane will automatically close issue that haven{"'"}t been completed or canceled.
</p> </p>
</div> </div>
</div> </div>
@ -100,7 +100,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
<CustomSelect <CustomSelect
value={currentProjectDetails?.close_in} value={currentProjectDetails?.close_in}
label={`${currentProjectDetails?.close_in} ${ label={`${currentProjectDetails?.close_in} ${
currentProjectDetails?.close_in === 1 ? "Month" : "Months" currentProjectDetails?.close_in === 1 ? "month" : "months"
}`} }`}
onChange={(val: number) => { onChange={(val: number) => {
handleChange({ close_in: val }); handleChange({ close_in: val });
@ -119,7 +119,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80" className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)} onClick={() => setmonthModal(true)}
> >
Customize Time Range Customize time range
</button> </button>
</> </>
</CustomSelect> </CustomSelect>

View File

@ -72,7 +72,7 @@ export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen,
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div> <div>
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100"> <Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Customise Time Range Customize time range
</Dialog.Title> </Dialog.Title>
<div className="mt-8 flex items-center gap-2"> <div className="mt-8 flex items-center gap-2">
<div className="flex w-full flex-col justify-center gap-1"> <div className="flex w-full flex-col justify-center gap-1">

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