Compare commits
302
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d6f2dd3dc | ||
|
|
8cbf75ad6c | ||
|
|
9f4f1cac42 | ||
|
|
1c752d7019 | ||
|
|
3519be9ce8 | ||
|
|
9ce158fc10 | ||
|
|
0036ac6afb | ||
|
|
7f7ceec24c | ||
|
|
adf366b325 | ||
|
|
2660d646ad | ||
|
|
dad36b404d | ||
|
|
51be70d814 | ||
|
|
4af5921991 | ||
|
|
588247f1c1 | ||
|
|
1bb93f1f50 | ||
|
|
d990f0038b | ||
|
|
67952bc225 | ||
|
|
a1f0f43992 | ||
|
|
007ed0afa4 | ||
|
|
62cca1c7cd | ||
|
|
5ba7d271b7 | ||
|
|
f6f9caf9e6 | ||
|
|
35e40a7bec | ||
|
|
3e08186d72 | ||
|
|
150553d420 | ||
|
|
1b30e4b57f | ||
|
|
a63c551e75 | ||
|
|
cfe7c5e0b7 | ||
|
|
12ad3892f8 | ||
|
|
353197f583 | ||
|
|
0cf498651c | ||
|
|
a18af1cecf | ||
|
|
61875722e4 | ||
|
|
7fed2ec6ef | ||
|
|
93ba04aebc | ||
|
|
bd3ea456c3 | ||
|
|
c0bf7783b1 | ||
|
|
9f34f41982 | ||
|
|
c940641ba1 | ||
|
|
567966459b | ||
|
|
6055f5c4ee | ||
|
|
abe34ad7b1 | ||
|
|
d596e41d4d | ||
|
|
fae1534887 | ||
|
|
448d8c63f3 | ||
|
|
844ae4869a | ||
|
|
afd7741d0c | ||
|
|
f074f9f003 | ||
|
|
f6500914be | ||
|
|
a6f306209d | ||
|
|
e5507651c3 | ||
|
|
e3005b7776 | ||
|
|
480e2c4d7f | ||
|
|
09e17858fe | ||
|
|
13b2a6fd53 | ||
|
|
4ab82b9616 | ||
|
|
09d73c5e04 | ||
|
|
ed60707bae | ||
|
|
b2c15125fc | ||
|
|
a8f125cfa8 | ||
|
|
15ce3537ad | ||
|
|
448f383ec9 | ||
|
|
0c39f0c563 | ||
|
|
65ddcb6d79 | ||
|
|
0c94b494ed | ||
|
|
e2921539d0 | ||
|
|
66d07e340b | ||
|
|
29ea592c4a | ||
|
|
05323d4697 | ||
|
|
8fcfebf0fc | ||
|
|
5d0533a44f | ||
|
|
3cf2172520 | ||
|
|
a8a5873d88 | ||
|
|
73c7b1bddc | ||
|
|
4441651f81 | ||
|
|
64c936b9b5 | ||
|
|
66ed6a1dc8 | ||
|
|
50275fd2ad | ||
|
|
112fe8e7e6 | ||
|
|
02e6439bd5 | ||
|
|
f9ee898d88 | ||
|
|
f79fdbf782 | ||
|
|
1d7b65ad83 | ||
|
|
1558f51c23 | ||
|
|
bc7dc43171 | ||
|
|
70a00a6309 | ||
|
|
4d56adba43 | ||
|
|
624d9dfd39 | ||
|
|
3e8c375d1c | ||
|
|
880813685b | ||
|
|
16abbe0b3e | ||
|
|
b65fa89cdb | ||
|
|
ed4aae47a2 | ||
|
|
5feaed3961 | ||
|
|
be5c4140ff | ||
|
|
9c4fcca6c1 | ||
|
|
5aad20e7ed | ||
|
|
3a599b6436 | ||
|
|
c5ccc29418 | ||
|
|
f5f90dab69 | ||
|
|
a94e38c093 | ||
|
|
63b7c1ee47 | ||
|
|
1866fd77bb | ||
|
|
531b9e3d64 | ||
|
|
06fb3e9b58 | ||
|
|
fb01e6d22c | ||
|
|
89bb439d62 | ||
|
|
e6055da150 | ||
|
|
952c64d449 | ||
|
|
dcf8b562d3 | ||
|
|
6a40dd911f | ||
|
|
9064709d5d | ||
|
|
6d8eda9814 | ||
|
|
7eaec16381 | ||
|
|
d7ed237f78 | ||
|
|
5c1607f39b | ||
|
|
c75ca8203f | ||
|
|
cfd97041b8 | ||
|
|
7337707a4e | ||
|
|
b441a2ce20 | ||
|
|
1509c8611d | ||
|
|
4e9715a5b2 | ||
|
|
10657d4796 | ||
|
|
653cc29290 | ||
|
|
fa9c6581fd | ||
|
|
96910e1897 | ||
|
|
2f69761130 | ||
|
|
a8f5a3eda1 | ||
|
|
248d094762 | ||
|
|
1255f4756d | ||
|
|
22f8eb9a68 | ||
|
|
3ba9cddc2a | ||
|
|
e3cb0ed13e | ||
|
|
2786f09e85 | ||
|
|
06daf68753 | ||
|
|
2138ddf1f5 | ||
|
|
f9fa345b25 | ||
|
|
c3e1d33518 | ||
|
|
9eb9b7bf6c | ||
|
|
c60b152a7c | ||
|
|
e07ffc3a46 | ||
|
|
dd3bca9a32 | ||
|
|
628591854d | ||
|
|
05e9c0f76f | ||
|
|
3bebcc4714 | ||
|
|
d16d32cea8 | ||
|
|
b654d30aeb | ||
|
|
c0a471e916 | ||
|
|
e2eeec8f79 | ||
|
|
ccd03a4a45 | ||
|
|
20bbe74b57 | ||
|
|
b7b8d3914a | ||
|
|
afb92ea850 | ||
|
|
fc4d06fe0c | ||
|
|
dd90f26d93 | ||
|
|
fbbf97f3a6 | ||
|
|
d9f31a1eb7 | ||
|
|
a06d59f77d | ||
|
|
722a053461 | ||
|
|
d6818e74fd | ||
|
|
37aade5ef6 | ||
|
|
b9e42d116e | ||
|
|
e0928d6ec5 | ||
|
|
909ccd578b | ||
|
|
08ee5dc6b1 | ||
|
|
a5a96d9f66 | ||
|
|
691ea0c080 | ||
|
|
8bd557a743 | ||
|
|
35b80b422d | ||
|
|
d6ffc3176e | ||
|
|
3503b22dd9 | ||
|
|
e13b679c28 | ||
|
|
21dd2e703b | ||
|
|
5dd5fe2d09 | ||
|
|
52d4828e1d | ||
|
|
c5baa6183c | ||
|
|
69387ffd8c | ||
|
|
32ab7951f7 | ||
|
|
5d67029b5a | ||
|
|
578d724e41 | ||
|
|
a3a792741f | ||
|
|
31624f3ae6 | ||
|
|
5152deb2d0 | ||
|
|
c6ba93da72 | ||
|
|
28b3c999ae | ||
|
|
82b9275609 | ||
|
|
f2054b6945 | ||
|
|
f3583f6415 | ||
|
|
02f423bcb6 | ||
|
|
3d34741356 | ||
|
|
7731ee5de4 | ||
|
|
0ba2c5456a | ||
|
|
a0ce3f0be6 | ||
|
|
472767ab67 | ||
|
|
ad60b8774e | ||
|
|
6c6f9a5bfd | ||
|
|
3056727190 | ||
|
|
053ae2063e | ||
|
|
2501c819d3 | ||
|
|
765cfdbf7e | ||
|
|
2f2caaaf6e | ||
|
|
7892a563b7 | ||
|
|
f3b7fc6eb5 | ||
|
|
f01f2fb9bd | ||
|
|
19434342d3 | ||
|
|
6962d7718f | ||
|
|
b6a3615f66 | ||
|
|
5191fc5f7c | ||
|
|
567afa6d39 | ||
|
|
feb0e40559 | ||
|
|
4a81b988b4 | ||
|
|
c755907d99 | ||
|
|
ad2fa91a2b | ||
|
|
10e5ba7b3e | ||
|
|
d477c19ad9 | ||
|
|
79249c5c9b | ||
|
|
4e3c9397ea | ||
|
|
cd26b2e096 | ||
|
|
27b1308227 | ||
|
|
f615f8ac0c | ||
|
|
031f6443a0 | ||
|
|
e48d98cea6 | ||
|
|
a830808f9d | ||
|
|
9a97c97336 | ||
|
|
2e346158ba | ||
|
|
283950c8e2 | ||
|
|
818d1147d5 | ||
|
|
c4594bff01 | ||
|
|
5e81600e38 | ||
|
|
846e73e3b8 | ||
|
|
4dc76eac19 | ||
|
|
f7dbc5e9c0 | ||
|
|
025c8b3835 | ||
|
|
9339138c0e | ||
|
|
988b27f909 | ||
|
|
a3bacbfef1 | ||
|
|
698718aa8d | ||
|
|
7fd775ab05 | ||
|
|
e5f8c94de8 | ||
|
|
9c388d8e50 | ||
|
|
bf09673d09 | ||
|
|
5869c91d70 | ||
|
|
53df658b60 | ||
|
|
505b14e3a6 | ||
|
|
b96d40f106 | ||
|
|
68150a9d2b | ||
|
|
e6b0012fe2 | ||
|
|
5739d95ab4 | ||
|
|
350e183375 | ||
|
|
e7ef6275cd | ||
|
|
4de0abfc22 | ||
|
|
5f796e732a | ||
|
|
0fb9a14f15 | ||
|
|
23c468786d | ||
|
|
0f06589b83 | ||
|
|
a84abc60b2 | ||
|
|
27324ddd93 | ||
|
|
d413dd1169 | ||
|
|
ef0e326ca0 | ||
|
|
96ad751e11 | ||
|
|
8370511a66 | ||
|
|
836dc4027b | ||
|
|
88754e6fc0 | ||
|
|
b6ee197b40 | ||
|
|
46f6b61928 | ||
|
|
5d8f2b6b75 | ||
|
|
d3ca8560fc | ||
|
|
ab9e0cf559 | ||
|
|
c07cfee018 | ||
|
|
ed8f0b8473 | ||
|
|
0082a98d53 | ||
|
|
d6aadb115d | ||
|
|
4639ab3d9c | ||
|
|
c11bf7c7de | ||
|
|
ae8902e815 | ||
|
|
164072e3cc | ||
|
|
928ebdf632 | ||
|
|
636e8e6c60 | ||
|
|
bfab4865cd | ||
|
|
dbd6de0988 | ||
|
|
32d37ec45e | ||
|
|
bcd2ac1317 | ||
|
|
0117ccfca2 | ||
|
|
0ba81a10f1 | ||
|
|
3f5bbf336c | ||
|
|
d6d51c2f43 | ||
|
|
d5d64e09d4 | ||
|
|
7d7683ae6f | ||
|
|
fcd64de8af | ||
|
|
b2765d47b4 | ||
|
|
aca0c251b8 | ||
|
|
6de6522a41 | ||
|
|
bff89ee4c6 | ||
|
|
7744d9b69a | ||
|
|
441cf39d2c | ||
|
|
4a7f80712b | ||
|
|
c7923f6d44 | ||
|
|
4fad685ec8 | ||
|
|
704b7d02ef | ||
|
|
4e9149a27c | ||
|
|
0416e07f46 | ||
|
|
e3e57df4a2 |
@@ -0,0 +1,56 @@
|
||||
name: Bug report
|
||||
description: Create a bug report to help us improve Plane
|
||||
title: "[bug]: "
|
||||
labels: [bug, need testing]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to fill out this bug report.
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the bug you encountered
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current behavior
|
||||
description: A concise description of what you're experiencing and what you expect
|
||||
placeholder: |
|
||||
When I do <X>, <Y> happens and I see the error message attached below:
|
||||
```...```
|
||||
What I expect is <Z>
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Add steps to reproduce this behaviour, include console or network logs and screenshots
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: env
|
||||
attributes:
|
||||
label: Environment
|
||||
options:
|
||||
- Production
|
||||
- Deploy preview
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
options:
|
||||
- Cloud
|
||||
- Self-hosted
|
||||
- Local
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Feature request
|
||||
description: Suggest a feature to improve Plane
|
||||
title: "[feature]: "
|
||||
labels: [feature]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to request a feature for Plane
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue related to this feature request already exists
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One paragraph description of the feature
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Why should this be worked on?
|
||||
description: A concise description of the problems or use cases for this feature request
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,6 @@
|
||||
contact_links:
|
||||
- name: Help and support
|
||||
about: Reach out to us on our Discord server or GitHub discussions.
|
||||
- name: Dedicated support
|
||||
url: mailto:support@plane.so
|
||||
about: Write to us if you'd like dedicated support using Plane
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Build Api Server Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'develop'
|
||||
- 'master'
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build_push_backend:
|
||||
name: Build Api Server Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3.3.0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.1.0
|
||||
with:
|
||||
platforms: linux/arm64,linux/amd64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2.5.0
|
||||
|
||||
- name: Login to Github Container Registry
|
||||
uses: docker/login-action@v2.1.0
|
||||
with:
|
||||
registry: "ghcr.io"
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4.3.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-backend
|
||||
|
||||
- name: Build Api Server
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
with:
|
||||
context: ./apiserver
|
||||
file: ./apiserver/Dockerfile.api
|
||||
platforms: linux/arm64,linux/amd64
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Build Frontend Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'develop'
|
||||
- 'master'
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build_push_frontend:
|
||||
name: Build Frontend Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3.3.0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.1.0
|
||||
with:
|
||||
platforms: linux/arm64,linux/amd64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2.5.0
|
||||
|
||||
- name: Login to Github Container Registry
|
||||
uses: docker/login-action@v2.1.0
|
||||
with:
|
||||
registry: "ghcr.io"
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4.3.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-frontend
|
||||
|
||||
- name: Build Frontend Server
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/app/Dockerfile.web
|
||||
platforms: linux/arm64,linux/amd64
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -2,35 +2,128 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://plane.so">
|
||||
<img src="https://res.cloudinary.com/dgxawjvpo/image/upload/v1673379660/Plane/plane-logo_0m83xue7R_f0v9r9.png" alt="Plane Logo" width="350">
|
||||
<img src="https://res.cloudinary.com/toolspacedev/image/upload/v1680596414/Plane/Plane_Icon_Blue_on_White_150x150_muysa3.jpg" alt="Plane Logo" width="70">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h3 align="center"><b>Plane</b></h3>
|
||||
<p align="center"><b>Open-source, self-hosted project planning tool</b></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.com/invite/A92xrEGCge">
|
||||
<img alt="Discord" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" />
|
||||
</a>
|
||||
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
|
||||
</p>
|
||||
|
||||
<br />
|
||||
Plane is an open-source project planning tool that is designed to help individuals and teams streamline their issues, sprints, and product roadmaps. It is easy to use and can be accessed by anyone, making it an ideal choice for a wide range of projects and organizations.
|
||||
<br /> <br />
|
||||
<p>
|
||||
<a href="https://app.plane.so/" target="_blank">
|
||||
<img
|
||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680599798/Plane/plane_1_1_tnb32j.png"
|
||||
alt="Plane Screens"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Meet Plane. 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/29tPNhaV) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
|
||||
|
||||
## Getting Started
|
||||
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/self-hosting).
|
||||
|
||||
Visit https://app.plane.so to get started with Plane.
|
||||
|
||||
## Documentation
|
||||
## ⚡️ Quick start with Docker Compose
|
||||
|
||||
### Docker Compose Setup
|
||||
|
||||
- Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/makeplane/plane
|
||||
```
|
||||
|
||||
- Change Directory
|
||||
|
||||
```bash
|
||||
cd plane
|
||||
```
|
||||
|
||||
- Run setup.sh
|
||||
|
||||
```bash
|
||||
./setup.sh localhost
|
||||
```
|
||||
|
||||
> If running in a cloud env replace localhost with public facing IP address of the VM
|
||||
|
||||
|
||||
- Run Docker compose up
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
|
||||
## 🚀 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.
|
||||
* **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**: 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.
|
||||
* **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to easily track and plan your project's progress.
|
||||
* **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.
|
||||
* **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.
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
<p>
|
||||
<a href="https://app.plane.so/" target="_blank">
|
||||
<img
|
||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601719/Plane/plane_2_iqao52.png"
|
||||
alt="Plane Issue Details"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://app.plane.so/" target="_blank">
|
||||
<img
|
||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680604273/Plane/plane_5_1_nwsl3a.png"
|
||||
alt="Plane Cycles and Modules"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://app.plane.so/" target="_blank">
|
||||
<img
|
||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601713/Plane/plane_4_cqm0g8.png"
|
||||
alt="Plane Quick Lists"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://app.plane.so/" target="_blank">
|
||||
<img
|
||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601712/Plane/plane_3_1_cu4fsc.png"
|
||||
alt="Plane Command K"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</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).
|
||||
|
||||
## Status
|
||||
|
||||
## 🔋 Status
|
||||
|
||||
- [x] Early Community Previews: We are open-sourcing and sharing the development version of Plane
|
||||
- [ ] Alpha: We are testing Plane with a closed set of customers
|
||||
@@ -38,7 +131,7 @@ To see how to Contribute, visit [here](https://github.com/makeplane/plane/blob/m
|
||||
- [ ] Public Beta: Stable enough for most non-enterprise use-cases
|
||||
- [ ] Public: Production-ready
|
||||
|
||||
## Community
|
||||
## ❤️ Community
|
||||
|
||||
The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects.
|
||||
|
||||
@@ -46,6 +139,6 @@ To chat with other community members you can join the [Plane Discord](https://di
|
||||
|
||||
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 security@plane.so to disclose any security vulnerabilities.
|
||||
|
||||
+12
-10
@@ -1,22 +1,24 @@
|
||||
SECRET_KEY="<-- django secret -->"
|
||||
DJANGO_SETTINGS_MODULE="plane.settings.production"
|
||||
# Database
|
||||
DATABASE_URL=postgres://plane:plane@db:5432/plane
|
||||
DATABASE_URL=postgres://plane:xyzzyspoon@db:5432/plane
|
||||
# Cache
|
||||
REDIS_URL=redis://redis:6379/
|
||||
# SMPT
|
||||
EMAIL_HOST="<-- email smtp -->"
|
||||
EMAIL_HOST_USER="<-- email host user -->"
|
||||
EMAIL_HOST_PASSWORD="<-- email host password -->"
|
||||
EMAIL_HOST=""
|
||||
EMAIL_HOST_USER=""
|
||||
EMAIL_HOST_PASSWORD=""
|
||||
# AWS
|
||||
AWS_REGION="<-- aws region -->"
|
||||
AWS_ACCESS_KEY_ID="<-- aws access key -->"
|
||||
AWS_SECRET_ACCESS_KEY="<-- aws secret acess key -->"
|
||||
AWS_S3_BUCKET_NAME="<-- aws s3 bucket name -->"
|
||||
AWS_REGION=""
|
||||
AWS_ACCESS_KEY_ID=""
|
||||
AWS_SECRET_ACCESS_KEY=""
|
||||
AWS_S3_BUCKET_NAME=""
|
||||
# FE
|
||||
WEB_URL="localhost/"
|
||||
# OAUTH
|
||||
GITHUB_CLIENT_SECRET="<-- github secret -->"
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
# Flags
|
||||
DISABLE_COLLECTSTATIC=1
|
||||
DOCKERIZED=1
|
||||
# GPT Envs
|
||||
OPENAI_API_KEY=0
|
||||
GPT_ENGINE=0
|
||||
@@ -3,7 +3,8 @@ import uuid
|
||||
import random
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from plane.db.models import ProjectIdentifier
|
||||
from plane.db.models import Issue, IssueComment, User, Project
|
||||
from plane.db.models import Issue, IssueComment, User, Project, ProjectMember
|
||||
|
||||
|
||||
|
||||
# Update description and description html values for old descriptions
|
||||
@@ -134,3 +135,42 @@ def update_project_cover_images():
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def update_user_view_property():
|
||||
try:
|
||||
project_members = ProjectMember.objects.all()
|
||||
updated_project_members = []
|
||||
for project_member in project_members:
|
||||
project_member.default_props = {
|
||||
"filters": {"type": None},
|
||||
"orderBy": "-created_at",
|
||||
"collapsed": True,
|
||||
"issueView": "list",
|
||||
"filterIssue": None,
|
||||
"groupByProperty": True,
|
||||
"showEmptyGroups": True,
|
||||
}
|
||||
updated_project_members.append(project_member)
|
||||
|
||||
ProjectMember.objects.bulk_update(
|
||||
updated_project_members, ["default_props"], batch_size=100
|
||||
)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
def update_label_color():
|
||||
try:
|
||||
labels = Label.objects.filter(color="")
|
||||
updated_labels = []
|
||||
for label in labels:
|
||||
label.color = "#" + "%06x" % random.randint(0, 0xFFFFFF)
|
||||
updated_labels.append(label)
|
||||
|
||||
Label.objects.bulk_update(updated_labels, ["color"], batch_size=100)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
@@ -10,6 +10,7 @@ from .workspace import (
|
||||
WorkSpaceMemberSerializer,
|
||||
TeamSerializer,
|
||||
WorkSpaceMemberInviteSerializer,
|
||||
WorkspaceLiteSerializer,
|
||||
)
|
||||
from .project import (
|
||||
ProjectSerializer,
|
||||
@@ -18,10 +19,11 @@ from .project import (
|
||||
ProjectMemberInviteSerializer,
|
||||
ProjectIdentifierSerializer,
|
||||
ProjectFavoriteSerializer,
|
||||
ProjectLiteSerializer,
|
||||
)
|
||||
from .state import StateSerializer
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .shortcut import ShortCutSerializer
|
||||
from .view import ViewSerializer
|
||||
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
|
||||
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer
|
||||
from .asset import FileAssetSerializer
|
||||
from .issue import (
|
||||
@@ -38,6 +40,7 @@ from .issue import (
|
||||
IssueFlatSerializer,
|
||||
IssueStateSerializer,
|
||||
IssueLinkSerializer,
|
||||
IssueLiteSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
@@ -58,3 +61,7 @@ from .integration import (
|
||||
GithubRepositorySyncSerializer,
|
||||
GithubCommentSyncSerializer,
|
||||
)
|
||||
|
||||
from .importer import ImporterSerializer
|
||||
|
||||
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
|
||||
|
||||
@@ -5,12 +5,23 @@ from rest_framework import serializers
|
||||
from .base import BaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .issue import IssueStateSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from plane.db.models import Cycle, CycleIssue, CycleFavorite
|
||||
|
||||
|
||||
class CycleSerializer(BaseSerializer):
|
||||
owned_by = UserLiteSerializer(read_only=True)
|
||||
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)
|
||||
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
|
||||
class Meta:
|
||||
model = Cycle
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from plane.db.models import Importer
|
||||
|
||||
|
||||
class ImporterSerializer(BaseSerializer):
|
||||
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Importer
|
||||
fields = "__all__"
|
||||
@@ -4,10 +4,10 @@ from rest_framework import serializers
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .state import StateSerializer
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .project import ProjectSerializer
|
||||
from .workspace import WorkSpaceSerializer
|
||||
from .project import ProjectSerializer, ProjectLiteSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Issue,
|
||||
@@ -50,8 +50,8 @@ class IssueFlatSerializer(BaseSerializer):
|
||||
class IssueCreateSerializer(BaseSerializer):
|
||||
state_detail = StateSerializer(read_only=True, source="state")
|
||||
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
|
||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||
workspace_detail = WorkSpaceSerializer(read_only=True, source="workspace")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
|
||||
assignees_list = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
|
||||
@@ -244,6 +244,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
|
||||
class IssueActivitySerializer(BaseSerializer):
|
||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
|
||||
class Meta:
|
||||
model = IssueActivity
|
||||
@@ -305,6 +306,16 @@ class LabelSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
class LabelLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Label
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"color",
|
||||
]
|
||||
|
||||
|
||||
class IssueLabelSerializer(BaseSerializer):
|
||||
# label_details = LabelSerializer(read_only=True, source="label")
|
||||
|
||||
@@ -434,6 +445,8 @@ class IssueStateSerializer(BaseSerializer):
|
||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||
label_details = LabelSerializer(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)
|
||||
bridge_id = serializers.UUIDField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
@@ -466,3 +479,29 @@ class IssueSerializer(BaseSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class IssueLiteSerializer(BaseSerializer):
|
||||
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)
|
||||
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -4,10 +4,18 @@ from rest_framework import serializers
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .project import ProjectSerializer
|
||||
from .project import ProjectSerializer, ProjectLiteSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from .issue import IssueStateSerializer
|
||||
|
||||
from plane.db.models import User, Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Module,
|
||||
ModuleMember,
|
||||
ModuleIssue,
|
||||
ModuleLink,
|
||||
ModuleFavorite,
|
||||
)
|
||||
|
||||
|
||||
class ModuleWriteSerializer(BaseSerializer):
|
||||
@@ -17,6 +25,9 @@ class ModuleWriteSerializer(BaseSerializer):
|
||||
required=False,
|
||||
)
|
||||
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = "__all__"
|
||||
@@ -128,14 +139,29 @@ class ModuleLinkSerializer(BaseSerializer):
|
||||
"module",
|
||||
]
|
||||
|
||||
# Validation if url already exists
|
||||
def create(self, validated_data):
|
||||
if ModuleLink.objects.filter(
|
||||
url=validated_data.get("url"), module_id=validated_data.get("module_id")
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
return ModuleLink.objects.create(**validated_data)
|
||||
|
||||
|
||||
class ModuleSerializer(BaseSerializer):
|
||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||
lead_detail = UserLiteSerializer(read_only=True, source="lead")
|
||||
members_detail = UserLiteSerializer(read_only=True, many=True, source="members")
|
||||
issue_module = ModuleIssueSerializer(read_only=True, many=True)
|
||||
link_module = ModuleLinkSerializer(read_only=True, many=True)
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
@@ -149,6 +175,7 @@ class ModuleSerializer(BaseSerializer):
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class ModuleFavoriteSerializer(BaseSerializer):
|
||||
module_detail = ModuleFlatSerializer(source="module", read_only=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .issue import IssueFlatSerializer, LabelSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from plane.db.models import Page, PageBlock, PageFavorite, PageLabel, Label
|
||||
|
||||
|
||||
class PageBlockSerializer(BaseSerializer):
|
||||
issue_detail = IssueFlatSerializer(source="issue", read_only=True)
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PageBlock
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"page",
|
||||
]
|
||||
|
||||
|
||||
class PageSerializer(BaseSerializer):
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
label_details = LabelSerializer(read_only=True, source="labels", many=True)
|
||||
labels_list = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
blocks = PageBlockSerializer(read_only=True, many=True)
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Page
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"owned_by",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
labels = validated_data.pop("labels_list", None)
|
||||
project_id = self.context["project_id"]
|
||||
owned_by_id = self.context["owned_by_id"]
|
||||
page = Page.objects.create(
|
||||
**validated_data, project_id=project_id, owned_by_id=owned_by_id
|
||||
)
|
||||
|
||||
if labels is not None:
|
||||
PageLabel.objects.bulk_create(
|
||||
[
|
||||
PageLabel(
|
||||
label=label,
|
||||
page=page,
|
||||
project_id=project_id,
|
||||
workspace_id=page.workspace_id,
|
||||
created_by_id=page.created_by_id,
|
||||
updated_by_id=page.updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
return page
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
labels = validated_data.pop("labels_list", None)
|
||||
if labels is not None:
|
||||
PageLabel.objects.filter(page=instance).delete()
|
||||
PageLabel.objects.bulk_create(
|
||||
[
|
||||
PageLabel(
|
||||
label=label,
|
||||
page=instance,
|
||||
project_id=instance.project_id,
|
||||
workspace_id=instance.workspace_id,
|
||||
created_by_id=instance.created_by_id,
|
||||
updated_by_id=instance.updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class PageFavoriteSerializer(BaseSerializer):
|
||||
page_detail = PageSerializer(source="page", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PageFavorite
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"user",
|
||||
]
|
||||
@@ -6,7 +6,7 @@ from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from plane.api.serializers.workspace import WorkSpaceSerializer
|
||||
from plane.api.serializers.workspace import WorkSpaceSerializer, WorkspaceLiteSerializer
|
||||
from plane.api.serializers.user import UserLiteSerializer
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
@@ -18,6 +18,8 @@ from plane.db.models import (
|
||||
|
||||
|
||||
class ProjectSerializer(BaseSerializer):
|
||||
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = "__all__"
|
||||
@@ -56,12 +58,15 @@ class ProjectSerializer(BaseSerializer):
|
||||
project_identifier = ProjectIdentifier.objects.filter(
|
||||
name=identifier, workspace_id=instance.workspace_id
|
||||
).first()
|
||||
|
||||
if project_identifier is None:
|
||||
project = super().update(instance, validated_data)
|
||||
_ = ProjectIdentifier.objects.update(name=identifier, project=project)
|
||||
project_identifier = ProjectIdentifier.objects.filter(
|
||||
project=project
|
||||
).first()
|
||||
if project_identifier is not None:
|
||||
project_identifier.name = identifier
|
||||
project_identifier.save()
|
||||
return project
|
||||
|
||||
# If found check if the project_id to be updated and identifier project id is same
|
||||
if project_identifier.project_id == instance.id:
|
||||
# If same pass update
|
||||
@@ -118,3 +123,10 @@ class ProjectFavoriteSerializer(BaseSerializer):
|
||||
"workspace",
|
||||
"user",
|
||||
]
|
||||
|
||||
|
||||
class ProjectLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = ["id", "identifier", "name"]
|
||||
read_only_fields = fields
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
|
||||
from plane.db.models import State
|
||||
|
||||
|
||||
class StateSerializer(BaseSerializer):
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
|
||||
class Meta:
|
||||
model = State
|
||||
fields = "__all__"
|
||||
@@ -12,3 +17,15 @@ class StateSerializer(BaseSerializer):
|
||||
"workspace",
|
||||
"project",
|
||||
]
|
||||
|
||||
|
||||
class StateLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = State
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"color",
|
||||
"group",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
@@ -1,14 +1,58 @@
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
|
||||
from plane.db.models import View
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from plane.db.models import IssueView, IssueViewFavorite
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class ViewSerializer(BaseSerializer):
|
||||
class IssueViewSerializer(BaseSerializer):
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = View
|
||||
model = IssueView
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"query",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
query_params = validated_data.get("query_data", {})
|
||||
|
||||
if not bool(query_params):
|
||||
raise serializers.ValidationError(
|
||||
{"query_data": ["Query data field cannot be empty"]}
|
||||
)
|
||||
|
||||
validated_data["query"] = issue_filters(query_params, "POST")
|
||||
return IssueView.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
query_params = validated_data.get("query_data", {})
|
||||
if not bool(query_params):
|
||||
raise serializers.ValidationError(
|
||||
{"query_data": ["Query data field cannot be empty"]}
|
||||
)
|
||||
|
||||
validated_data["query"] = issue_filters(query_params, "PATCH")
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class IssueViewFavoriteSerializer(BaseSerializer):
|
||||
view_detail = IssueViewSerializer(source="issue_view", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueViewFavorite
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"user",
|
||||
]
|
||||
|
||||
@@ -10,7 +10,6 @@ from plane.db.models import Workspace, WorkspaceMember, Team, WorkspaceMemberInv
|
||||
|
||||
|
||||
class WorkSpaceSerializer(BaseSerializer):
|
||||
|
||||
owner = UserLiteSerializer(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -28,7 +27,6 @@ class WorkSpaceSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class WorkSpaceMemberSerializer(BaseSerializer):
|
||||
|
||||
member = UserLiteSerializer(read_only=True)
|
||||
workspace = WorkSpaceSerializer(read_only=True)
|
||||
|
||||
@@ -38,7 +36,6 @@ class WorkSpaceMemberSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
||||
|
||||
workspace = WorkSpaceSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -47,7 +44,6 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class TeamSerializer(BaseSerializer):
|
||||
|
||||
members_detail = UserLiteSerializer(read_only=True, source="members", many=True)
|
||||
members = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
|
||||
@@ -93,3 +89,14 @@ class TeamSerializer(BaseSerializer):
|
||||
return super().update(instance, validated_data)
|
||||
else:
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class WorkspaceLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Workspace
|
||||
fields = [
|
||||
"name",
|
||||
"slug",
|
||||
"id",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
+247
-7
@@ -21,6 +21,7 @@ from plane.api.views import (
|
||||
# User
|
||||
UserEndpoint,
|
||||
UpdateUserOnBoardedEndpoint,
|
||||
UserActivityEndpoint,
|
||||
## End User
|
||||
# Workspaces
|
||||
WorkSpaceViewSet,
|
||||
@@ -38,9 +39,13 @@ from plane.api.views import (
|
||||
AddTeamToProjectEndpoint,
|
||||
UserLastProjectWithWorkspaceEndpoint,
|
||||
UserWorkspaceInvitationEndpoint,
|
||||
UserActivityGraphEndpoint,
|
||||
UserIssueCompletedGraphEndpoint,
|
||||
UserWorkspaceDashboardEndpoint,
|
||||
## End Workspaces
|
||||
# File Assets
|
||||
FileAssetEndpoint,
|
||||
UserAssetsEndpoint,
|
||||
## End File Assets
|
||||
# Projects
|
||||
ProjectViewSet,
|
||||
@@ -61,13 +66,14 @@ from plane.api.views import (
|
||||
IssueCommentViewSet,
|
||||
UserWorkSpaceIssues,
|
||||
BulkDeleteIssuesEndpoint,
|
||||
BulkImportIssuesEndpoint,
|
||||
ProjectUserViewsEndpoint,
|
||||
TimeLineIssueViewSet,
|
||||
IssuePropertyViewSet,
|
||||
LabelViewSet,
|
||||
SubIssuesEndpoint,
|
||||
IssueLinkViewSet,
|
||||
ModuleLinkViewSet,
|
||||
BulkCreateIssueLabelsEndpoint,
|
||||
## End Issues
|
||||
# States
|
||||
StateViewSet,
|
||||
@@ -76,7 +82,9 @@ from plane.api.views import (
|
||||
ShortCutViewSet,
|
||||
## End Shortcuts
|
||||
# Views
|
||||
ViewViewSet,
|
||||
IssueViewViewSet,
|
||||
ViewIssuesEndpoint,
|
||||
IssueViewFavoriteViewSet,
|
||||
## End Views
|
||||
# Cycles
|
||||
CycleViewSet,
|
||||
@@ -86,12 +94,26 @@ from plane.api.views import (
|
||||
CompletedCyclesEndpoint,
|
||||
CycleFavoriteViewSet,
|
||||
DraftCyclesEndpoint,
|
||||
TransferCycleIssueEndpoint,
|
||||
InCompleteCyclesEndpoint,
|
||||
## End Cycles
|
||||
# Modules
|
||||
ModuleViewSet,
|
||||
ModuleIssueViewSet,
|
||||
ModuleFavoriteViewSet,
|
||||
ModuleLinkViewSet,
|
||||
BulkImportModulesEndpoint,
|
||||
## End Modules
|
||||
# Pages
|
||||
PageViewSet,
|
||||
PageBlockViewSet,
|
||||
PageFavoriteViewSet,
|
||||
CreateIssueFromPageBlockEndpoint,
|
||||
RecentPagesEndpoint,
|
||||
FavoritePagesEndpoint,
|
||||
MyPagesEndpoint,
|
||||
CreatedbyOtherPagesEndpoint,
|
||||
## End Pages
|
||||
# Api Tokens
|
||||
ApiTokenEndpoint,
|
||||
## End Api Tokens
|
||||
@@ -102,7 +124,19 @@ from plane.api.views import (
|
||||
GithubRepositorySyncViewSet,
|
||||
GithubIssueSyncViewSet,
|
||||
GithubCommentSyncViewSet,
|
||||
BulkCreateGithubIssueSyncEndpoint,
|
||||
## End Integrations
|
||||
# Importer
|
||||
ServiceIssueImportSummaryEndpoint,
|
||||
ImportServiceEndpoint,
|
||||
UpdateServiceImportStatusEndpoint,
|
||||
## End importer
|
||||
# Search
|
||||
GlobalSearchEndpoint,
|
||||
## End Search
|
||||
# Gpt
|
||||
GPTIntegrationEndpoint,
|
||||
## End Gpt
|
||||
)
|
||||
|
||||
|
||||
@@ -153,6 +187,7 @@ urlpatterns = [
|
||||
UpdateUserOnBoardedEndpoint.as_view(),
|
||||
name="change-password",
|
||||
),
|
||||
path("users/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
|
||||
# user workspaces
|
||||
path(
|
||||
"users/me/workspaces/",
|
||||
@@ -176,6 +211,23 @@ urlpatterns = [
|
||||
name="workspace",
|
||||
),
|
||||
# user join workspace
|
||||
# User Graphs
|
||||
path(
|
||||
"users/me/workspaces/<str:slug>/activity-graph/",
|
||||
UserActivityGraphEndpoint.as_view(),
|
||||
name="user-activity-graph",
|
||||
),
|
||||
path(
|
||||
"users/me/workspaces/<str:slug>/issues-completed-graph/",
|
||||
UserIssueCompletedGraphEndpoint.as_view(),
|
||||
name="completed-graph",
|
||||
),
|
||||
path(
|
||||
"users/me/workspaces/<str:slug>/dashboard/",
|
||||
UserWorkspaceDashboardEndpoint.as_view(),
|
||||
name="user-workspace-dashboard",
|
||||
),
|
||||
## User Graph
|
||||
path(
|
||||
"users/me/invitations/workspaces/<str:slug>/<uuid:pk>/join/",
|
||||
JoinWorkspaceEndpoint.as_view(),
|
||||
@@ -452,7 +504,7 @@ urlpatterns = [
|
||||
# Views
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/views/",
|
||||
ViewViewSet.as_view(
|
||||
IssueViewViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
@@ -462,7 +514,7 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/",
|
||||
ViewViewSet.as_view(
|
||||
IssueViewViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"put": "update",
|
||||
@@ -472,6 +524,30 @@ urlpatterns = [
|
||||
),
|
||||
name="project-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:view_id>/issues/",
|
||||
ViewIssuesEndpoint.as_view(),
|
||||
name="project-view-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/",
|
||||
IssueViewFavoriteViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="user-favorite-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/<uuid:view_id>/",
|
||||
IssueViewFavoriteViewSet.as_view(
|
||||
{
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="user-favorite-view",
|
||||
),
|
||||
## End Views
|
||||
## Cycles
|
||||
path(
|
||||
@@ -557,6 +633,16 @@ urlpatterns = [
|
||||
),
|
||||
name="user-favorite-cycle",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/transfer-issues/",
|
||||
TransferCycleIssueEndpoint.as_view(),
|
||||
name="transfer-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/incomplete-cycles/",
|
||||
InCompleteCyclesEndpoint.as_view(),
|
||||
name="transfer-issues",
|
||||
),
|
||||
## End Cycles
|
||||
# Issue
|
||||
path(
|
||||
@@ -608,9 +694,20 @@ urlpatterns = [
|
||||
),
|
||||
name="project-issue-labels",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-create-labels/",
|
||||
BulkCreateIssueLabelsEndpoint.as_view(),
|
||||
name="project-bulk-labels",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-delete-issues/",
|
||||
BulkDeleteIssuesEndpoint.as_view(),
|
||||
name="project-issues-bulk",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-import-issues/<str:service>/",
|
||||
BulkImportIssuesEndpoint.as_view(),
|
||||
name="project-issues-bulk",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/my-issues/",
|
||||
@@ -728,12 +825,22 @@ urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/file-assets/",
|
||||
FileAssetEndpoint.as_view(),
|
||||
name="File Assets",
|
||||
name="file-assets",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/file-assets/<uuid:pk>/",
|
||||
"workspaces/file-assets/<uuid:workspace_id>/<str:asset_key>/",
|
||||
FileAssetEndpoint.as_view(),
|
||||
name="File Assets",
|
||||
name="file-assets",
|
||||
),
|
||||
path(
|
||||
"users/file-assets/",
|
||||
UserAssetsEndpoint.as_view(),
|
||||
name="user-file-assets",
|
||||
),
|
||||
path(
|
||||
"users/file-assets/<str:asset_key>/",
|
||||
UserAssetsEndpoint.as_view(),
|
||||
name="user-file-assets",
|
||||
),
|
||||
## End File Assets
|
||||
## Modules
|
||||
@@ -822,7 +929,100 @@ urlpatterns = [
|
||||
),
|
||||
name="user-favorite-module",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-import-modules/<str:service>/",
|
||||
BulkImportModulesEndpoint.as_view(),
|
||||
name="bulk-modules-create",
|
||||
),
|
||||
## End Modules
|
||||
# Pages
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
|
||||
PageViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/",
|
||||
PageViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="project-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/page-blocks/",
|
||||
PageBlockViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-page-blocks",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/page-blocks/<uuid:pk>/",
|
||||
PageBlockViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="project-page-blocks",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-pages/",
|
||||
PageFavoriteViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="user-favorite-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-pages/<uuid:page_id>/",
|
||||
PageFavoriteViewSet.as_view(
|
||||
{
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="user-favorite-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/page-blocks/<uuid:page_block_id>/issues/",
|
||||
CreateIssueFromPageBlockEndpoint.as_view(),
|
||||
name="page-block-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/recent-pages/",
|
||||
RecentPagesEndpoint.as_view(),
|
||||
name="recent-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/favorite-pages/",
|
||||
FavoritePagesEndpoint.as_view(),
|
||||
name="recent-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/my-pages/",
|
||||
MyPagesEndpoint.as_view(),
|
||||
name="user-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/created-by-other-pages/",
|
||||
CreatedbyOtherPagesEndpoint.as_view(),
|
||||
name="created-by-other-pages",
|
||||
),
|
||||
## End Pages
|
||||
# API Tokens
|
||||
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),
|
||||
path("api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-tokens"),
|
||||
@@ -909,6 +1109,10 @@ urlpatterns = [
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/bulk-create-github-issue-sync/",
|
||||
BulkCreateGithubIssueSyncEndpoint.as_view(),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:pk>/",
|
||||
GithubIssueSyncViewSet.as_view(
|
||||
@@ -938,4 +1142,40 @@ urlpatterns = [
|
||||
),
|
||||
## End Github Integrations
|
||||
## End Integrations
|
||||
# Importer
|
||||
path(
|
||||
"workspaces/<str:slug>/importers/<str:service>/",
|
||||
ServiceIssueImportSummaryEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/importers/<str:service>/",
|
||||
ImportServiceEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/importers/",
|
||||
ImportServiceEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/service/<str:service>/importers/<uuid:importer_id>/",
|
||||
UpdateServiceImportStatusEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
## End Importer
|
||||
# Search
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/search/",
|
||||
GlobalSearchEndpoint.as_view(),
|
||||
name="global-search",
|
||||
),
|
||||
## End Search
|
||||
# Gpt
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
|
||||
GPTIntegrationEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
## End Gpt
|
||||
]
|
||||
|
||||
@@ -16,6 +16,7 @@ from .project import (
|
||||
from .people import (
|
||||
UserEndpoint,
|
||||
UpdateUserOnBoardedEndpoint,
|
||||
UserActivityEndpoint,
|
||||
)
|
||||
|
||||
from .oauth import OauthEndpoint
|
||||
@@ -36,10 +37,13 @@ from .workspace import (
|
||||
UserLastProjectWithWorkspaceEndpoint,
|
||||
WorkspaceMemberUserEndpoint,
|
||||
WorkspaceMemberUserViewsEndpoint,
|
||||
UserActivityGraphEndpoint,
|
||||
UserIssueCompletedGraphEndpoint,
|
||||
UserWorkspaceDashboardEndpoint,
|
||||
)
|
||||
from .state import StateViewSet
|
||||
from .shortcut import ShortCutViewSet
|
||||
from .view import ViewViewSet
|
||||
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
|
||||
from .cycle import (
|
||||
CycleViewSet,
|
||||
CycleIssueViewSet,
|
||||
@@ -48,8 +52,10 @@ from .cycle import (
|
||||
CompletedCyclesEndpoint,
|
||||
CycleFavoriteViewSet,
|
||||
DraftCyclesEndpoint,
|
||||
TransferCycleIssueEndpoint,
|
||||
InCompleteCyclesEndpoint,
|
||||
)
|
||||
from .asset import FileAssetEndpoint
|
||||
from .asset import FileAssetEndpoint, UserAssetsEndpoint
|
||||
from .issue import (
|
||||
IssueViewSet,
|
||||
WorkSpaceIssuesEndpoint,
|
||||
@@ -62,6 +68,7 @@ from .issue import (
|
||||
UserWorkSpaceIssues,
|
||||
SubIssuesEndpoint,
|
||||
IssueLinkViewSet,
|
||||
BulkCreateIssueLabelsEndpoint,
|
||||
)
|
||||
|
||||
from .auth_extended import (
|
||||
@@ -96,4 +103,29 @@ from .integration import (
|
||||
GithubRepositorySyncViewSet,
|
||||
GithubCommentSyncViewSet,
|
||||
GithubRepositoriesEndpoint,
|
||||
BulkCreateGithubIssueSyncEndpoint,
|
||||
)
|
||||
|
||||
from .importer import (
|
||||
ServiceIssueImportSummaryEndpoint,
|
||||
ImportServiceEndpoint,
|
||||
UpdateServiceImportStatusEndpoint,
|
||||
BulkImportIssuesEndpoint,
|
||||
BulkImportModulesEndpoint,
|
||||
)
|
||||
|
||||
from .page import (
|
||||
PageViewSet,
|
||||
PageBlockViewSet,
|
||||
PageFavoriteViewSet,
|
||||
CreateIssueFromPageBlockEndpoint,
|
||||
RecentPagesEndpoint,
|
||||
FavoritePagesEndpoint,
|
||||
MyPagesEndpoint,
|
||||
CreatedbyOtherPagesEndpoint,
|
||||
)
|
||||
|
||||
from .search import GlobalSearchEndpoint
|
||||
|
||||
|
||||
from .gpt import GPTIntegrationEndpoint
|
||||
|
||||
@@ -17,8 +17,9 @@ class FileAssetEndpoint(BaseAPIView):
|
||||
A viewset for viewing and editing task instances.
|
||||
"""
|
||||
|
||||
def get(self, request, slug):
|
||||
files = FileAsset.objects.filter(workspace__slug=slug)
|
||||
def get(self, request, workspace_id, asset_key):
|
||||
asset_key = str(workspace_id) + "/" + asset_key
|
||||
files = FileAsset.objects.filter(asset=asset_key)
|
||||
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@@ -42,9 +43,55 @@ class FileAssetEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def delete(self, request, slug, pk):
|
||||
def delete(self, request, workspace_id, asset_key):
|
||||
try:
|
||||
file_asset = FileAsset.objects.get(pk=pk, workspace__slug=slug)
|
||||
asset_key = str(workspace_id) + "/" + asset_key
|
||||
file_asset = FileAsset.objects.get(asset=asset_key)
|
||||
# Delete the file from storage
|
||||
file_asset.asset.delete(save=False)
|
||||
# Delete the file object
|
||||
file_asset.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except FileAsset.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "File Asset doesn't exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class UserAssetsEndpoint(BaseAPIView):
|
||||
def get(self, request, asset_key):
|
||||
try:
|
||||
files = FileAsset.objects.filter(asset=asset_key, created_by=request.user)
|
||||
serializer = FileAssetSerializer(files, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
except FileAsset.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "File Asset does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def delete(self, request, asset_key):
|
||||
try:
|
||||
file_asset = FileAsset.objects.get(asset=asset_key, created_by=request.user)
|
||||
# Delete the file from storage
|
||||
file_asset.asset.delete(save=False)
|
||||
# Delete the file object
|
||||
|
||||
@@ -3,6 +3,7 @@ import uuid
|
||||
import random
|
||||
import string
|
||||
import json
|
||||
import requests
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
@@ -85,6 +86,28 @@ class SignInEndpoint(BaseAPIView):
|
||||
"user": serialized_user,
|
||||
}
|
||||
|
||||
# Send Analytics
|
||||
if settings.ANALYTICS_BASE_API:
|
||||
_ = requests.post(
|
||||
settings.ANALYTICS_BASE_API,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
|
||||
},
|
||||
json={
|
||||
"event_id": uuid.uuid4().hex,
|
||||
"event_data": {
|
||||
"medium": "email",
|
||||
},
|
||||
"user": {"email": email, "id": str(user.id)},
|
||||
"device_ctx": {
|
||||
"ip": request.META.get("REMOTE_ADDR"),
|
||||
"user_agent": request.META.get("HTTP_USER_AGENT"),
|
||||
},
|
||||
"event_type": "SIGN_UP",
|
||||
},
|
||||
)
|
||||
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
# Sign in Process
|
||||
else:
|
||||
@@ -114,7 +137,27 @@ class SignInEndpoint(BaseAPIView):
|
||||
user.save()
|
||||
|
||||
access_token, refresh_token = get_tokens_for_user(user)
|
||||
|
||||
# Send Analytics
|
||||
if settings.ANALYTICS_BASE_API:
|
||||
_ = requests.post(
|
||||
settings.ANALYTICS_BASE_API,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
|
||||
},
|
||||
json={
|
||||
"event_id": uuid.uuid4().hex,
|
||||
"event_data": {
|
||||
"medium": "email",
|
||||
},
|
||||
"user": {"email": email, "id": str(user.id)},
|
||||
"device_ctx": {
|
||||
"ip": request.META.get("REMOTE_ADDR"),
|
||||
"user_agent": request.META.get("HTTP_USER_AGENT"),
|
||||
},
|
||||
"event_type": "SIGN_IN",
|
||||
},
|
||||
)
|
||||
data = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
@@ -268,6 +311,29 @@ class MagicSignInEndpoint(BaseAPIView):
|
||||
if str(token) == str(user_token):
|
||||
if User.objects.filter(email=email).exists():
|
||||
user = User.objects.get(email=email)
|
||||
# Send event to Jitsu for tracking
|
||||
if settings.ANALYTICS_BASE_API:
|
||||
_ = requests.post(
|
||||
settings.ANALYTICS_BASE_API,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
|
||||
},
|
||||
json={
|
||||
"event_id": uuid.uuid4().hex,
|
||||
"event_data": {
|
||||
"medium": "code",
|
||||
},
|
||||
"user": {"email": email, "id": str(user.id)},
|
||||
"device_ctx": {
|
||||
"ip": request.META.get("REMOTE_ADDR"),
|
||||
"user_agent": request.META.get(
|
||||
"HTTP_USER_AGENT"
|
||||
),
|
||||
},
|
||||
"event_type": "SIGN_IN",
|
||||
},
|
||||
)
|
||||
else:
|
||||
user = User.objects.create(
|
||||
email=email,
|
||||
@@ -275,6 +341,29 @@ class MagicSignInEndpoint(BaseAPIView):
|
||||
password=make_password(uuid.uuid4().hex),
|
||||
is_password_autoset=True,
|
||||
)
|
||||
# Send event to Jitsu for tracking
|
||||
if settings.ANALYTICS_BASE_API:
|
||||
_ = requests.post(
|
||||
settings.ANALYTICS_BASE_API,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
|
||||
},
|
||||
json={
|
||||
"event_id": uuid.uuid4().hex,
|
||||
"event_data": {
|
||||
"medium": "code",
|
||||
},
|
||||
"user": {"email": email, "id": str(user.id)},
|
||||
"device_ctx": {
|
||||
"ip": request.META.get("REMOTE_ADDR"),
|
||||
"user_agent": request.META.get(
|
||||
"HTTP_USER_AGENT"
|
||||
),
|
||||
},
|
||||
"event_type": "SIGN_UP",
|
||||
},
|
||||
)
|
||||
|
||||
user.last_active = timezone.now()
|
||||
user.last_login_time = timezone.now()
|
||||
|
||||
@@ -3,9 +3,11 @@ import json
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef
|
||||
from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef, Count, Prefetch
|
||||
from django.core import serializers
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -18,11 +20,18 @@ from plane.api.serializers import (
|
||||
CycleSerializer,
|
||||
CycleIssueSerializer,
|
||||
CycleFavoriteSerializer,
|
||||
IssueStateSerializer,
|
||||
)
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Cycle, CycleIssue, Issue, CycleFavorite
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
Issue,
|
||||
CycleFavorite,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class CycleViewSet(BaseViewSet):
|
||||
@@ -38,6 +47,12 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
subquery = CycleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
cycle_id=OuterRef("pk"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
@@ -47,26 +62,42 @@ class CycleViewSet(BaseViewSet):
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(total_issues=Count("issue_cycle"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "name")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = CycleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
cycle_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
cycles = self.get_queryset().annotate(is_favorite=Exists(subquery))
|
||||
return Response(CycleSerializer(cycles, many=True).data)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
if (
|
||||
@@ -98,6 +129,36 @@ class CycleViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
try:
|
||||
cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now().date():
|
||||
return Response(
|
||||
{
|
||||
"error": "The Cycle has already been completed so it cannot be edited"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = CycleSerializer(cycle, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Cycle.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Cycle does not exist"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class CycleIssueViewSet(BaseViewSet):
|
||||
serializer_class = CycleIssueSerializer
|
||||
@@ -140,22 +201,43 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug, project_id, cycle_id):
|
||||
try:
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
queryset = self.get_queryset().order_by(f"issue__{order_by}")
|
||||
group_by = request.GET.get("group_by", False)
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.objects.filter(issue_cycle__cycle_id=cycle_id)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(bridge_id=F("issue_cycle__id"))
|
||||
.filter(project_id=project_id)
|
||||
.filter(workspace__slug=slug)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.order_by(order_by)
|
||||
.filter(**filters)
|
||||
)
|
||||
|
||||
cycle_issues = CycleIssueSerializer(queryset, many=True).data
|
||||
issues_data = IssueStateSerializer(issues, many=True).data
|
||||
|
||||
if group_by:
|
||||
return Response(
|
||||
group_results(cycle_issues, f"issue_detail.{group_by}"),
|
||||
group_results(issues_data, group_by),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
cycle_issues,
|
||||
issues_data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -178,6 +260,14 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
)
|
||||
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now().date():
|
||||
return Response(
|
||||
{
|
||||
"error": "The Cycle has already been completed so no new issues can be added"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get all CycleIssues already created
|
||||
cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues))
|
||||
records_to_update = []
|
||||
@@ -263,10 +353,20 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class CycleDateCheckEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug, project_id):
|
||||
try:
|
||||
start_date = request.data.get("start_date")
|
||||
end_date = request.data.get("end_date")
|
||||
start_date = request.data.get("start_date", False)
|
||||
end_date = request.data.get("end_date", False)
|
||||
|
||||
if not start_date or not end_date:
|
||||
return Response(
|
||||
{"error": "Start date and end date both are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cycles = Cycle.objects.filter(
|
||||
Q(start_date__lte=start_date, end_date__gte=start_date)
|
||||
@@ -294,6 +394,10 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class CurrentUpcomingCyclesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = CycleFavorite.objects.filter(
|
||||
@@ -302,18 +406,94 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
current_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
start_date__lte=timezone.now(),
|
||||
end_date__gte=timezone.now(),
|
||||
).annotate(is_favorite=Exists(subquery))
|
||||
current_cycle = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
start_date__lte=timezone.now(),
|
||||
end_date__gte=timezone.now(),
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(total_issues=Count("issue_cycle"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
upcoming_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
start_date__gt=timezone.now(),
|
||||
).annotate(is_favorite=Exists(subquery))
|
||||
upcoming_cycle = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
start_date__gt=timezone.now(),
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(total_issues=Count("issue_cycle"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
@@ -332,6 +512,10 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class CompletedCyclesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = CycleFavorite.objects.filter(
|
||||
@@ -340,11 +524,49 @@ class CompletedCyclesEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
completed_cycles = Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
end_date__lt=timezone.now(),
|
||||
).annotate(is_favorite=Exists(subquery))
|
||||
completed_cycles = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
end_date__lt=timezone.now(),
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(total_issues=Count("issue_cycle"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
@@ -364,13 +586,61 @@ class CompletedCyclesEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class DraftCyclesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
draft_cycles = Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
subquery = CycleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
cycle_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
end_date=None,
|
||||
start_date=None,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
draft_cycles = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
end_date=None,
|
||||
start_date=None,
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(total_issues=Count("issue_cycle"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
return Response(
|
||||
@@ -386,6 +656,10 @@ class DraftCyclesEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class CycleFavoriteViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
serializer_class = CycleFavoriteSerializer
|
||||
model = CycleFavorite
|
||||
|
||||
@@ -445,3 +719,82 @@ class CycleFavoriteViewSet(BaseViewSet):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug, project_id, cycle_id):
|
||||
try:
|
||||
new_cycle_id = request.data.get("new_cycle_id", False)
|
||||
|
||||
if not new_cycle_id:
|
||||
return Response(
|
||||
{"error": "New Cycle Id is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
new_cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
|
||||
)
|
||||
|
||||
if (
|
||||
new_cycle.end_date is not None
|
||||
and new_cycle.end_date < timezone.now().date()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "The cycle where the issues are transferred is already completed"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
cycle_id=cycle_id,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
issue__state__group__in=["backlog", "unstarted", "started"],
|
||||
)
|
||||
|
||||
updated_cycles = []
|
||||
for cycle_issue in cycle_issues:
|
||||
cycle_issue.cycle_id = new_cycle_id
|
||||
updated_cycles.append(cycle_issue)
|
||||
|
||||
cycle_issues = CycleIssue.objects.bulk_update(
|
||||
updated_cycles, ["cycle_id"], batch_size=100
|
||||
)
|
||||
|
||||
return Response({"message": "Success"}, status=status.HTTP_200_OK)
|
||||
except Cycle.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "New Cycle Does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class InCompleteCyclesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
cycles = Cycle.objects.filter(
|
||||
Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
).select_related("owned_by")
|
||||
|
||||
serializer = CycleSerializer(cycles, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Python imports
|
||||
import requests
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
import openai
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Workspace, Project
|
||||
from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
|
||||
|
||||
|
||||
class GPTIntegrationEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug, project_id):
|
||||
try:
|
||||
if not settings.OPENAI_API_KEY or not settings.GPT_ENGINE:
|
||||
return Response(
|
||||
{"error": "OpenAI API key and engine is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
count = 0
|
||||
|
||||
# If logger is enabled check for request limit
|
||||
if settings.LOGGER_BASE_URL:
|
||||
try:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
settings.LOGGER_BASE_URL,
|
||||
json={"user_id": str(request.user.id)},
|
||||
headers=headers,
|
||||
)
|
||||
count = response.json().get("count", 0)
|
||||
if not response.json().get("success", False):
|
||||
return Response(
|
||||
{
|
||||
"error": "You have surpassed the monthly limit for AI assistance"
|
||||
},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
|
||||
prompt = request.data.get("prompt", False)
|
||||
task = request.data.get("task", False)
|
||||
|
||||
if not task:
|
||||
return Response(
|
||||
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
final_text = task + "\n" + prompt
|
||||
|
||||
openai.api_key = settings.OPENAI_API_KEY
|
||||
response = openai.Completion.create(
|
||||
engine=settings.GPT_ENGINE,
|
||||
prompt=final_text,
|
||||
temperature=0.7,
|
||||
max_tokens=1024,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
text = response.choices[0].text.strip()
|
||||
text_html = text.replace("\n", "<br/>")
|
||||
return Response(
|
||||
{
|
||||
"response": text,
|
||||
"response_html": text_html,
|
||||
"count": count,
|
||||
"project_detail": ProjectLiteSerializer(project).data,
|
||||
"workspace_detail": WorkspaceLiteSerializer(workspace).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except (Workspace.DoesNotExist, Project.DoesNotExist) as e:
|
||||
return Response(
|
||||
{"error": "Workspace or Project Does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -0,0 +1,519 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Max
|
||||
|
||||
# Module imports
|
||||
from plane.api.views import BaseAPIView
|
||||
from plane.db.models import (
|
||||
WorkspaceIntegration,
|
||||
Importer,
|
||||
APIToken,
|
||||
Project,
|
||||
State,
|
||||
IssueSequence,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
IssueComment,
|
||||
IssueLink,
|
||||
IssueLabel,
|
||||
Workspace,
|
||||
IssueAssignee,
|
||||
Module,
|
||||
ModuleLink,
|
||||
ModuleIssue,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
ImporterSerializer,
|
||||
IssueFlatSerializer,
|
||||
ModuleSerializer,
|
||||
)
|
||||
from plane.utils.integrations.github import get_github_repo_details
|
||||
from plane.utils.importers.jira import jira_project_issue_summary
|
||||
from plane.bgtasks.importer_task import service_importer
|
||||
from plane.utils.html_processor import strip_tags
|
||||
|
||||
|
||||
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, service):
|
||||
try:
|
||||
if service == "github":
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
integration__provider="github", workspace__slug=slug
|
||||
)
|
||||
|
||||
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
|
||||
owner = request.GET.get("owner")
|
||||
repo = request.GET.get("repo")
|
||||
|
||||
issue_count, labels, collaborators = get_github_repo_details(
|
||||
access_tokens_url, owner, repo
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"issue_count": issue_count,
|
||||
"labels": labels,
|
||||
"collaborators": collaborators,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if service == "jira":
|
||||
project_name = request.data.get("project_name", "")
|
||||
api_token = request.data.get("api_token", "")
|
||||
email = request.data.get("email", "")
|
||||
cloud_hostname = request.data.get("cloud_hostname", "")
|
||||
if (
|
||||
not bool(project_name)
|
||||
or not bool(api_token)
|
||||
or not bool(email)
|
||||
or not bool(cloud_hostname)
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "Project name, Project key, API token, Cloud hostname and email are requied"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return Response(
|
||||
jira_project_issue_summary(
|
||||
email, api_token, project_name, cloud_hostname
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response(
|
||||
{"error": "Service not supported yet"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except WorkspaceIntegration.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Requested integration was not installed in the workspace"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ImportServiceEndpoint(BaseAPIView):
|
||||
def post(self, request, slug, service):
|
||||
try:
|
||||
project_id = request.data.get("project_id", False)
|
||||
|
||||
if not project_id:
|
||||
return Response(
|
||||
{"error": "Project ID is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
if service == "github":
|
||||
data = request.data.get("data", False)
|
||||
metadata = request.data.get("metadata", False)
|
||||
config = request.data.get("config", False)
|
||||
if not data or not metadata or not config:
|
||||
return Response(
|
||||
{"error": "Data, config and metadata are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
api_token = APIToken.objects.filter(
|
||||
user=request.user, workspace=workspace
|
||||
).first()
|
||||
if api_token is None:
|
||||
api_token = APIToken.objects.create(
|
||||
user=request.user,
|
||||
label="Importer",
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
importer = Importer.objects.create(
|
||||
service=service,
|
||||
project_id=project_id,
|
||||
status="queued",
|
||||
initiated_by=request.user,
|
||||
data=data,
|
||||
metadata=metadata,
|
||||
token=api_token,
|
||||
config=config,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
|
||||
service_importer.delay(service, importer.id)
|
||||
serializer = ImporterSerializer(importer)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
if service == "jira":
|
||||
data = request.data.get("data", False)
|
||||
metadata = request.data.get("metadata", False)
|
||||
config = request.data.get("config", False)
|
||||
if not data or not metadata:
|
||||
return Response(
|
||||
{"error": "Data, config and metadata are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
api_token = APIToken.objects.filter(
|
||||
user=request.user, workspace=workspace
|
||||
).first()
|
||||
if api_token is None:
|
||||
api_token = APIToken.objects.create(
|
||||
user=request.user,
|
||||
label="Importer",
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
importer = Importer.objects.create(
|
||||
service=service,
|
||||
project_id=project_id,
|
||||
status="queued",
|
||||
initiated_by=request.user,
|
||||
data=data,
|
||||
metadata=metadata,
|
||||
token=api_token,
|
||||
config=config,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
|
||||
service_importer.delay(service, importer.id)
|
||||
serializer = ImporterSerializer(importer)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(
|
||||
{"error": "Servivce not supported yet"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except (
|
||||
Workspace.DoesNotExist,
|
||||
WorkspaceIntegration.DoesNotExist,
|
||||
Project.DoesNotExist,
|
||||
) as e:
|
||||
return Response(
|
||||
{"error": "Workspace Integration or Project does not exist"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
imports = Importer.objects.filter(workspace__slug=slug).order_by(
|
||||
"-created_at"
|
||||
)
|
||||
serializer = ImporterSerializer(imports, many=True)
|
||||
return Response(serializer.data)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class UpdateServiceImportStatusEndpoint(BaseAPIView):
|
||||
def post(self, request, slug, project_id, service, importer_id):
|
||||
try:
|
||||
importer = Importer.objects.get(
|
||||
pk=importer_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
service=service,
|
||||
)
|
||||
importer.status = request.data.get("status", "processing")
|
||||
importer.save()
|
||||
return Response(status.HTTP_200_OK)
|
||||
except Importer.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Importer does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
def post(self, request, slug, project_id, service):
|
||||
try:
|
||||
# Get the project
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
|
||||
# Get the default state
|
||||
default_state = State.objects.filter(
|
||||
project_id=project_id, default=True
|
||||
).first()
|
||||
# if there is no default state assign any random state
|
||||
if default_state is None:
|
||||
default_state = State.objects.filter(project_id=project_id).first()
|
||||
|
||||
# Get the maximum sequence_id
|
||||
last_id = IssueSequence.objects.filter(project_id=project_id).aggregate(
|
||||
largest=Max("sequence")
|
||||
)["largest"]
|
||||
|
||||
last_id = 1 if last_id is None else last_id + 1
|
||||
|
||||
# Get the maximum sort order
|
||||
largest_sort_order = Issue.objects.filter(
|
||||
project_id=project_id, state=default_state
|
||||
).aggregate(largest=Max("sort_order"))["largest"]
|
||||
|
||||
largest_sort_order = (
|
||||
65535 if largest_sort_order is None else largest_sort_order + 10000
|
||||
)
|
||||
|
||||
# Get the issues_data
|
||||
issues_data = request.data.get("issues_data", [])
|
||||
|
||||
if not len(issues_data):
|
||||
return Response(
|
||||
{"error": "Issue data is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Issues
|
||||
bulk_issues = []
|
||||
for issue_data in issues_data:
|
||||
bulk_issues.append(
|
||||
Issue(
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
state_id=issue_data.get("state")
|
||||
if issue_data.get("state", False)
|
||||
else default_state.id,
|
||||
name=issue_data.get("name", "Issue Created through Bulk"),
|
||||
description_html=issue_data.get("description_html", "<p></p>"),
|
||||
description_stripped=(
|
||||
None
|
||||
if (
|
||||
issue_data.get("description_html") == ""
|
||||
or issue_data.get("description_html") is None
|
||||
)
|
||||
else strip_tags(issue_data.get("description_html"))
|
||||
),
|
||||
sequence_id=last_id,
|
||||
sort_order=largest_sort_order,
|
||||
start_date=issue_data.get("start_date", None),
|
||||
target_date=issue_data.get("target_date", None),
|
||||
priority=issue_data.get("priority", None),
|
||||
)
|
||||
)
|
||||
|
||||
largest_sort_order = largest_sort_order + 10000
|
||||
last_id = last_id + 1
|
||||
|
||||
issues = Issue.objects.bulk_create(
|
||||
bulk_issues,
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
# Sequences
|
||||
_ = IssueSequence.objects.bulk_create(
|
||||
[
|
||||
IssueSequence(
|
||||
issue=issue,
|
||||
sequence=issue.sequence_id,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
)
|
||||
for issue in issues
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
|
||||
# Attach Labels
|
||||
bulk_issue_labels = []
|
||||
for issue, issue_data in zip(issues, issues_data):
|
||||
labels_list = issue_data.get("labels_list", [])
|
||||
bulk_issue_labels = bulk_issue_labels + [
|
||||
IssueLabel(
|
||||
issue=issue,
|
||||
label_id=label_id,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for label_id in labels_list
|
||||
]
|
||||
|
||||
_ = IssueLabel.objects.bulk_create(
|
||||
bulk_issue_labels, batch_size=100, ignore_conflicts=True
|
||||
)
|
||||
|
||||
# Attach Assignees
|
||||
bulk_issue_assignees = []
|
||||
for issue, issue_data in zip(issues, issues_data):
|
||||
assignees_list = issue_data.get("assignees_list", [])
|
||||
bulk_issue_assignees = bulk_issue_assignees + [
|
||||
IssueAssignee(
|
||||
issue=issue,
|
||||
assignee_id=assignee_id,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for assignee_id in assignees_list
|
||||
]
|
||||
|
||||
_ = IssueAssignee.objects.bulk_create(
|
||||
bulk_issue_assignees, batch_size=100, ignore_conflicts=True
|
||||
)
|
||||
|
||||
# Track the issue activities
|
||||
IssueActivity.objects.bulk_create(
|
||||
[
|
||||
IssueActivity(
|
||||
issue=issue,
|
||||
actor=request.user,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
comment=f"{request.user.email} importer the issue from {service}",
|
||||
verb="created",
|
||||
)
|
||||
for issue in issues
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
|
||||
# Create Comments
|
||||
bulk_issue_comments = []
|
||||
for issue, issue_data in zip(issues, issues_data):
|
||||
comments_list = issue_data.get("comments_list", [])
|
||||
bulk_issue_comments = bulk_issue_comments + [
|
||||
IssueComment(
|
||||
issue=issue,
|
||||
comment_html=comment.get("comment_html", "<p></p>"),
|
||||
actor=request.user,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for comment in comments_list
|
||||
]
|
||||
|
||||
_ = IssueComment.objects.bulk_create(bulk_issue_comments, batch_size=100)
|
||||
|
||||
# Attach Links
|
||||
_ = IssueLink.objects.bulk_create(
|
||||
[
|
||||
IssueLink(
|
||||
issue=issue,
|
||||
url=issue_data.get("link", {}).get("url", "https://github.com"),
|
||||
title=issue_data.get("link", {}).get("title", "Original Issue"),
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for issue, issue_data in zip(issues, issues_data)
|
||||
]
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"issues": IssueFlatSerializer(issues, many=True).data},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
except Project.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project Does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class BulkImportModulesEndpoint(BaseAPIView):
|
||||
def post(self, request, slug, project_id, service):
|
||||
try:
|
||||
modules_data = request.data.get("modules_data", [])
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
|
||||
modules = Module.objects.bulk_create(
|
||||
[
|
||||
Module(
|
||||
name=module.get("name", uuid.uuid4().hex),
|
||||
description=module.get("description", ""),
|
||||
start_date=module.get("start_date", None),
|
||||
target_date=module.get("target_date", None),
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for module in modules_data
|
||||
],
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
_ = ModuleLink.objects.bulk_create(
|
||||
[
|
||||
ModuleLink(
|
||||
module=module,
|
||||
url=module_data.get("link", {}).get("url", "https://plane.so"),
|
||||
title=module_data.get("link", {}).get(
|
||||
"title", "Original Issue"
|
||||
),
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for module, module_data in zip(modules, modules_data)
|
||||
],
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
bulk_module_issues = []
|
||||
for module, module_data in zip(modules, modules_data):
|
||||
module_issues_list = module_data.get("module_issues_list", [])
|
||||
bulk_module_issues = bulk_module_issues + [
|
||||
ModuleIssue(
|
||||
issue_id=issue,
|
||||
module=module,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for issue in module_issues_list
|
||||
]
|
||||
|
||||
_ = ModuleIssue.objects.bulk_create(
|
||||
bulk_module_issues, batch_size=100, ignore_conflicts=True
|
||||
)
|
||||
|
||||
serializer = ModuleSerializer(modules, many=True)
|
||||
return Response(
|
||||
{"modules": serializer.data}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
except Project.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -2,6 +2,7 @@ from .base import IntegrationViewSet, WorkspaceIntegrationViewSet
|
||||
from .github import (
|
||||
GithubRepositorySyncViewSet,
|
||||
GithubIssueSyncViewSet,
|
||||
BulkCreateGithubIssueSyncEndpoint,
|
||||
GithubCommentSyncViewSet,
|
||||
GithubRepositoriesEndpoint,
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ from plane.utils.integrations.github import (
|
||||
get_github_metadata,
|
||||
delete_github_installation,
|
||||
)
|
||||
|
||||
from plane.api.permissions import WorkSpaceAdminPermission
|
||||
|
||||
class IntegrationViewSet(BaseViewSet):
|
||||
serializer_class = IntegrationSerializer
|
||||
@@ -75,11 +75,33 @@ class IntegrationViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def destroy(self, request, pk):
|
||||
try:
|
||||
integration = Integration.objects.get(pk=pk)
|
||||
if integration.verified:
|
||||
return Response(
|
||||
{"error": "Verified integrations cannot be updated"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
integration.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except Integration.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Integration Does not exist"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceIntegrationViewSet(BaseViewSet):
|
||||
serializer_class = WorkspaceIntegrationSerializer
|
||||
model = WorkspaceIntegration
|
||||
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
|
||||
@@ -13,6 +13,7 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
Label,
|
||||
GithubCommentSync,
|
||||
Project,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
GithubIssueSyncSerializer,
|
||||
@@ -20,15 +21,27 @@ from plane.api.serializers import (
|
||||
GithubCommentSyncSerializer,
|
||||
)
|
||||
from plane.utils.integrations.github import get_github_repos
|
||||
from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission
|
||||
|
||||
|
||||
class GithubRepositoriesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, workspace_integration_id):
|
||||
try:
|
||||
page = request.GET.get("page", 1)
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
workspace__slug=slug, pk=workspace_integration_id
|
||||
)
|
||||
|
||||
if workspace_integration.integration.provider != "github":
|
||||
return Response(
|
||||
{"error": "Not a github integration"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
|
||||
repositories_url = (
|
||||
workspace_integration.metadata["repositories_url"]
|
||||
@@ -44,6 +57,10 @@ class GithubRepositoriesEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class GithubRepositorySyncViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
serializer_class = GithubRepositorySyncSerializer
|
||||
model = GithubRepositorySync
|
||||
|
||||
@@ -84,10 +101,6 @@ class GithubRepositorySyncViewSet(BaseViewSet):
|
||||
GithubRepository.objects.filter(
|
||||
project_id=project_id, workspace__slug=slug
|
||||
).delete()
|
||||
# Project member delete
|
||||
ProjectMember.objects.filter(
|
||||
member=workspace_integration.actor, role=20, project_id=project_id
|
||||
).delete()
|
||||
|
||||
# Create repository
|
||||
repo = GithubRepository.objects.create(
|
||||
@@ -124,7 +137,7 @@ class GithubRepositorySyncViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
# Add bot as a member in the project
|
||||
_ = ProjectMember.objects.create(
|
||||
_ = ProjectMember.objects.get_or_create(
|
||||
member=workspace_integration.actor, role=20, project_id=project_id
|
||||
)
|
||||
|
||||
@@ -148,6 +161,10 @@ class GithubRepositorySyncViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class GithubIssueSyncViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
serializer_class = GithubIssueSyncSerializer
|
||||
model = GithubIssueSync
|
||||
|
||||
@@ -158,7 +175,52 @@ class GithubIssueSyncViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
|
||||
class BulkCreateGithubIssueSyncEndpoint(BaseAPIView):
|
||||
def post(self, request, slug, project_id, repo_sync_id):
|
||||
try:
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
|
||||
github_issue_syncs = request.data.get("github_issue_syncs", [])
|
||||
github_issue_syncs = GithubIssueSync.objects.bulk_create(
|
||||
[
|
||||
GithubIssueSync(
|
||||
issue_id=github_issue_sync.get("issue"),
|
||||
repo_issue_id=github_issue_sync.get("repo_issue_id"),
|
||||
issue_url=github_issue_sync.get("issue_url"),
|
||||
github_issue_id=github_issue_sync.get("github_issue_id"),
|
||||
repository_sync_id=repo_sync_id,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for github_issue_sync in github_issue_syncs
|
||||
],
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
serializer = GithubIssueSyncSerializer(github_issue_syncs, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
except Project.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project does not exist"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class GithubCommentSyncViewSet(BaseViewSet):
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
serializer_class = GithubCommentSyncSerializer
|
||||
model = GithubCommentSync
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# Python imports
|
||||
import json
|
||||
import random
|
||||
from itertools import groupby, chain
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Prefetch, OuterRef, Func, F, Q
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -24,6 +27,7 @@ from plane.api.serializers import (
|
||||
LabelSerializer,
|
||||
IssueFlatSerializer,
|
||||
IssueLinkSerializer,
|
||||
IssueLiteSerializer,
|
||||
)
|
||||
from plane.api.permissions import (
|
||||
ProjectEntityPermission,
|
||||
@@ -38,13 +42,11 @@ from plane.db.models import (
|
||||
TimelineIssue,
|
||||
IssueProperty,
|
||||
Label,
|
||||
IssueBlocker,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
IssueLink,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class IssueViewSet(BaseViewSet):
|
||||
@@ -133,59 +135,29 @@ class IssueViewSet(BaseViewSet):
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocked_issues",
|
||||
queryset=IssueBlocker.objects.select_related("blocked_by", "block"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocker_issues",
|
||||
queryset=IssueBlocker.objects.select_related("block", "blocked_by"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle",
|
||||
queryset=CycleIssue.objects.select_related("cycle", "issue"),
|
||||
),
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.select_related(
|
||||
"module", "issue"
|
||||
).prefetch_related("module__members"),
|
||||
),
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
queryset=IssueLink.objects.select_related("issue").select_related(
|
||||
"created_by"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
# Issue State groups
|
||||
type = request.GET.get("type", "all")
|
||||
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
if type == "backlog":
|
||||
group = ["backlog"]
|
||||
if type == "active":
|
||||
group = ["unstarted", "started"]
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
show_sub_issues = request.GET.get("show_sub_issues", "true")
|
||||
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.order_by(request.GET.get("order_by", "created_at"))
|
||||
.filter(state__group__in=group)
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__id"))
|
||||
.annotate(module_id=F("issue_module__id"))
|
||||
)
|
||||
|
||||
issues = IssueSerializer(issue_queryset, many=True).data
|
||||
issue_queryset = (
|
||||
issue_queryset
|
||||
if show_sub_issues == "true"
|
||||
else issue_queryset.filter(parent__isnull=True)
|
||||
)
|
||||
|
||||
issues = IssueLiteSerializer(issue_queryset, many=True).data
|
||||
|
||||
## Grouping the results
|
||||
group_by = request.GET.get("group_by", False)
|
||||
@@ -197,7 +169,6 @@ class IssueViewSet(BaseViewSet):
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
@@ -235,8 +206,20 @@ class IssueViewSet(BaseViewSet):
|
||||
{"error": "Project was not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
try:
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
except Issue.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
class UserWorkSpaceIssues(BaseAPIView):
|
||||
@method_decorator(gzip_page)
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
issues = (
|
||||
@@ -253,44 +236,9 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocked_issues",
|
||||
queryset=IssueBlocker.objects.select_related(
|
||||
"blocked_by", "block"
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocker_issues",
|
||||
queryset=IssueBlocker.objects.select_related(
|
||||
"block", "blocked_by"
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle",
|
||||
queryset=CycleIssue.objects.select_related("cycle", "issue"),
|
||||
),
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.select_related("module", "issue"),
|
||||
),
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
queryset=IssueLink.objects.select_related(
|
||||
"issue"
|
||||
).select_related("created_by"),
|
||||
)
|
||||
)
|
||||
.order_by("-created_at")
|
||||
)
|
||||
serializer = IssueSerializer(issues, many=True)
|
||||
serializer = IssueLiteSerializer(issues, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
@@ -305,10 +253,13 @@ class WorkSpaceIssuesEndpoint(BaseAPIView):
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
issues = Issue.objects.filter(workspace__slug=slug).filter(
|
||||
project__project_projectmember__member=self.request.user
|
||||
issues = (
|
||||
Issue.objects.filter(workspace__slug=slug)
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.order_by("-created_at")
|
||||
)
|
||||
serializer = IssueSerializer(issues, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -325,6 +276,7 @@ class IssueActivityEndpoint(BaseAPIView):
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
issue_activities = (
|
||||
@@ -333,8 +285,8 @@ class IssueActivityEndpoint(BaseAPIView):
|
||||
~Q(field="comment"),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
)
|
||||
.select_related("actor")
|
||||
).order_by("created_by")
|
||||
.select_related("actor", "workspace")
|
||||
).order_by("created_at")
|
||||
issue_comments = (
|
||||
IssueComment.objects.filter(issue_id=issue_id)
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
@@ -561,6 +513,7 @@ class LabelViewSet(BaseViewSet):
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("parent")
|
||||
.order_by("name")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@@ -605,6 +558,7 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
sub_issues = (
|
||||
@@ -617,37 +571,9 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocked_issues",
|
||||
queryset=IssueBlocker.objects.select_related(
|
||||
"blocked_by", "block"
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocker_issues",
|
||||
queryset=IssueBlocker.objects.select_related(
|
||||
"block", "blocked_by"
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle",
|
||||
queryset=CycleIssue.objects.select_related("cycle", "issue"),
|
||||
),
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.select_related("module", "issue"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
serializer = IssueSerializer(sub_issues, many=True)
|
||||
serializer = IssueLiteSerializer(sub_issues, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
@@ -715,5 +641,45 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
class BulkCreateIssueLabelsEndpoint(BaseAPIView):
|
||||
def post(self, request, slug, project_id):
|
||||
try:
|
||||
label_data = request.data.get("label_data", [])
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
labels = Label.objects.bulk_create(
|
||||
[
|
||||
Label(
|
||||
name=label.get("name", "Migrated"),
|
||||
description=label.get("description", "Migrated Issue"),
|
||||
color="#" + "%06x" % random.randint(0, 0xFFFFFF),
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for label in label_data
|
||||
],
|
||||
batch_size=50,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"labels": LabelSerializer(labels, many=True).data},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
except Project.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project Does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -3,8 +3,10 @@ import json
|
||||
|
||||
# Django Imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Prefetch, F, OuterRef, Func, Exists
|
||||
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.views.decorators.gzip import gzip_page
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -19,6 +21,7 @@ from plane.api.serializers import (
|
||||
ModuleIssueSerializer,
|
||||
ModuleLinkSerializer,
|
||||
ModuleFavoriteSerializer,
|
||||
IssueStateSerializer,
|
||||
)
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
@@ -31,6 +34,7 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class ModuleViewSet(BaseViewSet):
|
||||
@@ -47,29 +51,60 @@ class ModuleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
subquery = ModuleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
module_id=OuterRef("pk"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("lead")
|
||||
.prefetch_related("members")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.select_related(
|
||||
"module", "issue", "issue__state", "issue__project"
|
||||
).prefetch_related("issue__assignees", "issue__labels"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"link_module",
|
||||
queryset=ModuleLink.objects.select_related("module", "created_by"),
|
||||
)
|
||||
)
|
||||
.annotate(total_issues=Count("issue_module"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(issue_module__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(issue_module__issue__state__group="cancelled"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(issue_module__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(issue_module__issue__state__group="unstarted"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(issue_module__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "name")
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
@@ -101,23 +136,6 @@ class ModuleViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = ModuleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
module_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
modules = self.get_queryset().annotate(is_favorite=Exists(subquery))
|
||||
return Response(ModuleSerializer(modules, many=True).data)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ModuleIssueViewSet(BaseViewSet):
|
||||
serializer_class = ModuleIssueSerializer
|
||||
@@ -161,22 +179,43 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug, project_id, module_id):
|
||||
try:
|
||||
order_by = request.GET.get("order_by", "issue__created_at")
|
||||
queryset = self.get_queryset().order_by(f"issue__{order_by}")
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
group_by = request.GET.get("group_by", False)
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.objects.filter(issue_module__module_id=module_id)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(bridge_id=F("issue_module__id"))
|
||||
.filter(project_id=project_id)
|
||||
.filter(workspace__slug=slug)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.order_by(order_by)
|
||||
.filter(**filters)
|
||||
)
|
||||
|
||||
module_issues = ModuleIssueSerializer(queryset, many=True).data
|
||||
issues_data = IssueStateSerializer(issues, many=True).data
|
||||
|
||||
if group_by:
|
||||
return Response(
|
||||
group_results(module_issues, f"issue_detail.{group_by}"),
|
||||
group_results(issues_data, group_by),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
module_issues,
|
||||
issues_data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -302,11 +341,16 @@ class ModuleLinkViewSet(BaseViewSet):
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(module_id=self.kwargs.get("module_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
class ModuleFavoriteViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
serializer_class = ModuleFavoriteSerializer
|
||||
model = ModuleFavorite
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
# Third Party modules
|
||||
from rest_framework.response import Response
|
||||
@@ -204,7 +205,26 @@ class OauthEndpoint(BaseAPIView):
|
||||
"last_login_at": timezone.now(),
|
||||
},
|
||||
)
|
||||
|
||||
if settings.ANALYTICS_BASE_API:
|
||||
_ = requests.post(
|
||||
settings.ANALYTICS_BASE_API,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
|
||||
},
|
||||
json={
|
||||
"event_id": uuid.uuid4().hex,
|
||||
"event_data": {
|
||||
"medium": f"oauth-{medium}",
|
||||
},
|
||||
"user": {"email": email, "id": str(user.id)},
|
||||
"device_ctx": {
|
||||
"ip": request.META.get("REMOTE_ADDR"),
|
||||
"user_agent": request.META.get("HTTP_USER_AGENT"),
|
||||
},
|
||||
"event_type": "SIGN_IN",
|
||||
},
|
||||
)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
except User.DoesNotExist:
|
||||
@@ -253,6 +273,26 @@ class OauthEndpoint(BaseAPIView):
|
||||
"user": serialized_user,
|
||||
"permissions": [],
|
||||
}
|
||||
if settings.ANALYTICS_BASE_API:
|
||||
_ = requests.post(
|
||||
settings.ANALYTICS_BASE_API,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
|
||||
},
|
||||
json={
|
||||
"event_id": uuid.uuid4().hex,
|
||||
"event_data": {
|
||||
"medium": f"oauth-{medium}",
|
||||
},
|
||||
"user": {"email": email, "id": str(user.id)},
|
||||
"device_ctx": {
|
||||
"ip": request.META.get("REMOTE_ADDR"),
|
||||
"user_agent": request.META.get("HTTP_USER_AGENT"),
|
||||
},
|
||||
"event_type": "SIGN_UP",
|
||||
},
|
||||
)
|
||||
|
||||
SocialLoginConnection.objects.update_or_create(
|
||||
medium=medium,
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
# Python imports
|
||||
from datetime import timedelta, datetime, date
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Exists, OuterRef, Q, Prefetch
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
Page,
|
||||
PageBlock,
|
||||
PageFavorite,
|
||||
Issue,
|
||||
IssueAssignee,
|
||||
IssueActivity,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
PageSerializer,
|
||||
PageBlockSerializer,
|
||||
PageFavoriteSerializer,
|
||||
IssueLiteSerializer,
|
||||
)
|
||||
|
||||
|
||||
class PageViewSet(BaseViewSet):
|
||||
serializer_class = PageSerializer
|
||||
model = Page
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
search_fields = [
|
||||
"name",
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
subquery = PageFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
page_id=OuterRef("pk"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.order_by(self.request.GET.get("order_by", "-created_at"))
|
||||
.prefetch_related("labels")
|
||||
.order_by("name", "-is_favorite")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"), owned_by=self.request.user
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
serializer = PageSerializer(
|
||||
data=request.data,
|
||||
context={"project_id": project_id, "owned_by_id": request.user.id},
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class PageBlockViewSet(BaseViewSet):
|
||||
serializer_class = PageBlockSerializer
|
||||
model = PageBlock
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(page_id=self.kwargs.get("page_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("page")
|
||||
.select_related("issue")
|
||||
.order_by("sort_order")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
page_id=self.kwargs.get("page_id"),
|
||||
)
|
||||
|
||||
|
||||
class PageFavoriteViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
serializer_class = PageFavoriteSerializer
|
||||
model = PageFavorite
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(user=self.request.user)
|
||||
.select_related("page", "page__owned_by")
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
serializer = PageFavoriteSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(user=request.user, project_id=project_id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"error": "The page is already added to favorites"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
else:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, page_id):
|
||||
try:
|
||||
page_favorite = PageFavorite.objects.get(
|
||||
project=project_id,
|
||||
user=request.user,
|
||||
workspace__slug=slug,
|
||||
page_id=page_id,
|
||||
)
|
||||
page_favorite.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except PageFavorite.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Page is not in favorites"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class CreateIssueFromPageBlockEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug, project_id, page_id, page_block_id):
|
||||
try:
|
||||
page_block = PageBlock.objects.get(
|
||||
pk=page_block_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
page_id=page_id,
|
||||
)
|
||||
issue = Issue.objects.create(
|
||||
name=page_block.name,
|
||||
project_id=project_id,
|
||||
description=page_block.description,
|
||||
description_html=page_block.description_html,
|
||||
description_stripped=page_block.description_stripped,
|
||||
)
|
||||
_ = IssueAssignee.objects.create(
|
||||
issue=issue, assignee=request.user, project_id=project_id
|
||||
)
|
||||
|
||||
_ = IssueActivity.objects.create(
|
||||
issue=issue,
|
||||
actor=request.user,
|
||||
project_id=project_id,
|
||||
comment=f"{request.user.email} created the issue from {page_block.name} block",
|
||||
verb="created",
|
||||
)
|
||||
|
||||
page_block.issue = issue
|
||||
page_block.save()
|
||||
|
||||
return Response(IssueLiteSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
except PageBlock.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Page Block does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class RecentPagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = PageFavorite.objects.filter(
|
||||
user=request.user,
|
||||
page_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
current_time = date.today()
|
||||
day_before = current_time - timedelta(days=1)
|
||||
|
||||
todays_pages = (
|
||||
Page.objects.filter(
|
||||
updated_at__date=date.today(),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.filter(project__project_projectmember__member=request.user)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "-updated_at")
|
||||
)
|
||||
|
||||
yesterdays_pages = (
|
||||
Page.objects.filter(
|
||||
updated_at__date=day_before,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.filter(project__project_projectmember__member=request.user)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "-updated_at")
|
||||
)
|
||||
|
||||
earlier_this_week = (
|
||||
Page.objects.filter(
|
||||
updated_at__date__range=(
|
||||
(timezone.now() - timedelta(days=7)),
|
||||
(timezone.now() - timedelta(days=2)),
|
||||
),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.filter(project__project_projectmember__member=request.user)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "-updated_at")
|
||||
)
|
||||
todays_pages_serializer = PageSerializer(todays_pages, many=True)
|
||||
yesterday_pages_serializer = PageSerializer(yesterdays_pages, many=True)
|
||||
earlier_this_week_serializer = PageSerializer(earlier_this_week, many=True)
|
||||
return Response(
|
||||
{
|
||||
"today": todays_pages_serializer.data,
|
||||
"yesterday": yesterday_pages_serializer.data,
|
||||
"earlier_this_week": earlier_this_week_serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class FavoritePagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = PageFavorite.objects.filter(
|
||||
user=request.user,
|
||||
page_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
pages = (
|
||||
Page.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.filter(project__project_projectmember__member=request.user)
|
||||
.filter(is_favorite=True)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
serializer = PageSerializer(pages, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class MyPagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = PageFavorite.objects.filter(
|
||||
user=request.user,
|
||||
page_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
pages = (
|
||||
Page.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, owned_by=request.user
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.filter(project__project_projectmember__member=request.user)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "name")
|
||||
)
|
||||
serializer = PageSerializer(pages, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class CreatedbyOtherPagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = PageFavorite.objects.filter(
|
||||
user=request.user,
|
||||
page_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
pages = (
|
||||
Page.objects.filter(
|
||||
~Q(owned_by=request.user),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
access=0,
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "name")
|
||||
)
|
||||
serializer = PageSerializer(pages, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -7,10 +7,20 @@ from sentry_sdk import capture_exception
|
||||
# Module imports
|
||||
from plane.api.serializers import (
|
||||
UserSerializer,
|
||||
IssueActivitySerializer,
|
||||
)
|
||||
|
||||
from plane.api.views.base import BaseViewSet, BaseAPIView
|
||||
from plane.db.models import User, Workspace
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMemberInvite,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.utils.paginator import BasePaginator
|
||||
|
||||
|
||||
class UserEndpoint(BaseViewSet):
|
||||
serializer_class = UserSerializer
|
||||
@@ -22,11 +32,34 @@ class UserEndpoint(BaseViewSet):
|
||||
def retrieve(self, request):
|
||||
try:
|
||||
workspace = Workspace.objects.get(pk=request.user.last_workspace_id)
|
||||
workspace_invites = WorkspaceMemberInvite.objects.filter(
|
||||
email=request.user.email
|
||||
).count()
|
||||
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
|
||||
|
||||
return Response(
|
||||
{"user": UserSerializer(request.user).data, "slug": workspace.slug}
|
||||
{
|
||||
"user": UserSerializer(request.user).data,
|
||||
"slug": workspace.slug,
|
||||
"workspace_invites": workspace_invites,
|
||||
"assigned_issues": assigned_issues,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Workspace.DoesNotExist:
|
||||
return Response({"user": UserSerializer(request.user).data, "slug": None})
|
||||
workspace_invites = WorkspaceMemberInvite.objects.filter(
|
||||
email=request.user.email
|
||||
).count()
|
||||
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
|
||||
return Response(
|
||||
{
|
||||
"user": UserSerializer(request.user).data,
|
||||
"slug": None,
|
||||
"workspace_invites": workspace_invites,
|
||||
"assigned_issues": assigned_issues,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
@@ -40,6 +73,20 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView):
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
user.is_onboarded = request.data.get("is_onboarded", False)
|
||||
user.save()
|
||||
|
||||
if user.last_workspace_id is not None:
|
||||
user_role = WorkspaceMember.objects.filter(
|
||||
workspace_id=user.last_workspace_id, member=request.user.id
|
||||
).first()
|
||||
return Response(
|
||||
{
|
||||
"message": "Updated successfully",
|
||||
"role": user_role.company_role
|
||||
if user_role is not None
|
||||
else None,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response(
|
||||
{"message": "Updated successfully"}, status=status.HTTP_200_OK
|
||||
)
|
||||
@@ -49,3 +96,25 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class UserActivityEndpoint(BaseAPIView, BasePaginator):
|
||||
def get(self, request):
|
||||
try:
|
||||
queryset = IssueActivity.objects.filter(actor=request.user).select_related(
|
||||
"actor", "workspace"
|
||||
)
|
||||
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=queryset,
|
||||
on_results=lambda issue_activities: IssueActivitySerializer(
|
||||
issue_activities, many=True
|
||||
).data,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -64,6 +64,11 @@ class ProjectViewSet(BaseViewSet):
|
||||
return ProjectDetailSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
subquery = ProjectFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
@@ -72,6 +77,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
.select_related(
|
||||
"workspace", "workspace__owner", "default_assignee", "project_lead"
|
||||
)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@@ -82,7 +88,11 @@ class ProjectViewSet(BaseViewSet):
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
projects = self.get_queryset().annotate(is_favorite=Exists(subquery))
|
||||
projects = (
|
||||
self.get_queryset()
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.order_by("-is_favorite", "name")
|
||||
)
|
||||
return Response(ProjectDetailSerializer(projects, many=True).data)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
@@ -167,6 +177,12 @@ class ProjectViewSet(BaseViewSet):
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
else:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
except Workspace.DoesNotExist as e:
|
||||
return Response(
|
||||
{"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Python imports
|
||||
import re
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Q
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models import Workspace, Project, Issue, Cycle, Module, Page, IssueView
|
||||
|
||||
|
||||
class GlobalSearchEndpoint(BaseAPIView):
|
||||
"""Endpoint to search across multiple fields in the workspace and
|
||||
also show related workspace if found
|
||||
"""
|
||||
|
||||
def filter_workspaces(self, query, slug, project_id):
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
return Workspace.objects.filter(
|
||||
q, workspace_member__member=self.request.user
|
||||
).distinct().values("name", "id", "slug")
|
||||
|
||||
def filter_projects(self, query, slug, project_id):
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
return Project.objects.filter(
|
||||
q,
|
||||
Q(project_projectmember__member=self.request.user) | Q(network=2),
|
||||
workspace__slug=slug,
|
||||
).distinct().values("name", "id", "identifier", "workspace__slug")
|
||||
|
||||
def filter_issues(self, query, slug, project_id):
|
||||
fields = ["name", "sequence_id"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
if field == "sequence_id":
|
||||
sequences = re.findall(r"\d+\.\d+|\d+", query)
|
||||
for sequence_id in sequences:
|
||||
q |= Q(**{"sequence_id": sequence_id})
|
||||
else:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
return Issue.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
).distinct().values(
|
||||
"name",
|
||||
"id",
|
||||
"sequence_id",
|
||||
"project__identifier",
|
||||
"project_id",
|
||||
"workspace__slug",
|
||||
)
|
||||
|
||||
def filter_cycles(self, query, slug, project_id):
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
return Cycle.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
).distinct().values(
|
||||
"name",
|
||||
"id",
|
||||
"project_id",
|
||||
"workspace__slug",
|
||||
)
|
||||
|
||||
def filter_modules(self, query, slug, project_id):
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
return Module.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
).distinct().values(
|
||||
"name",
|
||||
"id",
|
||||
"project_id",
|
||||
"workspace__slug",
|
||||
)
|
||||
|
||||
def filter_pages(self, query, slug, project_id):
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
return Page.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
).distinct().values(
|
||||
"name",
|
||||
"id",
|
||||
"project_id",
|
||||
"workspace__slug",
|
||||
)
|
||||
|
||||
def filter_views(self, query, slug, project_id):
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
return IssueView.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
).distinct().values(
|
||||
"name",
|
||||
"id",
|
||||
"project_id",
|
||||
"workspace__slug",
|
||||
)
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
query = request.query_params.get("search", False)
|
||||
if not query:
|
||||
return Response(
|
||||
{
|
||||
"results": {
|
||||
"workspace": [],
|
||||
"project": [],
|
||||
"issue": [],
|
||||
"cycle": [],
|
||||
"module": [],
|
||||
"issue_view": [],
|
||||
"page": [],
|
||||
}
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
MODELS_MAPPER = {
|
||||
"workspace": self.filter_workspaces,
|
||||
"project": self.filter_projects,
|
||||
"issue": self.filter_issues,
|
||||
"cycle": self.filter_cycles,
|
||||
"module": self.filter_modules,
|
||||
"issue_view": self.filter_views,
|
||||
"page": self.filter_pages,
|
||||
}
|
||||
|
||||
results = {}
|
||||
|
||||
for model in MODELS_MAPPER.keys():
|
||||
func = MODELS_MAPPER.get(model, None)
|
||||
results[model] = func(query, slug, project_id)
|
||||
return Response({"results": results}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -1,14 +1,35 @@
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Prefetch, OuterRef, Exists
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from . import BaseViewSet
|
||||
from plane.api.serializers import ViewSerializer
|
||||
from . import BaseViewSet, BaseAPIView
|
||||
from plane.api.serializers import (
|
||||
IssueViewSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueViewFavoriteSerializer,
|
||||
)
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import View
|
||||
from plane.db.models import (
|
||||
IssueView,
|
||||
Issue,
|
||||
IssueBlocker,
|
||||
IssueLink,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
IssueViewFavorite,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class ViewViewSet(BaseViewSet):
|
||||
|
||||
serializer_class = ViewSerializer
|
||||
model = View
|
||||
class IssueViewViewSet(BaseViewSet):
|
||||
serializer_class = IssueViewSerializer
|
||||
model = IssueView
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
@@ -17,6 +38,12 @@ class ViewViewSet(BaseViewSet):
|
||||
serializer.save(project_id=self.kwargs.get("project_id"))
|
||||
|
||||
def get_queryset(self):
|
||||
subquery = IssueViewFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
view_id=OuterRef("pk"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
@@ -25,5 +52,108 @@ class ViewViewSet(BaseViewSet):
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.order_by("-is_favorite", "name")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
class ViewIssuesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id, view_id):
|
||||
try:
|
||||
view = IssueView.objects.get(pk=view_id)
|
||||
queries = view.query
|
||||
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
issues = (
|
||||
Issue.objects.filter(
|
||||
**queries, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
)
|
||||
|
||||
serializer = IssueLiteSerializer(issues, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except IssueView.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Issue View does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class IssueViewFavoriteViewSet(BaseViewSet):
|
||||
serializer_class = IssueViewFavoriteSerializer
|
||||
model = IssueViewFavorite
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(user=self.request.user)
|
||||
.select_related("view")
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
serializer = IssueViewFavoriteSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(user=request.user, project_id=project_id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"error": "The view is already added to favorites"},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
else:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, view_id):
|
||||
try:
|
||||
view_favourite = IssueViewFavorite.objects.get(
|
||||
project=project_id,
|
||||
user=request.user,
|
||||
workspace__slug=slug,
|
||||
view_id=view_id,
|
||||
)
|
||||
view_favourite.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except IssueViewFavorite.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "View is not in favorites"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Python imports
|
||||
import jwt
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
@@ -10,8 +11,16 @@ from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.db.models import CharField, Count, OuterRef, Func, F
|
||||
from django.db.models.functions import Cast
|
||||
from django.db.models import (
|
||||
CharField,
|
||||
Count,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
)
|
||||
from django.db.models.functions import ExtractWeek, Cast, ExtractDay
|
||||
from django.db.models.fields import DateField
|
||||
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
@@ -37,6 +46,8 @@ from plane.db.models import (
|
||||
WorkspaceMemberInvite,
|
||||
Team,
|
||||
ProjectMember,
|
||||
IssueActivity,
|
||||
Issue,
|
||||
)
|
||||
from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission
|
||||
from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
||||
@@ -59,7 +70,9 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
lookup_field = "slug"
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(super().get_queryset().select_related("owner"))
|
||||
return self.filter_queryset(
|
||||
super().get_queryset().select_related("owner")
|
||||
).order_by("name")
|
||||
|
||||
def create(self, request):
|
||||
try:
|
||||
@@ -578,3 +591,164 @@ class WorkspaceMemberUserViewsEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class UserActivityGraphEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
issue_activities = (
|
||||
IssueActivity.objects.filter(
|
||||
actor=request.user,
|
||||
workspace__slug=slug,
|
||||
created_at__date__gte=date.today() + relativedelta(months=-6),
|
||||
)
|
||||
.annotate(created_date=Cast("created_at", DateField()))
|
||||
.values("created_date")
|
||||
.annotate(activity_count=Count("created_date"))
|
||||
.order_by("created_date")
|
||||
)
|
||||
|
||||
return Response(issue_activities, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class UserIssueCompletedGraphEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
month = request.GET.get("month", 1)
|
||||
|
||||
issues = (
|
||||
Issue.objects.filter(
|
||||
assignees__in=[request.user],
|
||||
workspace__slug=slug,
|
||||
completed_at__month=month,
|
||||
completed_at__isnull=False,
|
||||
)
|
||||
.annotate(completed_week=ExtractWeek("completed_at"))
|
||||
.annotate(week=F("completed_week") % 4)
|
||||
.values("week")
|
||||
.annotate(completed_count=Count("completed_week"))
|
||||
.order_by("week")
|
||||
)
|
||||
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class WeekInMonth(Func):
|
||||
function = "FLOOR"
|
||||
template = "(((%(expressions)s - 1) / 7) + 1)::INTEGER"
|
||||
|
||||
|
||||
class UserWorkspaceDashboardEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
issue_activities = (
|
||||
IssueActivity.objects.filter(
|
||||
actor=request.user,
|
||||
workspace__slug=slug,
|
||||
created_at__date__gte=date.today() + relativedelta(months=-3),
|
||||
)
|
||||
.annotate(created_date=Cast("created_at", DateField()))
|
||||
.values("created_date")
|
||||
.annotate(activity_count=Count("created_date"))
|
||||
.order_by("created_date")
|
||||
)
|
||||
|
||||
month = request.GET.get("month", 1)
|
||||
|
||||
completed_issues = (
|
||||
Issue.objects.filter(
|
||||
assignees__in=[request.user],
|
||||
workspace__slug=slug,
|
||||
completed_at__month=month,
|
||||
completed_at__isnull=False,
|
||||
)
|
||||
.annotate(day_of_month=ExtractDay("completed_at"))
|
||||
.annotate(week_in_month=WeekInMonth(F("day_of_month")))
|
||||
.values("week_in_month")
|
||||
.annotate(completed_count=Count("id"))
|
||||
.order_by("week_in_month")
|
||||
)
|
||||
|
||||
assigned_issues = Issue.objects.filter(
|
||||
workspace__slug=slug, assignees__in=[request.user]
|
||||
).count()
|
||||
|
||||
pending_issues_count = Issue.objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
).count()
|
||||
|
||||
completed_issues_count = Issue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
state__group="completed",
|
||||
).count()
|
||||
|
||||
issues_due_week = (
|
||||
Issue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.annotate(target_week=ExtractWeek("target_date"))
|
||||
.filter(target_week=timezone.now().date().isocalendar()[1])
|
||||
.count()
|
||||
)
|
||||
|
||||
state_distribution = (
|
||||
Issue.objects.filter(workspace__slug=slug, assignees__in=[request.user])
|
||||
.annotate(state_group=F("state__group"))
|
||||
.values("state_group")
|
||||
.annotate(state_count=Count("state_group"))
|
||||
.order_by("state_group")
|
||||
)
|
||||
|
||||
overdue_issues = Issue.objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
target_date__lt=timezone.now(),
|
||||
completed_at__isnull=True,
|
||||
).values("id", "name", "workspace__slug", "project_id", "target_date")
|
||||
|
||||
upcoming_issues = Issue.objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
target_date__gte=timezone.now(),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
completed_at__isnull=True,
|
||||
).values("id", "name", "workspace__slug", "project_id", "target_date")
|
||||
|
||||
return Response(
|
||||
{
|
||||
"issue_activities": issue_activities,
|
||||
"completed_issues": completed_issues,
|
||||
"assigned_issues_count": assigned_issues,
|
||||
"pending_issues_count": pending_issues_count,
|
||||
"completed_issues_count": completed_issues_count,
|
||||
"issues_due_week_count": issues_due_week,
|
||||
"state_distribution": state_distribution,
|
||||
"overdue_issues": overdue_issues,
|
||||
"upcoming_issues": upcoming_issues,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# Python imports
|
||||
import json
|
||||
import requests
|
||||
import uuid
|
||||
import jwt
|
||||
from datetime import datetime
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
# Third Party imports
|
||||
from django_rq import job
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.api.serializers import ImporterSerializer
|
||||
from plane.db.models import (
|
||||
Importer,
|
||||
WorkspaceMember,
|
||||
GithubRepositorySync,
|
||||
GithubRepository,
|
||||
ProjectMember,
|
||||
WorkspaceIntegration,
|
||||
Label,
|
||||
User,
|
||||
)
|
||||
from .workspace_invitation_task import workspace_invitation
|
||||
|
||||
|
||||
@job("default")
|
||||
def service_importer(service, importer_id):
|
||||
try:
|
||||
importer = Importer.objects.get(pk=importer_id)
|
||||
importer.status = "processing"
|
||||
importer.save()
|
||||
|
||||
users = importer.data.get("users", [])
|
||||
|
||||
# For all invited users create the uers
|
||||
new_users = User.objects.bulk_create(
|
||||
[
|
||||
User(
|
||||
email=user.get("email").strip().lower(),
|
||||
username=uuid.uuid4().hex,
|
||||
password=make_password(uuid.uuid4().hex),
|
||||
is_password_autoset=True,
|
||||
)
|
||||
for user in users
|
||||
if user.get("import", False) == "invite"
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
workspace_users = User.objects.filter(
|
||||
email__in=[
|
||||
user.get("email").strip().lower()
|
||||
for user in users
|
||||
if user.get("import", False) == "invite"
|
||||
or user.get("import", False) == "map"
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# Add new users to Workspace and project automatically
|
||||
WorkspaceMember.objects.bulk_create(
|
||||
[
|
||||
WorkspaceMember(member=user, workspace_id=importer.workspace_id)
|
||||
for user in workspace_users
|
||||
],
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
ProjectMember.objects.bulk_create(
|
||||
[
|
||||
ProjectMember(
|
||||
project_id=importer.project_id,
|
||||
workspace_id=importer.workspace_id,
|
||||
member=user,
|
||||
)
|
||||
for user in workspace_users
|
||||
],
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
# Check if sync config is on for github importers
|
||||
if service == "github" and importer.config.get("sync", False):
|
||||
name = importer.metadata.get("name", False)
|
||||
url = importer.metadata.get("url", False)
|
||||
config = importer.metadata.get("config", {})
|
||||
owner = importer.metadata.get("owner", False)
|
||||
repository_id = importer.metadata.get("repository_id", False)
|
||||
|
||||
workspace_integration = WorkspaceIntegration.objects.get(
|
||||
workspace_id=importer.workspace_id, integration__provider="github"
|
||||
)
|
||||
|
||||
# Delete the old repository object
|
||||
GithubRepositorySync.objects.filter(project_id=importer.project_id).delete()
|
||||
GithubRepository.objects.filter(project_id=importer.project_id).delete()
|
||||
|
||||
# Create a Label for github
|
||||
label = Label.objects.filter(
|
||||
name="GitHub", project_id=importer.project_id
|
||||
).first()
|
||||
|
||||
if label is None:
|
||||
label = Label.objects.create(
|
||||
name="GitHub",
|
||||
project_id=importer.project_id,
|
||||
description="Label to sync Plane issues with GitHub issues",
|
||||
color="#003773",
|
||||
)
|
||||
# Create repository
|
||||
repo = GithubRepository.objects.create(
|
||||
name=name,
|
||||
url=url,
|
||||
config=config,
|
||||
repository_id=repository_id,
|
||||
owner=owner,
|
||||
project_id=importer.project_id,
|
||||
)
|
||||
|
||||
# Create repo sync
|
||||
repo_sync = GithubRepositorySync.objects.create(
|
||||
repository=repo,
|
||||
workspace_integration=workspace_integration,
|
||||
actor=workspace_integration.actor,
|
||||
credentials=importer.data.get("credentials", {}),
|
||||
project_id=importer.project_id,
|
||||
label=label,
|
||||
)
|
||||
|
||||
# Add bot as a member in the project
|
||||
_ = ProjectMember.objects.get_or_create(
|
||||
member=workspace_integration.actor,
|
||||
role=20,
|
||||
project_id=importer.project_id,
|
||||
)
|
||||
|
||||
if settings.PROXY_BASE_URL:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
import_data_json = json.dumps(
|
||||
ImporterSerializer(importer).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
)
|
||||
res = requests.post(
|
||||
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(importer.workspace_id)}/projects/{str(importer.project_id)}/importers/{str(service)}/",
|
||||
json=import_data_json,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
return
|
||||
except Exception as e:
|
||||
importer = Importer.objects.get(pk=importer_id)
|
||||
importer.status = "failed"
|
||||
importer.save()
|
||||
capture_exception(e)
|
||||
return
|
||||
@@ -676,8 +676,8 @@ def create_comment_activity(
|
||||
verb="created",
|
||||
actor=actor,
|
||||
field="comment",
|
||||
new_value=requested_data.get("comment_html"),
|
||||
new_identifier=requested_data.get("id"),
|
||||
new_value=requested_data.get("comment_html", ""),
|
||||
new_identifier=requested_data.get("id", None),
|
||||
issue_comment_id=requested_data.get("id", None),
|
||||
)
|
||||
)
|
||||
@@ -696,11 +696,11 @@ def update_comment_activity(
|
||||
verb="updated",
|
||||
actor=actor,
|
||||
field="comment",
|
||||
old_value=current_instance.get("comment_html"),
|
||||
old_value=current_instance.get("comment_html", ""),
|
||||
old_identifier=current_instance.get("id"),
|
||||
new_value=requested_data.get("comment_html"),
|
||||
new_identifier=current_instance.get("id"),
|
||||
issue_comment_id=current_instance.get("id"),
|
||||
new_value=requested_data.get("comment_html", ""),
|
||||
new_identifier=current_instance.get("id", None),
|
||||
issue_comment_id=current_instance.get("id", None),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -742,7 +742,11 @@ def issue_activity(event):
|
||||
try:
|
||||
issue_activities = []
|
||||
type = event.get("type")
|
||||
requested_data = json.loads(event.get("requested_data"))
|
||||
requested_data = (
|
||||
json.loads(event.get("requested_data"))
|
||||
if event.get("current_instance") is not None
|
||||
else None
|
||||
)
|
||||
current_instance = (
|
||||
json.loads(event.get("current_instance"))
|
||||
if event.get("current_instance") is not None
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from django_rq import job
|
||||
from sentry_sdk import capture_exception
|
||||
from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Workspace, User, WorkspaceMemberInvite
|
||||
@@ -13,9 +16,7 @@ from plane.db.models import Workspace, User, WorkspaceMemberInvite
|
||||
|
||||
@job("default")
|
||||
def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
|
||||
try:
|
||||
|
||||
workspace = Workspace.objects.get(pk=workspace_id)
|
||||
workspace_member_invite = WorkspaceMemberInvite.objects.get(
|
||||
token=token, email=email
|
||||
@@ -49,6 +50,18 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email])
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
|
||||
# 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"{workspace_member_invite.email} has been invited to {workspace.name} as a {workspace_member_invite.role}",
|
||||
)
|
||||
except SlackApiError as e:
|
||||
print(f"Got an error: {e.response['error']}")
|
||||
|
||||
return
|
||||
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Generated by Django 3.2.16 on 2023-03-15 19:10
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0022_auto_20230307_0304'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Importer',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('service', models.CharField(choices=[('github', 'GitHub')], max_length=50)),
|
||||
('status', models.CharField(choices=[('queued', 'Queued'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='queued', max_length=50)),
|
||||
('metadata', models.JSONField(default=dict)),
|
||||
('config', models.JSONField(default=dict)),
|
||||
('data', models.JSONField(default=dict)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='importer_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='imports', to=settings.AUTH_USER_MODEL)),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_importer', to='db.project')),
|
||||
('token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='importer', to='db.apitoken')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='importer_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_importer', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Importer',
|
||||
'verbose_name_plural': 'Importers',
|
||||
'db_table': 'importers',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IssueView',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('name', models.CharField(max_length=255, verbose_name='View Name')),
|
||||
('description', models.TextField(blank=True, verbose_name='View Description')),
|
||||
('query', models.JSONField(verbose_name='View Query')),
|
||||
('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)),
|
||||
('query_data', models.JSONField(default=dict)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueview_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueview', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueview_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueview', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Issue View',
|
||||
'verbose_name_plural': 'Issue Views',
|
||||
'db_table': 'issue_views',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IssueViewFavorite',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueviewfavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueviewfavorite', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueviewfavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_view_favorites', to=settings.AUTH_USER_MODEL)),
|
||||
('view', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='view_favorites', to='db.issueview')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueviewfavorite', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'View Favorite',
|
||||
'verbose_name_plural': 'View Favorites',
|
||||
'db_table': 'view_favorites',
|
||||
'ordering': ('-created_at',),
|
||||
'unique_together': {('view', 'user')},
|
||||
},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='label',
|
||||
unique_together={('name', 'project')},
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='View',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,113 @@
|
||||
# Generated by Django 3.2.16 on 2023-03-21 20:08
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0023_auto_20230316_0040'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Page',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.JSONField(blank=True, default=dict)),
|
||||
('description_html', models.TextField(blank=True, default='<p></p>')),
|
||||
('description_stripped', models.TextField(blank=True, null=True)),
|
||||
('access', models.PositiveSmallIntegerField(choices=[(0, 'Public'), (1, 'Private')], default=0)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='page_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pages', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Page',
|
||||
'verbose_name_plural': 'Pages',
|
||||
'db_table': 'pages',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='issue_views_view',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='importer',
|
||||
name='service',
|
||||
field=models.CharField(choices=[('github', 'GitHub'), ('jira', 'Jira')], max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='project',
|
||||
name='cover_image',
|
||||
field=models.URLField(blank=True, max_length=800, null=True),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PageBlock',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.JSONField(blank=True, default=dict)),
|
||||
('description_html', models.TextField(blank=True, default='<p></p>')),
|
||||
('description_stripped', models.TextField(blank=True, null=True)),
|
||||
('completed_at', models.DateTimeField(null=True)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pageblock_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('issue', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blocks', to='db.issue')),
|
||||
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocks', to='db.page')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_pageblock', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pageblock_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_pageblock', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Page Block',
|
||||
'verbose_name_plural': 'Page Blocks',
|
||||
'db_table': 'page_blocks',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='page',
|
||||
name='project',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_page', to='db.project'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='page',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='page_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='page',
|
||||
name='workspace',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_page', to='db.workspace'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PageFavorite',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagefavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_favorites', to='db.page')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_pagefavorite', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagefavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_favorites', to=settings.AUTH_USER_MODEL)),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_pagefavorite', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Page Favorite',
|
||||
'verbose_name_plural': 'Page Favorites',
|
||||
'db_table': 'page_favorites',
|
||||
'ordering': ('-created_at',),
|
||||
'unique_together': {('page', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
# Generated by Django 3.2.18 on 2023-03-30 20:33
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0024_auto_20230322_0138'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='page',
|
||||
name='color',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pageblock',
|
||||
name='sort_order',
|
||||
field=models.FloatField(default=65535),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pageblock',
|
||||
name='sync',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='page_view',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PageLabel',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagelabel_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('label', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_labels', to='db.label')),
|
||||
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_labels', to='db.page')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_pagelabel', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagelabel_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_pagelabel', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Page Label',
|
||||
'verbose_name_plural': 'Page Labels',
|
||||
'db_table': 'page_labels',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='page',
|
||||
name='labels',
|
||||
field=models.ManyToManyField(blank=True, related_name='pages', through='db.PageLabel', to='db.Label'),
|
||||
),
|
||||
]
|
||||
@@ -31,6 +31,7 @@ from .issue import (
|
||||
Label,
|
||||
IssueBlocker,
|
||||
IssueLink,
|
||||
IssueSequence,
|
||||
)
|
||||
|
||||
from .asset import FileAsset
|
||||
@@ -43,7 +44,7 @@ from .cycle import Cycle, CycleIssue, CycleFavorite
|
||||
|
||||
from .shortcut import Shortcut
|
||||
|
||||
from .view import View
|
||||
from .view import IssueView, IssueViewFavorite
|
||||
|
||||
from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite
|
||||
|
||||
@@ -57,3 +58,7 @@ from .integration import (
|
||||
GithubIssueSync,
|
||||
GithubCommentSync,
|
||||
)
|
||||
|
||||
from .importer import Importer
|
||||
|
||||
from .page import Page, PageBlock, PageFavorite, PageLabel
|
||||
@@ -1,3 +1,6 @@
|
||||
# Python imports
|
||||
from uuid import uuid4
|
||||
|
||||
# Django import
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -7,7 +10,9 @@ from . import BaseModel
|
||||
|
||||
|
||||
def get_upload_path(instance, filename):
|
||||
return f"{instance.workspace.id}/{filename}"
|
||||
if instance.workspace_id is not None:
|
||||
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
|
||||
return f"user-{uuid4().hex}-{filename}"
|
||||
|
||||
|
||||
def file_size(value):
|
||||
@@ -15,6 +20,7 @@ def file_size(value):
|
||||
if value.size > limit:
|
||||
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
||||
|
||||
|
||||
class FileAsset(BaseModel):
|
||||
"""
|
||||
A file asset.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from . import ProjectBaseModel
|
||||
|
||||
|
||||
class Importer(ProjectBaseModel):
|
||||
service = models.CharField(
|
||||
max_length=50,
|
||||
choices=(
|
||||
("github", "GitHub"),
|
||||
("jira", "Jira"),
|
||||
),
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=(
|
||||
("queued", "Queued"),
|
||||
("processing", "Processing"),
|
||||
("completed", "Completed"),
|
||||
("failed", "Failed"),
|
||||
),
|
||||
default="queued",
|
||||
)
|
||||
initiated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="imports"
|
||||
)
|
||||
metadata = models.JSONField(default=dict)
|
||||
config = models.JSONField(default=dict)
|
||||
data = models.JSONField(default=dict)
|
||||
token = models.ForeignKey(
|
||||
"db.APIToken", on_delete=models.CASCADE, related_name="importer"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Importer"
|
||||
verbose_name_plural = "Importers"
|
||||
db_table = "importers"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the service"""
|
||||
return f"{self.service} <{self.project.name}>"
|
||||
@@ -85,7 +85,7 @@ class Issue(ProjectBaseModel):
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
from plane.db.models import State
|
||||
from plane.db.models import State, PageBlock
|
||||
|
||||
# Get the completed states of the project
|
||||
completed_states = State.objects.filter(
|
||||
@@ -94,7 +94,15 @@ class Issue(ProjectBaseModel):
|
||||
# Check if the current issue state and completed state id are same
|
||||
if self.state.id in completed_states:
|
||||
self.completed_at = timezone.now()
|
||||
# check if there are any page blocks
|
||||
PageBlock.objects.filter(issue_id=self.id).filter().update(
|
||||
completed_at=timezone.now()
|
||||
)
|
||||
|
||||
else:
|
||||
PageBlock.objects.filter(issue_id=self.id).filter().update(
|
||||
completed_at=None
|
||||
)
|
||||
self.completed_at = None
|
||||
|
||||
except ImportError:
|
||||
@@ -307,6 +315,7 @@ class Label(ProjectBaseModel):
|
||||
color = models.CharField(max_length=255, blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "project"]
|
||||
verbose_name = "Label"
|
||||
verbose_name_plural = "Labels"
|
||||
db_table = "labels"
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from . import ProjectBaseModel
|
||||
from plane.utils.html_processor import strip_tags
|
||||
|
||||
|
||||
class Page(ProjectBaseModel):
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.JSONField(default=dict, blank=True)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
owned_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pages"
|
||||
)
|
||||
access = models.PositiveSmallIntegerField(
|
||||
choices=((0, "Public"), (1, "Private")), default=0
|
||||
)
|
||||
color = models.CharField(max_length=255, blank=True)
|
||||
labels = models.ManyToManyField(
|
||||
"db.Label", blank=True, related_name="pages", through="db.PageLabel"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page"
|
||||
verbose_name_plural = "Pages"
|
||||
db_table = "pages"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return owner email and page name"""
|
||||
return f"{self.owned_by.email} <{self.name}>"
|
||||
|
||||
|
||||
class PageBlock(ProjectBaseModel):
|
||||
page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="blocks")
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.JSONField(default=dict, blank=True)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.SET_NULL, related_name="blocks", null=True
|
||||
)
|
||||
completed_at = models.DateTimeField(null=True)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
sync = models.BooleanField(default=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
largest_sort_order = PageBlock.objects.filter(
|
||||
project=self.project, page=self.page
|
||||
).aggregate(largest=models.Max("sort_order"))["largest"]
|
||||
if largest_sort_order is not None:
|
||||
self.sort_order = largest_sort_order + 10000
|
||||
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
|
||||
if self.completed_at and self.issue:
|
||||
try:
|
||||
from plane.db.models import State, Issue
|
||||
|
||||
completed_state = State.objects.filter(
|
||||
group="completed", project=self.project
|
||||
).first()
|
||||
if completed_state is not None:
|
||||
Issue.objects.update(pk=self.issue_id, state=completed_state)
|
||||
except ImportError:
|
||||
pass
|
||||
super(PageBlock, self).save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page Block"
|
||||
verbose_name_plural = "Page Blocks"
|
||||
db_table = "page_blocks"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return page and page block"""
|
||||
return f"{self.page.name} <{self.name}>"
|
||||
|
||||
|
||||
class PageFavorite(ProjectBaseModel):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="page_favorites",
|
||||
)
|
||||
page = models.ForeignKey(
|
||||
"db.Page", on_delete=models.CASCADE, related_name="page_favorites"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["page", "user"]
|
||||
verbose_name = "Page Favorite"
|
||||
verbose_name_plural = "Page Favorites"
|
||||
db_table = "page_favorites"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return user and the page"""
|
||||
return f"{self.user.email} <{self.page.name}>"
|
||||
|
||||
|
||||
class PageLabel(ProjectBaseModel):
|
||||
label = models.ForeignKey(
|
||||
"db.Label", on_delete=models.CASCADE, related_name="page_labels"
|
||||
)
|
||||
page = models.ForeignKey(
|
||||
"db.Page", on_delete=models.CASCADE, related_name="page_labels"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page Label"
|
||||
verbose_name_plural = "Page Labels"
|
||||
db_table = "page_labels"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.page.name} {self.label.name}"
|
||||
@@ -21,10 +21,13 @@ ROLE_CHOICES = (
|
||||
|
||||
def get_default_props():
|
||||
return {
|
||||
"filters": {"type": None},
|
||||
"orderBy": "-created_at",
|
||||
"collapsed": True,
|
||||
"issueView": "list",
|
||||
"groupByProperty": None,
|
||||
"orderBy": None,
|
||||
"filterIssue": None,
|
||||
"groupByProperty": True,
|
||||
"showEmptyGroups": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +66,9 @@ class Project(BaseModel):
|
||||
icon = models.CharField(max_length=255, null=True, blank=True)
|
||||
module_view = models.BooleanField(default=True)
|
||||
cycle_view = models.BooleanField(default=True)
|
||||
cover_image = models.URLField(blank=True, null=True)
|
||||
issue_views_view = models.BooleanField(default=True)
|
||||
page_view = models.BooleanField(default=True)
|
||||
cover_image = models.URLField(blank=True, null=True, max_length=800)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the project"""
|
||||
|
||||
@@ -11,9 +11,12 @@ from django.utils import timezone
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from sentry_sdk import capture_exception
|
||||
from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
@@ -123,6 +126,16 @@ def send_welcome_email(sender, instance, created, **kwargs):
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -1,22 +1,48 @@
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
# Module import
|
||||
from . import ProjectBaseModel
|
||||
|
||||
|
||||
class View(ProjectBaseModel):
|
||||
class IssueView(ProjectBaseModel):
|
||||
name = models.CharField(max_length=255, verbose_name="View Name")
|
||||
description = models.TextField(verbose_name="View Description", blank=True)
|
||||
query = models.JSONField(verbose_name="View Query")
|
||||
access = models.PositiveSmallIntegerField(
|
||||
default=1, choices=((0, "Private"), (1, "Public"))
|
||||
)
|
||||
query_data = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "View"
|
||||
verbose_name_plural = "Views"
|
||||
db_table = "views"
|
||||
verbose_name = "Issue View"
|
||||
verbose_name_plural = "Issue Views"
|
||||
db_table = "issue_views"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the View"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
|
||||
class IssueViewFavorite(ProjectBaseModel):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="user_view_favorites",
|
||||
)
|
||||
view = models.ForeignKey(
|
||||
"db.IssueView", on_delete=models.CASCADE, related_name="view_favorites"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["view", "user"]
|
||||
verbose_name = "View Favorite"
|
||||
verbose_name_plural = "View Favorites"
|
||||
db_table = "view_favorites"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return user and the view"""
|
||||
return f"{self.user.email} <{self.view.name}>"
|
||||
|
||||
@@ -48,6 +48,7 @@ MIDDLEWARE = [
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"crum.CurrentRequestUserMiddleware",
|
||||
"django.middleware.gzip.GZipMiddleware",
|
||||
]
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
|
||||
@@ -78,3 +78,13 @@ if DOCKERIZED:
|
||||
|
||||
WEB_URL = os.environ.get("WEB_URL", "localhost:3000")
|
||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
|
||||
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
|
||||
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
|
||||
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
|
||||
|
||||
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
|
||||
|
||||
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
|
||||
|
||||
@@ -22,13 +22,7 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
# CORS WHITELIST ON PROD
|
||||
CORS_ORIGIN_WHITELIST = [
|
||||
# "https://example.com",
|
||||
# "https://sub.example.com",
|
||||
# "http://localhost:8080",
|
||||
# "http://127.0.0.1:9000"
|
||||
]
|
||||
|
||||
# Parse database configuration from $DATABASE_URL
|
||||
DATABASES["default"] = dj_database_url.config()
|
||||
SITE_ID = 1
|
||||
@@ -43,12 +37,33 @@ DOCKERIZED = os.environ.get(
|
||||
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
# Allow all host headers
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
# TODO: Make it FALSE and LIST DOMAINS IN FULL PROD.
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
|
||||
|
||||
CORS_ALLOW_METHODS = [
|
||||
"DELETE",
|
||||
"GET",
|
||||
"OPTIONS",
|
||||
"PATCH",
|
||||
"POST",
|
||||
"PUT",
|
||||
]
|
||||
|
||||
CORS_ALLOW_HEADERS = [
|
||||
"accept",
|
||||
"accept-encoding",
|
||||
"authorization",
|
||||
"content-type",
|
||||
"dnt",
|
||||
"origin",
|
||||
"user-agent",
|
||||
"x-csrftoken",
|
||||
"x-requested-with",
|
||||
]
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
# Simplified static file serving.
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
@@ -211,3 +226,13 @@ RQ_QUEUES = {
|
||||
WEB_URL = os.environ.get("WEB_URL")
|
||||
|
||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
|
||||
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
|
||||
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
|
||||
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
|
||||
|
||||
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
|
||||
|
||||
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
|
||||
|
||||
@@ -187,3 +187,13 @@ RQ_QUEUES = {
|
||||
WEB_URL = os.environ.get("WEB_URL")
|
||||
|
||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
|
||||
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
|
||||
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
|
||||
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
|
||||
|
||||
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
|
||||
|
||||
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
|
||||
|
||||
@@ -27,6 +27,15 @@ def group_results(results_data, group_by):
|
||||
"""
|
||||
response_dict = dict()
|
||||
|
||||
if group_by == "priority":
|
||||
response_dict = {
|
||||
"urgent": [],
|
||||
"high": [],
|
||||
"medium": [],
|
||||
"low": [],
|
||||
"None": [],
|
||||
}
|
||||
|
||||
for value in results_data:
|
||||
group_attribute = resolve_keys(group_by, value)
|
||||
if isinstance(group_attribute, list):
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
|
||||
def jira_project_issue_summary(email, api_token, project_name, hostname):
|
||||
try:
|
||||
auth = HTTPBasicAuth(email, api_token)
|
||||
headers = {"Accept": "application/json"}
|
||||
|
||||
issue_url = f"https://{hostname}/rest/api/3/search?jql=project={project_name} AND issuetype=Story"
|
||||
issue_response = requests.request(
|
||||
"GET", issue_url, headers=headers, auth=auth
|
||||
).json()["total"]
|
||||
|
||||
module_url = f"https://{hostname}/rest/api/3/search?jql=project={project_name} AND issuetype=Epic"
|
||||
module_response = requests.request(
|
||||
"GET", module_url, headers=headers, auth=auth
|
||||
).json()["total"]
|
||||
|
||||
status_url = f"https://{hostname}/rest/api/3/status/?jql=project={project_name}"
|
||||
status_response = requests.request(
|
||||
"GET", status_url, headers=headers, auth=auth
|
||||
).json()
|
||||
|
||||
labels_url = f"https://{hostname}/rest/api/3/label/?jql=project={project_name}"
|
||||
labels_response = requests.request(
|
||||
"GET", labels_url, headers=headers, auth=auth
|
||||
).json()["total"]
|
||||
|
||||
users_url = (
|
||||
f"https://{hostname}/rest/api/3/users/search?jql=project={project_name}"
|
||||
)
|
||||
users_response = requests.request(
|
||||
"GET", users_url, headers=headers, auth=auth
|
||||
).json()
|
||||
|
||||
return {
|
||||
"issues": issue_response,
|
||||
"modules": module_response,
|
||||
"labels": labels_response,
|
||||
"states": len(status_response),
|
||||
"users": (
|
||||
[
|
||||
user
|
||||
for user in users_response
|
||||
if user.get("accountType") == "atlassian"
|
||||
]
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return {"error": "Something went wrong could not fetch information from jira"}
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import jwt
|
||||
import requests
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from datetime import datetime, timedelta
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
@@ -30,7 +31,7 @@ def get_github_metadata(installation_id):
|
||||
|
||||
url = f"https://api.github.com/app/installations/{installation_id}"
|
||||
headers = {
|
||||
"Authorization": "Bearer " + token,
|
||||
"Authorization": "Bearer " + str(token),
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
response = requests.get(url, headers=headers).json()
|
||||
@@ -41,7 +42,7 @@ def get_github_repos(access_tokens_url, repositories_url):
|
||||
token = get_jwt_token()
|
||||
|
||||
headers = {
|
||||
"Authorization": "Bearer " + token,
|
||||
"Authorization": "Bearer " + str(token),
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
|
||||
@@ -50,9 +51,9 @@ def get_github_repos(access_tokens_url, repositories_url):
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
oauth_token = oauth_response.get("token")
|
||||
oauth_token = oauth_response.get("token", "")
|
||||
headers = {
|
||||
"Authorization": "Bearer " + oauth_token,
|
||||
"Authorization": "Bearer " + str(oauth_token),
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
response = requests.get(
|
||||
@@ -67,8 +68,63 @@ def delete_github_installation(installation_id):
|
||||
|
||||
url = f"https://api.github.com/app/installations/{installation_id}"
|
||||
headers = {
|
||||
"Authorization": "Bearer " + token,
|
||||
"Authorization": "Bearer " + str(token),
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
response = requests.delete(url, headers=headers)
|
||||
return response
|
||||
|
||||
|
||||
def get_github_repo_details(access_tokens_url, owner, repo):
|
||||
token = get_jwt_token()
|
||||
|
||||
headers = {
|
||||
"Authorization": "Bearer " + str(token),
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
|
||||
oauth_response = requests.post(
|
||||
access_tokens_url,
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
oauth_token = oauth_response.get("token")
|
||||
headers = {
|
||||
"Authorization": "Bearer " + oauth_token,
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
open_issues = requests.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}",
|
||||
headers=headers,
|
||||
).json()["open_issues_count"]
|
||||
|
||||
total_labels = 0
|
||||
|
||||
labels_response = requests.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/labels?per_page=100&page=1",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Check if there are more pages
|
||||
if len(labels_response.links.keys()):
|
||||
# get the query parameter of last
|
||||
last_url = labels_response.links.get("last").get("url")
|
||||
parsed_url = urlparse(last_url)
|
||||
last_page_value = parse_qs(parsed_url.query)["page"][0]
|
||||
total_labels = total_labels + 100 * (last_page_value - 1)
|
||||
|
||||
# Get labels in last page
|
||||
last_page_labels = requests.get(last_url, headers=headers).json()
|
||||
total_labels = total_labels + len(last_page_labels)
|
||||
else:
|
||||
total_labels = len(labels_response.json())
|
||||
|
||||
# Currently only supporting upto 100 collaborators
|
||||
# TODO: Update this function to fetch all collaborators
|
||||
collaborators = requests.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/collaborators?per_page=100&page=1",
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
return open_issues, total_labels, collaborators
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
from django.utils.timezone import make_aware
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
|
||||
def filter_state(params, filter, method):
|
||||
if method == "GET":
|
||||
states = params.get("state").split(",")
|
||||
if len(states) and "" not in states:
|
||||
filter["state__in"] = states
|
||||
else:
|
||||
if params.get("state", None) and len(params.get("state")):
|
||||
filter["state__in"] = params.get("state")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_priority(params, filter, method):
|
||||
if method == "GET":
|
||||
priorties = params.get("priority").split(",")
|
||||
if len(priorties) and "" not in priorties:
|
||||
filter["priority__in"] = priorties
|
||||
else:
|
||||
if params.get("priority", None) and len(params.get("priority")):
|
||||
filter["priority__in"] = params.get("priority")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_parent(params, filter, method):
|
||||
if method == "GET":
|
||||
parents = params.get("parent").split(",")
|
||||
if len(parents) and "" not in parents:
|
||||
filter["parent__in"] = parents
|
||||
else:
|
||||
if params.get("parent", None) and len(params.get("parent")):
|
||||
filter["parent__in"] = params.get("parent")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_labels(params, filter, method):
|
||||
if method == "GET":
|
||||
labels = params.get("labels").split(",")
|
||||
if len(labels) and "" not in labels:
|
||||
filter["labels__in"] = labels
|
||||
else:
|
||||
if params.get("labels", None) and len(params.get("labels")):
|
||||
filter["labels__in"] = params.get("labels")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_assignees(params, filter, method):
|
||||
if method == "GET":
|
||||
assignees = params.get("assignees").split(",")
|
||||
if len(assignees) and "" not in assignees:
|
||||
filter["assignees__in"] = assignees
|
||||
else:
|
||||
if params.get("assignees", None) and len(params.get("assignees")):
|
||||
filter["assignees__in"] = params.get("assignees")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_created_by(params, filter, method):
|
||||
if method == "GET":
|
||||
created_bys = params.get("created_by").split(",")
|
||||
if len(created_bys) and "" not in created_bys:
|
||||
filter["created_by__in"] = created_bys
|
||||
else:
|
||||
if params.get("created_by", None) and len(params.get("created_by")):
|
||||
filter["created_by__in"] = params.get("created_by")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_name(params, filter, method):
|
||||
if params.get("name", "") != "":
|
||||
filter["name__icontains"] = params.get("name")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_created_at(params, filter, method):
|
||||
if method == "GET":
|
||||
created_ats = params.get("created_at").split(",")
|
||||
if len(created_ats) and "" not in created_ats:
|
||||
for query in created_ats:
|
||||
created_at_query = query.split(";")
|
||||
if len(created_at_query) == 2 and "after" in created_at_query:
|
||||
filter["created_at__date__gte"] = created_at_query[0]
|
||||
else:
|
||||
filter["created_at__date__lte"] = created_at_query[0]
|
||||
else:
|
||||
if params.get("created_at", None) and len(params.get("created_at")):
|
||||
for query in params.get("created_at"):
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["created_at__date__gte"] = query.get("datetime")
|
||||
else:
|
||||
filter["created_at__date__lte"] = query.get("datetime")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_updated_at(params, filter, method):
|
||||
if method == "GET":
|
||||
updated_ats = params.get("updated_at").split(",")
|
||||
if len(updated_ats) and "" not in updated_ats:
|
||||
for query in updated_ats:
|
||||
updated_at_query = query.split(";")
|
||||
if len(updated_at_query) == 2 and "after" in updated_at_query:
|
||||
filter["updated_at__date__gte"] = updated_at_query[0]
|
||||
else:
|
||||
filter["updated_at__date__lte"] = updated_at_query[0]
|
||||
else:
|
||||
if params.get("updated_at", None) and len(params.get("updated_at")):
|
||||
for query in params.get("updated_at"):
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["updated_at__date__gte"] = query.get("datetime")
|
||||
else:
|
||||
filter["updated_at__date__lte"] = query.get("datetime")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_start_date(params, filter, method):
|
||||
if method == "GET":
|
||||
start_dates = params.get("start_date").split(",")
|
||||
if len(start_dates) and "" not in start_dates:
|
||||
for query in start_dates:
|
||||
start_date_query = query.split(";")
|
||||
if len(start_date_query) == 2 and "after" in start_date_query:
|
||||
filter["start_date__gte"] = start_date_query[0]
|
||||
else:
|
||||
filter["start_date__lte"] = start_date_query[0]
|
||||
else:
|
||||
if params.get("start_date", None) and len(params.get("start_date")):
|
||||
for query in params.get("start_date"):
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["start_date__gte"] = query.get("datetime")
|
||||
else:
|
||||
filter["start_date__lte"] = query.get("datetime")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_target_date(params, filter, method):
|
||||
if method == "GET":
|
||||
target_dates = params.get("target_date").split(",")
|
||||
if len(target_dates) and "" not in target_dates:
|
||||
for query in target_dates:
|
||||
target_date_query = query.split(";")
|
||||
if len(target_date_query) == 2 and "after" in target_date_query:
|
||||
filter["target_date__gte"] = target_date_query[0]
|
||||
else:
|
||||
filter["target_date__lte"] = target_date_query[0]
|
||||
else:
|
||||
if params.get("target_date", None) and len(params.get("target_date")):
|
||||
for query in params.get("target_date"):
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["target_date__gte"] = query.get("datetime")
|
||||
else:
|
||||
filter["target_date__lte"] = query.get("datetime")
|
||||
|
||||
return filter
|
||||
|
||||
|
||||
def filter_completed_at(params, filter, method):
|
||||
if method == "GET":
|
||||
completed_ats = params.get("completed_at").split(",")
|
||||
if len(completed_ats) and "" not in completed_ats:
|
||||
for query in completed_ats:
|
||||
completed_at_query = query.split(";")
|
||||
if len(completed_at_query) == 2 and "after" in completed_at_query:
|
||||
filter["completed_at__date__gte"] = completed_at_query[0]
|
||||
else:
|
||||
filter["completed_at__lte"] = completed_at_query[0]
|
||||
else:
|
||||
if params.get("completed_at", None) and len(params.get("completed_at")):
|
||||
for query in params.get("completed_at"):
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["completed_at__date__gte"] = query.get("datetime")
|
||||
else:
|
||||
filter["completed_at__lte"] = query.get("datetime")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_issue_state_type(params, filter, method):
|
||||
type = params.get("type", "all")
|
||||
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
if type == "backlog":
|
||||
group = ["backlog"]
|
||||
if type == "active":
|
||||
group = ["unstarted", "started"]
|
||||
|
||||
filter["state__group__in"] = group
|
||||
return filter
|
||||
|
||||
|
||||
def issue_filters(query_params, method):
|
||||
filter = dict()
|
||||
|
||||
ISSUE_FILTER = {
|
||||
"state": filter_state,
|
||||
"priority": filter_priority,
|
||||
"parent": filter_parent,
|
||||
"labels": filter_labels,
|
||||
"assignees": filter_assignees,
|
||||
"created_by": filter_created_by,
|
||||
"name": filter_name,
|
||||
"created_at": filter_created_at,
|
||||
"updated_at": filter_updated_at,
|
||||
"start_date": filter_start_date,
|
||||
"target_date": filter_target_date,
|
||||
"completed_at": filter_completed_at,
|
||||
"type": filter_issue_state_type,
|
||||
}
|
||||
|
||||
for key, value in ISSUE_FILTER.items():
|
||||
if key in query_params:
|
||||
func = value
|
||||
func(query_params, filter, method)
|
||||
|
||||
return filter
|
||||
@@ -7,7 +7,7 @@ psycopg2==2.9.5
|
||||
django-oauth-toolkit==2.2.0
|
||||
mistune==2.0.4
|
||||
djangorestframework==3.14.0
|
||||
redis==4.4.2
|
||||
redis==4.5.4
|
||||
django-nested-admin==4.0.2
|
||||
django-cors-headers==3.13.0
|
||||
whitenoise==6.3.0
|
||||
@@ -26,4 +26,6 @@ google-api-python-client==2.75.0
|
||||
django-rq==2.6.0
|
||||
django-redis==5.2.0
|
||||
uvicorn==0.20.0
|
||||
channels==4.0.0
|
||||
channels==4.0.0
|
||||
openai==0.27.2
|
||||
slack-sdk==3.20.2
|
||||
@@ -1,7 +1,8 @@
|
||||
NEXT_PUBLIC_API_BASE_URL = "http://localhost"
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID="<-- google client id -->"
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME="<-- github app name -->"
|
||||
NEXT_PUBLIC_GITHUB_ID="<-- github client id -->"
|
||||
NEXT_PUBLIC_SENTRY_DSN="<-- sentry dns -->"
|
||||
# Replace with your instance Public IP
|
||||
# NEXT_PUBLIC_API_BASE_URL = "http://localhost"
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID=""
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME=""
|
||||
NEXT_PUBLIC_GITHUB_ID=""
|
||||
NEXT_PUBLIC_SENTRY_DSN=""
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||
NEXT_PUBLIC_ENABLE_SENTRY=0
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { Button, Input } from "components/ui";
|
||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// services
|
||||
import authenticationService from "services/authentication.service";
|
||||
import useToast from "hooks/use-toast";
|
||||
@@ -90,7 +90,7 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className="mt-5 space-y-5">
|
||||
<form className="space-y-5 py-5 px-5">
|
||||
{(codeSent || codeResent) && (
|
||||
<div className="rounded-md bg-green-50 p-4">
|
||||
<div className="flex">
|
||||
@@ -140,8 +140,8 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`text-xs mt-5 w-full flex justify-end outline-none ${
|
||||
isResendDisabled ? "text-gray-400 cursor-default" : "cursor-pointer text-theme"
|
||||
className={`mt-5 flex w-full justify-end text-xs outline-none ${
|
||||
isResendDisabled ? "cursor-default text-gray-400" : "cursor-pointer text-theme"
|
||||
} `}
|
||||
onClick={() => {
|
||||
setIsCodeResending(true);
|
||||
@@ -169,27 +169,29 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
)}
|
||||
<div>
|
||||
{codeSent ? (
|
||||
<Button
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="w-full text-center"
|
||||
size="md"
|
||||
onClick={handleSubmit(handleSignin)}
|
||||
disabled={isSubmitting || (!isValid && isDirty)}
|
||||
loading={isSubmitting || (!isValid && isDirty)}
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</PrimaryButton>
|
||||
) : (
|
||||
<Button
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="w-full text-center"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
handleSubmit(onSubmit)().then(() => {
|
||||
setResendCodeTimer(30);
|
||||
});
|
||||
}}
|
||||
disabled={isSubmitting || (!isValid && isDirty)}
|
||||
loading={isSubmitting || (!isValid && isDirty)}
|
||||
>
|
||||
{isSubmitting ? "Sending code..." : "Send code"}
|
||||
</Button>
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React from "react";
|
||||
// next
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
// react hook form
|
||||
import { useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { Button, Input } from "components/ui";
|
||||
// services
|
||||
import authenticationService from "services/authentication.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
|
||||
// ui
|
||||
import { Input, SecondaryButton } from "components/ui";
|
||||
// types
|
||||
type EmailPasswordFormValues = {
|
||||
email: string;
|
||||
@@ -58,7 +60,7 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<form className="mt-5" onSubmit={handleSubmit(onSubmit)}>
|
||||
<form className="mt-5 py-5 px-5" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<Input
|
||||
id="email"
|
||||
@@ -97,13 +99,13 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Button
|
||||
disabled={isSubmitting || (!isValid && isDirty)}
|
||||
className="w-full text-center"
|
||||
<SecondaryButton
|
||||
type="submit"
|
||||
className="w-full text-center"
|
||||
loading={isSubmitting || (!isValid && isDirty)}
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign In"}
|
||||
</Button>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
|
||||
@@ -19,28 +19,6 @@ export const EmailSignInForm: FC<EmailSignInFormProps> = (props) => {
|
||||
) : (
|
||||
<EmailPasswordForm onSuccess={handleSuccess} />
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white px-2 text-gray-500">or</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="mt-6 flex w-full flex-col items-stretch gap-y-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center rounded border border-gray-300 px-3 py-2 text-sm duration-300 hover:bg-gray-100"
|
||||
onClick={() => setUseCode((prev) => !prev)}
|
||||
>
|
||||
<KeyIcon className="h-[25px] w-[25px]" />
|
||||
<span className="w-full text-center font-medium">
|
||||
{useCode ? "Continue with Password" : "Continue with Code"}
|
||||
</span>
|
||||
</button>
|
||||
</div> */}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
// images
|
||||
import githubImage from "/public/logos/github.png";
|
||||
import githubImage from "/public/logos/github-black.png";
|
||||
|
||||
const { NEXT_PUBLIC_GITHUB_ID } = process.env;
|
||||
|
||||
@@ -33,19 +33,15 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||
>
|
||||
<button className="flex w-full items-center rounded bg-black px-3 py-2 text-sm text-white opacity-90 duration-300 hover:opacity-100">
|
||||
<Image
|
||||
src={githubImage}
|
||||
height={25}
|
||||
width={25}
|
||||
className="flex-shrink-0"
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
<span className="w-full text-center font-medium">Continue with GitHub</span>
|
||||
</button>
|
||||
</Link>
|
||||
<div className="px-1 w-full">
|
||||
<Link
|
||||
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||
>
|
||||
<button className="flex w-full items-center justify-center gap-3 rounded-md border border-gray-200 p-2 text-sm font-medium text-gray-600 duration-300 hover:bg-gray-50">
|
||||
<Image src={githubImage} height={22} width={22} color="#000" alt="GitHub Logo" />
|
||||
<span>Sign In with Github</span>
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||
theme: "outline",
|
||||
size: "large",
|
||||
logo_alignment: "center",
|
||||
width: document.getElementById("googleSignInButton")?.offsetWidth,
|
||||
width: "410",
|
||||
text: "continue_with",
|
||||
} as GsiButtonConfiguration // customization attributes
|
||||
);
|
||||
@@ -47,7 +47,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
|
||||
<div className="w-full" id="googleSignInButton" ref={googleSignInButton} />
|
||||
<div className="h-12" id="googleSignInButton" ref={googleSignInButton} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,12 +14,13 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="grid h-8 w-8 cursor-pointer place-items-center flex-shrink-0 rounded border border-gray-300 text-center text-sm hover:bg-gray-100"
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-8 w-8 flex-shrink-0 cursor-pointer place-items-center rounded border border-gray-300 text-center text-sm hover:bg-gray-100"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeftIcon className="h-3 w-3" />
|
||||
</div>
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
@@ -44,7 +45,7 @@ const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) =>
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="px-3 text-sm max-w-64">
|
||||
<div className="max-w-64 px-3 text-sm">
|
||||
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
|
||||
{icon}
|
||||
<span className="break-all">{title}</span>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useRouter } from "next/router";
|
||||
import React, { Dispatch, SetStateAction, useCallback } from "react";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// cmdk
|
||||
import { Command } from "cmdk";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
// constants
|
||||
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
// icons
|
||||
import { CheckIcon } from "components/icons";
|
||||
import projectService from "services/project.service";
|
||||
import { Avatar } from "components/ui";
|
||||
|
||||
type Props = {
|
||||
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
|
||||
issue: IIssue;
|
||||
};
|
||||
|
||||
export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { data: members } = useSWR(
|
||||
projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const options =
|
||||
members?.map(({ member }) => ({
|
||||
value: member.id,
|
||||
query:
|
||||
(member.first_name && member.first_name !== "" ? member.first_name : member.email) +
|
||||
" " +
|
||||
member.last_name ?? "",
|
||||
content: (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar user={member} />
|
||||
{member.first_name && member.first_name !== "" ? member.first_name : member.email}
|
||||
</div>
|
||||
{issue.assignees.includes(member.id) && (
|
||||
<div>
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})) ?? [];
|
||||
|
||||
const updateIssue = useCallback(
|
||||
async (formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
mutate(
|
||||
ISSUE_DETAILS(issueId as string),
|
||||
(prevData: IIssue) => ({
|
||||
...prevData,
|
||||
...formData,
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
const payload = { ...formData };
|
||||
await issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
|
||||
.then(() => {
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, issueId, projectId]
|
||||
);
|
||||
|
||||
const handleIssueAssignees = (assignee: string) => {
|
||||
const updatedAssignees = issue.assignees ?? [];
|
||||
|
||||
if (updatedAssignees.includes(assignee)) {
|
||||
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
|
||||
} else {
|
||||
updatedAssignees.push(assignee);
|
||||
}
|
||||
|
||||
updateIssue({ assignees_list: updatedAssignees });
|
||||
setIsPaletteOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.map((option) => (
|
||||
<Command.Item
|
||||
key={option.value}
|
||||
onSelect={() => handleIssueAssignees(option.value)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
{option.content}
|
||||
</Command.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useRouter } from "next/router";
|
||||
import React, { Dispatch, SetStateAction, useCallback } from "react";
|
||||
import { mutate } from "swr";
|
||||
|
||||
// cmdk
|
||||
import { Command } from "cmdk";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
// constants
|
||||
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
import { PRIORITIES } from "constants/project";
|
||||
// icons
|
||||
import { CheckIcon, getPriorityIcon } from "components/icons";
|
||||
|
||||
type Props = {
|
||||
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
|
||||
issue: IIssue;
|
||||
};
|
||||
|
||||
export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const submitChanges = useCallback(
|
||||
async (formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
mutate(
|
||||
ISSUE_DETAILS(issueId as string),
|
||||
(prevData: IIssue) => ({
|
||||
...prevData,
|
||||
...formData,
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
const payload = { ...formData };
|
||||
await issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
|
||||
.then(() => {
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, issueId, projectId]
|
||||
);
|
||||
|
||||
const handleIssueState = (priority: string | null) => {
|
||||
submitChanges({ priority });
|
||||
setIsPaletteOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{PRIORITIES.map((priority) => (
|
||||
<Command.Item
|
||||
key={priority}
|
||||
onSelect={() => handleIssueState(priority)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{getPriorityIcon(priority)}
|
||||
<span className="capitalize">{priority ?? "None"}</span>
|
||||
</div>
|
||||
<div>{priority === issue.priority && <CheckIcon className="h-3 w-3" />}</div>
|
||||
</Command.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useRouter } from "next/router";
|
||||
import React, { Dispatch, SetStateAction, useCallback } from "react";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// cmdk
|
||||
import { Command } from "cmdk";
|
||||
// ui
|
||||
import { Spinner } from "components/ui";
|
||||
// helpers
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import stateService from "services/state.service";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
// fetch keys
|
||||
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, STATE_LIST } from "constants/fetch-keys";
|
||||
// icons
|
||||
import { CheckIcon, getStateGroupIcon } from "components/icons";
|
||||
|
||||
type Props = {
|
||||
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
|
||||
issue: IIssue;
|
||||
};
|
||||
|
||||
export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { data: stateGroups, mutate: mutateIssueDetails } = useSWR(
|
||||
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => stateService.getStates(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
const states = getStatesList(stateGroups ?? {});
|
||||
|
||||
const submitChanges = useCallback(
|
||||
async (formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
mutate(
|
||||
ISSUE_DETAILS(issueId as string),
|
||||
(prevData: IIssue) => ({
|
||||
...prevData,
|
||||
...formData,
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
const payload = { ...formData };
|
||||
await issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
|
||||
.then(() => {
|
||||
mutateIssueDetails();
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, issueId, projectId, mutateIssueDetails]
|
||||
);
|
||||
|
||||
const handleIssueState = (stateId: string) => {
|
||||
submitChanges({ state: stateId });
|
||||
setIsPaletteOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{states ? (
|
||||
states.length > 0 ? (
|
||||
states.map((state) => (
|
||||
<Command.Item
|
||||
key={state.id}
|
||||
onSelect={() => handleIssueState(state.id)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{getStateGroupIcon(state.group, "16", "16", state.color)}
|
||||
<p>{state.name}</p>
|
||||
</div>
|
||||
<div>{state.id === issue.state && <CheckIcon className="h-3 w-3" />}</div>
|
||||
</Command.Item>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center">No states found</div>
|
||||
)
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,5 @@
|
||||
export * from "./command-pallette";
|
||||
export * from "./shortcuts-modal";
|
||||
export * from "./change-issue-state";
|
||||
export * from "./change-issue-priority";
|
||||
export * from "./change-issue-assignee";
|
||||
|
||||
@@ -3,6 +3,8 @@ import React, { useEffect, useState } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { MacCommandIcon } from "components/icons";
|
||||
// ui
|
||||
import { Input } from "components/ui";
|
||||
|
||||
@@ -15,7 +17,7 @@ const shortcuts = [
|
||||
{
|
||||
title: "Navigation",
|
||||
shortcuts: [
|
||||
{ keys: "Ctrl,/,Cmd,K", description: "To open navigator" },
|
||||
{ keys: "Ctrl,K", description: "To open navigator" },
|
||||
{ keys: "↑", description: "Move up" },
|
||||
{ keys: "↓", description: "Move down" },
|
||||
{ keys: "←", description: "Move left" },
|
||||
@@ -31,11 +33,13 @@ const shortcuts = [
|
||||
{ keys: "C", description: "To create issue" },
|
||||
{ keys: "Q", description: "To create cycle" },
|
||||
{ keys: "M", description: "To create module" },
|
||||
{ keys: "V", description: "To create view" },
|
||||
{ keys: "D", description: "To create page" },
|
||||
{ keys: "Delete", description: "To bulk delete issues" },
|
||||
{ keys: "H", description: "To open shortcuts guide" },
|
||||
{
|
||||
keys: "Ctrl,/,Cmd,Alt,C",
|
||||
description: "To copy issue url when on issue detail page.",
|
||||
keys: "Ctrl,Alt,C",
|
||||
description: "To copy issue url when on issue detail page",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -100,13 +104,17 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
</span>
|
||||
</Dialog.Title>
|
||||
<div>
|
||||
<Input
|
||||
id="search"
|
||||
name="search"
|
||||
type="text"
|
||||
placeholder="Search for shortcuts"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="flex w-full items-center justify-start gap-1 rounded border-[0.6px] border-gray-200 bg-gray-100 px-3 py-2">
|
||||
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-gray-500" />
|
||||
<Input
|
||||
className="w-full border-none bg-transparent py-1 px-2 text-xs text-gray-500 focus:outline-none"
|
||||
id="search"
|
||||
name="search"
|
||||
type="text"
|
||||
placeholder="Search for shortcuts"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-y-3">
|
||||
{query.trim().length > 0 ? (
|
||||
@@ -114,14 +122,20 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
filteredShortcuts.map((shortcut) => (
|
||||
<div key={shortcut.keys} className="flex w-full flex-col">
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">{shortcut.description}</p>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<div className="flex items-center gap-x-2.5">
|
||||
{shortcut.keys.split(",").map((key, index) => (
|
||||
<span key={index} className="flex items-center gap-1">
|
||||
<kbd className="rounded bg-gray-200 px-1 text-sm">
|
||||
{key}
|
||||
</kbd>
|
||||
{key === "Ctrl" ? (
|
||||
<span className="flex h-full items-center rounded-sm border border-gray-200 bg-gray-100 p-2">
|
||||
<MacCommandIcon />
|
||||
</span>
|
||||
) : (
|
||||
<kbd className="rounded-sm border border-gray-200 bg-gray-100 px-2 py-1 text-sm font-medium text-gray-800">
|
||||
{key === "Ctrl" ? <MacCommandIcon /> : key}
|
||||
</kbd>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -147,14 +161,20 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
<p className="mb-4 font-medium">{title}</p>
|
||||
<div className="flex flex-col gap-y-3">
|
||||
{shortcuts.map(({ keys, description }, index) => (
|
||||
<div key={index} className="flex justify-between">
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">{description}</p>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<div className="flex items-center gap-x-2.5">
|
||||
{keys.split(",").map((key, index) => (
|
||||
<span key={index} className="flex items-center gap-1">
|
||||
<kbd className="rounded bg-gray-200 px-1 text-sm">
|
||||
{key}
|
||||
</kbd>
|
||||
{key === "Ctrl" ? (
|
||||
<span className="flex h-full items-center rounded-sm border border-gray-200 bg-gray-100 p-2">
|
||||
<MacCommandIcon />
|
||||
</span>
|
||||
) : (
|
||||
<kbd className="rounded-sm border border-gray-200 bg-gray-100 px-2 py-1 text-sm font-medium text-gray-800">
|
||||
{key === "Ctrl" ? <MacCommandIcon /> : key}
|
||||
</kbd>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
// hooks
|
||||
import useIssueView from "hooks/use-issue-view";
|
||||
import useProjectIssuesView from "hooks/use-issues-view";
|
||||
// components
|
||||
import { SingleBoard } from "components/core/board-view/single-board";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, IProjectMember, IState, UserAuth } from "types";
|
||||
import { IIssue, IState, UserAuth } from "types";
|
||||
import { getStateGroupIcon } from "components/icons";
|
||||
|
||||
type Props = {
|
||||
type: "issue" | "cycle" | "module";
|
||||
issues: IIssue[];
|
||||
states: IState[] | undefined;
|
||||
members: IProjectMember[] | undefined;
|
||||
addIssueToState: (groupTitle: string, stateId: string | null) => void;
|
||||
addIssueToState: (groupTitle: string) => void;
|
||||
makeIssueCopy: (issue: IIssue) => void;
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
removeIssue: ((bridgeId: string) => void) | null;
|
||||
isCompleted?: boolean;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const AllBoards: React.FC<Props> = ({
|
||||
type,
|
||||
issues,
|
||||
states,
|
||||
members,
|
||||
addIssueToState,
|
||||
makeIssueCopy,
|
||||
handleEditIssue,
|
||||
@@ -32,58 +32,75 @@ export const AllBoards: React.FC<Props> = ({
|
||||
handleDeleteIssue,
|
||||
handleTrashBox,
|
||||
removeIssue,
|
||||
isCompleted = false,
|
||||
userAuth,
|
||||
}) => {
|
||||
const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssueView(issues);
|
||||
const {
|
||||
groupedByIssues,
|
||||
groupByProperty: selectedGroup,
|
||||
showEmptyGroups,
|
||||
} = useProjectIssuesView();
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupedByIssues ? (
|
||||
<div className="h-[calc(100vh-157px)] w-full lg:h-[calc(100vh-115px)]">
|
||||
<div className="horizontal-scroll-enable flex h-full gap-x-4 overflow-x-auto overflow-y-hidden">
|
||||
{Object.keys(groupedByIssues).map((singleGroup, index) => {
|
||||
const currentState =
|
||||
selectedGroup === "state_detail.name"
|
||||
? states?.find((s) => s.name === singleGroup)
|
||||
: null;
|
||||
<div className="horizontal-scroll-enable flex h-[calc(100vh-140px)] gap-x-4">
|
||||
{Object.keys(groupedByIssues).map((singleGroup, index) => {
|
||||
const currentState =
|
||||
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
|
||||
|
||||
const stateId =
|
||||
selectedGroup === "state_detail.name"
|
||||
? states?.find((s) => s.name === singleGroup)?.id ?? null
|
||||
: null;
|
||||
if (!showEmptyGroups && groupedByIssues[singleGroup].length === 0) return null;
|
||||
|
||||
const bgColor =
|
||||
selectedGroup === "state_detail.name"
|
||||
? states?.find((s) => s.name === singleGroup)?.color
|
||||
: "#000000";
|
||||
return (
|
||||
<SingleBoard
|
||||
key={index}
|
||||
type={type}
|
||||
currentState={currentState}
|
||||
groupTitle={singleGroup}
|
||||
handleEditIssue={handleEditIssue}
|
||||
makeIssueCopy={makeIssueCopy}
|
||||
addIssueToState={() => addIssueToState(singleGroup)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
openIssuesListModal={openIssuesListModal ?? null}
|
||||
handleTrashBox={handleTrashBox}
|
||||
removeIssue={removeIssue}
|
||||
isCompleted={isCompleted}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{!showEmptyGroups && (
|
||||
<div className="h-full w-96 flex-shrink-0 space-y-3 p-1">
|
||||
<h2 className="text-lg font-semibold">Hidden groups</h2>
|
||||
<div className="space-y-3">
|
||||
{Object.keys(groupedByIssues).map((singleGroup, index) => {
|
||||
const currentState =
|
||||
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
|
||||
|
||||
return (
|
||||
<SingleBoard
|
||||
key={index}
|
||||
type={type}
|
||||
currentState={currentState}
|
||||
bgColor={bgColor}
|
||||
groupTitle={singleGroup}
|
||||
groupedByIssues={groupedByIssues}
|
||||
selectedGroup={selectedGroup}
|
||||
members={members}
|
||||
handleEditIssue={handleEditIssue}
|
||||
makeIssueCopy={makeIssueCopy}
|
||||
addIssueToState={() => addIssueToState(singleGroup, stateId)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
openIssuesListModal={openIssuesListModal ?? null}
|
||||
orderBy={orderBy}
|
||||
handleTrashBox={handleTrashBox}
|
||||
removeIssue={removeIssue}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
if (groupedByIssues[singleGroup].length === 0)
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between gap-2 rounded bg-white p-2 shadow"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentState &&
|
||||
getStateGroupIcon(currentState.group, "16", "16", currentState.color)}
|
||||
<h4 className="text-sm capitalize">
|
||||
{selectedGroup === "state"
|
||||
? addSpaceIfCamelCase(currentState?.name ?? "")
|
||||
: addSpaceIfCamelCase(singleGroup)}
|
||||
</h4>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">0</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">Loading...</div>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,80 +1,149 @@
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import projectService from "services/project.service";
|
||||
// hooks
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
// component
|
||||
import { Avatar } from "components/ui";
|
||||
// icons
|
||||
import { ArrowsPointingInIcon, ArrowsPointingOutIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, IProjectMember, IState, NestedKeyOf } from "types";
|
||||
import { getStateGroupIcon } from "components/icons";
|
||||
import { IIssueLabels, IState } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
groupedByIssues: {
|
||||
[key: string]: IIssue[];
|
||||
};
|
||||
currentState?: IState | null;
|
||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
||||
groupTitle: string;
|
||||
bgColor?: string;
|
||||
addIssueToState: () => void;
|
||||
members: IProjectMember[] | undefined;
|
||||
isCollapsed: boolean;
|
||||
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isCompleted?: boolean;
|
||||
};
|
||||
|
||||
export const BoardHeader: React.FC<Props> = ({
|
||||
groupedByIssues,
|
||||
currentState,
|
||||
selectedGroup,
|
||||
groupTitle,
|
||||
bgColor,
|
||||
addIssueToState,
|
||||
isCollapsed,
|
||||
setIsCollapsed,
|
||||
members,
|
||||
isCompleted = false,
|
||||
}) => {
|
||||
const createdBy =
|
||||
selectedGroup === "created_by"
|
||||
? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "loading..."
|
||||
: null;
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
let assignees: any;
|
||||
if (selectedGroup === "assignees") {
|
||||
assignees = groupTitle && groupTitle !== "" ? groupTitle.split(",") : [];
|
||||
assignees =
|
||||
assignees.length > 0
|
||||
? assignees
|
||||
.map((a: string) => members?.find((m) => m.member.id === a)?.member.first_name)
|
||||
.join(", ")
|
||||
: "No assignee";
|
||||
}
|
||||
const { groupedByIssues, groupByProperty: selectedGroup } = useIssuesView();
|
||||
|
||||
const { data: issueLabels } = useSWR<IIssueLabels[]>(
|
||||
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: members } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
let bgColor = "#000000";
|
||||
if (selectedGroup === "state") bgColor = currentState?.color ?? "#000000";
|
||||
|
||||
if (selectedGroup === "priority")
|
||||
groupTitle === "high"
|
||||
? (bgColor = "#dc2626")
|
||||
: groupTitle === "medium"
|
||||
? (bgColor = "#f97316")
|
||||
: groupTitle === "low"
|
||||
? (bgColor = "#22c55e")
|
||||
: (bgColor = "#ff0000");
|
||||
|
||||
const getGroupTitle = () => {
|
||||
let title = addSpaceIfCamelCase(groupTitle);
|
||||
|
||||
switch (selectedGroup) {
|
||||
case "state":
|
||||
title = addSpaceIfCamelCase(currentState?.name ?? "");
|
||||
break;
|
||||
case "labels":
|
||||
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None";
|
||||
break;
|
||||
case "created_by":
|
||||
const member = members?.find((member) => member.member.id === groupTitle)?.member;
|
||||
title =
|
||||
member?.first_name && member.first_name !== ""
|
||||
? `${member.first_name} ${member.last_name}`
|
||||
: member?.email ?? "";
|
||||
break;
|
||||
}
|
||||
|
||||
return title;
|
||||
};
|
||||
|
||||
const getGroupIcon = () => {
|
||||
let icon;
|
||||
|
||||
switch (selectedGroup) {
|
||||
case "state":
|
||||
icon = currentState && getStateGroupIcon(currentState.group, "18", "18", bgColor);
|
||||
break;
|
||||
case "priority":
|
||||
icon = getPriorityIcon(groupTitle, "h-[18px] w-[18px] flex items-center");
|
||||
break;
|
||||
case "labels":
|
||||
const labelColor =
|
||||
issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000";
|
||||
icon = (
|
||||
<span
|
||||
className="h-[18px] w-[18px] flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: labelColor }}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "created_by":
|
||||
const member = members?.find((member) => member.member.id === groupTitle)?.member;
|
||||
icon = <Avatar user={member} height="24px" width="24px" fontSize="12px" />;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return icon;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-between px-1 ${
|
||||
className={`flex justify-between items-center px-1 ${
|
||||
!isCollapsed ? "flex-col rounded-md border bg-gray-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
|
||||
<div
|
||||
className={`flex cursor-pointer items-center gap-x-3.5 ${
|
||||
className={`flex cursor-pointer items-center gap-x-3 ${
|
||||
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
|
||||
}`}
|
||||
>
|
||||
{currentState && getStateGroupIcon(currentState.group, "20", "20", bgColor)}
|
||||
<span className="flex items-center">{getGroupIcon()}</span>
|
||||
<h2
|
||||
className={`text-xl font-semibold capitalize`}
|
||||
className="text-lg font-semibold capitalize"
|
||||
style={{
|
||||
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
|
||||
}}
|
||||
>
|
||||
{selectedGroup === "created_by"
|
||||
? createdBy
|
||||
: selectedGroup === "assignees"
|
||||
? assignees
|
||||
: addSpaceIfCamelCase(groupTitle)}
|
||||
{getGroupTitle()}
|
||||
</h2>
|
||||
<span className="ml-0.5 text-sm bg-gray-100 py-1 px-3 rounded-full">
|
||||
{groupedByIssues[groupTitle].length}
|
||||
<span className="ml-0.5 rounded-full bg-gray-100 py-1 px-3 text-sm">
|
||||
{groupedByIssues?.[groupTitle].length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,13 +162,15 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
<ArrowsPointingOutIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 text-gray-700 outline-none duration-300 hover:bg-gray-100"
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
{!isCompleted && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 text-gray-700 outline-none duration-300 hover:bg-gray-100"
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter } from "next/router";
|
||||
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
||||
import { Draggable } from "react-beautiful-dnd";
|
||||
// hooks
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useIssuesProperties from "hooks/use-issue-properties";
|
||||
// components
|
||||
import { BoardHeader, SingleBoardIssue } from "components/core";
|
||||
@@ -16,177 +17,169 @@ import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, IProjectMember, IState, NestedKeyOf, UserAuth } from "types";
|
||||
import { IIssue, IState, UserAuth } from "types";
|
||||
|
||||
type Props = {
|
||||
type?: "issue" | "cycle" | "module";
|
||||
currentState?: IState | null;
|
||||
bgColor?: string;
|
||||
groupTitle: string;
|
||||
groupedByIssues: {
|
||||
[key: string]: IIssue[];
|
||||
};
|
||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
||||
members: IProjectMember[] | undefined;
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
makeIssueCopy: (issue: IIssue) => void;
|
||||
addIssueToState: () => void;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
orderBy: NestedKeyOf<IIssue> | null;
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
removeIssue: ((bridgeId: string) => void) | null;
|
||||
isCompleted?: boolean;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const SingleBoard: React.FC<Props> = ({
|
||||
type,
|
||||
currentState,
|
||||
bgColor,
|
||||
groupTitle,
|
||||
groupedByIssues,
|
||||
selectedGroup,
|
||||
members,
|
||||
handleEditIssue,
|
||||
makeIssueCopy,
|
||||
addIssueToState,
|
||||
handleDeleteIssue,
|
||||
openIssuesListModal,
|
||||
orderBy,
|
||||
handleTrashBox,
|
||||
removeIssue,
|
||||
isCompleted = false,
|
||||
userAuth,
|
||||
}) => {
|
||||
// collapse/expand
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
|
||||
const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssuesView();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
||||
|
||||
if (selectedGroup === "priority")
|
||||
groupTitle === "high"
|
||||
? (bgColor = "#dc2626")
|
||||
: groupTitle === "medium"
|
||||
? (bgColor = "#f97316")
|
||||
: groupTitle === "low"
|
||||
? (bgColor = "#22c55e")
|
||||
: (bgColor = "#ff0000");
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
useEffect(() => {
|
||||
if (currentState?.group === "completed" || currentState?.group === "cancelled")
|
||||
setIsCollapsed(false);
|
||||
}, [currentState]);
|
||||
|
||||
return (
|
||||
<div className={`h-full flex-shrink-0 rounded ${!isCollapsed ? "" : "w-96 bg-gray-50"}`}>
|
||||
<div className={`h-full flex-shrink-0 ${!isCollapsed ? "" : "w-96 bg-gray-50"}`}>
|
||||
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3"}`}>
|
||||
<BoardHeader
|
||||
addIssueToState={addIssueToState}
|
||||
currentState={currentState}
|
||||
bgColor={bgColor}
|
||||
selectedGroup={selectedGroup}
|
||||
groupTitle={groupTitle}
|
||||
groupedByIssues={groupedByIssues}
|
||||
isCollapsed={isCollapsed}
|
||||
setIsCollapsed={setIsCollapsed}
|
||||
members={members}
|
||||
isCompleted={isCompleted}
|
||||
/>
|
||||
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`relative h-full overflow-y-auto p-1 ${
|
||||
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
|
||||
} ${!isCollapsed ? "hidden" : "block"}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{orderBy !== "sort_order" && (
|
||||
<>
|
||||
<div
|
||||
className={`absolute ${
|
||||
snapshot.isDraggingOver ? "block" : "hidden"
|
||||
} pointer-events-none top-0 left-0 z-[99] h-full w-full bg-gray-100 opacity-50`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute ${
|
||||
snapshot.isDraggingOver ? "block" : "hidden"
|
||||
} pointer-events-none top-1/2 left-1/2 z-[99] -translate-y-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-white p-2 text-xs`}
|
||||
>
|
||||
This board is ordered by {replaceUnderscoreIfSnakeCase(orderBy ?? "created_at")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{groupedByIssues[groupTitle].map((issue, index: number) => (
|
||||
<Draggable
|
||||
key={issue.id}
|
||||
draggableId={issue.id}
|
||||
index={index}
|
||||
isDragDisabled={
|
||||
isNotAllowed || selectedGroup === "created_by" || selectedGroup === "assignees"
|
||||
}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<SingleBoardIssue
|
||||
key={index}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
type={type}
|
||||
issue={issue}
|
||||
selectedGroup={selectedGroup}
|
||||
properties={properties}
|
||||
editIssue={() => handleEditIssue(issue)}
|
||||
makeIssueCopy={() => makeIssueCopy(issue)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
orderBy={orderBy}
|
||||
handleTrashBox={handleTrashBox}
|
||||
removeIssue={() => {
|
||||
removeIssue && removeIssue(issue.bridge);
|
||||
}}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
<span
|
||||
style={{
|
||||
display: orderBy === "sort_order" ? "inline" : "none",
|
||||
}}
|
||||
{isCollapsed && (
|
||||
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`relative h-full overflow-y-auto p-1 ${
|
||||
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
|
||||
} ${!isCollapsed ? "hidden" : "block"}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{provided.placeholder}
|
||||
</span>
|
||||
{type === "issue" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 font-medium text-theme outline-none"
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Issue
|
||||
</button>
|
||||
) : (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 font-medium text-theme outline-none"
|
||||
{orderBy !== "sort_order" && (
|
||||
<>
|
||||
<div
|
||||
className={`absolute ${
|
||||
snapshot.isDraggingOver ? "block" : "hidden"
|
||||
} pointer-events-none top-0 left-0 z-[99] h-full w-full bg-gray-100 opacity-50`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute ${
|
||||
snapshot.isDraggingOver ? "block" : "hidden"
|
||||
} pointer-events-none top-1/2 left-1/2 z-[99] -translate-y-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-white p-2 text-xs`}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Issue
|
||||
</button>
|
||||
}
|
||||
optionsPosition="left"
|
||||
noBorder
|
||||
This board is ordered by{" "}
|
||||
{replaceUnderscoreIfSnakeCase(orderBy ?? "created_at")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{groupedByIssues?.[groupTitle].map((issue, index) => (
|
||||
<Draggable
|
||||
key={issue.id}
|
||||
draggableId={issue.id}
|
||||
index={index}
|
||||
isDragDisabled={isNotAllowed || selectedGroup === "created_by"}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<SingleBoardIssue
|
||||
key={index}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
type={type}
|
||||
index={index}
|
||||
selectedGroup={selectedGroup}
|
||||
issue={issue}
|
||||
groupTitle={groupTitle}
|
||||
properties={properties}
|
||||
editIssue={() => handleEditIssue(issue)}
|
||||
makeIssueCopy={() => makeIssueCopy(issue)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
handleTrashBox={handleTrashBox}
|
||||
removeIssue={() => {
|
||||
if (removeIssue && issue.bridge_id) removeIssue(issue.bridge_id);
|
||||
}}
|
||||
isCompleted={isCompleted}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
<span
|
||||
style={{
|
||||
display: orderBy === "sort_order" ? "inline" : "none",
|
||||
}}
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
|
||||
{openIssuesListModal && (
|
||||
<CustomMenu.MenuItem onClick={openIssuesListModal}>
|
||||
Add an existing issue
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
{provided.placeholder}
|
||||
</span>
|
||||
{type === "issue" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 font-medium text-theme outline-none"
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Issue
|
||||
</button>
|
||||
) : (
|
||||
!isCompleted && (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 font-medium text-theme outline-none"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Issue
|
||||
</button>
|
||||
}
|
||||
optionsPosition="left"
|
||||
noBorder
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={addIssueToState}>
|
||||
Create new
|
||||
</CustomMenu.MenuItem>
|
||||
{openIssuesListModal && (
|
||||
<CustomMenu.MenuItem onClick={openIssuesListModal}>
|
||||
Add an existing issue
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// hooks
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import {
|
||||
@@ -24,41 +25,46 @@ import {
|
||||
ViewStateSelect,
|
||||
} from "components/issues/view-select";
|
||||
// ui
|
||||
import { ContextMenu, CustomMenu, Tooltip } from "components/ui";
|
||||
import { ContextMenu, CustomMenu } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
ClipboardDocumentCheckIcon,
|
||||
LinkIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
XMarkIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
|
||||
} from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { handleIssuesMutation } from "constants/issue";
|
||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||
// types
|
||||
import {
|
||||
CycleIssueResponse,
|
||||
IIssue,
|
||||
ModuleIssueResponse,
|
||||
NestedKeyOf,
|
||||
Properties,
|
||||
UserAuth,
|
||||
} from "types";
|
||||
import { IIssue, Properties, TIssueGroupByOptions, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
import {
|
||||
CYCLE_DETAILS,
|
||||
CYCLE_ISSUES_WITH_PARAMS,
|
||||
MODULE_DETAILS,
|
||||
MODULE_ISSUES_WITH_PARAMS,
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
type?: string;
|
||||
provided: DraggableProvided;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
issue: IIssue;
|
||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
||||
properties: Properties;
|
||||
groupTitle?: string;
|
||||
index: number;
|
||||
selectedGroup: TIssueGroupByOptions;
|
||||
editIssue: () => void;
|
||||
makeIssueCopy: () => void;
|
||||
removeIssue?: (() => void) | null;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
orderBy: NestedKeyOf<IIssue> | null;
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
isCompleted?: boolean;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
@@ -67,20 +73,24 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
provided,
|
||||
snapshot,
|
||||
issue,
|
||||
selectedGroup,
|
||||
properties,
|
||||
index,
|
||||
selectedGroup,
|
||||
editIssue,
|
||||
makeIssueCopy,
|
||||
removeIssue,
|
||||
groupTitle,
|
||||
handleDeleteIssue,
|
||||
orderBy,
|
||||
handleTrashBox,
|
||||
isCompleted = false,
|
||||
userAuth,
|
||||
}) => {
|
||||
// context menu
|
||||
const [contextMenu, setContextMenu] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const { orderBy, params } = useIssuesView();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
@@ -91,75 +101,58 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
if (cycleId)
|
||||
mutate<CycleIssueResponse[]>(
|
||||
CYCLE_ISSUES(cycleId as string),
|
||||
(prevData) => {
|
||||
const updatedIssues = (prevData ?? []).map((p) => {
|
||||
if (p.issue_detail.id === issue.id) {
|
||||
return {
|
||||
...p,
|
||||
issue_detail: {
|
||||
...p.issue_detail,
|
||||
...formData,
|
||||
assignees: formData.assignees_list ?? p.issue_detail.assignees_list,
|
||||
},
|
||||
};
|
||||
}
|
||||
return p;
|
||||
});
|
||||
return [...updatedIssues];
|
||||
},
|
||||
mutate<
|
||||
| {
|
||||
[key: string]: IIssue[];
|
||||
}
|
||||
| IIssue[]
|
||||
>(
|
||||
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params),
|
||||
(prevData) =>
|
||||
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
|
||||
false
|
||||
);
|
||||
|
||||
if (moduleId)
|
||||
mutate<ModuleIssueResponse[]>(
|
||||
MODULE_ISSUES(moduleId as string),
|
||||
(prevData) => {
|
||||
const updatedIssues = (prevData ?? []).map((p) => {
|
||||
if (p.issue_detail.id === issue.id) {
|
||||
return {
|
||||
...p,
|
||||
issue_detail: {
|
||||
...p.issue_detail,
|
||||
...formData,
|
||||
assignees: formData.assignees_list ?? p.issue_detail.assignees_list,
|
||||
},
|
||||
};
|
||||
}
|
||||
return p;
|
||||
});
|
||||
return [...updatedIssues];
|
||||
},
|
||||
else if (moduleId)
|
||||
mutate<
|
||||
| {
|
||||
[key: string]: IIssue[];
|
||||
}
|
||||
| IIssue[]
|
||||
>(
|
||||
MODULE_ISSUES_WITH_PARAMS(moduleId as string),
|
||||
(prevData) =>
|
||||
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
|
||||
false
|
||||
);
|
||||
else
|
||||
mutate<
|
||||
| {
|
||||
[key: string]: IIssue[];
|
||||
}
|
||||
| IIssue[]
|
||||
>(
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
|
||||
(prevData) =>
|
||||
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
|
||||
false
|
||||
);
|
||||
|
||||
mutate<IIssue[]>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === issue.id)
|
||||
return { ...p, ...formData, assignees: formData.assignees_list ?? p.assignees_list };
|
||||
|
||||
return p;
|
||||
}),
|
||||
|
||||
false
|
||||
);
|
||||
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
|
||||
.then((res) => {
|
||||
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
|
||||
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
|
||||
|
||||
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
|
||||
.then(() => {
|
||||
if (cycleId) {
|
||||
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
|
||||
mutate(CYCLE_DETAILS(cycleId as string));
|
||||
} else if (moduleId) {
|
||||
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
|
||||
mutate(MODULE_DETAILS(moduleId as string));
|
||||
} else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, moduleId, issue]
|
||||
[workspaceSlug, projectId, cycleId, moduleId, issue, groupTitle, index, selectedGroup, params]
|
||||
);
|
||||
|
||||
const getStyle = (
|
||||
@@ -168,9 +161,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
) => {
|
||||
if (orderBy === "sort_order") return style;
|
||||
if (!snapshot.isDragging) return {};
|
||||
if (!snapshot.isDropAnimating) {
|
||||
return style;
|
||||
}
|
||||
if (!snapshot.isDropAnimating) return style;
|
||||
|
||||
return {
|
||||
...style,
|
||||
@@ -196,7 +187,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
|
||||
}, [snapshot, handleTrashBox]);
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -206,18 +197,31 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
isOpen={contextMenu}
|
||||
setIsOpen={setContextMenu}
|
||||
>
|
||||
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
|
||||
Edit issue
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
|
||||
Make a copy...
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
|
||||
Delete issue
|
||||
</ContextMenu.Item>
|
||||
{!isNotAllowed && (
|
||||
<>
|
||||
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
|
||||
Edit issue
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
|
||||
Make a copy...
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
|
||||
Delete issue
|
||||
</ContextMenu.Item>
|
||||
</>
|
||||
)}
|
||||
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
|
||||
Copy issue link
|
||||
</ContextMenu.Item>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
|
||||
Open issue in new tab
|
||||
</ContextMenu.Item>
|
||||
</a>
|
||||
</ContextMenu>
|
||||
<div
|
||||
className={`mb-3 rounded bg-white shadow ${
|
||||
@@ -233,22 +237,36 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
setContextMenuPosition({ x: e.pageX, y: e.pageY });
|
||||
}}
|
||||
>
|
||||
<div className="group/card relative select-none p-4">
|
||||
<div className="group/card relative select-none p-3.5">
|
||||
{!isNotAllowed && (
|
||||
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
|
||||
<div className="z-1 absolute top-1.5 right-1.5 opacity-0 group-hover/card:opacity-100">
|
||||
{type && !isNotAllowed && (
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={editIssue}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
<span>Edit issue</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{type !== "issue" && removeIssue && (
|
||||
<CustomMenu.MenuItem onClick={removeIssue}>
|
||||
<>Remove from {type}</>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
<span>Remove from {type}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
|
||||
Delete issue
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Delete issue</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
Copy issue link
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<span>Copy issue Link</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
@@ -301,7 +319,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
{properties.labels && issue.label_details.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.label_details.map((label) => (
|
||||
<span
|
||||
<div
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
||||
>
|
||||
@@ -312,7 +330,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,7 @@ import issuesServices from "services/issues.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Button } from "components/ui";
|
||||
import { DangerButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { LayerDiagonalIcon } from "components/icons";
|
||||
@@ -100,12 +100,6 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
||||
type: "success",
|
||||
message: res.message,
|
||||
});
|
||||
|
||||
mutate<IIssue[]>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) => (prevData ?? []).filter((p) => !data.delete_issue_ids.includes(p.id)),
|
||||
false
|
||||
);
|
||||
handleClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -211,17 +205,10 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
||||
|
||||
{filteredIssues.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit(handleDelete)}
|
||||
theme="danger"
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<DangerButton onClick={handleSubmit(handleDelete)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Deleting..." : "Delete selected issues"}
|
||||
</Button>
|
||||
</DangerButton>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
import React, { useState } from "react";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// helper
|
||||
import { renderDateFormat } from "helpers/date-time.helper";
|
||||
import {
|
||||
startOfWeek,
|
||||
lastDayOfWeek,
|
||||
eachDayOfInterval,
|
||||
weekDayInterval,
|
||||
formatDate,
|
||||
getCurrentWeekStartDate,
|
||||
getCurrentWeekEndDate,
|
||||
subtractMonths,
|
||||
addMonths,
|
||||
updateDateWithYear,
|
||||
updateDateWithMonth,
|
||||
isSameMonth,
|
||||
isSameYear,
|
||||
subtract7DaysToDate,
|
||||
addSevenDaysToDate,
|
||||
} from "helpers/calendar.helper";
|
||||
// ui
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
|
||||
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
||||
import { CustomMenu } from "components/ui";
|
||||
// icon
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import cyclesService from "services/cycles.service";
|
||||
// fetch key
|
||||
import {
|
||||
CYCLE_CALENDAR_ISSUES,
|
||||
MODULE_CALENDAR_ISSUES,
|
||||
PROJECT_CALENDAR_ISSUES,
|
||||
} from "constants/fetch-keys";
|
||||
// type
|
||||
import { IIssue } from "types";
|
||||
// constant
|
||||
import { monthOptions, yearOptions } from "constants/calendar";
|
||||
import modulesService from "services/modules.service";
|
||||
|
||||
interface ICalendarRange {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export const CalendarView = () => {
|
||||
const [showWeekEnds, setShowWeekEnds] = useState<boolean>(false);
|
||||
const [currentDate, setCurrentDate] = useState<Date>(new Date());
|
||||
const [isMonthlyView, setIsMonthlyView] = useState<boolean>(true);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const [calendarDateRange, setCalendarDateRange] = useState<ICalendarRange>({
|
||||
startDate: startOfWeek(currentDate),
|
||||
endDate: lastDayOfWeek(currentDate),
|
||||
});
|
||||
|
||||
const targetDateFilter = {
|
||||
target_date: `${renderDateFormat(calendarDateRange.startDate)};after,${renderDateFormat(
|
||||
calendarDateRange.endDate
|
||||
)};before`,
|
||||
};
|
||||
|
||||
const { data: projectCalendarIssues } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_CALENDAR_ISSUES(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () =>
|
||||
issuesService.getIssuesWithParams(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
targetDateFilter
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: cycleCalendarIssues } = useSWR(
|
||||
workspaceSlug && projectId && cycleId
|
||||
? CYCLE_CALENDAR_ISSUES(projectId as string, cycleId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId && cycleId
|
||||
? () =>
|
||||
cyclesService.getCycleIssuesWithParams(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
cycleId as string,
|
||||
targetDateFilter
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: moduleCalendarIssues } = useSWR(
|
||||
workspaceSlug && projectId && moduleId
|
||||
? MODULE_CALENDAR_ISSUES(projectId as string, moduleId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId && moduleId
|
||||
? () =>
|
||||
modulesService.getModuleIssuesWithParams(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
moduleId as string,
|
||||
targetDateFilter
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const totalDate = eachDayOfInterval({
|
||||
start: calendarDateRange.startDate,
|
||||
end: calendarDateRange.endDate,
|
||||
});
|
||||
|
||||
const onlyWeekDays = weekDayInterval({
|
||||
start: calendarDateRange.startDate,
|
||||
end: calendarDateRange.endDate,
|
||||
});
|
||||
|
||||
const currentViewDays = showWeekEnds ? totalDate : onlyWeekDays;
|
||||
|
||||
const calendarIssues = cycleCalendarIssues ?? moduleCalendarIssues ?? projectCalendarIssues;
|
||||
|
||||
const currentViewDaysData = currentViewDays.map((date: Date) => {
|
||||
const filterIssue =
|
||||
calendarIssues && calendarIssues.length > 0
|
||||
? (calendarIssues as IIssue[]).filter(
|
||||
(issue) =>
|
||||
issue.target_date && renderDateFormat(issue.target_date) === renderDateFormat(date)
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
date: renderDateFormat(date),
|
||||
issues: filterIssue,
|
||||
};
|
||||
});
|
||||
|
||||
const weeks = ((date: Date[]) => {
|
||||
const weeks = [];
|
||||
if (showWeekEnds) {
|
||||
for (let day = 0; day <= 6; day++) {
|
||||
weeks.push(date[day]);
|
||||
}
|
||||
} else {
|
||||
for (let day = 0; day <= 4; day++) {
|
||||
weeks.push(date[day]);
|
||||
}
|
||||
}
|
||||
|
||||
return weeks;
|
||||
})(currentViewDays);
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
const { source, destination, draggableId } = result;
|
||||
|
||||
if (!destination || !workspaceSlug || !projectId) return;
|
||||
if (source.droppableId === destination.droppableId) return;
|
||||
|
||||
const fetchKey = cycleId
|
||||
? CYCLE_CALENDAR_ISSUES(projectId as string, cycleId as string)
|
||||
: moduleId
|
||||
? MODULE_CALENDAR_ISSUES(projectId as string, moduleId as string)
|
||||
: PROJECT_CALENDAR_ISSUES(projectId as string);
|
||||
|
||||
mutate<IIssue[]>(
|
||||
fetchKey,
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === draggableId)
|
||||
return {
|
||||
...p,
|
||||
target_date: destination.droppableId,
|
||||
};
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
issuesService.patchIssue(workspaceSlug as string, projectId as string, draggableId, {
|
||||
target_date: destination?.droppableId,
|
||||
});
|
||||
};
|
||||
|
||||
const updateDate = (date: Date) => {
|
||||
setCurrentDate(date);
|
||||
|
||||
setCalendarDateRange({
|
||||
startDate: startOfWeek(date),
|
||||
endDate: lastDayOfWeek(date),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div className="h-full overflow-y-auto rounded-lg text-gray-600 -m-2">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="relative flex h-full w-full gap-2 items-center justify-start text-sm ">
|
||||
<Popover className="flex h-full items-center justify-start rounded-lg">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button className={`group flex h-full items-start gap-1 text-gray-800`}>
|
||||
<div className="flex items-center justify-center gap-2 text-2xl font-semibold">
|
||||
<span className="text-black">{formatDate(currentDate, "Month")}</span>{" "}
|
||||
<span>{formatDate(currentDate, "yyyy")}</span>
|
||||
</div>
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-10 left-0 z-20 w-full max-w-xs flex flex-col transform overflow-hidden bg-white shadow-lg rounded-[10px]">
|
||||
<div className="flex justify-center items-center text-sm gap-5 px-2 py-2">
|
||||
{yearOptions.map((year) => (
|
||||
<button
|
||||
onClick={() => updateDate(updateDateWithYear(year.label, currentDate))}
|
||||
className={` ${
|
||||
isSameYear(year.value, currentDate)
|
||||
? "text-sm font-medium text-gray-800"
|
||||
: "text-xs text-gray-400 "
|
||||
} hover:text-sm hover:text-gray-800 hover:font-medium `}
|
||||
>
|
||||
{year.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 px-2 border-t border-gray-200">
|
||||
{monthOptions.map((month) => (
|
||||
<button
|
||||
onClick={() =>
|
||||
updateDate(updateDateWithMonth(month.value, currentDate))
|
||||
}
|
||||
className={`text-gray-400 text-xs px-2 py-2 hover:font-medium hover:text-gray-800 ${
|
||||
isSameMonth(month.value, currentDate)
|
||||
? "font-medium text-gray-800"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{month.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
if (isMonthlyView) {
|
||||
updateDate(subtractMonths(currentDate, 1));
|
||||
} else {
|
||||
setCurrentDate(subtract7DaysToDate(currentDate));
|
||||
setCalendarDateRange({
|
||||
startDate: getCurrentWeekStartDate(subtract7DaysToDate(currentDate)),
|
||||
endDate: getCurrentWeekEndDate(subtract7DaysToDate(currentDate)),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
if (isMonthlyView) {
|
||||
updateDate(addMonths(currentDate, 1));
|
||||
} else {
|
||||
setCurrentDate(addSevenDaysToDate(currentDate));
|
||||
setCalendarDateRange({
|
||||
startDate: getCurrentWeekStartDate(addSevenDaysToDate(currentDate)),
|
||||
endDate: getCurrentWeekEndDate(addSevenDaysToDate(currentDate)),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-2 items-center justify-end">
|
||||
<button
|
||||
className="group flex cursor-pointer items-center gap-2 rounded-md border bg-white px-4 py-1.5 text-sm hover:bg-gray-100 hover:text-gray-900 focus:outline-none"
|
||||
onClick={() => {
|
||||
if (isMonthlyView) {
|
||||
updateDate(new Date());
|
||||
} else {
|
||||
setCurrentDate(new Date());
|
||||
setCalendarDateRange({
|
||||
startDate: getCurrentWeekStartDate(new Date()),
|
||||
endDate: getCurrentWeekEndDate(new Date()),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Today{" "}
|
||||
</button>
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<div
|
||||
className={`group flex cursor-pointer items-center gap-2 rounded-md border bg-white px-3 py-1.5 text-sm hover:bg-gray-100 hover:text-gray-900 focus:outline-none `}
|
||||
>
|
||||
{isMonthlyView ? "Monthly" : "Weekly"}
|
||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setIsMonthlyView(true);
|
||||
setCalendarDateRange({
|
||||
startDate: startOfWeek(currentDate),
|
||||
endDate: lastDayOfWeek(currentDate),
|
||||
});
|
||||
}}
|
||||
className="w-52 text-sm text-gray-600"
|
||||
>
|
||||
<div className="flex w-full max-w-[260px] items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2">Monthly View</span>
|
||||
<CheckIcon
|
||||
className={`h-4 w-4 flex-shrink-0 ${
|
||||
isMonthlyView ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setIsMonthlyView(false);
|
||||
setCalendarDateRange({
|
||||
startDate: getCurrentWeekStartDate(currentDate),
|
||||
endDate: getCurrentWeekEndDate(currentDate),
|
||||
});
|
||||
}}
|
||||
className="w-52 text-sm text-gray-600"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2">Weekly View</span>
|
||||
<CheckIcon
|
||||
className={`h-4 w-4 flex-shrink-0 ${
|
||||
isMonthlyView ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<div className="mt-1 flex w-52 items-center justify-between border-t border-gray-200 py-2 px-1 text-sm text-gray-600">
|
||||
<h4>Show weekends</h4>
|
||||
<button
|
||||
type="button"
|
||||
className={`relative inline-flex h-3.5 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
showWeekEnds ? "bg-green-500" : "bg-gray-200"
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={showWeekEnds}
|
||||
onClick={() => setShowWeekEnds(!showWeekEnds)}
|
||||
>
|
||||
<span className="sr-only">Show weekends</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block h-2.5 w-2.5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
showWeekEnds ? "translate-x-2.5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
|
||||
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
|
||||
}`}
|
||||
>
|
||||
{weeks.map((date, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-start p-1.5 gap-2 border-gray-300 bg-gray-100 text-base font-medium text-gray-600 ${
|
||||
!isMonthlyView
|
||||
? showWeekEnds
|
||||
? (index + 1) % 7 === 0
|
||||
? ""
|
||||
: "border-r"
|
||||
: (index + 1) % 5 === 0
|
||||
? ""
|
||||
: "border-r"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
{isMonthlyView ? formatDate(date, "eee").substring(0, 3) : formatDate(date, "eee")}
|
||||
</span>
|
||||
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`grid h-full auto-rows-[minmax(150px,1fr)] ${
|
||||
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
|
||||
} `}
|
||||
>
|
||||
{currentViewDaysData.map((date, index) => (
|
||||
<StrictModeDroppable droppableId={date.date}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={`flex flex-col gap-1.5 border-t border-gray-300 p-2.5 text-left text-sm font-medium hover:bg-gray-100 ${
|
||||
showWeekEnds
|
||||
? (index + 1) % 7 === 0
|
||||
? ""
|
||||
: "border-r"
|
||||
: (index + 1) % 5 === 0
|
||||
? ""
|
||||
: "border-r"
|
||||
}`}
|
||||
>
|
||||
{isMonthlyView && <span>{formatDate(new Date(date.date), "d")}</span>}
|
||||
{date.issues.length > 0 &&
|
||||
date.issues.map((issue: IIssue, index) => (
|
||||
<Draggable draggableId={issue.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={`w-full cursor-pointer truncate rounded bg-white p-1.5 hover:scale-105 ${
|
||||
snapshot.isDragging ? "shadow-lg" : ""
|
||||
}`}
|
||||
>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}
|
||||
className="w-full"
|
||||
>
|
||||
{issue.name}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./calendar"
|
||||
@@ -1,17 +1,25 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, SubmitHandler, useForm } from "react-hook-form";
|
||||
// hooks
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
|
||||
import useToast from "hooks/use-toast";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
// ui
|
||||
import { Button } from "components/ui";
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { LayerDiagonalIcon } from "components/icons";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
|
||||
|
||||
type FormInput = {
|
||||
issues: string[];
|
||||
@@ -32,8 +40,13 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const router = useRouter();
|
||||
const { cycleId, moduleId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { params } = useIssuesView();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setQuery("");
|
||||
@@ -63,6 +76,9 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
await handleOnSubmit(data);
|
||||
if (cycleId) mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
|
||||
if (moduleId) mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
|
||||
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
@@ -180,17 +196,10 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
/>
|
||||
{filteredIssues.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Adding..." : "Add selected issues"}
|
||||
</Button>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
// icons
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
CalendarDaysIcon,
|
||||
ChartBarIcon,
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
RectangleGroupIcon,
|
||||
Squares2X2Icon,
|
||||
TrashIcon,
|
||||
UserIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "components/icons";
|
||||
// helpers
|
||||
import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper";
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import RemirrorRichTextEditor from "components/rich-text-editor";
|
||||
import Link from "next/link";
|
||||
|
||||
const activityDetails: {
|
||||
[key: string]: {
|
||||
message?: string;
|
||||
icon: JSX.Element;
|
||||
};
|
||||
} = {
|
||||
assignee: {
|
||||
message: "removed the assignee",
|
||||
icon: <UserGroupIcon className="h-3 w-3" color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
assignees: {
|
||||
message: "added a new assignee",
|
||||
icon: <UserGroupIcon className="h-3 w-3" color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
blocks: {
|
||||
message: "marked this issue being blocked by",
|
||||
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
|
||||
},
|
||||
blocking: {
|
||||
message: "marked this issue is blocking",
|
||||
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
|
||||
},
|
||||
cycles: {
|
||||
message: "set the cycle to",
|
||||
icon: <CyclesIcon height="12" width="12" color="#6b7280" />,
|
||||
},
|
||||
labels: {
|
||||
icon: <TagIcon height="12" width="12" color="#6b7280" />,
|
||||
},
|
||||
modules: {
|
||||
message: "set the module to",
|
||||
icon: <RectangleGroupIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
},
|
||||
state: {
|
||||
message: "set the state to",
|
||||
icon: <Squares2X2Icon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
},
|
||||
priority: {
|
||||
message: "set the priority to",
|
||||
icon: <ChartBarIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
},
|
||||
name: {
|
||||
message: "set the name to",
|
||||
icon: <ChatBubbleBottomCenterTextIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
},
|
||||
description: {
|
||||
message: "updated the description.",
|
||||
icon: <ChatBubbleBottomCenterTextIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
},
|
||||
target_date: {
|
||||
message: "set the due date to",
|
||||
icon: <CalendarDaysIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
},
|
||||
parent: {
|
||||
message: "set the parent to",
|
||||
icon: <UserIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
},
|
||||
issue: {
|
||||
message: "deleted the issue.",
|
||||
icon: <TrashIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
},
|
||||
};
|
||||
|
||||
export const Feeds: React.FC<any> = ({ activities }) => (
|
||||
<div>
|
||||
<ul role="list" className="-mb-4">
|
||||
{activities.map((activity: any, activityIdx: number) => {
|
||||
// determines what type of action is performed
|
||||
let action = activityDetails[activity.field as keyof typeof activityDetails]?.message;
|
||||
if (activity.field === "labels") {
|
||||
action = activity.new_value !== "" ? "added a new label" : "removed the label";
|
||||
} else if (activity.field === "blocking") {
|
||||
action =
|
||||
activity.new_value !== ""
|
||||
? "marked this issue is blocking"
|
||||
: "removed the issue from blocking";
|
||||
} else if (activity.field === "blocks") {
|
||||
action =
|
||||
activity.new_value !== "" ? "marked this issue being blocked by" : "removed blocker";
|
||||
} else if (activity.field === "target_date") {
|
||||
action =
|
||||
activity.new_value && activity.new_value !== ""
|
||||
? "set the due date to"
|
||||
: "removed the due date";
|
||||
} else if (activity.field === "parent") {
|
||||
action =
|
||||
activity.new_value && activity.new_value !== ""
|
||||
? "set the parent to"
|
||||
: "removed the parent";
|
||||
} else if (activity.field === "priority") {
|
||||
action =
|
||||
activity.new_value && activity.new_value !== ""
|
||||
? "set the priority to"
|
||||
: "removed the priority";
|
||||
} else if (activity.field === "description") {
|
||||
action = "updated the";
|
||||
}
|
||||
// for values that are after the action clause
|
||||
let value: any = activity.new_value ? activity.new_value : activity.old_value;
|
||||
if (
|
||||
activity.verb === "created" &&
|
||||
activity.field !== "cycles" &&
|
||||
activity.field !== "modules"
|
||||
) {
|
||||
const { workspace_detail, project, issue } = activity;
|
||||
value = (
|
||||
<span className="text-gray-600">
|
||||
created{" "}
|
||||
<Link href={`/${workspace_detail.slug}/projects/${project}/issues/${issue}`}>
|
||||
<a className="inline-flex items-center hover:underline">
|
||||
this issue. <ArrowTopRightOnSquareIcon className="h-3.5 w-3.5 ml-1" />
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
);
|
||||
} else if (activity.field === "state") {
|
||||
value = activity.new_value ? addSpaceIfCamelCase(activity.new_value) : "None";
|
||||
} else if (activity.field === "labels") {
|
||||
let name;
|
||||
let id = "#000000";
|
||||
if (activity.new_value !== "") {
|
||||
name = activity.new_value;
|
||||
id = activity.new_identifier ? activity.new_identifier : id;
|
||||
} else {
|
||||
name = activity.old_value;
|
||||
id = activity.old_identifier ? activity.old_identifier : id;
|
||||
}
|
||||
|
||||
value = name;
|
||||
} else if (activity.field === "assignees") {
|
||||
value = activity.new_value;
|
||||
} else if (activity.field === "target_date") {
|
||||
const date =
|
||||
activity.new_value && activity.new_value !== ""
|
||||
? activity.new_value
|
||||
: activity.old_value;
|
||||
value = renderShortNumericDateFormat(date as string);
|
||||
} else if (activity.field === "description") {
|
||||
value = "description";
|
||||
}
|
||||
|
||||
if (activity.field === "comment") {
|
||||
return (
|
||||
<div key={activity.id}>
|
||||
<div className="relative flex items-start space-x-3">
|
||||
<div className="relative px-1">
|
||||
{activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
|
||||
<Image
|
||||
src={activity.actor_detail.avatar}
|
||||
alt={activity.actor_detail.first_name}
|
||||
height={30}
|
||||
width={30}
|
||||
className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}
|
||||
>
|
||||
{activity.actor_detail.first_name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="absolute -bottom-0.5 -right-1 rounded-tl bg-white px-0.5 py-px">
|
||||
<ChatBubbleLeftEllipsisIcon
|
||||
className="h-3.5 w-3.5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div>
|
||||
<div className="text-xs">
|
||||
{activity.actor_detail.first_name}
|
||||
{activity.actor_detail.is_bot ? "Bot" : " " + activity.actor_detail.last_name}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Commented {timeAgo(activity.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="issue-comments-section p-0">
|
||||
<RemirrorRichTextEditor
|
||||
value={
|
||||
activity.new_value && activity.new_value !== ""
|
||||
? activity.new_value
|
||||
: activity.old_value
|
||||
}
|
||||
editable={false}
|
||||
onBlur={() => ({})}
|
||||
noBorder
|
||||
customClassName="text-xs bg-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if ("field" in activity && activity.field !== "updated_by") {
|
||||
return (
|
||||
<li key={activity.id}>
|
||||
<div className="relative pb-1">
|
||||
{activities.length > 1 && activityIdx !== activities.length - 1 ? (
|
||||
<span
|
||||
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative flex items-start space-x-2">
|
||||
<>
|
||||
<div>
|
||||
<div className="relative px-1.5">
|
||||
<div className="mt-1.5">
|
||||
<div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-gray-100 ring-white">
|
||||
{activity.field ? (
|
||||
activityDetails[activity.field as keyof typeof activityDetails]?.icon
|
||||
) : activity.actor_detail.avatar &&
|
||||
activity.actor_detail.avatar !== "" ? (
|
||||
<Image
|
||||
src={activity.actor_detail.avatar}
|
||||
alt={activity.actor_detail.first_name}
|
||||
height={24}
|
||||
width={24}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`}
|
||||
>
|
||||
{activity.actor_detail.first_name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 py-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
<span className="text-gray font-medium">
|
||||
{activity.actor_detail.first_name}
|
||||
{activity.actor_detail.is_bot
|
||||
? " Bot"
|
||||
: " " + activity.actor_detail.last_name}
|
||||
</span>
|
||||
<span> {action} </span>
|
||||
<span className="text-xs font-medium text-gray-900"> {value} </span>
|
||||
<span className="whitespace-nowrap">{timeAgo(activity.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,337 @@
|
||||
import React from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
|
||||
// icons
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
|
||||
// ui
|
||||
import { Avatar } from "components/ui";
|
||||
// helpers
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import projectService from "services/project.service";
|
||||
import stateService from "services/state.service";
|
||||
// types
|
||||
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATE_LIST } from "constants/fetch-keys";
|
||||
import { IIssueFilterOptions } from "types";
|
||||
|
||||
export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, viewId } = router.query;
|
||||
|
||||
const { data: members } = useSWR(
|
||||
projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: issueLabels } = useSWR(
|
||||
projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: stateGroups } = useSWR(
|
||||
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
|
||||
workspaceSlug
|
||||
? () => stateService.getStates(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
const states = getStatesList(stateGroups ?? {});
|
||||
|
||||
if (!filters) return <></>;
|
||||
|
||||
const nullFilters = Object.keys(filters).filter(
|
||||
(key) => filters[key as keyof IIssueFilterOptions] === null
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2 text-xs">
|
||||
{Object.keys(filters).map((key) => {
|
||||
if (filters[key as keyof typeof filters] !== null)
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-x-2 rounded-full border bg-white px-2 py-1"
|
||||
>
|
||||
<span className="font-medium capitalize text-gray-500">
|
||||
{replaceUnderscoreIfSnakeCase(key)}:
|
||||
</span>
|
||||
{filters[key as keyof IIssueFilterOptions] === null ||
|
||||
(filters[key as keyof IIssueFilterOptions]?.length ?? 0) <= 0 ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 font-medium">None</span>
|
||||
) : Array.isArray(filters[key as keyof IIssueFilterOptions]) ? (
|
||||
<div className="space-x-2">
|
||||
{key === "state" ? (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{filters.state?.map((stateId: any) => {
|
||||
const state = states?.find((s) => s.id === stateId);
|
||||
|
||||
return (
|
||||
<p
|
||||
key={state?.id}
|
||||
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 font-medium text-white"
|
||||
style={{
|
||||
color: state?.color,
|
||||
backgroundColor: `${state?.color}20`,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{getStateGroupIcon(
|
||||
state?.group ?? "backlog",
|
||||
"12",
|
||||
"12",
|
||||
state?.color
|
||||
)}
|
||||
</span>
|
||||
<span>{state?.name ?? ""}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters(
|
||||
{
|
||||
state: filters.state?.filter((s: any) => s !== stateId),
|
||||
},
|
||||
!Boolean(viewId)
|
||||
)
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
state: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : key === "priority" ? (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{filters.priority?.map((priority: any) => (
|
||||
<p
|
||||
key={priority}
|
||||
className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 font-medium capitalize text-white ${
|
||||
priority === "urgent"
|
||||
? "bg-red-100 text-red-600 hover:bg-red-100"
|
||||
: priority === "high"
|
||||
? "bg-orange-100 text-orange-500 hover:bg-orange-100"
|
||||
: priority === "medium"
|
||||
? "bg-yellow-100 text-yellow-500 hover:bg-yellow-100"
|
||||
: priority === "low"
|
||||
? "bg-green-100 text-green-500 hover:bg-green-100"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<span>{getPriorityIcon(priority)}</span>
|
||||
<span>{priority ? priority : "None"}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters(
|
||||
{
|
||||
priority: filters.priority?.filter((p: any) => p !== priority),
|
||||
},
|
||||
!Boolean(viewId)
|
||||
)
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
priority: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : key === "assignees" ? (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{filters.assignees?.map((memberId: string) => {
|
||||
const member = members?.find((m) => m.member.id === memberId)?.member;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={memberId}
|
||||
className="inline-flex items-center gap-x-1 rounded-full px-1 font-medium capitalize"
|
||||
>
|
||||
<Avatar user={member} />
|
||||
<span>{member?.first_name}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters(
|
||||
{
|
||||
assignees: filters.assignees?.filter(
|
||||
(p: any) => p !== memberId
|
||||
),
|
||||
},
|
||||
!Boolean(viewId)
|
||||
)
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
assignees: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (key as keyof IIssueFilterOptions) === "created_by" ? (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{filters.created_by?.map((memberId: string) => {
|
||||
const member = members?.find((m) => m.member.id === memberId)?.member;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${memberId}-${key}`}
|
||||
className="inline-flex items-center gap-x-1 rounded-full px-1 font-medium capitalize"
|
||||
>
|
||||
<Avatar user={member} />
|
||||
<span>{member?.first_name}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters(
|
||||
{
|
||||
created_by: filters.created_by?.filter(
|
||||
(p: any) => p !== memberId
|
||||
),
|
||||
},
|
||||
!Boolean(viewId)
|
||||
)
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
created_by: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : key === "labels" ? (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{filters.labels?.map((labelId: string) => {
|
||||
const label = issueLabels?.find((l) => l.id === labelId);
|
||||
|
||||
if (!label) return null;
|
||||
const color = label.color !== "" ? label.color : "#0f172a";
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 font-medium"
|
||||
style={{
|
||||
background: `${color}33`, // add 20% opacity
|
||||
}}
|
||||
key={labelId}
|
||||
>
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
color: color,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters(
|
||||
{
|
||||
labels: filters.labels?.filter((l: any) => l !== labelId),
|
||||
},
|
||||
!Boolean(viewId)
|
||||
)
|
||||
}
|
||||
>
|
||||
<XMarkIcon
|
||||
className="h-3 w-3"
|
||||
style={{
|
||||
color: color,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
labels: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
(filters[key as keyof IIssueFilterOptions] as any)?.join(", ")
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="capitalize">{filters[key as keyof typeof filters]}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
state: null,
|
||||
priority: null,
|
||||
assignees: null,
|
||||
labels: null,
|
||||
created_by: null,
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-x-1 rounded-full border bg-white px-3 py-1.5 text-xs"
|
||||
>
|
||||
<span className="font-medium">Clear all filters</span>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// services
|
||||
import aiService from "services/ai.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
|
||||
import { IIssue, IPageBlock } from "types";
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
inset?: string;
|
||||
content: string;
|
||||
htmlContent?: string;
|
||||
onResponse: (response: string) => void;
|
||||
projectId: string;
|
||||
block?: IPageBlock;
|
||||
issue?: IIssue;
|
||||
};
|
||||
|
||||
type FormData = {
|
||||
prompt: string;
|
||||
task: string;
|
||||
};
|
||||
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export const GptAssistantModal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
handleClose,
|
||||
inset = "top-0 left-0",
|
||||
content,
|
||||
htmlContent,
|
||||
onResponse,
|
||||
projectId,
|
||||
block,
|
||||
issue,
|
||||
}) => {
|
||||
const [response, setResponse] = useState("");
|
||||
const [invalidResponse, setInvalidResponse] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
setFocus,
|
||||
formState: { isSubmitting },
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
prompt: content,
|
||||
task: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
setResponse("");
|
||||
setInvalidResponse(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleResponse = async (formData: FormData) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
if (formData.task === "") {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Please enter some task to get AI assistance.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await aiService
|
||||
.createGptTask(workspaceSlug as string, projectId as string, {
|
||||
prompt: content && content !== "" ? content : htmlContent ?? "",
|
||||
task: formData.task,
|
||||
})
|
||||
.then((res) => {
|
||||
setResponse(res.response_html);
|
||||
setFocus("task");
|
||||
|
||||
if (res.response === "") setInvalidResponse(true);
|
||||
else setInvalidResponse(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.status === 429)
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message:
|
||||
"You have reached the maximum number of requests of 50 requests per month per user.",
|
||||
});
|
||||
else
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Some error occurred. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setFocus("task");
|
||||
}, [isOpen, setFocus]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border bg-white p-4 shadow ${
|
||||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{((content && content !== "") || htmlContent !== "<p></p>") && (
|
||||
<div className="remirror-section text-sm">
|
||||
Content:
|
||||
<RemirrorRichTextEditor
|
||||
value={htmlContent ?? <p>{content}</p>}
|
||||
customClassName="-m-3"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
editable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{response !== "" && (
|
||||
<div className="text-sm page-block-section">
|
||||
Response:
|
||||
<RemirrorRichTextEditor
|
||||
value={`<p>${response}</p>`}
|
||||
customClassName="-mx-3 -my-3"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
editable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{invalidResponse && (
|
||||
<div className="text-sm text-red-500">
|
||||
No response could be generated. This may be due to insufficient content or task
|
||||
information. Please try again.
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
type="text"
|
||||
name="task"
|
||||
register={register}
|
||||
placeholder={`${
|
||||
content && content !== ""
|
||||
? "Tell AI what action to perform on this content..."
|
||||
: "Ask AI anything..."
|
||||
}`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<div className={`flex gap-2 ${response === "" ? "justify-end" : "justify-between"}`}>
|
||||
{response !== "" && (
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
onResponse(response);
|
||||
onClose();
|
||||
if (block)
|
||||
trackEventServices.trackUseGPTResponseEvent(
|
||||
block,
|
||||
"USE_GPT_RESPONSE_IN_PAGE_BLOCK"
|
||||
);
|
||||
else if (issue)
|
||||
trackEventServices.trackUseGPTResponseEvent(issue, "USE_GPT_RESPONSE_IN_ISSUE");
|
||||
}}
|
||||
>
|
||||
Use this response
|
||||
</PrimaryButton>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<SecondaryButton onClick={onClose}>Close</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleSubmit(handleResponse)}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? "Generating response..."
|
||||
: response === ""
|
||||
? "Generate response"
|
||||
: "Generate again"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -13,8 +13,7 @@ import { Tab, Transition, Popover } from "@headlessui/react";
|
||||
import fileService from "services/file.service";
|
||||
|
||||
// components
|
||||
import { Input, Spinner } from "components/ui";
|
||||
import { PrimaryButton } from "components/ui/button/primary-button";
|
||||
import { Input, Spinner, PrimaryButton } from "components/ui";
|
||||
// hooks
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
|
||||
@@ -108,7 +107,7 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
|
||||
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
|
||||
placeholder="Search for images"
|
||||
/>
|
||||
<PrimaryButton className="bg-indigo-600" size="sm">
|
||||
<PrimaryButton type="submit" className="bg-indigo-600" size="sm">
|
||||
Search
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
|
||||
@@ -3,27 +3,32 @@ import React, { useCallback, useState } from "react";
|
||||
import NextImage from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react-dropzone
|
||||
import { useDropzone } from "react-dropzone";
|
||||
|
||||
// headless ui
|
||||
import { Transition, Dialog } from "@headlessui/react";
|
||||
|
||||
// services
|
||||
import fileServices from "services/file.service";
|
||||
// icon
|
||||
import { UserCircleIcon } from "components/icons";
|
||||
// ui
|
||||
import { Button } from "components/ui";
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { UserCircleIcon } from "components/icons";
|
||||
|
||||
type TImageUploadModalProps = {
|
||||
type Props = {
|
||||
value?: string | null;
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
onSuccess: (url: string) => void;
|
||||
userImage?: boolean;
|
||||
};
|
||||
|
||||
export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
|
||||
const { value, onSuccess, isOpen, onClose } = props;
|
||||
|
||||
export const ImageUploadModal: React.FC<Props> = ({
|
||||
value,
|
||||
onSuccess,
|
||||
isOpen,
|
||||
onClose,
|
||||
userImage,
|
||||
}) => {
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const [isImageUploading, setIsImageUploading] = useState(false);
|
||||
|
||||
@@ -34,37 +39,62 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
|
||||
setImage(acceptedFiles[0]);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
open: openFileDialog,
|
||||
} = useDropzone({
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsImageUploading(true);
|
||||
|
||||
if (image === null || !workspaceSlug) return;
|
||||
if (!image || !workspaceSlug) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("asset", image);
|
||||
formData.append("attributes", JSON.stringify({}));
|
||||
|
||||
fileServices
|
||||
.uploadFile(workspaceSlug as string, formData)
|
||||
.then((res) => {
|
||||
const imageUrl = res.asset;
|
||||
onSuccess(imageUrl);
|
||||
setIsImageUploading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
if (userImage) {
|
||||
fileServices
|
||||
.uploadUserFile(formData)
|
||||
.then((res) => {
|
||||
const imageUrl = res.asset;
|
||||
|
||||
onSuccess(imageUrl);
|
||||
setIsImageUploading(false);
|
||||
setImage(null);
|
||||
|
||||
if (value) {
|
||||
const index = value.indexOf(".com");
|
||||
const asset = value.substring(index + 5);
|
||||
|
||||
fileServices.deleteUserFile(asset);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
} else
|
||||
fileServices
|
||||
.uploadFile(workspaceSlug as string, formData)
|
||||
.then((res) => {
|
||||
const imageUrl = res.asset;
|
||||
onSuccess(imageUrl);
|
||||
setIsImageUploading(false);
|
||||
setImage(null);
|
||||
|
||||
if (value) {
|
||||
const index = value.indexOf(".com");
|
||||
const asset = value.substring(index + 5);
|
||||
|
||||
fileServices.deleteFile(asset);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setImage(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -109,11 +139,10 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{image !== null || (value && value !== null && value !== "") ? (
|
||||
{image !== null || (value && value !== "") ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openFileDialog}
|
||||
className="absolute top-0 right-0 z-40 translate-x-1/2 -translate-y-1/2 rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600"
|
||||
>
|
||||
Edit
|
||||
@@ -142,16 +171,14 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
||||
<Button theme="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleSubmit}
|
||||
disabled={isImageUploading || image === null}
|
||||
disabled={!image}
|
||||
loading={isImageUploading}
|
||||
>
|
||||
{isImageUploading ? "Uploading..." : "Upload & Save"}
|
||||
</Button>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
|
||||
@@ -3,10 +3,12 @@ export * from "./list-view";
|
||||
export * from "./sidebar";
|
||||
export * from "./bulk-delete-issues-modal";
|
||||
export * from "./existing-issues-list-modal";
|
||||
export * from "./gpt-assistant-modal";
|
||||
export * from "./image-upload-modal";
|
||||
export * from "./issues-view-filter";
|
||||
export * from "./issues-view";
|
||||
export * from "./link-modal";
|
||||
export * from "./not-authorized-view";
|
||||
export * from "./multi-level-select";
|
||||
export * from "./image-picker-popover";
|
||||
export * from "./filter-list";
|
||||
export * from "./feeds";
|
||||
|
||||
@@ -4,42 +4,41 @@ import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
import useIssuesProperties from "hooks/use-issue-properties";
|
||||
import useIssueView from "hooks/use-issue-view";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
// headless ui
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
// components
|
||||
import { SelectFilters } from "components/views";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
// icons
|
||||
import { ChevronDownIcon, ListBulletIcon } from "@heroicons/react/24/outline";
|
||||
import { ChevronDownIcon, ListBulletIcon, CalendarDaysIcon } from "@heroicons/react/24/outline";
|
||||
import { Squares2X2Icon } from "@heroicons/react/20/solid";
|
||||
// helpers
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, Properties } from "types";
|
||||
// common
|
||||
import { Properties } from "types";
|
||||
// constants
|
||||
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
|
||||
|
||||
type Props = {
|
||||
issues?: IIssue[];
|
||||
};
|
||||
|
||||
export const IssuesFilterView: React.FC<Props> = ({ issues }) => {
|
||||
export const IssuesFilterView: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const { workspaceSlug, projectId, viewId } = router.query;
|
||||
|
||||
const {
|
||||
issueView,
|
||||
setIssueViewToList,
|
||||
setIssueViewToKanban,
|
||||
setIssueView,
|
||||
groupByProperty,
|
||||
setGroupByProperty,
|
||||
setOrderBy,
|
||||
setFilterIssue,
|
||||
orderBy,
|
||||
filterIssue,
|
||||
setOrderBy,
|
||||
showEmptyGroups,
|
||||
setShowEmptyGroups,
|
||||
filters,
|
||||
setFilters,
|
||||
resetFilterToDefault,
|
||||
setNewFilterDefaultView,
|
||||
} = useIssueView(issues ?? []);
|
||||
} = useIssuesView();
|
||||
|
||||
const [properties, setProperties] = useIssuesProperties(
|
||||
workspaceSlug as string,
|
||||
@@ -47,172 +46,215 @@ export const IssuesFilterView: React.FC<Props> = ({ issues }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{issues && issues.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
||||
issueView === "list" ? "bg-gray-200" : ""
|
||||
}`}
|
||||
onClick={() => setIssueViewToList()}
|
||||
>
|
||||
<ListBulletIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
||||
issueView === "kanban" ? "bg-gray-200" : ""
|
||||
}`}
|
||||
onClick={() => setIssueViewToKanban()}
|
||||
>
|
||||
<Squares2X2Icon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex items-center gap-2 rounded-md border bg-transparent p-2 text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none ${
|
||||
open ? "bg-gray-100 text-gray-900" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
<span>View</span>
|
||||
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</Popover.Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
||||
issueView === "list" ? "bg-gray-200" : ""
|
||||
}`}
|
||||
onClick={() => setIssueView("list")}
|
||||
>
|
||||
<ListBulletIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
||||
issueView === "kanban" ? "bg-gray-200" : ""
|
||||
}`}
|
||||
onClick={() => setIssueView("kanban")}
|
||||
>
|
||||
<Squares2X2Icon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
||||
issueView === "calendar" ? "bg-gray-200" : ""
|
||||
}`}
|
||||
onClick={() => setIssueView("calendar")}
|
||||
>
|
||||
<CalendarDaysIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<SelectFilters
|
||||
filters={filters}
|
||||
onSelect={(option) => {
|
||||
const key = option.key as keyof typeof filters;
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform overflow-hidden rounded-lg bg-white p-3 shadow-lg">
|
||||
<div className="relative divide-y-2">
|
||||
{issues && (
|
||||
<div className="space-y-4 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm text-gray-600">Group by</h4>
|
||||
<CustomMenu
|
||||
label={
|
||||
GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
|
||||
?.name ?? "Select"
|
||||
}
|
||||
width="lg"
|
||||
>
|
||||
{GROUP_BY_OPTIONS.map((option) =>
|
||||
issueView === "kanban" && option.key === null ? null : (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => setGroupByProperty(option.key)}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
)
|
||||
)}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm text-gray-600">Order by</h4>
|
||||
<CustomMenu
|
||||
label={
|
||||
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
|
||||
"Select"
|
||||
}
|
||||
width="lg"
|
||||
>
|
||||
{ORDER_BY_OPTIONS.map((option) =>
|
||||
groupByProperty === "priority" &&
|
||||
option.key === "priority" ? null : (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
setOrderBy(option.key);
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
)
|
||||
)}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm text-gray-600">Issue type</h4>
|
||||
<CustomMenu
|
||||
label={
|
||||
FILTER_ISSUE_OPTIONS.find((option) => option.key === filterIssue)
|
||||
?.name ?? "Select"
|
||||
}
|
||||
width="lg"
|
||||
>
|
||||
{FILTER_ISSUE_OPTIONS.map((option) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => setFilterIssue(option.key)}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className="relative flex justify-end gap-x-3">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs"
|
||||
onClick={() => resetFilterToDefault()}
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-medium text-theme"
|
||||
onClick={() => setNewFilterDefaultView()}
|
||||
>
|
||||
Set as default
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2 py-3">
|
||||
<h4 className="text-sm text-gray-600">Display Properties</h4>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{Object.keys(properties).map((key) => {
|
||||
if (
|
||||
issueView === "kanban" &&
|
||||
((groupByProperty === "state_detail.name" && key === "state") ||
|
||||
(groupByProperty === "priority" && key === "priority"))
|
||||
)
|
||||
return;
|
||||
const valueExists = filters[key]?.includes(option.value);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||
properties[key as keyof Properties]
|
||||
? "border-theme bg-theme text-white"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
onClick={() => setProperties(key as keyof Properties)}
|
||||
>
|
||||
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
if (valueExists) {
|
||||
setFilters(
|
||||
{
|
||||
...(filters ?? {}),
|
||||
[option.key]: ((filters[key] ?? []) as any[])?.filter(
|
||||
(val) => val !== option.value
|
||||
),
|
||||
},
|
||||
!Boolean(viewId)
|
||||
);
|
||||
} else {
|
||||
setFilters(
|
||||
{
|
||||
...(filters ?? {}),
|
||||
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
|
||||
},
|
||||
!Boolean(viewId)
|
||||
);
|
||||
}
|
||||
}}
|
||||
direction="left"
|
||||
height="rg"
|
||||
/>
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex items-center gap-2 rounded-md border bg-transparent px-3 py-1.5 text-xs hover:bg-gray-100 hover:text-gray-900 focus:outline-none ${
|
||||
open ? "bg-gray-100 text-gray-900" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
View
|
||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform overflow-hidden rounded-lg bg-white p-3 shadow-lg">
|
||||
<div className="relative divide-y-2">
|
||||
<div className="space-y-4 pb-3 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-gray-600">Group by</h4>
|
||||
<CustomMenu
|
||||
label={
|
||||
GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)?.name ??
|
||||
"Select"
|
||||
}
|
||||
width="lg"
|
||||
>
|
||||
{GROUP_BY_OPTIONS.map((option) =>
|
||||
issueView === "kanban" && option.key === null ? null : (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => setGroupByProperty(option.key)}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
)
|
||||
)}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-gray-600">Order by</h4>
|
||||
<CustomMenu
|
||||
label={
|
||||
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
|
||||
"Select"
|
||||
}
|
||||
width="lg"
|
||||
>
|
||||
{ORDER_BY_OPTIONS.map((option) =>
|
||||
groupByProperty === "priority" && option.key === "priority" ? null : (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
setOrderBy(option.key);
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
)
|
||||
)}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-gray-600">Issue type</h4>
|
||||
<CustomMenu
|
||||
label={
|
||||
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters.type)
|
||||
?.name ?? "Select"
|
||||
}
|
||||
width="lg"
|
||||
>
|
||||
{FILTER_ISSUE_OPTIONS.map((option) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
type: option.key,
|
||||
})
|
||||
}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-gray-600">Show empty states</h4>
|
||||
<button
|
||||
type="button"
|
||||
className={`relative inline-flex h-3.5 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
showEmptyGroups ? "bg-green-500" : "bg-gray-200"
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={showEmptyGroups}
|
||||
onClick={() => setShowEmptyGroups(!showEmptyGroups)}
|
||||
>
|
||||
<span className="sr-only">Show empty groups</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block h-2.5 w-2.5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
showEmptyGroups ? "translate-x-2.5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative flex justify-end gap-x-3">
|
||||
<button type="button" onClick={() => resetFilterToDefault()}>
|
||||
Reset to default
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-theme"
|
||||
onClick={() => setNewFilterDefaultView()}
|
||||
>
|
||||
Set as default
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 py-3">
|
||||
<h4 className="text-sm text-gray-600">Display Properties</h4>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{Object.keys(properties).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||
properties[key as keyof Properties]
|
||||
? "border-theme bg-theme text-white"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
onClick={() => setProperties(key as keyof Properties)}
|
||||
>
|
||||
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,49 +9,62 @@ import { DragDropContext, DropResult } from "react-beautiful-dnd";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import stateService from "services/state.service";
|
||||
import projectService from "services/project.service";
|
||||
import modulesService from "services/modules.service";
|
||||
// hooks
|
||||
import useIssueView from "hooks/use-issue-view";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
// components
|
||||
import { AllLists, AllBoards } from "components/core";
|
||||
import { AllLists, AllBoards, FilterList } from "components/core";
|
||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
||||
import { CreateUpdateViewModal } from "components/views";
|
||||
import { TransferIssues, TransferIssuesModal } from "components/cycles";
|
||||
// ui
|
||||
import { EmptySpace, EmptySpaceItem, PrimaryButton, Spinner } from "components/ui";
|
||||
import { CalendarView } from "./calendar-view";
|
||||
// icons
|
||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ListBulletIcon,
|
||||
PlusIcon,
|
||||
RectangleStackIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ExclamationIcon, TransferIcon } from "components/icons";
|
||||
// helpers
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
// types
|
||||
import { CycleIssueResponse, IIssue, ModuleIssueResponse, UserAuth } from "types";
|
||||
import { IIssue, IIssueFilterOptions, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import {
|
||||
CYCLE_ISSUES,
|
||||
MODULE_ISSUES,
|
||||
PROJECT_ISSUES_LIST,
|
||||
PROJECT_MEMBERS,
|
||||
CYCLE_DETAILS,
|
||||
CYCLE_ISSUES_WITH_PARAMS,
|
||||
MODULE_DETAILS,
|
||||
MODULE_ISSUES_WITH_PARAMS,
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||
STATE_LIST,
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
type?: "issue" | "cycle" | "module";
|
||||
issues: IIssue[];
|
||||
openIssuesListModal?: () => void;
|
||||
isCompleted?: boolean;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const IssuesView: React.FC<Props> = ({
|
||||
type = "issue",
|
||||
issues,
|
||||
openIssuesListModal,
|
||||
isCompleted = false,
|
||||
userAuth,
|
||||
}) => {
|
||||
// create issue modal
|
||||
const [createIssueModal, setCreateIssueModal] = useState(false);
|
||||
const [createViewModal, setCreateViewModal] = useState<any>(null);
|
||||
const [preloadedData, setPreloadedData] = useState<
|
||||
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
|
||||
>(undefined);
|
||||
|
||||
// updates issue modal
|
||||
// update issue modal
|
||||
const [editIssueModal, setEditIssueModal] = useState(false);
|
||||
const [issueToEdit, setIssueToEdit] = useState<
|
||||
(IIssue & { actionType: "edit" | "delete" }) | undefined
|
||||
@@ -64,15 +77,24 @@ export const IssuesView: React.FC<Props> = ({
|
||||
// trash box
|
||||
const [trashBox, setTrashBox] = useState(false);
|
||||
|
||||
// transfer issue
|
||||
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
issueView,
|
||||
groupedByIssues,
|
||||
issueView,
|
||||
groupByProperty: selectedGroup,
|
||||
orderBy,
|
||||
} = useIssueView(issues);
|
||||
filters,
|
||||
isNotEmpty,
|
||||
setFilters,
|
||||
params,
|
||||
} = useIssuesView();
|
||||
|
||||
const { data: stateGroups } = useSWR(
|
||||
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
|
||||
@@ -82,13 +104,6 @@ export const IssuesView: React.FC<Props> = ({
|
||||
);
|
||||
const states = getStatesList(stateGroups ?? {});
|
||||
|
||||
const { data: members } = useSWR(
|
||||
projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const handleDeleteIssue = useCallback(
|
||||
(issue: IIssue) => {
|
||||
setDeleteIssueModal(true);
|
||||
@@ -101,7 +116,7 @@ export const IssuesView: React.FC<Props> = ({
|
||||
(result: DropResult) => {
|
||||
setTrashBox(false);
|
||||
|
||||
if (!result.destination || !workspaceSlug || !projectId) return;
|
||||
if (!result.destination || !workspaceSlug || !projectId || !groupedByIssues) return;
|
||||
|
||||
const { source, destination } = result;
|
||||
|
||||
@@ -156,90 +171,105 @@ export const IssuesView: React.FC<Props> = ({
|
||||
draggedItem.sort_order = newSortOrder;
|
||||
}
|
||||
|
||||
if (orderBy === "sort_order" || source.droppableId !== destination.droppableId) {
|
||||
const sourceGroup = source.droppableId; // source group id
|
||||
const destinationGroup = destination.droppableId; // destination group id
|
||||
const destinationGroup = destination.droppableId; // destination group id
|
||||
|
||||
if (!sourceGroup || !destinationGroup) return;
|
||||
if (orderBy === "sort_order" || source.droppableId !== destination.droppableId) {
|
||||
// different group/column;
|
||||
|
||||
// source.droppableId !== destination.droppableId -> even if order by is not sort_order,
|
||||
// if the issue is moved to a different group, then we will change the group of the
|
||||
// dragged item(or issue)
|
||||
|
||||
if (selectedGroup === "priority") draggedItem.priority = destinationGroup;
|
||||
else if (selectedGroup === "state_detail.name") {
|
||||
const destinationState = states?.find((s) => s.name === destinationGroup);
|
||||
else if (selectedGroup === "state") draggedItem.state = destinationGroup;
|
||||
}
|
||||
|
||||
if (!destinationState) return;
|
||||
const sourceGroup = source.droppableId; // source group id
|
||||
|
||||
draggedItem.state = destinationState.id;
|
||||
draggedItem.state_detail = destinationState;
|
||||
}
|
||||
|
||||
if (cycleId)
|
||||
mutate<CycleIssueResponse[]>(
|
||||
CYCLE_ISSUES(cycleId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
const updatedIssues = prevData.map((issue) => {
|
||||
if (issue.issue_detail.id === draggedItem.id) {
|
||||
return {
|
||||
...issue,
|
||||
issue_detail: draggedItem,
|
||||
};
|
||||
}
|
||||
return issue;
|
||||
});
|
||||
return [...updatedIssues];
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
if (moduleId)
|
||||
mutate<ModuleIssueResponse[]>(
|
||||
MODULE_ISSUES(moduleId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
const updatedIssues = prevData.map((issue) => {
|
||||
if (issue.issue_detail.id === draggedItem.id) {
|
||||
return {
|
||||
...issue,
|
||||
issue_detail: draggedItem,
|
||||
};
|
||||
}
|
||||
return issue;
|
||||
});
|
||||
return [...updatedIssues];
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
mutate<IIssue[]>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
// TODO: move this mutation logic to a separate function
|
||||
if (cycleId)
|
||||
mutate<{
|
||||
[key: string]: IIssue[];
|
||||
}>(
|
||||
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const updatedIssues = prevData.map((i) => {
|
||||
if (i.id === draggedItem.id) return draggedItem;
|
||||
const sourceGroupArray = prevData[sourceGroup];
|
||||
const destinationGroupArray = groupedByIssues[destinationGroup];
|
||||
|
||||
return i;
|
||||
});
|
||||
sourceGroupArray.splice(source.index, 1);
|
||||
destinationGroupArray.splice(destination.index, 0, draggedItem);
|
||||
|
||||
return updatedIssues;
|
||||
return {
|
||||
...prevData,
|
||||
[sourceGroup]: sourceGroupArray,
|
||||
[destinationGroup]: destinationGroupArray,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
else if (moduleId)
|
||||
mutate<{
|
||||
[key: string]: IIssue[];
|
||||
}>(
|
||||
MODULE_ISSUES_WITH_PARAMS(moduleId as string, params),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const sourceGroupArray = prevData[sourceGroup];
|
||||
const destinationGroupArray = groupedByIssues[destinationGroup];
|
||||
|
||||
sourceGroupArray.splice(source.index, 1);
|
||||
destinationGroupArray.splice(destination.index, 0, draggedItem);
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
[sourceGroup]: sourceGroupArray,
|
||||
[destinationGroup]: destinationGroupArray,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
else
|
||||
mutate<{ [key: string]: IIssue[] }>(
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const sourceGroupArray = prevData[sourceGroup];
|
||||
const destinationGroupArray = groupedByIssues[destinationGroup];
|
||||
|
||||
sourceGroupArray.splice(source.index, 1);
|
||||
destinationGroupArray.splice(destination.index, 0, draggedItem);
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
[sourceGroup]: sourceGroupArray,
|
||||
[destinationGroup]: destinationGroupArray,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
// patch request
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, {
|
||||
priority: draggedItem.priority,
|
||||
state: draggedItem.state,
|
||||
sort_order: draggedItem.sort_order,
|
||||
})
|
||||
.then((res) => {
|
||||
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
|
||||
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
|
||||
|
||||
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
|
||||
});
|
||||
}
|
||||
// patch request
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, {
|
||||
priority: draggedItem.priority,
|
||||
state: draggedItem.state,
|
||||
sort_order: draggedItem.sort_order,
|
||||
})
|
||||
.then(() => {
|
||||
if (cycleId) {
|
||||
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
|
||||
mutate(CYCLE_DETAILS(cycleId as string));
|
||||
}
|
||||
if (moduleId) {
|
||||
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
|
||||
mutate(MODULE_DETAILS(moduleId as string));
|
||||
}
|
||||
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -250,17 +280,16 @@ export const IssuesView: React.FC<Props> = ({
|
||||
projectId,
|
||||
selectedGroup,
|
||||
orderBy,
|
||||
states,
|
||||
handleDeleteIssue,
|
||||
params,
|
||||
]
|
||||
);
|
||||
|
||||
const addIssueToState = useCallback(
|
||||
(groupTitle: string, stateId: string | null) => {
|
||||
(groupTitle: string) => {
|
||||
setCreateIssueModal(true);
|
||||
if (selectedGroup)
|
||||
setPreloadedData({
|
||||
state: stateId ?? undefined,
|
||||
[selectedGroup]: groupTitle,
|
||||
actionType: "createIssue",
|
||||
});
|
||||
@@ -293,13 +322,9 @@ export const IssuesView: React.FC<Props> = ({
|
||||
|
||||
const removeIssueFromCycle = useCallback(
|
||||
(bridgeId: string) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
|
||||
mutate<CycleIssueResponse[]>(
|
||||
CYCLE_ISSUES(cycleId as string),
|
||||
(prevData) => prevData?.filter((p) => p.id !== bridgeId),
|
||||
false
|
||||
);
|
||||
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
|
||||
|
||||
issuesService
|
||||
.removeIssueFromCycle(
|
||||
@@ -315,18 +340,14 @@ export const IssuesView: React.FC<Props> = ({
|
||||
console.log(e);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId]
|
||||
[workspaceSlug, projectId, cycleId, params]
|
||||
);
|
||||
|
||||
const removeIssueFromModule = useCallback(
|
||||
(bridgeId: string) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
if (!workspaceSlug || !projectId || !moduleId) return;
|
||||
|
||||
mutate<ModuleIssueResponse[]>(
|
||||
MODULE_ISSUES(moduleId as string),
|
||||
(prevData) => prevData?.filter((p) => p.id !== bridgeId),
|
||||
false
|
||||
);
|
||||
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
|
||||
|
||||
modulesService
|
||||
.removeIssueFromModule(
|
||||
@@ -342,7 +363,7 @@ export const IssuesView: React.FC<Props> = ({
|
||||
console.log(e);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, moduleId]
|
||||
[workspaceSlug, projectId, moduleId, params]
|
||||
);
|
||||
|
||||
const handleTrashBox = useCallback(
|
||||
@@ -352,8 +373,17 @@ export const IssuesView: React.FC<Props> = ({
|
||||
[trashBox, setTrashBox]
|
||||
);
|
||||
|
||||
const nullFilters = Object.keys(filters).filter(
|
||||
(key) => filters[key as keyof IIssueFilterOptions] === null
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateViewModal
|
||||
isOpen={createViewModal !== null}
|
||||
handleClose={() => setCreateViewModal(null)}
|
||||
preLoadedData={createViewModal}
|
||||
/>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
|
||||
handleClose={() => setCreateIssueModal(false)}
|
||||
@@ -372,69 +402,147 @@ export const IssuesView: React.FC<Props> = ({
|
||||
isOpen={deleteIssueModal}
|
||||
data={issueToDelete}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<DragDropContext onDragEnd={handleOnDragEnd}>
|
||||
<StrictModeDroppable droppableId="trashBox">
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`${
|
||||
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
|
||||
} fixed top-9 right-9 z-20 flex h-28 w-96 items-center justify-center gap-2 rounded border-2 border-red-500 bg-red-100 p-3 text-xs font-medium italic text-red-500 ${
|
||||
snapshot.isDraggingOver ? "bg-red-500 text-white" : ""
|
||||
} duration-200`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
<TransferIssuesModal
|
||||
handleClose={() => setTransferIssuesModal(false)}
|
||||
isOpen={transferIssuesModal}
|
||||
/>
|
||||
<div className="mb-5 -mt-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FilterList filters={filters} setFilters={setFilters} />
|
||||
{Object.keys(filters).length > 0 &&
|
||||
nullFilters.length !== Object.keys(filters).length && (
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
if (viewId) {
|
||||
setFilters({}, true);
|
||||
setToastAlert({
|
||||
title: "View updated",
|
||||
message: "Your view has been updated",
|
||||
type: "success",
|
||||
});
|
||||
} else
|
||||
setCreateViewModal({
|
||||
query: filters,
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Drop issue here to delete
|
||||
</div>
|
||||
{!viewId && <PlusIcon className="h-4 w-4" />}
|
||||
{viewId ? "Update" : "Save"} view
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
{issueView === "list" ? (
|
||||
<AllLists
|
||||
type={type}
|
||||
issues={issues}
|
||||
states={states}
|
||||
members={members}
|
||||
addIssueToState={addIssueToState}
|
||||
makeIssueCopy={makeIssueCopy}
|
||||
handleEditIssue={handleEditIssue}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
|
||||
removeIssue={
|
||||
type === "cycle"
|
||||
? removeIssueFromCycle
|
||||
: type === "module"
|
||||
? removeIssueFromModule
|
||||
: null
|
||||
}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
) : (
|
||||
<AllBoards
|
||||
type={type}
|
||||
issues={issues}
|
||||
states={states}
|
||||
members={members}
|
||||
addIssueToState={addIssueToState}
|
||||
makeIssueCopy={makeIssueCopy}
|
||||
handleEditIssue={handleEditIssue}
|
||||
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
handleTrashBox={handleTrashBox}
|
||||
removeIssue={
|
||||
type === "cycle"
|
||||
? removeIssueFromCycle
|
||||
: type === "module"
|
||||
? removeIssueFromModule
|
||||
: null
|
||||
}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
)}
|
||||
</DragDropContext>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
|
||||
<div className="mb-5 border-t" />
|
||||
)}
|
||||
|
||||
<DragDropContext onDragEnd={handleOnDragEnd}>
|
||||
<StrictModeDroppable droppableId="trashBox">
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`${
|
||||
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
|
||||
} fixed top-9 right-9 z-20 flex h-28 w-96 flex-col items-center justify-center gap-2 rounded border-2 border-red-500 bg-red-100 p-3 text-xs font-medium italic text-red-500 ${
|
||||
snapshot.isDraggingOver ? "bg-red-500 text-white" : ""
|
||||
} duration-200`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Drop issue here to delete
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
{groupedByIssues ? (
|
||||
isNotEmpty ? (
|
||||
<>
|
||||
{isCompleted && <TransferIssues handleClick={() => setTransferIssuesModal(true)} />}
|
||||
{issueView === "list" ? (
|
||||
<AllLists
|
||||
type={type}
|
||||
states={states}
|
||||
addIssueToState={addIssueToState}
|
||||
makeIssueCopy={makeIssueCopy}
|
||||
handleEditIssue={handleEditIssue}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
|
||||
removeIssue={
|
||||
type === "cycle"
|
||||
? removeIssueFromCycle
|
||||
: type === "module"
|
||||
? removeIssueFromModule
|
||||
: null
|
||||
}
|
||||
isCompleted={isCompleted}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
) : issueView === "kanban" ? (
|
||||
<AllBoards
|
||||
type={type}
|
||||
states={states}
|
||||
addIssueToState={addIssueToState}
|
||||
makeIssueCopy={makeIssueCopy}
|
||||
handleEditIssue={handleEditIssue}
|
||||
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
handleTrashBox={handleTrashBox}
|
||||
removeIssue={
|
||||
type === "cycle"
|
||||
? removeIssueFromCycle
|
||||
: type === "module"
|
||||
? removeIssueFromModule
|
||||
: null
|
||||
}
|
||||
isCompleted={isCompleted}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
) : (
|
||||
<CalendarView />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<EmptySpace
|
||||
title="You don't have any issue yet."
|
||||
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
|
||||
Icon={RectangleStackIcon}
|
||||
>
|
||||
<EmptySpaceItem
|
||||
title="Create a new issue"
|
||||
description={
|
||||
<span>
|
||||
Use <pre className="inline rounded bg-gray-200 px-2 py-1">C</pre> shortcut to
|
||||
create a new issue
|
||||
</span>
|
||||
}
|
||||
Icon={PlusIcon}
|
||||
action={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "c",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
/>
|
||||
{openIssuesListModal && (
|
||||
<EmptySpaceItem
|
||||
title="Add an existing issue"
|
||||
description="Open list"
|
||||
Icon={ListBulletIcon}
|
||||
action={openIssuesListModal}
|
||||
/>
|
||||
)}
|
||||
</EmptySpace>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</DragDropContext>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Button, Input } from "components/ui";
|
||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// types
|
||||
import type { IIssueLink, ModuleLink } from "types";
|
||||
|
||||
@@ -116,12 +112,10 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button theme="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Adding Link..." : "Add Link"}
|
||||
</Button>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -1,66 +1,69 @@
|
||||
// hooks
|
||||
import useIssueView from "hooks/use-issue-view";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
// components
|
||||
import { SingleList } from "components/core/list-view/single-list";
|
||||
// types
|
||||
import { IIssue, IProjectMember, IState, UserAuth } from "types";
|
||||
import { IIssue, IState, UserAuth } from "types";
|
||||
|
||||
// types
|
||||
type Props = {
|
||||
type: "issue" | "cycle" | "module";
|
||||
issues: IIssue[];
|
||||
states: IState[] | undefined;
|
||||
members: IProjectMember[] | undefined;
|
||||
addIssueToState: (groupTitle: string, stateId: string | null) => void;
|
||||
addIssueToState: (groupTitle: string) => void;
|
||||
makeIssueCopy: (issue: IIssue) => void;
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
removeIssue: ((bridgeId: string) => void) | null;
|
||||
isCompleted?: boolean;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const AllLists: React.FC<Props> = ({
|
||||
type,
|
||||
issues,
|
||||
states,
|
||||
members,
|
||||
addIssueToState,
|
||||
makeIssueCopy,
|
||||
openIssuesListModal,
|
||||
handleEditIssue,
|
||||
handleDeleteIssue,
|
||||
removeIssue,
|
||||
isCompleted = false,
|
||||
userAuth,
|
||||
}) => {
|
||||
const { groupedByIssues, groupByProperty: selectedGroup } = useIssueView(issues);
|
||||
const { groupedByIssues, groupByProperty: selectedGroup, showEmptyGroups } = useIssuesView();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-5">
|
||||
{Object.keys(groupedByIssues).map((singleGroup) => {
|
||||
const stateId =
|
||||
selectedGroup === "state_detail.name"
|
||||
? states?.find((s) => s.name === singleGroup)?.id ?? null
|
||||
: null;
|
||||
<>
|
||||
{groupedByIssues && (
|
||||
<div className="flex flex-col space-y-5">
|
||||
{Object.keys(groupedByIssues).map((singleGroup) => {
|
||||
const currentState =
|
||||
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
|
||||
|
||||
return (
|
||||
<SingleList
|
||||
key={singleGroup}
|
||||
type={type}
|
||||
groupTitle={singleGroup}
|
||||
groupedByIssues={groupedByIssues}
|
||||
selectedGroup={selectedGroup}
|
||||
members={members}
|
||||
addIssueToState={() => addIssueToState(singleGroup, stateId)}
|
||||
makeIssueCopy={makeIssueCopy}
|
||||
handleEditIssue={handleEditIssue}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
|
||||
removeIssue={removeIssue}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
if (!showEmptyGroups && groupedByIssues[singleGroup].length === 0) return null;
|
||||
|
||||
return (
|
||||
<SingleList
|
||||
key={singleGroup}
|
||||
type={type}
|
||||
groupTitle={singleGroup}
|
||||
groupedByIssues={groupedByIssues}
|
||||
selectedGroup={selectedGroup}
|
||||
currentState={currentState}
|
||||
addIssueToState={() => addIssueToState(singleGroup)}
|
||||
makeIssueCopy={makeIssueCopy}
|
||||
handleEditIssue={handleEditIssue}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
|
||||
removeIssue={removeIssue}
|
||||
isCompleted={isCompleted}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
ViewPrioritySelect,
|
||||
ViewStateSelect,
|
||||
} from "components/issues/view-select";
|
||||
|
||||
// hooks
|
||||
import useIssueView from "hooks/use-issues-view";
|
||||
// ui
|
||||
import { Tooltip, CustomMenu, ContextMenu } from "components/ui";
|
||||
// icons
|
||||
@@ -25,22 +26,35 @@ import {
|
||||
LinkIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
XMarkIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||
import { handleIssuesMutation } from "constants/issue";
|
||||
// types
|
||||
import { CycleIssueResponse, IIssue, ModuleIssueResponse, Properties, UserAuth } from "types";
|
||||
import { IIssue, Properties, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
import {
|
||||
CYCLE_DETAILS,
|
||||
CYCLE_ISSUES_WITH_PARAMS,
|
||||
MODULE_DETAILS,
|
||||
MODULE_ISSUES_WITH_PARAMS,
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||
} from "constants/fetch-keys";
|
||||
import { DIVIDER } from "@blueprintjs/core/lib/esm/common/classes";
|
||||
|
||||
type Props = {
|
||||
type?: string;
|
||||
issue: IIssue;
|
||||
properties: Properties;
|
||||
groupTitle?: string;
|
||||
editIssue: () => void;
|
||||
index: number;
|
||||
makeIssueCopy: () => void;
|
||||
removeIssue?: (() => void) | null;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
isCompleted?: boolean;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
@@ -49,9 +63,12 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
issue,
|
||||
properties,
|
||||
editIssue,
|
||||
index,
|
||||
makeIssueCopy,
|
||||
removeIssue,
|
||||
groupTitle,
|
||||
handleDeleteIssue,
|
||||
isCompleted = false,
|
||||
userAuth,
|
||||
}) => {
|
||||
// context menu
|
||||
@@ -63,80 +80,63 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { groupByProperty: selectedGroup, params } = useIssueView();
|
||||
|
||||
const partialUpdateIssue = useCallback(
|
||||
(formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
if (cycleId)
|
||||
mutate<CycleIssueResponse[]>(
|
||||
CYCLE_ISSUES(cycleId as string),
|
||||
(prevData) => {
|
||||
const updatedIssues = (prevData ?? []).map((p) => {
|
||||
if (p.issue_detail.id === issue.id) {
|
||||
return {
|
||||
...p,
|
||||
issue_detail: {
|
||||
...p.issue_detail,
|
||||
...formData,
|
||||
assignees: formData.assignees_list ?? p.issue_detail.assignees_list,
|
||||
},
|
||||
};
|
||||
}
|
||||
return p;
|
||||
});
|
||||
return [...updatedIssues];
|
||||
},
|
||||
mutate<
|
||||
| {
|
||||
[key: string]: IIssue[];
|
||||
}
|
||||
| IIssue[]
|
||||
>(
|
||||
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params),
|
||||
(prevData) =>
|
||||
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
|
||||
false
|
||||
);
|
||||
|
||||
if (moduleId)
|
||||
mutate<ModuleIssueResponse[]>(
|
||||
MODULE_ISSUES(moduleId as string),
|
||||
(prevData) => {
|
||||
const updatedIssues = (prevData ?? []).map((p) => {
|
||||
if (p.issue_detail.id === issue.id) {
|
||||
return {
|
||||
...p,
|
||||
issue_detail: {
|
||||
...p.issue_detail,
|
||||
...formData,
|
||||
assignees: formData.assignees_list ?? p.issue_detail.assignees_list,
|
||||
},
|
||||
};
|
||||
}
|
||||
return p;
|
||||
});
|
||||
return [...updatedIssues];
|
||||
},
|
||||
mutate<
|
||||
| {
|
||||
[key: string]: IIssue[];
|
||||
}
|
||||
| IIssue[]
|
||||
>(
|
||||
MODULE_ISSUES_WITH_PARAMS(moduleId as string, params),
|
||||
(prevData) =>
|
||||
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
|
||||
false
|
||||
);
|
||||
|
||||
mutate<IIssue[]>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
mutate<
|
||||
| {
|
||||
[key: string]: IIssue[];
|
||||
}
|
||||
| IIssue[]
|
||||
>(
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === issue.id)
|
||||
return { ...p, ...formData, assignees: formData.assignees_list ?? p.assignees_list };
|
||||
|
||||
return p;
|
||||
}),
|
||||
|
||||
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
|
||||
false
|
||||
);
|
||||
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
|
||||
.then((res) => {
|
||||
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
|
||||
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
|
||||
|
||||
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
.then(() => {
|
||||
if (cycleId) {
|
||||
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
|
||||
mutate(CYCLE_DETAILS(cycleId as string));
|
||||
} else if (moduleId) {
|
||||
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
|
||||
mutate(MODULE_DETAILS(moduleId as string));
|
||||
} else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, moduleId, issue]
|
||||
[workspaceSlug, projectId, cycleId, moduleId, issue, groupTitle, index, selectedGroup, params]
|
||||
);
|
||||
|
||||
const handleCopyText = () => {
|
||||
@@ -153,7 +153,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -163,123 +163,147 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
isOpen={contextMenu}
|
||||
setIsOpen={setContextMenu}
|
||||
>
|
||||
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
|
||||
Edit issue
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
|
||||
Make a copy...
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
|
||||
Delete issue
|
||||
</ContextMenu.Item>
|
||||
{!isNotAllowed && (
|
||||
<>
|
||||
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
|
||||
Edit issue
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
|
||||
Make a copy...
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
|
||||
Delete issue
|
||||
</ContextMenu.Item>
|
||||
</>
|
||||
)}
|
||||
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
|
||||
Copy issue link
|
||||
</ContextMenu.Item>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
|
||||
Open issue in new tab
|
||||
</ContextMenu.Item>
|
||||
</a>
|
||||
</ContextMenu>
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 px-4 py-3 text-sm"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu(true);
|
||||
setContextMenuPosition({ x: e.pageX, y: e.pageY });
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<div className="border-b border-gray-300 last:border-b-0">
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 px-4 py-3"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu(true);
|
||||
setContextMenuPosition({ x: e.pageX, y: e.pageY });
|
||||
}}
|
||||
>
|
||||
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
|
||||
<a className="group relative flex items-center gap-2">
|
||||
{properties.key && (
|
||||
<Tooltip
|
||||
tooltipHeading="ID"
|
||||
tooltipHeading="Issue ID"
|
||||
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
<span className="flex-shrink-0 text-xs text-gray-400">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span className="w-auto max-w-lg overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{issue.name}
|
||||
</span>
|
||||
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span className="text-sm text-gray-800">{truncateText(issue.name, 50)}</span>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
|
||||
{properties.priority && (
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="right"
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.state && (
|
||||
<ViewStateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="right"
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.due_date && (
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.sub_issue_count && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded-md border px-3 py-1.5 text-xs shadow-sm">
|
||||
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
)}
|
||||
{properties.labels && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.label_details.map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
||||
>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{properties.priority && (
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="right"
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.state && (
|
||||
<ViewStateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="right"
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.due_date && (
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.sub_issue_count && (
|
||||
<div className="flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs shadow-sm">
|
||||
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
)}
|
||||
{properties.labels && issue.label_details.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.label_details.map((label) => (
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<ViewAssigneeSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="right"
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{type && !isNotAllowed && (
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
|
||||
{type !== "issue" && removeIssue && (
|
||||
<CustomMenu.MenuItem onClick={removeIssue}>
|
||||
<>Remove from {type}</>
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<ViewAssigneeSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="right"
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{type && !isNotAllowed && (
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={editIssue}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
<span>Edit issue</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
|
||||
Delete issue
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
{type !== "issue" && removeIssue && (
|
||||
<CustomMenu.MenuItem onClick={removeIssue}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
<span>Remove from {type}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Delete issue</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<span>Copy issue link</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,48 +1,61 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import projectService from "services/project.service";
|
||||
// hooks
|
||||
import useIssuesProperties from "hooks/use-issue-properties";
|
||||
// components
|
||||
import { SingleListIssue } from "components/core";
|
||||
// ui
|
||||
import { Avatar, CustomMenu } from "components/ui";
|
||||
// icons
|
||||
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, IProjectMember, NestedKeyOf, UserAuth } from "types";
|
||||
import { CustomMenu } from "components/ui";
|
||||
import { IIssue, IIssueLabels, IState, TIssueGroupByOptions, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
type?: "issue" | "cycle" | "module";
|
||||
currentState?: IState | null;
|
||||
bgColor?: string;
|
||||
groupTitle: string;
|
||||
groupedByIssues: {
|
||||
[key: string]: IIssue[];
|
||||
};
|
||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
||||
members: IProjectMember[] | undefined;
|
||||
selectedGroup: TIssueGroupByOptions;
|
||||
addIssueToState: () => void;
|
||||
makeIssueCopy: (issue: IIssue) => void;
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
removeIssue: ((bridgeId: string) => void) | null;
|
||||
isCompleted?: boolean;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const SingleList: React.FC<Props> = ({
|
||||
type,
|
||||
currentState,
|
||||
bgColor,
|
||||
groupTitle,
|
||||
groupedByIssues,
|
||||
selectedGroup,
|
||||
members,
|
||||
addIssueToState,
|
||||
makeIssueCopy,
|
||||
handleEditIssue,
|
||||
handleDeleteIssue,
|
||||
openIssuesListModal,
|
||||
removeIssue,
|
||||
isCompleted = false,
|
||||
userAuth,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
@@ -50,107 +63,116 @@ export const SingleList: React.FC<Props> = ({
|
||||
|
||||
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
||||
|
||||
const createdBy =
|
||||
selectedGroup === "created_by"
|
||||
? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "Loading..."
|
||||
: null;
|
||||
const { data: issueLabels } = useSWR<IIssueLabels[]>(
|
||||
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
let assignees: any;
|
||||
if (selectedGroup === "assignees") {
|
||||
assignees = groupTitle && groupTitle !== "" ? groupTitle.split(",") : [];
|
||||
assignees =
|
||||
assignees.length > 0
|
||||
? assignees
|
||||
.map((a: string) => members?.find((m) => m.member.id === a)?.member.first_name)
|
||||
.join(", ")
|
||||
: "No assignee";
|
||||
}
|
||||
const { data: members } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const getGroupTitle = () => {
|
||||
let title = addSpaceIfCamelCase(groupTitle);
|
||||
|
||||
switch (selectedGroup) {
|
||||
case "state":
|
||||
title = addSpaceIfCamelCase(currentState?.name ?? "");
|
||||
break;
|
||||
case "labels":
|
||||
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None";
|
||||
break;
|
||||
case "created_by":
|
||||
const member = members?.find((member) => member.member.id === groupTitle)?.member;
|
||||
title =
|
||||
member?.first_name && member.first_name !== ""
|
||||
? `${member.first_name} ${member.last_name}`
|
||||
: member?.email ?? "";
|
||||
break;
|
||||
}
|
||||
|
||||
return title;
|
||||
};
|
||||
|
||||
const getGroupIcon = () => {
|
||||
let icon;
|
||||
|
||||
switch (selectedGroup) {
|
||||
case "state":
|
||||
icon = currentState && getStateGroupIcon(currentState.group, "18", "18", bgColor);
|
||||
break;
|
||||
case "priority":
|
||||
icon = getPriorityIcon(groupTitle, "h-[18px] w-[18px] flex items-center");
|
||||
break;
|
||||
case "labels":
|
||||
const labelColor =
|
||||
issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000";
|
||||
icon = (
|
||||
<span
|
||||
className="h-[18px] w-[18px] flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: labelColor }}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "created_by":
|
||||
const member = members?.find((member) => member.member.id === groupTitle)?.member;
|
||||
icon = <Avatar user={member} height="24px" width="24px" fontSize="12px" />;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return icon;
|
||||
};
|
||||
|
||||
return (
|
||||
<Disclosure key={groupTitle} as="div" defaultOpen>
|
||||
{({ open }) => (
|
||||
<div className="rounded-lg bg-white">
|
||||
<div className="rounded-t-lg bg-gray-100 px-4 py-3">
|
||||
<div className="rounded-[10px] border border-gray-300 bg-white">
|
||||
<div
|
||||
className={`flex items-center justify-between bg-gray-100 px-5 py-3 ${
|
||||
open ? "rounded-t-[10px]" : "rounded-[10px]"
|
||||
}`}
|
||||
>
|
||||
<Disclosure.Button>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span>
|
||||
<ChevronDownIcon
|
||||
className={`h-4 w-4 text-gray-500 ${!open ? "-rotate-90 transform" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex items-center gap-x-3">
|
||||
{selectedGroup !== null && (
|
||||
<span className="flex items-center">{getGroupIcon()}</span>
|
||||
)}
|
||||
{selectedGroup !== null ? (
|
||||
<h2 className="font-medium capitalize leading-5">
|
||||
{selectedGroup === "created_by"
|
||||
? createdBy
|
||||
: selectedGroup === "assignees"
|
||||
? assignees
|
||||
: addSpaceIfCamelCase(groupTitle)}
|
||||
<h2 className="text-base font-semibold capitalize leading-6 text-gray-800">
|
||||
{getGroupTitle()}
|
||||
</h2>
|
||||
) : (
|
||||
<h2 className="font-medium leading-5">All Issues</h2>
|
||||
)}
|
||||
<p className="text-sm text-gray-500">
|
||||
<span className="rounded-full bg-gray-200 py-0.5 px-3 text-sm text-black">
|
||||
{groupedByIssues[groupTitle as keyof IIssue].length}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform opacity-0"
|
||||
enterTo="transform opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform opacity-100"
|
||||
leaveTo="transform opacity-0"
|
||||
>
|
||||
<Disclosure.Panel>
|
||||
<div className="divide-y-2">
|
||||
{groupedByIssues[groupTitle] ? (
|
||||
groupedByIssues[groupTitle].length > 0 ? (
|
||||
groupedByIssues[groupTitle].map((issue: IIssue) => (
|
||||
<SingleListIssue
|
||||
key={issue.id}
|
||||
type={type}
|
||||
issue={issue}
|
||||
properties={properties}
|
||||
editIssue={() => handleEditIssue(issue)}
|
||||
makeIssueCopy={() => makeIssueCopy(issue)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
removeIssue={() => {
|
||||
removeIssue && removeIssue(issue.bridge);
|
||||
}}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="px-4 py-3 text-sm text-gray-500">No issues.</p>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">Loading...</div>
|
||||
)}
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
<div className="p-3">
|
||||
{type === "issue" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-gray-100"
|
||||
className="p-1 text-gray-500 hover:bg-gray-100"
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add issue
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
) : isCompleted ? (
|
||||
""
|
||||
) : (
|
||||
<CustomMenu
|
||||
label={
|
||||
<span className="flex items-center gap-1">
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add issue
|
||||
</span>
|
||||
customButton={
|
||||
<div className="flex items-center cursor-pointer">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</div>
|
||||
}
|
||||
optionsPosition="left"
|
||||
optionsPosition="right"
|
||||
noBorder
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
|
||||
@@ -162,6 +184,44 @@ export const SingleList: React.FC<Props> = ({
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform opacity-0"
|
||||
enterTo="transform opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform opacity-100"
|
||||
leaveTo="transform opacity-0"
|
||||
>
|
||||
<Disclosure.Panel>
|
||||
{groupedByIssues[groupTitle] ? (
|
||||
groupedByIssues[groupTitle].length > 0 ? (
|
||||
groupedByIssues[groupTitle].map((issue, index) => (
|
||||
<SingleListIssue
|
||||
key={issue.id}
|
||||
type={type}
|
||||
issue={issue}
|
||||
properties={properties}
|
||||
groupTitle={groupTitle}
|
||||
index={index}
|
||||
editIssue={() => handleEditIssue(issue)}
|
||||
makeIssueCopy={() => makeIssueCopy(issue)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
removeIssue={() => {
|
||||
if (removeIssue !== null && issue.bridge_id) removeIssue(issue.bridge_id);
|
||||
}}
|
||||
isCompleted={isCompleted}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="px-4 py-3 text-sm text-gray-500">No issues.</p>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">Loading...</div>
|
||||
)}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from "react";
|
||||
// next
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
// layouts
|
||||
import DefaultLayout from "layouts/default-layout";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
// icons
|
||||
import { LockIcon } from "components/icons";
|
||||
// img
|
||||
import ProjectSettingImg from "public/project-setting.svg";
|
||||
|
||||
type TNotAuthorizedViewProps = {
|
||||
actionButton?: React.ReactNode;
|
||||
@@ -27,25 +28,27 @@ export const NotAuthorizedView: React.FC<TNotAuthorizedViewProps> = (props) => {
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
|
||||
<LockIcon className="h-16 w-16 text-gray-400" />
|
||||
<div className="h-44 w-72">
|
||||
<Image src={ProjectSettingImg} height="176" width="288" alt="ProjectSettingImg" />
|
||||
</div>
|
||||
<h1 className="text-xl font-medium text-gray-900">
|
||||
Oops! You are not authorized to view this page
|
||||
</h1>
|
||||
|
||||
<div className="w-full md:w-1/3">
|
||||
<div className="w-full text-base text-gray-500 max-w-md ">
|
||||
{user ? (
|
||||
<p className="text-base font-light">
|
||||
You have signed in as <span className="font-medium">{user.email}</span>.{" "}
|
||||
<p className="">
|
||||
You have signed in as {user.email}.{" "}
|
||||
<Link href={`/signin?next=${currentPath}`}>
|
||||
<a className="font-medium">Sign in</a>
|
||||
<a className="text-gray-900 font-medium">Sign in</a>
|
||||
</Link>{" "}
|
||||
with different account that has access to this page.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-base font-light">
|
||||
<p className="">
|
||||
You need to{" "}
|
||||
<Link href={`/signin?next=${currentPath}`}>
|
||||
<a className="font-medium">Sign in</a>
|
||||
<a className="text-gray-900 font-medium">Sign in</a>
|
||||
</Link>{" "}
|
||||
with an account that has access to this page.
|
||||
</p>
|
||||
|
||||
@@ -54,11 +54,14 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }
|
||||
<LinkIcon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="w-4/5">{link.title}</h5>
|
||||
<h5 className="w-4/5 break-all">{link.title}</h5>
|
||||
<p className="mt-0.5 text-gray-500">
|
||||
Added {timeAgo(link.created_at)}
|
||||
<br />
|
||||
by {link.created_by_detail.email}
|
||||
by{" "}
|
||||
{link.created_by_detail.is_bot
|
||||
? link.created_by_detail.first_name + " Bot"
|
||||
: link.created_by_detail.email}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -39,7 +39,6 @@ const ProgressChart: React.FC<Props> = ({ issues, start, end }) => {
|
||||
|
||||
const CustomTooltip = ({ active, payload }: TooltipProps<ValueType, NameType>) => {
|
||||
if (active && payload && payload.length) {
|
||||
console.log(payload[0].payload.currentDate);
|
||||
return (
|
||||
<div className="rounded-sm bg-gray-300 p-1 text-xs text-gray-800">
|
||||
<p>{payload[0].payload.currentDate}</p>
|
||||
@@ -81,6 +80,7 @@ const ProgressChart: React.FC<Props> = ({ issues, start, end }) => {
|
||||
tick={{ fontSize: "12px", fill: "#1f2937" }}
|
||||
tickSize={10}
|
||||
minTickGap={10}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
|
||||
@@ -12,13 +12,13 @@ import issuesServices from "services/issues.service";
|
||||
import projectService from "services/project.service";
|
||||
// hooks
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
// components
|
||||
import { LinksList, SingleProgressStats } from "components/core";
|
||||
import { SingleProgressStats } from "components/core";
|
||||
// ui
|
||||
import { Avatar } from "components/ui";
|
||||
// icons
|
||||
import User from "public/user.png";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue, IIssueLabels, IModule, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
@@ -28,8 +28,6 @@ type Props = {
|
||||
groupedIssues: any;
|
||||
issues: IIssue[];
|
||||
module?: IModule;
|
||||
setModuleLinkModal?: any;
|
||||
handleDeleteLink?: any;
|
||||
userAuth?: UserAuth;
|
||||
};
|
||||
|
||||
@@ -47,13 +45,13 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
groupedIssues,
|
||||
issues,
|
||||
module,
|
||||
setModuleLinkModal,
|
||||
handleDeleteLink,
|
||||
userAuth,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { filters, setFilters } = useIssuesView();
|
||||
|
||||
const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees");
|
||||
|
||||
const { data: issueLabels } = useSWR<IIssueLabels[]>(
|
||||
@@ -72,14 +70,12 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
|
||||
const currentValue = (tab: string | null) => {
|
||||
switch (tab) {
|
||||
case "Links":
|
||||
return 0;
|
||||
case "Assignees":
|
||||
return 1;
|
||||
return 0;
|
||||
case "Labels":
|
||||
return 2;
|
||||
return 1;
|
||||
case "States":
|
||||
return 3;
|
||||
return 2;
|
||||
|
||||
default:
|
||||
return 3;
|
||||
@@ -91,12 +87,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
onChange={(i) => {
|
||||
switch (i) {
|
||||
case 0:
|
||||
return setTab("Links");
|
||||
case 1:
|
||||
return setTab("Assignees");
|
||||
case 2:
|
||||
case 1:
|
||||
return setTab("Labels");
|
||||
case 3:
|
||||
case 2:
|
||||
return setTab("States");
|
||||
|
||||
default:
|
||||
@@ -109,20 +103,6 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
className={`flex w-full items-center justify-between rounded-md bg-gray-100 px-1 py-1.5
|
||||
${module ? "text-xs" : "text-sm"} `}
|
||||
>
|
||||
{module ? (
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`w-full rounded px-3 py-1 text-gray-900 ${
|
||||
selected ? " bg-theme text-white" : " hover:bg-hover-gray"
|
||||
}`
|
||||
}
|
||||
>
|
||||
Links
|
||||
</Tab>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`w-full rounded px-3 py-1 text-gray-900 ${
|
||||
@@ -134,7 +114,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`w-full rounded px-3 py-1 text-gray-900 ${
|
||||
`w-full rounded px-3 py-1 text-gray-900 ${
|
||||
selected ? " bg-theme text-white" : " hover:bg-hover-gray"
|
||||
}`
|
||||
}
|
||||
@@ -151,34 +131,12 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
States
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels className="flex w-full items-center justify-between p-1">
|
||||
{module ? (
|
||||
<Tab.Panel as="div" className="flex w-full flex-col text-xs ">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-start gap-2 rounded px-4 py-2 hover:bg-theme/5"
|
||||
onClick={() => setModuleLinkModal(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" /> <span>Add Link</span>
|
||||
</button>
|
||||
<div className="mt-2 space-y-2 hover:bg-theme/5">
|
||||
{userAuth && module.link_module && module.link_module.length > 0 ? (
|
||||
<LinksList
|
||||
links={module.link_module}
|
||||
handleDeleteLink={handleDeleteLink}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
||||
<Tab.Panel as="div" className="flex w-full flex-col text-xs ">
|
||||
<Tab.Panels className="flex w-full items-center justify-between pt-1">
|
||||
<Tab.Panel as="div" className="flex w-full flex-col text-xs">
|
||||
{members?.map((member, index) => {
|
||||
const totalArray = issues?.filter((i) => i.assignees?.includes(member.member.id));
|
||||
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
|
||||
|
||||
if (totalArray.length > 0) {
|
||||
return (
|
||||
<SingleProgressStats
|
||||
@@ -191,6 +149,15 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
}
|
||||
completed={completeArray.length}
|
||||
total={totalArray.length}
|
||||
onClick={() => {
|
||||
if (filters.assignees?.includes(member.member.id))
|
||||
setFilters({
|
||||
assignees: filters.assignees?.filter((a) => a !== member.member.id),
|
||||
});
|
||||
else
|
||||
setFilters({ assignees: [...(filters?.assignees ?? []), member.member.id] });
|
||||
}}
|
||||
selected={filters.assignees?.includes(member.member.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -222,10 +189,11 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
""
|
||||
)}
|
||||
</Tab.Panel>
|
||||
<Tab.Panel as="div" className="flex w-full flex-col ">
|
||||
{issueLabels?.map((issue, index) => {
|
||||
const totalArray = issues?.filter((i) => i.labels?.includes(issue.id));
|
||||
<Tab.Panel as="div" className="w-full space-y-1">
|
||||
{issueLabels?.map((label, index) => {
|
||||
const totalArray = issues?.filter((i) => i.labels?.includes(label.id));
|
||||
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
|
||||
|
||||
if (totalArray.length > 0) {
|
||||
return (
|
||||
<SingleProgressStats
|
||||
@@ -233,16 +201,25 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full "
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.color,
|
||||
backgroundColor:
|
||||
label.color && label.color !== "" ? label.color : "#000000",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs capitalize">{issue.name}</span>
|
||||
<span className="text-xs capitalize">{label.name}</span>
|
||||
</div>
|
||||
}
|
||||
completed={completeArray.length}
|
||||
total={totalArray.length}
|
||||
onClick={() => {
|
||||
if (filters.labels?.includes(label.id))
|
||||
setFilters({
|
||||
labels: filters.labels?.filter((l) => l !== label.id),
|
||||
});
|
||||
else setFilters({ labels: [...(filters?.labels ?? []), label.id] });
|
||||
}}
|
||||
selected={filters.labels?.includes(label.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -263,7 +240,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
<span className="text-xs capitalize">{group}</span>
|
||||
</div>
|
||||
}
|
||||
completed={groupedIssues[group].length}
|
||||
completed={groupedIssues[group]}
|
||||
total={issues.length}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -6,17 +6,26 @@ type TSingleProgressStatsProps = {
|
||||
title: any;
|
||||
completed: number;
|
||||
total: number;
|
||||
onClick?: () => void;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
|
||||
title,
|
||||
completed,
|
||||
total,
|
||||
onClick,
|
||||
selected = false,
|
||||
}) => (
|
||||
<div className="flex w-full items-center justify-between py-3 text-xs">
|
||||
<div
|
||||
className={`flex w-full items-center justify-between rounded p-2 text-xs ${
|
||||
onClick ? "cursor-pointer hover:bg-gray-100" : ""
|
||||
} ${selected ? "bg-gray-100" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex w-1/2 items-center justify-start gap-2">{title}</div>
|
||||
<div className="flex w-1/2 items-center justify-end gap-1 px-2">
|
||||
<div className="flex h-5 items-center justify-center gap-1 ">
|
||||
<div className="flex h-5 items-center justify-center gap-1">
|
||||
<span className="h-4 w-4 ">
|
||||
<ProgressBar value={completed} maxValue={total} />
|
||||
</span>
|
||||
|
||||
@@ -9,12 +9,14 @@ import cyclesService from "services/cycles.service";
|
||||
// components
|
||||
import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
|
||||
// icons
|
||||
import { CompletedCycleIcon } from "components/icons";
|
||||
import { CompletedCycleIcon, ExclamationIcon } from "components/icons";
|
||||
// types
|
||||
import { ICycle, SelectCycleType } from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_COMPLETE_LIST } from "constants/fetch-keys";
|
||||
import { Loader } from "components/ui";
|
||||
import { EmptyState, Loader } from "components/ui";
|
||||
// image
|
||||
import emptyCycle from "public/empty-state/empty-cycle.svg";
|
||||
|
||||
export interface CompletedCyclesListProps {
|
||||
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -61,24 +63,31 @@ export const CompletedCyclesList: React.FC<CompletedCyclesListProps> = ({
|
||||
/>
|
||||
{completedCycles ? (
|
||||
completedCycles.completed_cycles.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
||||
{completedCycles.completed_cycles.map((cycle) => (
|
||||
<SingleCycleCard
|
||||
key={cycle.id}
|
||||
cycle={cycle}
|
||||
handleDeleteCycle={() => handleDeleteCycle(cycle)}
|
||||
handleEditCycle={() => handleEditCycle(cycle)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<ExclamationIcon height={14} width={14} />
|
||||
<span>Completed cycles are not editable.</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
||||
{completedCycles.completed_cycles.map((cycle) => (
|
||||
<SingleCycleCard
|
||||
key={cycle.id}
|
||||
cycle={cycle}
|
||||
handleDeleteCycle={() => handleDeleteCycle(cycle)}
|
||||
handleEditCycle={() => handleEditCycle(cycle)}
|
||||
isCompleted
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
||||
<CompletedCycleIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
No completed cycles yet. Create with{" "}
|
||||
<pre className="inline rounded bg-gray-200 px-2 py-1">Q</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
<EmptyState
|
||||
type="cycle"
|
||||
title="Create New Cycle"
|
||||
description="Sprint more effectively with Cycles by confining your project
|
||||
to a fixed amount of time. Create new cycle now."
|
||||
imgURL={emptyCycle}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user