Compare commits
238
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
621fcfc041 | ||
|
|
1538b99a28 | ||
|
|
33a904bc3e | ||
|
|
0d264838a9 | ||
|
|
c638b6aba6 | ||
|
|
cb814dd68b | ||
|
|
e17c824119 | ||
|
|
68930c256f | ||
|
|
e59137f6f2 | ||
|
|
7f235739bd | ||
|
|
20162050c3 | ||
|
|
06ad0d0f7a | ||
|
|
cfd7e1d154 | ||
|
|
fdf7ea1f82 | ||
|
|
8f12d3d01b | ||
|
|
62dc6c2f3f | ||
|
|
6f03022f65 | ||
|
|
f2701a12ea | ||
|
|
9129a6cde2 | ||
|
|
2ba4594b29 | ||
|
|
165d16e32b | ||
|
|
73388195ef | ||
|
|
4dda4ec610 | ||
|
|
e68a5382f9 | ||
|
|
5b6caadd6f | ||
|
|
73a8bbb31f | ||
|
|
952d35dd79 | ||
|
|
170b3d6eec | ||
|
|
d04a422054 | ||
|
|
affc7655f7 | ||
|
|
50c78628b3 | ||
|
|
3c2f5d12ed | ||
|
|
9f04933957 | ||
|
|
8d37a3e58b | ||
|
|
4f61c5d552 | ||
|
|
7149d20601 | ||
|
|
d30a88832a | ||
|
|
ebce364104 | ||
|
|
c5206a7792 | ||
|
|
390b837561 | ||
|
|
fb1932e309 | ||
|
|
ac125965eb | ||
|
|
63a36fb25d | ||
|
|
2b280935a1 | ||
|
|
be5ef61428 | ||
|
|
1627a587ee | ||
|
|
682a1477fb | ||
|
|
ea87823478 | ||
|
|
b8c06b3121 | ||
|
|
ca2366aa9b | ||
|
|
acff6396f9 | ||
|
|
fa5c994ddc | ||
|
|
5f20e65ca6 | ||
|
|
792162ae66 | ||
|
|
e22f552ea0 | ||
|
|
396fbc4ebb | ||
|
|
3ed937378f | ||
|
|
0fa3a8c3e3 | ||
|
|
bd0cfef02f | ||
|
|
7aa9e0bba1 | ||
|
|
718f62a898 | ||
|
|
d26d01ace4 | ||
|
|
60e44fc1a2 | ||
|
|
45eaa23ed0 | ||
|
|
600fedd5ba | ||
|
|
6de54089cd | ||
|
|
dc53708109 | ||
|
|
f5351e4419 | ||
|
|
819508d5fc | ||
|
|
0beb654069 | ||
|
|
98cef0e1e8 | ||
|
|
8a6036a20a | ||
|
|
85b6c78e75 | ||
|
|
3f401b0fc5 | ||
|
|
365c758a25 | ||
|
|
ac98381f23 | ||
|
|
3e436179fe | ||
|
|
e23075b7b9 | ||
|
|
61761fedc5 | ||
|
|
5a36a7931f | ||
|
|
363c5c8ec4 | ||
|
|
5e5d1a4699 | ||
|
|
e2294f9105 | ||
|
|
f757d8232b | ||
|
|
6af54ebbe7 | ||
|
|
3913cf571f | ||
|
|
8638170a98 | ||
|
|
ebff5d8c54 | ||
|
|
b7ce69c220 | ||
|
|
e4da207df5 | ||
|
|
3a0c5bab76 | ||
|
|
1cd1505c7d | ||
|
|
bc7fab96c3 | ||
|
|
a358260a22 | ||
|
|
3b103da6a3 | ||
|
|
23b4145565 | ||
|
|
5848c326c7 | ||
|
|
ce253b3cc9 | ||
|
|
f50872f2a9 | ||
|
|
a0b8f7188f | ||
|
|
c7d930f89b | ||
|
|
81da8715d5 | ||
|
|
b4c8323886 | ||
|
|
c3ffd233a6 | ||
|
|
3fa6185b63 | ||
|
|
6de94efc7d | ||
|
|
0cd6d9d570 | ||
|
|
657241c9c1 | ||
|
|
dc9ce5101c | ||
|
|
3457411c6a | ||
|
|
b7a7508d5d | ||
|
|
484a88d881 | ||
|
|
cd69b06e5e | ||
|
|
c4609b95cd | ||
|
|
6eb7ec0697 | ||
|
|
e232d39f0e | ||
|
|
8a26fd0a97 | ||
|
|
537a82028e | ||
|
|
440ed08728 | ||
|
|
c199e76038 | ||
|
|
b40fd4bbc2 | ||
|
|
5190ea7280 | ||
|
|
db488338fb | ||
|
|
9196fb4562 | ||
|
|
34ff8fecc5 | ||
|
|
d6dbfdc731 | ||
|
|
f2e8add29d | ||
|
|
032ef831b2 | ||
|
|
0f9812cf2c | ||
|
|
f734aad10d | ||
|
|
6279a04267 | ||
|
|
ce26bed44a | ||
|
|
88d2adddc7 | ||
|
|
e4e66b3ae4 | ||
|
|
f1f716e8f6 | ||
|
|
61b9e7a161 | ||
|
|
dfa3a7b78d | ||
|
|
d5c2965946 | ||
|
|
cfa283116b | ||
|
|
725c9375ea | ||
|
|
22c1f6f8e2 | ||
|
|
748e5e7fb7 | ||
|
|
7aa0ace555 | ||
|
|
f2c5bb5c03 | ||
|
|
800075b781 | ||
|
|
6865cf4b54 | ||
|
|
bc457846fe | ||
|
|
d411cd7576 | ||
|
|
2dbe1dd401 | ||
|
|
ec3f891b4a | ||
|
|
b6c911f484 | ||
|
|
fc48fb97d1 | ||
|
|
3618f79f89 | ||
|
|
ea563d20a3 | ||
|
|
805b8f47cc | ||
|
|
5f216fbbbc | ||
|
|
33ea1cb9d3 | ||
|
|
0caadd0f7a | ||
|
|
62e736677a | ||
|
|
bd9de0c213 | ||
|
|
c80968bb23 | ||
|
|
441203867b | ||
|
|
eac4b21ead | ||
|
|
6fe35c9fe6 | ||
|
|
08e77cb19e | ||
|
|
03e74415f2 | ||
|
|
c81bc4e5d2 | ||
|
|
1026ae3eb1 | ||
|
|
3fe32606a9 | ||
|
|
d88a95b1e9 | ||
|
|
c21fb6e942 | ||
|
|
98e6d3de22 | ||
|
|
9b1ae6bcd4 | ||
|
|
3947a86fa7 | ||
|
|
beedd57ee1 | ||
|
|
0a3d13706e | ||
|
|
c093209338 | ||
|
|
a68d94c33f | ||
|
|
35f9876981 | ||
|
|
c0b732f1f1 | ||
|
|
6be775434d | ||
|
|
687b05d221 | ||
|
|
59a33587a0 | ||
|
|
e46487c130 | ||
|
|
5b72b1672f | ||
|
|
ec818a5523 | ||
|
|
100c431ac3 | ||
|
|
ea06ee4529 | ||
|
|
35455c2bf7 | ||
|
|
c49f614352 | ||
|
|
95fe4a3831 | ||
|
|
14dd498d08 | ||
|
|
86ec46db2c | ||
|
|
6a579f85ad | ||
|
|
105428894f | ||
|
|
f2144c3e89 | ||
|
|
cf662f6e6c | ||
|
|
65037b5031 | ||
|
|
c9d8a8dbd1 | ||
|
|
6b8b981e1d | ||
|
|
1562939287 | ||
|
|
cc07e2790d | ||
|
|
97386e9d07 | ||
|
|
ff5cddeb95 | ||
|
|
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 |
+1
-3
@@ -1,10 +1,8 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
// This tells ESLint to load the config from the package `eslint-config-custom`
|
||||
extends: ["custom"],
|
||||
settings: {
|
||||
next: {
|
||||
rootDir: ["apps/*"],
|
||||
rootDir: ["app/"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,6 +44,15 @@ body:
|
||||
- Deploy preview
|
||||
validations:
|
||||
required: true
|
||||
type: dropdown
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser
|
||||
options:
|
||||
- Google Chrome
|
||||
- Mozilla Firefox
|
||||
- Safari
|
||||
- Other
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
|
||||
@@ -2,43 +2,129 @@
|
||||
|
||||
<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>
|
||||
|
||||
> 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.
|
||||
Meet Plane. An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘♀️.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Visit https://app.plane.so to get started with Plane.
|
||||
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
|
||||
|
||||
## Documentation
|
||||
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).
|
||||
|
||||
|
||||
## ⚡️ 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
|
||||
```
|
||||
|
||||
<strong>You can use the default email and password for your first login `captain@plane.so` and `password123`.</strong>
|
||||
|
||||
## 🚀 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
|
||||
|
||||
|
||||
- [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
|
||||
- [ ] Public Alpha: Anyone can sign up over at [app.plane.so](https://app.plane.so). But go easy on us, there are a few hiccups
|
||||
- [ ] 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 +132,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.
|
||||
|
||||
@@ -3,15 +3,19 @@ DJANGO_SETTINGS_MODULE="plane.settings.production"
|
||||
DATABASE_URL=postgres://plane:xyzzyspoon@db:5432/plane
|
||||
# Cache
|
||||
REDIS_URL=redis://redis:6379/
|
||||
# SMPT
|
||||
# SMTP
|
||||
EMAIL_HOST=""
|
||||
EMAIL_HOST_USER=""
|
||||
EMAIL_HOST_PASSWORD=""
|
||||
# AWS
|
||||
EMAIL_PORT="587"
|
||||
EMAIL_USE_TLS="1"
|
||||
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
|
||||
# AWS
|
||||
AWS_REGION=""
|
||||
AWS_ACCESS_KEY_ID=""
|
||||
AWS_SECRET_ACCESS_KEY=""
|
||||
AWS_S3_BUCKET_NAME=""
|
||||
AWS_S3_ENDPOINT_URL=""
|
||||
# FE
|
||||
WEB_URL="localhost/"
|
||||
# OAUTH
|
||||
@@ -19,3 +23,6 @@ GITHUB_CLIENT_SECRET=""
|
||||
# Flags
|
||||
DISABLE_COLLECTSTATIC=1
|
||||
DOCKERIZED=1
|
||||
# GPT Envs
|
||||
OPENAI_API_KEY=0
|
||||
GPT_ENGINE=0
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
|
||||
worker: python manage.py rqworker
|
||||
worker: celery -A plane worker -l info
|
||||
@@ -3,7 +3,7 @@ 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, Label
|
||||
|
||||
|
||||
# Update description and description html values for old descriptions
|
||||
@@ -134,3 +134,43 @@ 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": None,
|
||||
"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")
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
set -e
|
||||
|
||||
python manage.py wait_for_db
|
||||
python manage.py rqworker
|
||||
celery -A plane worker -l info
|
||||
@@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
|
||||
@@ -11,6 +11,7 @@ from .workspace import (
|
||||
TeamSerializer,
|
||||
WorkSpaceMemberInviteSerializer,
|
||||
WorkspaceLiteSerializer,
|
||||
WorkspaceThemeSerializer,
|
||||
)
|
||||
from .project import (
|
||||
ProjectSerializer,
|
||||
@@ -41,6 +42,7 @@ from .issue import (
|
||||
IssueStateSerializer,
|
||||
IssueLinkSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
@@ -65,3 +67,5 @@ from .integration import (
|
||||
from .importer import ImporterSerializer
|
||||
|
||||
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
|
||||
|
||||
from .estimate import EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
|
||||
from plane.db.models import Estimate, EstimatePoint
|
||||
|
||||
|
||||
class EstimateSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Estimate
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
]
|
||||
|
||||
|
||||
class EstimatePointSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = EstimatePoint
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"estimate",
|
||||
"workspace",
|
||||
"project",
|
||||
]
|
||||
|
||||
|
||||
class EstimateReadSerializer(BaseSerializer):
|
||||
points = EstimatePointSerializer(read_only=True, many=True)
|
||||
|
||||
class Meta:
|
||||
model = Estimate
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"points",
|
||||
"name",
|
||||
"description",
|
||||
]
|
||||
@@ -1,11 +1,13 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from plane.db.models import Importer
|
||||
|
||||
|
||||
class ImporterSerializer(BaseSerializer):
|
||||
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Importer
|
||||
|
||||
@@ -25,6 +25,7 @@ from plane.db.models import (
|
||||
Module,
|
||||
ModuleIssue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
)
|
||||
|
||||
|
||||
@@ -99,7 +100,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
project = self.context["project"]
|
||||
issue = Issue.objects.create(**validated_data, project=project)
|
||||
|
||||
if blockers is not None:
|
||||
if blockers is not None and len(blockers):
|
||||
IssueBlocker.objects.bulk_create(
|
||||
[
|
||||
IssueBlocker(
|
||||
@@ -115,7 +116,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
if assignees is not None:
|
||||
if assignees is not None and len(assignees):
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
@@ -130,8 +131,19 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
else:
|
||||
# Then assign it to default assignee
|
||||
if project.default_assignee is not None:
|
||||
IssueAssignee.objects.create(
|
||||
assignee=project.default_assignee,
|
||||
issue=issue,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
created_by=issue.created_by,
|
||||
updated_by=issue.updated_by,
|
||||
)
|
||||
|
||||
if labels is not None:
|
||||
if labels is not None and len(labels):
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
@@ -147,7 +159,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
if blocks is not None:
|
||||
if blocks is not None and len(blocks):
|
||||
IssueBlocker.objects.bulk_create(
|
||||
[
|
||||
IssueBlocker(
|
||||
@@ -254,7 +266,8 @@ class IssueActivitySerializer(BaseSerializer):
|
||||
class IssueCommentSerializer(BaseSerializer):
|
||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
|
||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
|
||||
class Meta:
|
||||
model = IssueComment
|
||||
@@ -297,6 +310,9 @@ class IssuePropertySerializer(BaseSerializer):
|
||||
|
||||
|
||||
class LabelSerializer(BaseSerializer):
|
||||
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Label
|
||||
fields = "__all__"
|
||||
@@ -439,6 +455,21 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
return IssueLink.objects.create(**validated_data)
|
||||
|
||||
|
||||
class IssueAttachmentSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueAttachment
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
]
|
||||
|
||||
|
||||
# Issue Serializer with state details
|
||||
class IssueStateSerializer(BaseSerializer):
|
||||
state_detail = StateSerializer(read_only=True, source="state")
|
||||
@@ -466,6 +497,7 @@ class IssueSerializer(BaseSerializer):
|
||||
issue_cycle = IssueCycleDetailSerializer(read_only=True)
|
||||
issue_module = IssueModuleDetailSerializer(read_only=True)
|
||||
issue_link = IssueLinkSerializer(read_only=True, many=True)
|
||||
issue_attachment = IssueAttachmentSerializer(read_only=True, many=True)
|
||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -490,6 +522,8 @@ class IssueLiteSerializer(BaseSerializer):
|
||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||
cycle_id = serializers.UUIDField(read_only=True)
|
||||
module_id = serializers.UUIDField(read_only=True)
|
||||
attachment_count = serializers.IntegerField(read_only=True)
|
||||
link_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
|
||||
@@ -139,6 +139,16 @@ 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")
|
||||
|
||||
@@ -25,22 +25,18 @@ class IssueViewSerializer(BaseSerializer):
|
||||
|
||||
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")
|
||||
if bool(query_params):
|
||||
validated_data["query"] = issue_filters(query_params, "POST")
|
||||
else:
|
||||
validated_data["query"] = dict()
|
||||
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"]}
|
||||
)
|
||||
|
||||
if bool(query_params):
|
||||
validated_data["query"] = issue_filters(query_params, "POST")
|
||||
else:
|
||||
validated_data["query"] = dict()
|
||||
validated_data["query"] = issue_filters(query_params, "PATCH")
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
@@ -5,8 +5,15 @@ from rest_framework import serializers
|
||||
from .base import BaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
|
||||
from plane.db.models import User, Workspace, WorkspaceMember, Team, TeamMember
|
||||
from plane.db.models import Workspace, WorkspaceMember, Team, WorkspaceMemberInvite
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
Team,
|
||||
TeamMember,
|
||||
WorkspaceMemberInvite,
|
||||
WorkspaceTheme,
|
||||
)
|
||||
|
||||
|
||||
class WorkSpaceSerializer(BaseSerializer):
|
||||
@@ -100,3 +107,13 @@ class WorkspaceLiteSerializer(BaseSerializer):
|
||||
"id",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class WorkspaceThemeSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = WorkspaceTheme
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"actor",
|
||||
]
|
||||
|
||||
@@ -42,6 +42,7 @@ from plane.api.views import (
|
||||
UserActivityGraphEndpoint,
|
||||
UserIssueCompletedGraphEndpoint,
|
||||
UserWorkspaceDashboardEndpoint,
|
||||
WorkspaceThemeViewSet,
|
||||
## End Workspaces
|
||||
# File Assets
|
||||
FileAssetEndpoint,
|
||||
@@ -74,10 +75,16 @@ from plane.api.views import (
|
||||
SubIssuesEndpoint,
|
||||
IssueLinkViewSet,
|
||||
BulkCreateIssueLabelsEndpoint,
|
||||
IssueAttachmentEndpoint,
|
||||
## End Issues
|
||||
# States
|
||||
StateViewSet,
|
||||
StateDeleteIssueCheckEndpoint,
|
||||
## End States
|
||||
# Estimates
|
||||
ProjectEstimatePointEndpoint,
|
||||
BulkEstimatePointEndpoint,
|
||||
## End Estimates
|
||||
# Shortcuts
|
||||
ShortCutViewSet,
|
||||
## End Shortcuts
|
||||
@@ -133,10 +140,14 @@ from plane.api.views import (
|
||||
## End importer
|
||||
# Search
|
||||
GlobalSearchEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
## End Search
|
||||
# Gpt
|
||||
GPTIntegrationEndpoint,
|
||||
## End Gpt
|
||||
# Release Notes
|
||||
ReleaseNotesEndpoint,
|
||||
## End Release Notes
|
||||
)
|
||||
|
||||
|
||||
@@ -342,6 +353,27 @@ urlpatterns = [
|
||||
WorkspaceMemberUserViewsEndpoint.as_view(),
|
||||
name="workspace-member-details",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-themes/",
|
||||
WorkspaceThemeViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="workspace-themes",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-themes/<uuid:pk>/",
|
||||
WorkspaceThemeViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="workspace-themes",
|
||||
),
|
||||
## End Workspaces ##
|
||||
# Projects
|
||||
path(
|
||||
@@ -476,7 +508,40 @@ urlpatterns = [
|
||||
),
|
||||
name="project-state",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:pk>/",
|
||||
StateDeleteIssueCheckEndpoint.as_view(),
|
||||
name="state-delete-check",
|
||||
),
|
||||
# End States ##
|
||||
# Estimates
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/project-estimates/",
|
||||
ProjectEstimatePointEndpoint.as_view(),
|
||||
name="project-estimate-points",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/",
|
||||
BulkEstimatePointEndpoint.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="bulk-create-estimate-points",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/",
|
||||
BulkEstimatePointEndpoint.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="bulk-create-estimate-points",
|
||||
),
|
||||
# End Estimates ##
|
||||
# Shortcuts
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/shortcuts/",
|
||||
@@ -741,6 +806,16 @@ urlpatterns = [
|
||||
),
|
||||
name="project-issue-links",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
|
||||
IssueAttachmentEndpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
|
||||
IssueAttachmentEndpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
## End Issues
|
||||
## Issue Activity
|
||||
path(
|
||||
@@ -1158,6 +1233,11 @@ urlpatterns = [
|
||||
ImportServiceEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/importers/<str:service>/<uuid:pk>/",
|
||||
ImportServiceEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/service/<str:service>/importers/<uuid:importer_id>/",
|
||||
UpdateServiceImportStatusEndpoint.as_view(),
|
||||
@@ -1170,6 +1250,11 @@ urlpatterns = [
|
||||
GlobalSearchEndpoint.as_view(),
|
||||
name="global-search",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/search-issues/",
|
||||
IssueSearchEndpoint.as_view(),
|
||||
name="project-issue-search",
|
||||
),
|
||||
## End Search
|
||||
# Gpt
|
||||
path(
|
||||
@@ -1178,4 +1263,11 @@ urlpatterns = [
|
||||
name="importer",
|
||||
),
|
||||
## End Gpt
|
||||
# Release Notes
|
||||
path(
|
||||
"release-notes/",
|
||||
ReleaseNotesEndpoint.as_view(),
|
||||
name="release-notes",
|
||||
),
|
||||
## End Release Notes
|
||||
]
|
||||
|
||||
@@ -40,8 +40,9 @@ from .workspace import (
|
||||
UserActivityGraphEndpoint,
|
||||
UserIssueCompletedGraphEndpoint,
|
||||
UserWorkspaceDashboardEndpoint,
|
||||
WorkspaceThemeViewSet,
|
||||
)
|
||||
from .state import StateViewSet
|
||||
from .state import StateViewSet, StateDeleteIssueCheckEndpoint
|
||||
from .shortcut import ShortCutViewSet
|
||||
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
|
||||
from .cycle import (
|
||||
@@ -69,6 +70,7 @@ from .issue import (
|
||||
SubIssuesEndpoint,
|
||||
IssueLinkViewSet,
|
||||
BulkCreateIssueLabelsEndpoint,
|
||||
IssueAttachmentEndpoint,
|
||||
)
|
||||
|
||||
from .auth_extended import (
|
||||
@@ -125,7 +127,15 @@ from .page import (
|
||||
CreatedbyOtherPagesEndpoint,
|
||||
)
|
||||
|
||||
from .search import GlobalSearchEndpoint
|
||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||
|
||||
|
||||
from .gpt import GPTIntegrationEndpoint
|
||||
|
||||
from .estimate import (
|
||||
ProjectEstimatePointEndpoint,
|
||||
BulkEstimatePointEndpoint,
|
||||
)
|
||||
|
||||
|
||||
from .release import ReleaseNotesEndpoint
|
||||
|
||||
@@ -65,6 +65,8 @@ class FileAssetEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class UserAssetsEndpoint(BaseAPIView):
|
||||
parser_classes = (MultiPartParser, FormParser)
|
||||
|
||||
def get(self, request, asset_key):
|
||||
try:
|
||||
files = FileAsset.objects.filter(asset=asset_key, created_by=request.user)
|
||||
|
||||
@@ -10,7 +10,7 @@ from rest_framework.views import APIView
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.exceptions import NotFound
|
||||
|
||||
from sentry_sdk import capture_exception
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
|
||||
# Module imports
|
||||
@@ -39,7 +39,7 @@ class BaseViewSet(ModelViewSet, BasePaginator):
|
||||
try:
|
||||
return self.model.objects.all()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
||||
@@ -28,6 +28,8 @@ from plane.db.models import (
|
||||
CycleIssue,
|
||||
Issue,
|
||||
CycleFavorite,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
@@ -226,6 +228,20 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
.prefetch_related("labels")
|
||||
.order_by(order_by)
|
||||
.filter(**filters)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
issues_data = IssueStateSerializer(issues, many=True).data
|
||||
@@ -317,21 +333,19 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "issue.activity",
|
||||
"requested_data": json.dumps({"cycles_list": issues}),
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("pk", None)),
|
||||
"project_id": str(self.kwargs.get("project_id", None)),
|
||||
"current_instance": json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": serializers.serialize(
|
||||
"json", record_to_create
|
||||
),
|
||||
}
|
||||
),
|
||||
},
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"cycles_list": issues}),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("pk", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": serializers.serialize(
|
||||
"json", record_to_create
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Return all Cycle Issues
|
||||
@@ -370,7 +384,8 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
|
||||
cycles = Cycle.objects.filter(
|
||||
Q(start_date__lte=start_date, end_date__gte=start_date)
|
||||
| Q(start_date__gte=end_date, end_date__lte=end_date),
|
||||
| Q(start_date__lte=end_date, end_date__gte=end_date)
|
||||
| Q(start_date__gte=start_date, end_date__lte=end_date),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Project, Estimate, EstimatePoint
|
||||
from plane.api.serializers import (
|
||||
EstimateSerializer,
|
||||
EstimatePointSerializer,
|
||||
EstimateReadSerializer,
|
||||
)
|
||||
|
||||
|
||||
class ProjectEstimatePointEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
project = Project.objects.get(workspace__slug=slug, pk=project_id)
|
||||
if project.estimate_id is not None:
|
||||
estimate_points = EstimatePoint.objects.filter(
|
||||
estimate_id=project.estimate_id,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
serializer = EstimatePointSerializer(estimate_points, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response([], 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 BulkEstimatePointEndpoint(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
model = Estimate
|
||||
serializer_class = EstimateSerializer
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
estimates = Estimate.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).prefetch_related("points")
|
||||
serializer = EstimateReadSerializer(estimates, many=True)
|
||||
return Response(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,
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
if not request.data.get("estimate", False):
|
||||
return Response(
|
||||
{"error": "Estimate is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
estimate_points = request.data.get("estimate_points", [])
|
||||
|
||||
if not len(estimate_points) or len(estimate_points) > 8:
|
||||
return Response(
|
||||
{"error": "Estimate points are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
estimate_serializer = EstimateSerializer(data=request.data.get("estimate"))
|
||||
if not estimate_serializer.is_valid():
|
||||
return Response(
|
||||
estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
try:
|
||||
estimate = estimate_serializer.save(project_id=project_id)
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{"errror": "Estimate with the name already exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
estimate_points = EstimatePoint.objects.bulk_create(
|
||||
[
|
||||
EstimatePoint(
|
||||
estimate=estimate,
|
||||
key=estimate_point.get("key", 0),
|
||||
value=estimate_point.get("value", ""),
|
||||
description=estimate_point.get("description", ""),
|
||||
project_id=project_id,
|
||||
workspace_id=estimate.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for estimate_point in estimate_points
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
estimate_point_serializer = EstimatePointSerializer(
|
||||
estimate_points, many=True
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"estimate": estimate_serializer.data,
|
||||
"estimate_points": estimate_point_serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Estimate.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Estimate 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,
|
||||
)
|
||||
|
||||
def retrieve(self, request, slug, project_id, estimate_id):
|
||||
try:
|
||||
estimate = Estimate.objects.get(
|
||||
pk=estimate_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
serializer = EstimateReadSerializer(estimate)
|
||||
return Response(
|
||||
serializer.data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Estimate.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Estimate 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,
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, project_id, estimate_id):
|
||||
try:
|
||||
if not request.data.get("estimate", False):
|
||||
return Response(
|
||||
{"error": "Estimate is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not len(request.data.get("estimate_points", [])):
|
||||
return Response(
|
||||
{"error": "Estimate points are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
estimate = Estimate.objects.get(pk=estimate_id)
|
||||
|
||||
estimate_serializer = EstimateSerializer(
|
||||
estimate, data=request.data.get("estimate"), partial=True
|
||||
)
|
||||
if not estimate_serializer.is_valid():
|
||||
return Response(
|
||||
estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
try:
|
||||
estimate = estimate_serializer.save()
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{"errror": "Estimate with the name already exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
estimate_points_data = request.data.get("estimate_points", [])
|
||||
|
||||
estimate_points = EstimatePoint.objects.filter(
|
||||
pk__in=[
|
||||
estimate_point.get("id") for estimate_point in estimate_points_data
|
||||
],
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
estimate_id=estimate_id,
|
||||
)
|
||||
|
||||
updated_estimate_points = []
|
||||
for estimate_point in estimate_points:
|
||||
# Find the data for that estimate point
|
||||
estimate_point_data = [
|
||||
point
|
||||
for point in estimate_points_data
|
||||
if point.get("id") == str(estimate_point.id)
|
||||
]
|
||||
if len(estimate_point_data):
|
||||
estimate_point.value = estimate_point_data[0].get(
|
||||
"value", estimate_point.value
|
||||
)
|
||||
updated_estimate_points.append(estimate_point)
|
||||
|
||||
try:
|
||||
EstimatePoint.objects.bulk_update(
|
||||
updated_estimate_points, ["value"], batch_size=10
|
||||
)
|
||||
except IntegrityError as e:
|
||||
return Response(
|
||||
{"error": "Values need to be unique for each key"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
estimate_point_serializer = EstimatePointSerializer(estimate_points, many=True)
|
||||
return Response(
|
||||
{
|
||||
"estimate": estimate_serializer.data,
|
||||
"estimate_points": estimate_point_serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Estimate.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Estimate 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,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, estimate_id):
|
||||
try:
|
||||
estimate = Estimate.objects.get(
|
||||
pk=estimate_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
estimate.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -65,29 +65,35 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
# Check for all the keys
|
||||
params = {
|
||||
"project_key": "Project key is required",
|
||||
"api_token": "API token is required",
|
||||
"email": "Email is required",
|
||||
"cloud_hostname": "Cloud hostname is required",
|
||||
}
|
||||
|
||||
return Response(
|
||||
jira_project_issue_summary(
|
||||
email, api_token, project_name, cloud_hostname
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
for key, error_message in params.items():
|
||||
if not request.GET.get(key, False):
|
||||
return Response(
|
||||
{"error": error_message}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
project_key = request.GET.get("project_key", "")
|
||||
api_token = request.GET.get("api_token", "")
|
||||
email = request.GET.get("email", "")
|
||||
cloud_hostname = request.GET.get("cloud_hostname", "")
|
||||
|
||||
response = jira_project_issue_summary(
|
||||
email, api_token, project_key, cloud_hostname
|
||||
)
|
||||
if "error" in response:
|
||||
return Response(response, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(
|
||||
response,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response(
|
||||
{"error": "Service not supported yet"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -98,7 +104,7 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -213,8 +219,10 @@ class ImportServiceEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
imports = Importer.objects.filter(workspace__slug=slug).order_by(
|
||||
"-created_at"
|
||||
imports = (
|
||||
Importer.objects.filter(workspace__slug=slug)
|
||||
.order_by("-created_at")
|
||||
.select_related("initiated_by", "project", "workspace")
|
||||
)
|
||||
serializer = ImporterSerializer(imports, many=True)
|
||||
return Response(serializer.data)
|
||||
@@ -225,6 +233,20 @@ class ImportServiceEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def delete(self, request, slug, service, pk):
|
||||
try:
|
||||
importer = Importer.objects.filter(
|
||||
pk=pk, service=service, workspace__slug=slug
|
||||
)
|
||||
importer.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
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):
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
# Python imports
|
||||
import json
|
||||
import random
|
||||
from itertools import groupby, chain
|
||||
from itertools import chain
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Prefetch, OuterRef, Func, F, Q
|
||||
from django.db.models import Prefetch, OuterRef, Func, F, Q, Count
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
@@ -28,6 +30,7 @@ from plane.api.serializers import (
|
||||
IssueFlatSerializer,
|
||||
IssueLinkSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
)
|
||||
from plane.api.permissions import (
|
||||
ProjectEntityPermission,
|
||||
@@ -43,6 +46,8 @@ from plane.db.models import (
|
||||
IssueProperty,
|
||||
Label,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
State,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
@@ -82,16 +87,14 @@ class IssueViewSet(BaseViewSet):
|
||||
)
|
||||
if current_instance is not None:
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "issue.activity.updated",
|
||||
"requested_data": requested_data,
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("pk", None)),
|
||||
"project_id": str(self.kwargs.get("project_id", None)),
|
||||
"current_instance": json.dumps(
|
||||
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
||||
),
|
||||
},
|
||||
type="issue.activity.updated",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("pk", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
||||
),
|
||||
)
|
||||
|
||||
return super().perform_update(serializer)
|
||||
@@ -102,18 +105,16 @@ class IssueViewSet(BaseViewSet):
|
||||
)
|
||||
if current_instance is not None:
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "issue.activity.deleted",
|
||||
"requested_data": json.dumps(
|
||||
{"issue_id": str(self.kwargs.get("pk", None))}
|
||||
),
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("pk", None)),
|
||||
"project_id": str(self.kwargs.get("project_id", None)),
|
||||
"current_instance": json.dumps(
|
||||
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
||||
),
|
||||
},
|
||||
type="issue.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
{"issue_id": str(self.kwargs.get("pk", None))}
|
||||
),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("pk", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
||||
),
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
@@ -149,6 +150,20 @@ class IssueViewSet(BaseViewSet):
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__id"))
|
||||
.annotate(module_id=F("issue_module__id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
issue_queryset = (
|
||||
@@ -187,16 +202,12 @@ class IssueViewSet(BaseViewSet):
|
||||
|
||||
# Track the issue
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "issue.activity.created",
|
||||
"requested_data": json.dumps(
|
||||
self.request.data, cls=DjangoJSONEncoder
|
||||
),
|
||||
"actor_id": str(request.user.id),
|
||||
"issue_id": str(serializer.data.get("id", None)),
|
||||
"project_id": str(project_id),
|
||||
"current_instance": None,
|
||||
},
|
||||
type="issue.activity.created",
|
||||
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(serializer.data.get("id", None)),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -237,6 +248,20 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.order_by("-created_at")
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
serializer = IssueLiteSerializer(issues, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -328,14 +353,12 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
actor=self.request.user if self.request.user is not None else None,
|
||||
)
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "comment.activity.created",
|
||||
"requested_data": json.dumps(serializer.data, cls=DjangoJSONEncoder),
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("issue_id")),
|
||||
"project_id": str(self.kwargs.get("project_id")),
|
||||
"current_instance": None,
|
||||
},
|
||||
type="comment.activity.created",
|
||||
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("issue_id")),
|
||||
project_id=str(self.kwargs.get("project_id")),
|
||||
current_instance=None,
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
@@ -345,17 +368,15 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
)
|
||||
if current_instance is not None:
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "comment.activity.updated",
|
||||
"requested_data": requested_data,
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("issue_id", None)),
|
||||
"project_id": str(self.kwargs.get("project_id", None)),
|
||||
"current_instance": json.dumps(
|
||||
IssueCommentSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
},
|
||||
type="comment.activity.updated",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
IssueCommentSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
)
|
||||
|
||||
return super().perform_update(serializer)
|
||||
@@ -366,19 +387,17 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
)
|
||||
if current_instance is not None:
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "comment.activity.deleted",
|
||||
"requested_data": json.dumps(
|
||||
{"comment_id": str(self.kwargs.get("pk", None))}
|
||||
),
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("issue_id", None)),
|
||||
"project_id": str(self.kwargs.get("project_id", None)),
|
||||
"current_instance": json.dumps(
|
||||
IssueCommentSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
},
|
||||
type="comment.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
{"comment_id": str(self.kwargs.get("pk", None))}
|
||||
),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
IssueCommentSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
@@ -573,8 +592,31 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
.prefetch_related("labels")
|
||||
)
|
||||
|
||||
serializer = IssueLiteSerializer(sub_issues, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
state_distribution = (
|
||||
State.objects.filter(workspace__slug=slug, project_id=project_id)
|
||||
.annotate(
|
||||
state_count=Count(
|
||||
"state_issue",
|
||||
filter=Q(state_issue__parent_id=issue_id),
|
||||
)
|
||||
)
|
||||
.order_by("group")
|
||||
.values("group", "state_count")
|
||||
)
|
||||
|
||||
result = {item["group"]: item["state_count"] for item in state_distribution}
|
||||
|
||||
serializer = IssueLiteSerializer(
|
||||
sub_issues,
|
||||
many=True,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"sub_issues": serializer.data,
|
||||
"state_distribution": result,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
@@ -632,6 +674,54 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
issue_id=self.kwargs.get("issue_id"),
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="link.activity.created",
|
||||
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("issue_id")),
|
||||
project_id=str(self.kwargs.get("project_id")),
|
||||
current_instance=None,
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
|
||||
current_instance = (
|
||||
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
|
||||
)
|
||||
if current_instance is not None:
|
||||
issue_activity.delay(
|
||||
type="link.activity.updated",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
IssueLinkSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
)
|
||||
|
||||
return super().perform_update(serializer)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
current_instance = (
|
||||
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
|
||||
)
|
||||
if current_instance is not None:
|
||||
issue_activity.delay(
|
||||
type="link.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
{"link_id": str(self.kwargs.get("pk", None))}
|
||||
),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
IssueLinkSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
@@ -683,3 +773,72 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class IssueAttachmentEndpoint(BaseAPIView):
|
||||
serializer_class = IssueAttachmentSerializer
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
model = IssueAttachment
|
||||
parser_classes = (MultiPartParser, FormParser)
|
||||
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
serializer = IssueAttachmentSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id, issue_id=issue_id)
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.created",
|
||||
requested_data=None,
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
serializer.data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
)
|
||||
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, slug, project_id, issue_id, pk):
|
||||
try:
|
||||
issue_attachment = IssueAttachment.objects.get(pk=pk)
|
||||
issue_attachment.asset.delete(save=False)
|
||||
issue_attachment.delete()
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.deleted",
|
||||
requested_data=None,
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=None,
|
||||
)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except IssueAttachment.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Issue Attachment does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
issue_attachments = IssueAttachment.objects.filter(
|
||||
issue_id=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
serilaizer = IssueAttachmentSerializer(issue_attachments, many=True)
|
||||
return Response(serilaizer.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,
|
||||
)
|
||||
|
||||
@@ -31,6 +31,8 @@ from plane.db.models import (
|
||||
Issue,
|
||||
ModuleLink,
|
||||
ModuleFavorite,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
@@ -204,6 +206,20 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
.prefetch_related("labels")
|
||||
.order_by(order_by)
|
||||
.filter(**filters)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
issues_data = IssueStateSerializer(issues, many=True).data
|
||||
@@ -286,21 +302,19 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "issue.activity",
|
||||
"requested_data": json.dumps({"modules_list": issues}),
|
||||
"actor_id": str(self.request.user.id),
|
||||
"issue_id": str(self.kwargs.get("pk", None)),
|
||||
"project_id": str(self.kwargs.get("project_id", None)),
|
||||
"current_instance": json.dumps(
|
||||
{
|
||||
"updated_module_issues": update_module_issue_activity,
|
||||
"created_module_issues": serializers.serialize(
|
||||
"json", record_to_create
|
||||
),
|
||||
}
|
||||
),
|
||||
},
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"modules_list": issues}),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("pk", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_module_issues": update_module_issue_activity,
|
||||
"created_module_issues": serializers.serialize(
|
||||
"json", record_to_create
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
return Response(
|
||||
|
||||
@@ -14,7 +14,7 @@ from rest_framework.permissions import AllowAny
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
from rest_framework import status
|
||||
|
||||
from sentry_sdk import capture_exception
|
||||
# sso authentication
|
||||
from google.oauth2 import id_token
|
||||
from google.auth.transport import requests as google_auth_request
|
||||
@@ -48,7 +48,7 @@ def validate_google_token(token, client_id):
|
||||
}
|
||||
return data
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
raise exceptions.AuthenticationFailed("Error with Google connection.")
|
||||
|
||||
|
||||
@@ -305,8 +305,7 @@ class OauthEndpoint(BaseAPIView):
|
||||
)
|
||||
return Response(data, status=status.HTTP_201_CREATED)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{
|
||||
"error": "Something went wrong. Please try again later or contact the support team."
|
||||
|
||||
@@ -344,7 +344,7 @@ class RecentPagesEndpoint(BaseAPIView):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -17,6 +17,7 @@ from plane.db.models import (
|
||||
WorkspaceMemberInvite,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.utils.paginator import BasePaginator
|
||||
|
||||
@@ -72,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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.integrations.github import get_release_notes
|
||||
|
||||
|
||||
class ReleaseNotesEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
try:
|
||||
release_notes = get_release_notes()
|
||||
return Response(release_notes, 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,
|
||||
)
|
||||
@@ -12,6 +12,7 @@ from sentry_sdk import capture_exception
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models import Workspace, Project, Issue, Cycle, Module, Page, IssueView
|
||||
from plane.utils.issue_search import search_issues
|
||||
|
||||
|
||||
class GlobalSearchEndpoint(BaseAPIView):
|
||||
@@ -24,20 +25,26 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
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")
|
||||
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")
|
||||
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"]
|
||||
@@ -49,18 +56,22 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
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",
|
||||
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):
|
||||
@@ -68,16 +79,20 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
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",
|
||||
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):
|
||||
@@ -85,16 +100,20 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
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",
|
||||
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):
|
||||
@@ -102,16 +121,20 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
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",
|
||||
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):
|
||||
@@ -119,16 +142,20 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
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",
|
||||
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):
|
||||
@@ -168,7 +195,57 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
return Response({"results": results}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class IssueSearchEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
query = request.query_params.get("search", False)
|
||||
parent = request.query_params.get("parent", False)
|
||||
blocker_blocked_by = request.query_params.get("blocker_blocked_by", False)
|
||||
issue_id = request.query_params.get("issue_id", False)
|
||||
|
||||
issues = search_issues(query)
|
||||
issues = issues.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
)
|
||||
|
||||
if parent == "true" and issue_id:
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id), ~Q(pk=issue.parent_id), parent__isnull=True
|
||||
).exclude(
|
||||
pk__in=Issue.objects.filter(parent__isnull=False).values_list(
|
||||
"parent_id", flat=True
|
||||
)
|
||||
)
|
||||
if blocker_blocked_by == "true" and issue_id:
|
||||
issues = issues.filter(blocker_issues=issue_id, blocked_issues=issue_id)
|
||||
|
||||
return Response(
|
||||
issues.values(
|
||||
"name",
|
||||
"id",
|
||||
"sequence_id",
|
||||
"project__identifier",
|
||||
"project_id",
|
||||
"workspace__slug",
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Issue.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Issue 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,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Python imports
|
||||
from itertools import groupby
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
@@ -8,10 +11,10 @@ from sentry_sdk import capture_exception
|
||||
|
||||
|
||||
# Module imports
|
||||
from . import BaseViewSet
|
||||
from . import BaseViewSet, BaseAPIView
|
||||
from plane.api.serializers import StateSerializer
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import State
|
||||
from plane.db.models import State, Issue
|
||||
|
||||
|
||||
class StateViewSet(BaseViewSet):
|
||||
@@ -36,6 +39,25 @@ class StateViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
serializer = StateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{"error": "State with the name already exists"},
|
||||
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 list(self, request, slug, project_id):
|
||||
try:
|
||||
state_dict = dict()
|
||||
@@ -66,7 +88,37 @@ class StateViewSet(BaseViewSet):
|
||||
{"error": "Default state cannot be deleted"}, status=False
|
||||
)
|
||||
|
||||
# Check for any issues in the state
|
||||
issue_exist = Issue.objects.filter(state=pk).exists()
|
||||
|
||||
if issue_exist:
|
||||
return Response(
|
||||
{
|
||||
"error": "The state is not empty, only empty states can be deleted"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
state.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except State.DoesNotExist:
|
||||
return Response({"error": "State does not exists"}, status=status.HTTP_404)
|
||||
|
||||
|
||||
class StateDeleteIssueCheckEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id, pk):
|
||||
try:
|
||||
issue_count = Issue.objects.filter(
|
||||
state=pk, workspace__slug=slug, project_id=project_id
|
||||
).count()
|
||||
return Response({"issue_count": issue_count}, 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,
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ from plane.api.serializers import (
|
||||
WorkSpaceMemberInviteSerializer,
|
||||
UserLiteSerializer,
|
||||
ProjectMemberSerializer,
|
||||
WorkspaceThemeSerializer,
|
||||
)
|
||||
from plane.api.views.base import BaseAPIView
|
||||
from . import BaseViewSet
|
||||
@@ -48,6 +49,7 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
IssueActivity,
|
||||
Issue,
|
||||
WorkspaceTheme,
|
||||
)
|
||||
from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission
|
||||
from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
||||
@@ -143,7 +145,6 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
@@ -331,7 +332,6 @@ class JoinWorkspaceEndpoint(BaseAPIView):
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
@@ -752,3 +752,35 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceThemeViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
model = WorkspaceTheme
|
||||
serializer_class = WorkspaceThemeSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(workspace__slug=self.kwargs.get("slug"))
|
||||
|
||||
def create(self, request, slug):
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = WorkspaceThemeSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(workspace=workspace, actor=request.user)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Workspace.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,23 +2,26 @@
|
||||
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 celery import shared_task
|
||||
|
||||
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
@job("default")
|
||||
@shared_task
|
||||
def email_verification(first_name, email, token, current_site):
|
||||
|
||||
try:
|
||||
realtivelink = "/request-email-verification/" + "?token=" + str(token)
|
||||
abs_url = "http://" + current_site + realtivelink
|
||||
|
||||
from_email_string = f"Team Plane <team@mailer.plane.so>"
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"Verify your Email!"
|
||||
|
||||
|
||||
@@ -2,23 +2,24 @@
|
||||
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 celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
@job("default")
|
||||
@shared_task
|
||||
def forgot_password(first_name, email, uidb64, token, current_site):
|
||||
|
||||
try:
|
||||
realtivelink = f"/email-verify/?uidb64={uidb64}&token={token}/"
|
||||
abs_url = "http://" + current_site + realtivelink
|
||||
|
||||
from_email_string = f"Team Plane <team@mailer.plane.so>"
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"Verify your Email!"
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
# Third Party imports
|
||||
from django_rq import job
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
@@ -29,7 +29,7 @@ from plane.db.models import (
|
||||
from .workspace_invitation_task import workspace_invitation
|
||||
|
||||
|
||||
@job("default")
|
||||
@shared_task
|
||||
def service_importer(service, importer_id):
|
||||
try:
|
||||
importer = Importer.objects.get(pk=importer_id)
|
||||
@@ -38,54 +38,55 @@ def service_importer(service, importer_id):
|
||||
|
||||
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,
|
||||
)
|
||||
# Check if we need to import users as well
|
||||
if len(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"
|
||||
]
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
# 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,
|
||||
)
|
||||
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):
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third Party imports
|
||||
from django_rq import job
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
@@ -136,6 +136,7 @@ def track_priority(
|
||||
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
|
||||
)
|
||||
)
|
||||
print(issue_activities)
|
||||
|
||||
|
||||
# Track chnages in state of the issue
|
||||
@@ -633,6 +634,40 @@ def create_issue_activity(
|
||||
)
|
||||
|
||||
|
||||
def track_estimate_points(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
if current_instance.get("estimate_point") != requested_data.get("estimate_point"):
|
||||
if requested_data.get("estimate_point") == None:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("estimate_point"),
|
||||
new_value=requested_data.get("estimate_point"),
|
||||
field="estimate_point",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the estimate point to None",
|
||||
)
|
||||
)
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("estimate_point"),
|
||||
new_value=requested_data.get("estimate_point"),
|
||||
field="estimate_point",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the estimate point to {requested_data.get('estimate_point')}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
@@ -650,7 +685,14 @@ def update_issue_activity(
|
||||
"blockers_list": track_blockings,
|
||||
"cycles_list": track_cycles,
|
||||
"modules_list": track_modules,
|
||||
"estimate_point": track_estimate_points,
|
||||
}
|
||||
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
|
||||
for key in requested_data:
|
||||
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
|
||||
if func is not None:
|
||||
@@ -664,9 +706,29 @@ def update_issue_activity(
|
||||
)
|
||||
|
||||
|
||||
def delete_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} deleted the issue",
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
field="issue",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_comment_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
@@ -686,6 +748,11 @@ def create_comment_activity(
|
||||
def update_comment_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
|
||||
if current_instance.get("comment_html") != requested_data.get("comment_html"):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -705,21 +772,6 @@ def update_comment_activity(
|
||||
)
|
||||
|
||||
|
||||
def delete_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} deleted the issue",
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
field="issue",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_comment_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
@@ -736,28 +788,119 @@ def delete_comment_activity(
|
||||
)
|
||||
|
||||
|
||||
def create_link_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} created a link",
|
||||
verb="created",
|
||||
actor=actor,
|
||||
field="link",
|
||||
new_value=requested_data.get("url", ""),
|
||||
new_identifier=requested_data.get("id", None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_link_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
|
||||
if current_instance.get("url") != requested_data.get("url"):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated a link",
|
||||
verb="updated",
|
||||
actor=actor,
|
||||
field="link",
|
||||
old_value=current_instance.get("url", ""),
|
||||
old_identifier=current_instance.get("id"),
|
||||
new_value=requested_data.get("url", ""),
|
||||
new_identifier=current_instance.get("id", None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_link_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} deleted the link",
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
field="link",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_attachment_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} created an attachment",
|
||||
verb="created",
|
||||
actor=actor,
|
||||
field="attachment",
|
||||
new_value=current_instance.get("access", ""),
|
||||
new_identifier=current_instance.get("id", None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_attachment_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} deleted the attachment",
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
field="attachment",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Receive message from room group
|
||||
@job("default")
|
||||
def issue_activity(event):
|
||||
@shared_task
|
||||
def issue_activity(
|
||||
type, requested_data, current_instance, issue_id, actor_id, project_id
|
||||
):
|
||||
try:
|
||||
issue_activities = []
|
||||
type = event.get("type")
|
||||
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
|
||||
else None
|
||||
)
|
||||
issue_id = event.get("issue_id", None)
|
||||
actor_id = event.get("actor_id")
|
||||
project_id = event.get("project_id")
|
||||
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
ACTIVITY_MAPPER = {
|
||||
@@ -767,6 +910,11 @@ def issue_activity(event):
|
||||
"comment.activity.created": create_comment_activity,
|
||||
"comment.activity.updated": update_comment_activity,
|
||||
"comment.activity.deleted": delete_comment_activity,
|
||||
"link.activity.created": create_link_activity,
|
||||
"link.activity.updated": update_link_activity,
|
||||
"link.activity.deleted": delete_link_activity,
|
||||
"attachment.activity.created": create_attachment_activity,
|
||||
"attachment.activity.deleted": delete_attachment_activity,
|
||||
}
|
||||
|
||||
func = ACTIVITY_MAPPER.get(type)
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
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 celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
|
||||
@job("default")
|
||||
@shared_task
|
||||
def magic_link(email, key, token, current_site):
|
||||
|
||||
try:
|
||||
realtivelink = f"/magic-sign-in/?password={token}&key={key}"
|
||||
abs_url = "http://" + current_site + realtivelink
|
||||
|
||||
from_email_string = f"Team Plane <team@mailer.plane.so>"
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"Login for Plane"
|
||||
|
||||
@@ -30,6 +30,5 @@ def magic_link(email, key, token, current_site):
|
||||
msg.send()
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
@@ -2,20 +2,19 @@
|
||||
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 celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Project, User, ProjectMemberInvite
|
||||
|
||||
|
||||
@job("default")
|
||||
@shared_task
|
||||
def project_invitation(email, project_id, token, current_site):
|
||||
|
||||
try:
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
project_member_invite = ProjectMemberInvite.objects.get(
|
||||
token=token, email=email
|
||||
@@ -24,7 +23,7 @@ def project_invitation(email, project_id, token, current_site):
|
||||
relativelink = f"/project-member-invitation/{project_member_invite.id}"
|
||||
abs_url = "http://" + current_site + relativelink
|
||||
|
||||
from_email_string = f"Team Plane <team@mailer.plane.so>"
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"{project.created_by.first_name or project.created_by.email} invited you to join {project.name} on Plane"
|
||||
|
||||
@@ -35,7 +34,9 @@ def project_invitation(email, project_id, token, current_site):
|
||||
"invitation_url": abs_url,
|
||||
}
|
||||
|
||||
html_content = render_to_string("emails/invitations/project_invitation.html", context)
|
||||
html_content = render_to_string(
|
||||
"emails/invitations/project_invitation.html", context
|
||||
)
|
||||
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
@@ -49,6 +50,5 @@ def project_invitation(email, project_id, token, current_site):
|
||||
except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist) as e:
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from django_rq import job
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
@@ -14,7 +14,7 @@ from slack_sdk.errors import SlackApiError
|
||||
from plane.db.models import Workspace, User, WorkspaceMemberInvite
|
||||
|
||||
|
||||
@job("default")
|
||||
@shared_task
|
||||
def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
try:
|
||||
workspace = Workspace.objects.get(pk=workspace_id)
|
||||
@@ -27,7 +27,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
)
|
||||
abs_url = "http://" + current_site + realtivelink
|
||||
|
||||
from_email_string = f"Team Plane <team@mailer.plane.so>"
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"{invitor or email} invited you to join {workspace.name} on Plane"
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import os
|
||||
from celery import Celery
|
||||
from plane.settings.redis import redis_instance
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
|
||||
|
||||
ri = redis_instance()
|
||||
|
||||
app = Celery("plane")
|
||||
|
||||
# Using a string here means the worker will not have to
|
||||
# pickle the object when using Windows.
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
|
||||
# Load task modules from all registered Django app configs.
|
||||
app.autodiscover_tasks()
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.18 on 2023-04-04 21:50
|
||||
|
||||
from django.db import migrations, models
|
||||
import plane.db.models.project
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0025_auto_20230331_0203'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='projectmember',
|
||||
name='view_props',
|
||||
field=models.JSONField(default=plane.db.models.project.get_default_props),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
# Generated by Django 3.2.18 on 2023-04-08 21:42
|
||||
|
||||
from django.conf import settings
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import plane.db.models.issue
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0026_alter_projectmember_view_props'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Estimate',
|
||||
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.TextField(blank=True, verbose_name='Estimate Description')),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='estimate_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_estimate', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='estimate_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_estimate', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Estimate',
|
||||
'verbose_name_plural': 'Estimates',
|
||||
'db_table': 'estimates',
|
||||
'ordering': ('name',),
|
||||
'unique_together': {('name', 'project')},
|
||||
},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='issue',
|
||||
name='attachments',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issue',
|
||||
name='estimate_point',
|
||||
field=models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(7)]),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IssueAttachment',
|
||||
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)),
|
||||
('attributes', models.JSONField(default=dict)),
|
||||
('asset', models.FileField(upload_to=plane.db.models.issue.get_upload_path, validators=[plane.db.models.issue.file_size])),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueattachment_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_attachment', to='db.issue')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueattachment', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueattachment_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_issueattachment', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Issue Attachment',
|
||||
'verbose_name_plural': 'Issue Attachments',
|
||||
'db_table': 'issue_attachments',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='estimate',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projects', to='db.estimate'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EstimatePoint',
|
||||
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)),
|
||||
('key', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(7)])),
|
||||
('description', models.TextField(blank=True)),
|
||||
('value', models.CharField(max_length=20)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='estimatepoint_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('estimate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='points', to='db.estimate')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_estimatepoint', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='estimatepoint_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_estimatepoint', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Estimate Point',
|
||||
'verbose_name_plural': 'Estimate Points',
|
||||
'db_table': 'estimate_points',
|
||||
'ordering': ('value',),
|
||||
'unique_together': {('value', 'estimate')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 3.2.18 on 2023-04-14 11:33
|
||||
|
||||
from django.conf import settings
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0027_auto_20230409_0312'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='theme',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='issue',
|
||||
name='estimate_point',
|
||||
field=models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(7)]),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WorkspaceTheme',
|
||||
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=300)),
|
||||
('colors', models.JSONField(default=dict)),
|
||||
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='themes', to=settings.AUTH_USER_MODEL)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacetheme_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacetheme_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='themes', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Workspace Theme',
|
||||
'verbose_name_plural': 'Workspace Themes',
|
||||
'db_table': 'workspace_themes',
|
||||
'ordering': ('-created_at',),
|
||||
'unique_together': {('workspace', 'name')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -8,6 +8,7 @@ from .workspace import (
|
||||
Team,
|
||||
WorkspaceMemberInvite,
|
||||
TeamMember,
|
||||
WorkspaceTheme,
|
||||
)
|
||||
|
||||
from .project import (
|
||||
@@ -32,6 +33,7 @@ from .issue import (
|
||||
IssueBlocker,
|
||||
IssueLink,
|
||||
IssueSequence,
|
||||
IssueAttachment,
|
||||
)
|
||||
|
||||
from .asset import FileAsset
|
||||
@@ -61,4 +63,6 @@ from .integration import (
|
||||
|
||||
from .importer import Importer
|
||||
|
||||
from .page import Page, PageBlock, PageFavorite, PageLabel
|
||||
from .page import Page, PageBlock, PageFavorite, PageLabel
|
||||
|
||||
from .estimate import Estimate, EstimatePoint
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
|
||||
# Module imports
|
||||
from . import ProjectBaseModel
|
||||
|
||||
|
||||
class Estimate(ProjectBaseModel):
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(verbose_name="Estimate Description", blank=True)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the estimate"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "project"]
|
||||
verbose_name = "Estimate"
|
||||
verbose_name_plural = "Estimates"
|
||||
db_table = "estimates"
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
class EstimatePoint(ProjectBaseModel):
|
||||
estimate = models.ForeignKey(
|
||||
"db.Estimate",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="points",
|
||||
)
|
||||
key = models.IntegerField(
|
||||
default=0, validators=[MinValueValidator(0), MaxValueValidator(7)]
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
value = models.CharField(max_length=20)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the estimate"""
|
||||
return f"{self.estimate.name} <{self.key}> <{self.value}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["value", "estimate"]
|
||||
verbose_name = "Estimate Point"
|
||||
verbose_name_plural = "Estimate Points"
|
||||
db_table = "estimate_points"
|
||||
ordering = ("value",)
|
||||
@@ -1,3 +1,6 @@
|
||||
# Python import
|
||||
from uuid import uuid4
|
||||
|
||||
# Django imports
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
@@ -5,6 +8,8 @@ from django.conf import settings
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Module imports
|
||||
from . import ProjectBaseModel
|
||||
@@ -33,6 +38,9 @@ class Issue(ProjectBaseModel):
|
||||
blank=True,
|
||||
related_name="state_issue",
|
||||
)
|
||||
estimate_point = models.IntegerField(
|
||||
validators=[MinValueValidator(0), MaxValueValidator(7)], null=True, blank=True
|
||||
)
|
||||
name = models.CharField(max_length=255, verbose_name="Issue Name")
|
||||
description = models.JSONField(blank=True, default=dict)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
@@ -54,7 +62,6 @@ class Issue(ProjectBaseModel):
|
||||
through_fields=("issue", "assignee"),
|
||||
)
|
||||
sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID")
|
||||
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
|
||||
labels = models.ManyToManyField(
|
||||
"db.Label", blank=True, related_name="labels", through="IssueLabel"
|
||||
)
|
||||
@@ -194,6 +201,38 @@ class IssueLink(ProjectBaseModel):
|
||||
return f"{self.issue.name} {self.url}"
|
||||
|
||||
|
||||
def get_upload_path(instance, filename):
|
||||
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
|
||||
|
||||
|
||||
def file_size(value):
|
||||
limit = 5 * 1024 * 1024
|
||||
if value.size > limit:
|
||||
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
||||
|
||||
|
||||
class IssueAttachment(ProjectBaseModel):
|
||||
attributes = models.JSONField(default=dict)
|
||||
asset = models.FileField(
|
||||
upload_to=get_upload_path,
|
||||
validators=[
|
||||
file_size,
|
||||
],
|
||||
)
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.CASCADE, related_name="issue_attachment"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Attachment"
|
||||
verbose_name_plural = "Issue Attachments"
|
||||
db_table = "issue_attachments"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.asset}"
|
||||
|
||||
|
||||
class IssueActivity(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity"
|
||||
|
||||
@@ -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": None,
|
||||
"showEmptyGroups": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +69,9 @@ class Project(BaseModel):
|
||||
issue_views_view = models.BooleanField(default=True)
|
||||
page_view = models.BooleanField(default=True)
|
||||
cover_image = models.URLField(blank=True, null=True, max_length=800)
|
||||
estimate = models.ForeignKey(
|
||||
"db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the project"""
|
||||
@@ -127,7 +133,7 @@ class ProjectMember(ProjectBaseModel):
|
||||
)
|
||||
comment = models.TextField(blank=True, null=True)
|
||||
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
|
||||
view_props = models.JSONField(null=True)
|
||||
view_props = models.JSONField(default=get_default_props)
|
||||
default_props = models.JSONField(default=get_default_props)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -72,6 +72,7 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
my_issues_prop = models.JSONField(null=True)
|
||||
role = models.CharField(max_length=300, null=True, blank=True)
|
||||
is_bot = models.BooleanField(default=False)
|
||||
theme = models.JSONField(default=dict)
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
|
||||
@@ -108,7 +109,7 @@ def send_welcome_email(sender, instance, created, **kwargs):
|
||||
if created and not instance.is_bot:
|
||||
first_name = instance.first_name.capitalize()
|
||||
to_email = instance.email
|
||||
from_email_string = f"Team Plane <team@mailer.plane.so>"
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"Welcome to Plane ✈️!"
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ class Workspace(BaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
|
||||
class WorkspaceMember(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member"
|
||||
@@ -111,7 +110,6 @@ class Team(BaseModel):
|
||||
|
||||
|
||||
class TeamMember(BaseModel):
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
Workspace, on_delete=models.CASCADE, related_name="team_member"
|
||||
)
|
||||
@@ -129,3 +127,24 @@ class TeamMember(BaseModel):
|
||||
verbose_name_plural = "Team Members"
|
||||
db_table = "team_members"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class WorkspaceTheme(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="themes"
|
||||
)
|
||||
name = models.CharField(max_length=300)
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes"
|
||||
)
|
||||
colors = models.JSONField(default=dict)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.name) + str(self.actor.email)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "name"]
|
||||
verbose_name = "Workspace Theme"
|
||||
verbose_name_plural = "Workspace Themes"
|
||||
db_table = "workspace_themes"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
@@ -35,7 +35,6 @@ INSTALLED_APPS = [
|
||||
"rest_framework_simplejwt.token_blacklist",
|
||||
"corsheaders",
|
||||
"taggit",
|
||||
"django_rq",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -175,11 +174,12 @@ EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
# Host for sending e-mail.
|
||||
EMAIL_HOST = os.environ.get("EMAIL_HOST")
|
||||
# Port for sending e-mail.
|
||||
EMAIL_PORT = 587
|
||||
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587))
|
||||
# Optional SMTP authentication information for EMAIL_HOST.
|
||||
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER")
|
||||
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD")
|
||||
EMAIL_USE_TLS = True
|
||||
EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "1") == "1"
|
||||
EMAIL_FROM = os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>")
|
||||
|
||||
|
||||
SIMPLE_JWT = {
|
||||
@@ -208,3 +208,7 @@ SIMPLE_JWT = {
|
||||
"SLIDING_TOKEN_LIFETIME": timedelta(minutes=5),
|
||||
"SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
|
||||
}
|
||||
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_ACCEPT_CONTENT = ['application/json']
|
||||
|
||||
@@ -59,16 +59,8 @@ if os.environ.get("SENTRY_DSN", False):
|
||||
|
||||
REDIS_HOST = "localhost"
|
||||
REDIS_PORT = 6379
|
||||
REDIS_URL = False
|
||||
REDIS_URL = os.environ.get("REDIS_URL")
|
||||
|
||||
RQ_QUEUES = {
|
||||
"default": {
|
||||
"HOST": "localhost",
|
||||
"PORT": 6379,
|
||||
"DB": 0,
|
||||
"DEFAULT_TIMEOUT": 360,
|
||||
},
|
||||
}
|
||||
|
||||
MEDIA_URL = "/uploads/"
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
|
||||
@@ -88,3 +80,9 @@ 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)
|
||||
|
||||
CELERY_RESULT_BACKEND = os.environ.get("REDIS_URL")
|
||||
CELERY_BROKER_URL = os.environ.get("REDIS_URL")
|
||||
|
||||
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Production settings and globals."""
|
||||
from urllib.parse import urlparse
|
||||
import ssl
|
||||
import certifi
|
||||
|
||||
import dj_database_url
|
||||
from urllib.parse import urlparse
|
||||
@@ -103,7 +105,7 @@ if (
|
||||
AWS_S3_ADDRESSING_STYLE = "auto"
|
||||
|
||||
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||
AWS_S3_ENDPOINT_URL = ""
|
||||
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
|
||||
|
||||
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
|
||||
AWS_S3_KEY_PREFIX = ""
|
||||
@@ -236,3 +238,17 @@ 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)
|
||||
|
||||
redis_url = os.environ.get("REDIS_URL")
|
||||
broker_url = (
|
||||
f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
||||
)
|
||||
|
||||
if DOCKERIZED:
|
||||
CELERY_BROKER_URL = REDIS_URL
|
||||
CELERY_RESULT_BACKEND = REDIS_URL
|
||||
else:
|
||||
CELERY_RESULT_BACKEND = broker_url
|
||||
CELERY_BROKER_URL = broker_url
|
||||
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import os
|
||||
import redis
|
||||
from django.conf import settings
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def redis_instance():
|
||||
# Run in local redis url is false
|
||||
if not settings.REDIS_URL:
|
||||
ri = redis.StrictRedis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0)
|
||||
# connect to redis
|
||||
if (
|
||||
settings.DOCKERIZED
|
||||
or os.environ.get("DJANGO_SETTINGS_MODULE", "plane.settings.production")
|
||||
== "plane.settings.local"
|
||||
):
|
||||
ri = redis.Redis.from_url(settings.REDIS_URL, db=0)
|
||||
else:
|
||||
# Run in prod redis url is true check with dockerized value
|
||||
if settings.DOCKERIZED:
|
||||
ri = redis.from_url(settings.REDIS_URL, db=0)
|
||||
else:
|
||||
url = urlparse(settings.REDIS_URL)
|
||||
ri = redis.Redis(
|
||||
host=url.hostname,
|
||||
port=url.port,
|
||||
password=url.password,
|
||||
ssl=True,
|
||||
ssl_cert_reqs=None,
|
||||
)
|
||||
|
||||
return ri
|
||||
url = urlparse(settings.REDIS_URL)
|
||||
ri = redis.Redis(
|
||||
host=url.hostname,
|
||||
port=url.port,
|
||||
password=url.password,
|
||||
ssl=True,
|
||||
ssl_cert_reqs=None,
|
||||
)
|
||||
|
||||
return ri
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Production settings and globals."""
|
||||
from urllib.parse import urlparse
|
||||
import ssl
|
||||
import certifi
|
||||
|
||||
import dj_database_url
|
||||
from urllib.parse import urlparse
|
||||
@@ -9,7 +11,6 @@ from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
|
||||
from .common import * # noqa
|
||||
|
||||
# Database
|
||||
DEBUG = True
|
||||
DATABASES = {
|
||||
@@ -197,3 +198,11 @@ 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)
|
||||
|
||||
redis_url = os.environ.get("REDIS_URL")
|
||||
broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
||||
|
||||
CELERY_RESULT_BACKEND = broker_url
|
||||
CELERY_BROKER_URL = broker_url
|
||||
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
|
||||
@@ -3,33 +3,33 @@ from requests.auth import HTTPBasicAuth
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
|
||||
def jira_project_issue_summary(email, api_token, project_name, hostname):
|
||||
def jira_project_issue_summary(email, api_token, project_key, 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_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} 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_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype=Epic"
|
||||
module_response = requests.request(
|
||||
"GET", module_url, headers=headers, auth=auth
|
||||
).json()["total"]
|
||||
|
||||
status_url = f"https://{hostname}/rest/api/3/status/?jql=project={project_name}"
|
||||
status_url = f"https://{hostname}/rest/api/3/status/?jql=project={project_key}"
|
||||
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_url = f"https://{hostname}/rest/api/3/label/?jql=project={project_key}"
|
||||
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}"
|
||||
f"https://{hostname}/rest/api/3/users/search?jql=project={project_key}"
|
||||
)
|
||||
users_response = requests.request(
|
||||
"GET", users_url, headers=headers, auth=auth
|
||||
|
||||
@@ -5,6 +5,7 @@ from urllib.parse import urlparse, parse_qs
|
||||
from datetime import datetime, timedelta
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def get_jwt_token():
|
||||
@@ -128,3 +129,24 @@ def get_github_repo_details(access_tokens_url, owner, repo):
|
||||
).json()
|
||||
|
||||
return open_issues, total_labels, collaborators
|
||||
|
||||
|
||||
def get_release_notes():
|
||||
token = settings.GITHUB_ACCESS_TOKEN
|
||||
|
||||
if token:
|
||||
headers = {
|
||||
"Authorization": "Bearer " + str(token),
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
}
|
||||
else:
|
||||
headers = {
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
}
|
||||
url = "https://api.github.com/repos/makeplane/plane/releases?per_page=5&page=1"
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
return {"error": "Unable to render information from Github Repository"}
|
||||
|
||||
return response.json()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Python imports
|
||||
import re
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Q
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue
|
||||
|
||||
|
||||
def search_issues(query):
|
||||
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,
|
||||
).distinct()
|
||||
@@ -23,9 +23,9 @@ django-guardian==2.4.0
|
||||
dj_rest_auth==2.2.5
|
||||
google-auth==2.16.0
|
||||
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
|
||||
openai==0.27.2
|
||||
slack-sdk==3.20.2
|
||||
slack-sdk==3.20.2
|
||||
celery==5.2.7
|
||||
@@ -1 +1 @@
|
||||
python-3.11.2
|
||||
python-3.11.3
|
||||
@@ -144,7 +144,7 @@
|
||||
<p style="margin: 0;">We have put together some resources to help you get started. Please find them below:</p>
|
||||
<p style="margin: 0;"> </p>
|
||||
<ul style="margin: 0; margin-top:20px;">
|
||||
<li><a href="https://docs.plane.so/get-started" target="_blank" style="color: #0092ff; text-decoration: underline;">Getting started with Plane</a></li>
|
||||
<li><a href="https://docs.plane.so/quick-start" target="_blank" style="color: #0092ff; text-decoration: underline;">Getting started with Plane</a></li>
|
||||
<li><a href="https://plane.so/changelog" target="_blank" style="color: #0092ff; text-decoration: underline;">Plane Changelog</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Replace with your instance Public IP
|
||||
# NEXT_PUBLIC_API_BASE_URL = "http://localhost"
|
||||
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
|
||||
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
|
||||
NEXT_PUBLIC_ENABLE_SENTRY=0
|
||||
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
|
||||
NEXT_PUBLIC_TRACK_EVENTS=0
|
||||
@@ -1,12 +1,7 @@
|
||||
module.exports = {
|
||||
extends: ["next", "turbo", "prettier"],
|
||||
extends: ["next", "prettier"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["react", "@typescript-eslint"],
|
||||
settings: {
|
||||
next: {
|
||||
rootDir: ["app/", "docs/", "packages/*/"],
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@next/next/no-html-link-for-pages": "off",
|
||||
"react/jsx-key": "off",
|
||||
@@ -4,9 +4,10 @@ RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
COPY . .
|
||||
RUN yarn global add turbo
|
||||
|
||||
RUN yarn install
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["yarn","dev"]
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
FROM node:18-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
RUN yarn install --frozen-lockfile
|
||||
COPY . .
|
||||
|
||||
# build
|
||||
RUN yarn build
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 plane
|
||||
RUN adduser --system --uid 1001 captain
|
||||
USER captain
|
||||
|
||||
COPY --from=builder /app/next.config.js .
|
||||
COPY --from=builder /app/package.json .
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
EXPOSE 3000
|
||||
+2
-3
@@ -121,7 +121,7 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
) || "Email ID is not valid",
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter you email Id"
|
||||
placeholder="Enter your Email ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +141,7 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
<button
|
||||
type="button"
|
||||
className={`mt-5 flex w-full justify-end text-xs outline-none ${
|
||||
isResendDisabled ? "cursor-default text-gray-400" : "cursor-pointer text-theme"
|
||||
isResendDisabled ? "cursor-default text-gray-400" : "cursor-pointer text-brand-accent"
|
||||
} `}
|
||||
onClick={() => {
|
||||
setIsCodeResending(true);
|
||||
@@ -180,7 +180,6 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="w-full text-center"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
+2
-2
@@ -50,7 +50,7 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
if (!error?.response?.data) return;
|
||||
Object.keys(error.response.data).forEach((key) => {
|
||||
const err = error.response.data[key];
|
||||
console.log("err", err);
|
||||
console.log(err);
|
||||
setError(key as keyof EmailPasswordFormValues, {
|
||||
type: "manual",
|
||||
message: Array.isArray(err) ? err.join(", ") : err,
|
||||
@@ -94,7 +94,7 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="ml-auto text-sm">
|
||||
<Link href={"/forgot-password"}>
|
||||
<a className="font-medium text-theme hover:text-indigo-500">Forgot your password?</a>
|
||||
<a className="font-medium text-brand-accent hover:text-indigo-500">Forgot your password?</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
+1
-1
@@ -37,7 +37,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
|
||||
<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">
|
||||
<button className="flex w-full items-center justify-center gap-3 rounded-md border border-brand-base 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>
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./project";
|
||||
export * from "./workspace";
|
||||
export * from "./not-authorized-view";
|
||||
+19
-14
@@ -7,16 +7,16 @@ import { useRouter } from "next/router";
|
||||
import DefaultLayout from "layouts/default-layout";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
// img
|
||||
import ProjectSettingImg from "public/project-setting.svg";
|
||||
// images
|
||||
import ProjectNotAuthorizedImg from "public/auth/project-not-authorized.svg";
|
||||
import WorkspaceNotAuthorizedImg from "public/auth/workspace-not-authorized.svg";
|
||||
|
||||
type TNotAuthorizedViewProps = {
|
||||
type Props = {
|
||||
actionButton?: React.ReactNode;
|
||||
type: "project" | "workspace";
|
||||
};
|
||||
|
||||
export const NotAuthorizedView: React.FC<TNotAuthorizedViewProps> = (props) => {
|
||||
const { actionButton } = props;
|
||||
|
||||
export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
|
||||
const { user } = useUser();
|
||||
const { asPath: currentPath } = useRouter();
|
||||
|
||||
@@ -29,26 +29,31 @@ 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">
|
||||
<div className="h-44 w-72">
|
||||
<Image src={ProjectSettingImg} height="176" width="288" alt="ProjectSettingImg" />
|
||||
<Image
|
||||
src={type === "project" ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg}
|
||||
height="176"
|
||||
width="288"
|
||||
alt="ProjectSettingImg"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-xl font-medium text-gray-900">
|
||||
<h1 className="text-xl font-medium text-brand-base">
|
||||
Oops! You are not authorized to view this page
|
||||
</h1>
|
||||
|
||||
<div className="w-full text-base text-gray-500 max-w-md ">
|
||||
<div className="w-full text-base text-brand-secondary max-w-md ">
|
||||
{user ? (
|
||||
<p className="">
|
||||
You have signed in as {user.email}.{" "}
|
||||
<p>
|
||||
You have signed in as {user.email}. <br />
|
||||
<Link href={`/signin?next=${currentPath}`}>
|
||||
<a className="text-gray-900 font-medium">Sign in</a>
|
||||
<a className="text-brand-base font-medium">Sign in</a>
|
||||
</Link>{" "}
|
||||
with different account that has access to this page.
|
||||
</p>
|
||||
) : (
|
||||
<p className="">
|
||||
<p>
|
||||
You need to{" "}
|
||||
<Link href={`/signin?next=${currentPath}`}>
|
||||
<a className="text-gray-900 font-medium">Sign in</a>
|
||||
<a className="text-brand-base font-medium">Sign in</a>
|
||||
</Link>{" "}
|
||||
with an account that has access to this page.
|
||||
</p>
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./join-project";
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
// ui
|
||||
import { PrimaryButton } from "components/ui";
|
||||
// icons
|
||||
import { AssignmentClipboardIcon } from "components/icons";
|
||||
// images
|
||||
import JoinProjectImg from "public/auth/project-not-authorized.svg";
|
||||
// fetch-keys
|
||||
import { USER_PROJECT_VIEW } from "constants/fetch-keys";
|
||||
|
||||
export const JoinProject: React.FC = () => {
|
||||
const [isJoiningProject, setIsJoiningProject] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const handleJoin = () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
setIsJoiningProject(true);
|
||||
projectService
|
||||
.joinProject(workspaceSlug as string, {
|
||||
project_ids: [projectId as string],
|
||||
})
|
||||
.then(async () => {
|
||||
await mutate(USER_PROJECT_VIEW(projectId.toString()));
|
||||
setIsJoiningProject(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsJoiningProject(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
|
||||
<div className="h-44 w-72">
|
||||
<Image src={JoinProjectImg} height="176" width="288" alt="JoinProject" />
|
||||
</div>
|
||||
<h1 className="text-xl font-medium text-gray-900">You are not a member of this project</h1>
|
||||
|
||||
<div className="w-full max-w-md text-base text-gray-500 ">
|
||||
<p className="mx-auto w-full text-sm md:w-3/4">
|
||||
You are not a member of this project, but you can join this project by clicking the button
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<PrimaryButton
|
||||
className="flex items-center gap-1"
|
||||
loading={isJoiningProject}
|
||||
onClick={handleJoin}
|
||||
>
|
||||
<AssignmentClipboardIcon height={16} width={16} color="white" />
|
||||
{isJoiningProject ? "Joining..." : "Click to join"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./not-a-member";
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// layouts
|
||||
import DefaultLayout from "layouts/default-layout";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
|
||||
export const NotAWorkspaceMember = () => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<DefaultLayout
|
||||
meta={{
|
||||
title: "Plane - Unauthorized User",
|
||||
description: "Unauthorized user",
|
||||
}}
|
||||
>
|
||||
<div className="grid h-full place-items-center p-4">
|
||||
<div className="space-y-8 text-center">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Not Authorized!</h3>
|
||||
<p className="text-sm text-gray-500 w-1/2 mx-auto">
|
||||
You{"'"}re not a member of this workspace. Please contact the workspace admin to get
|
||||
an invitation or check your pending invitations.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<Link href="/invitations">
|
||||
<a>
|
||||
<SecondaryButton>Check pending invites</SecondaryButton>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="/create-workspace">
|
||||
<a>
|
||||
<PrimaryButton>Create new workspace</PrimaryButton>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
};
|
||||
@@ -16,7 +16,7 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
|
||||
<div className="flex items-center">
|
||||
<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"
|
||||
className="grid h-8 w-8 flex-shrink-0 cursor-pointer place-items-center rounded border border-brand-base text-center text-sm hover:bg-brand-surface-1"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeftIcon className="h-3 w-3" />
|
||||
@@ -37,7 +37,7 @@ const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) =>
|
||||
<>
|
||||
{link ? (
|
||||
<Link href={link}>
|
||||
<a className="border-r-2 border-gray-300 px-3 text-sm">
|
||||
<a className="border-r-2 border-brand-base px-3 text-sm">
|
||||
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
|
||||
{icon ?? null}
|
||||
{title}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
|
||||
// cmdk
|
||||
import { Command } from "cmdk";
|
||||
import { THEMES_OBJ } from "constants/themes";
|
||||
import { useTheme } from "next-themes";
|
||||
import { SettingIcon } from "components/icons";
|
||||
|
||||
type Props = {
|
||||
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
// useEffect only runs on the client, so now we can safely show the UI
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{THEMES_OBJ.map((theme) => (
|
||||
<Command.Item
|
||||
key={theme.value}
|
||||
onSelect={() => {
|
||||
setTheme(theme.value);
|
||||
setIsPaletteOpen(false);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<SettingIcon className="h-4 w-4 text-brand-secondary" />
|
||||
{theme.label}
|
||||
</div>
|
||||
</Command.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
-2
@@ -98,8 +98,7 @@ export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue }
|
||||
<Command.Item
|
||||
key={option.value}
|
||||
onSelect={() => handleIssueAssignees(option.value)}
|
||||
className="focus:bg-slate-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
{option.content}
|
||||
</Command.Item>
|
||||
+1
-2
@@ -60,8 +60,7 @@ export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue }
|
||||
<Command.Item
|
||||
key={priority}
|
||||
onSelect={() => handleIssueState(priority)}
|
||||
className="focus:bg-slate-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{getPriorityIcon(priority)}
|
||||
+1
-2
@@ -75,8 +75,7 @@ export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue }) =
|
||||
<Command.Item
|
||||
key={state.id}
|
||||
onSelect={() => handleIssueState(state.id)}
|
||||
className="focus:bg-slate-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{getStateGroupIcon(state.group, "16", "16", state.color)}
|
||||
+143
-120
@@ -44,6 +44,7 @@ import {
|
||||
ChangeIssueState,
|
||||
ChangeIssuePriority,
|
||||
ChangeIssueAssignee,
|
||||
ChangeInterfaceTheme,
|
||||
} from "components/command-palette";
|
||||
import { BulkDeleteIssuesModal } from "components/core";
|
||||
import { CreateUpdateCycleModal } from "components/cycles";
|
||||
@@ -51,6 +52,8 @@ import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||
import { CreateUpdateModuleModal } from "components/modules";
|
||||
import { CreateProjectModal } from "components/project";
|
||||
import { CreateUpdateViewModal } from "components/views";
|
||||
import { CreateUpdatePageModal } from "components/pages";
|
||||
|
||||
import { Spinner } from "components/ui";
|
||||
// helpers
|
||||
import {
|
||||
@@ -76,6 +79,7 @@ export const CommandPalette: React.FC = () => {
|
||||
const [isCreateModuleModalOpen, setIsCreateModuleModalOpen] = useState(false);
|
||||
const [isBulkDeleteIssuesModalOpen, setIsBulkDeleteIssuesModalOpen] = useState(false);
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false);
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState<string>("");
|
||||
const [results, setResults] = useState<IWorkspaceSearchResults>({
|
||||
@@ -174,40 +178,49 @@ export const CommandPalette: React.FC = () => {
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const singleShortcutKeys = ["p", "v", "d", "h", "q", "m"];
|
||||
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
|
||||
if (!key) return;
|
||||
const keyPressed = key.toLowerCase();
|
||||
if (
|
||||
!(e.target instanceof HTMLTextAreaElement) &&
|
||||
!(e.target instanceof HTMLInputElement) &&
|
||||
!(e.target as Element).classList?.contains("remirror-editor")
|
||||
) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
if ((ctrlKey || metaKey) && keyPressed === "k") {
|
||||
e.preventDefault();
|
||||
setIsPaletteOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
|
||||
if (e.altKey) {
|
||||
} else if ((ctrlKey || metaKey) && keyPressed === "c") {
|
||||
if (altKey) {
|
||||
e.preventDefault();
|
||||
copyIssueUrlToClipboard();
|
||||
}
|
||||
} else if (e.key.toLowerCase() === "c") {
|
||||
} else if (keyPressed === "c") {
|
||||
e.preventDefault();
|
||||
setIsIssueModalOpen(true);
|
||||
} else if (e.key.toLowerCase() === "p") {
|
||||
e.preventDefault();
|
||||
setIsProjectModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") {
|
||||
} else if ((ctrlKey || metaKey) && keyPressed === "b") {
|
||||
e.preventDefault();
|
||||
toggleCollapsed();
|
||||
} else if (e.key.toLowerCase() === "h") {
|
||||
e.preventDefault();
|
||||
setIsShortcutsModalOpen(true);
|
||||
} else if (e.key.toLowerCase() === "q") {
|
||||
e.preventDefault();
|
||||
setIsCreateCycleModalOpen(true);
|
||||
} else if (e.key.toLowerCase() === "m") {
|
||||
e.preventDefault();
|
||||
setIsCreateModuleModalOpen(true);
|
||||
} else if (e.key === "Delete") {
|
||||
} else if (key === "Delete") {
|
||||
e.preventDefault();
|
||||
setIsBulkDeleteIssuesModalOpen(true);
|
||||
} else if (
|
||||
singleShortcutKeys.includes(keyPressed) &&
|
||||
(ctrlKey || metaKey || altKey || shiftKey)
|
||||
) {
|
||||
e.preventDefault();
|
||||
} else if (keyPressed === "p") {
|
||||
setIsProjectModalOpen(true);
|
||||
} else if (keyPressed === "v") {
|
||||
setIsCreateViewModalOpen(true);
|
||||
} else if (keyPressed === "d") {
|
||||
setIsCreateUpdatePageModalOpen(true);
|
||||
} else if (keyPressed === "h") {
|
||||
setIsShortcutsModalOpen(true);
|
||||
} else if (keyPressed === "q") {
|
||||
setIsCreateCycleModalOpen(true);
|
||||
} else if (keyPressed === "m") {
|
||||
setIsCreateModuleModalOpen(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -288,6 +301,11 @@ export const CommandPalette: React.FC = () => {
|
||||
setIsCreateViewModalOpen(true);
|
||||
};
|
||||
|
||||
const createNewPage = () => {
|
||||
setIsPaletteOpen(false);
|
||||
setIsCreateUpdatePageModalOpen(true);
|
||||
};
|
||||
|
||||
const createNewModule = () => {
|
||||
setIsPaletteOpen(false);
|
||||
setIsCreateModuleModalOpen(true);
|
||||
@@ -323,6 +341,10 @@ export const CommandPalette: React.FC = () => {
|
||||
handleClose={() => setIsCreateViewModalOpen(false)}
|
||||
isOpen={isCreateViewModalOpen}
|
||||
/>
|
||||
<CreateUpdatePageModal
|
||||
isOpen={isCreateUpdatePageModalOpen}
|
||||
handleClose={() => setIsCreateUpdatePageModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{issueId && issueDetails && (
|
||||
@@ -358,7 +380,7 @@ export const CommandPalette: React.FC = () => {
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
|
||||
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-20">
|
||||
@@ -371,7 +393,7 @@ export const CommandPalette: React.FC = () => {
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-brand-base divide-opacity-10 rounded-xl bg-brand-surface-2 border-brand-base border shadow-2xl transition-all">
|
||||
<Command
|
||||
filter={(value, search) => {
|
||||
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
||||
@@ -394,7 +416,7 @@ export const CommandPalette: React.FC = () => {
|
||||
>
|
||||
{issueId && issueDetails && (
|
||||
<div className="flex p-3">
|
||||
<p className="overflow-hidden truncate rounded-md bg-gray-100 p-1 px-2 text-xs font-medium text-gray-500">
|
||||
<p className="overflow-hidden truncate rounded-md bg-brand-surface-1 p-1 px-2 text-xs font-medium text-brand-secondary">
|
||||
{issueDetails.project_detail?.identifier}-{issueDetails.sequence_id}{" "}
|
||||
{issueDetails?.name}
|
||||
</p>
|
||||
@@ -402,11 +424,11 @@ export const CommandPalette: React.FC = () => {
|
||||
)}
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-brand-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Command.Input
|
||||
className="w-full border-0 border-b bg-transparent p-4 pl-11 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
className="w-full border-0 border-b border-brand-base bg-transparent p-4 pl-11 text-brand-base placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
onValueChange={(e) => {
|
||||
@@ -420,7 +442,9 @@ export const CommandPalette: React.FC = () => {
|
||||
resultsCount === 0 &&
|
||||
searchTerm !== "" &&
|
||||
debouncedSearchTerm !== "" && (
|
||||
<div className="my-4 text-center text-gray-500">No results found.</div>
|
||||
<div className="my-4 text-center text-brand-secondary">
|
||||
No results found.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isLoading || isSearching) && (
|
||||
@@ -479,11 +503,13 @@ export const CommandPalette: React.FC = () => {
|
||||
setIsPaletteOpen(false);
|
||||
}}
|
||||
value={value}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden text-gray-700">
|
||||
<Icon className="h-4 w-4 text-gray-500" color="#6b7280" />
|
||||
<div className="flex items-center gap-2 overflow-hidden text-brand-secondary">
|
||||
<Icon
|
||||
className="h-4 w-4 text-brand-secondary"
|
||||
color="#6b7280"
|
||||
/>
|
||||
<p className="block flex-1 truncate">{item.name}</p>
|
||||
</div>
|
||||
</Command.Item>
|
||||
@@ -506,11 +532,10 @@ export const CommandPalette: React.FC = () => {
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-state"]);
|
||||
}}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<Squares2X2Icon className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<Squares2X2Icon className="h-4 w-4 text-brand-secondary" />
|
||||
Change state...
|
||||
</div>
|
||||
</Command.Item>
|
||||
@@ -520,11 +545,10 @@ export const CommandPalette: React.FC = () => {
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-priority"]);
|
||||
}}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<ChartBarIcon className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<ChartBarIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Change priority...
|
||||
</div>
|
||||
</Command.Item>
|
||||
@@ -534,11 +558,10 @@ export const CommandPalette: React.FC = () => {
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-assignee"]);
|
||||
}}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<UsersIcon className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<UsersIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Assign to...
|
||||
</div>
|
||||
</Command.Item>
|
||||
@@ -547,31 +570,26 @@ export const CommandPalette: React.FC = () => {
|
||||
handleIssueAssignees(user.id);
|
||||
setSearchTerm("");
|
||||
}}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
{issueDetails?.assignees.includes(user.id) ? (
|
||||
<>
|
||||
<UserMinusIcon className="h-4 w-4 text-gray-500" />
|
||||
<UserMinusIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Un-assign from me
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlusIcon className="h-4 w-4 text-gray-500" />
|
||||
<UserPlusIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Assign to me
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
onSelect={deleteIssue}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<TrashIcon className="h-4 w-4 text-gray-500" />
|
||||
<Command.Item onSelect={deleteIssue} className="focus:outline-none">
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<TrashIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Delete issue
|
||||
</div>
|
||||
</Command.Item>
|
||||
@@ -580,11 +598,10 @@ export const CommandPalette: React.FC = () => {
|
||||
setIsPaletteOpen(false);
|
||||
copyIssueUrlToClipboard();
|
||||
}}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<LinkIcon className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<LinkIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Copy issue URL to clipboard
|
||||
</div>
|
||||
</Command.Item>
|
||||
@@ -593,10 +610,9 @@ export const CommandPalette: React.FC = () => {
|
||||
<Command.Group heading="Issue">
|
||||
<Command.Item
|
||||
onSelect={createNewIssue}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:bg-brand-surface-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<LayerDiagonalIcon className="h-4 w-4" color="#6b7280" />
|
||||
Create new issue
|
||||
</div>
|
||||
@@ -608,10 +624,9 @@ export const CommandPalette: React.FC = () => {
|
||||
<Command.Group heading="Project">
|
||||
<Command.Item
|
||||
onSelect={createNewProject}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<AssignmentClipboardIcon className="h-4 w-4" color="#6b7280" />
|
||||
Create new project
|
||||
</div>
|
||||
@@ -625,10 +640,9 @@ export const CommandPalette: React.FC = () => {
|
||||
<Command.Group heading="Cycle">
|
||||
<Command.Item
|
||||
onSelect={createNewCycle}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<ContrastIcon className="h-4 w-4" color="#6b7280" />
|
||||
Create new cycle
|
||||
</div>
|
||||
@@ -639,10 +653,9 @@ export const CommandPalette: React.FC = () => {
|
||||
<Command.Group heading="Module">
|
||||
<Command.Item
|
||||
onSelect={createNewModule}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<PeopleGroupIcon className="h-4 w-4" color="#6b7280" />
|
||||
Create new module
|
||||
</div>
|
||||
@@ -651,16 +664,22 @@ export const CommandPalette: React.FC = () => {
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group heading="View">
|
||||
<Command.Item
|
||||
onSelect={createNewView}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<Command.Item onSelect={createNewView} className="focus:outline-none">
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<ViewListIcon className="h-4 w-4" color="#6b7280" />
|
||||
Create new view
|
||||
</div>
|
||||
<kbd>Q</kbd>
|
||||
<kbd>V</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group heading="Page">
|
||||
<Command.Item onSelect={createNewPage} className="focus:outline-none">
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<DocumentTextIcon className="h-4 w-4" color="#6b7280" />
|
||||
Create new page
|
||||
</div>
|
||||
<kbd>D</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
</>
|
||||
@@ -673,10 +692,9 @@ export const CommandPalette: React.FC = () => {
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "settings"]);
|
||||
}}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<SettingIcon className="h-4 w-4" color="#6b7280" />
|
||||
Search settings...
|
||||
</div>
|
||||
@@ -685,14 +703,26 @@ export const CommandPalette: React.FC = () => {
|
||||
<Command.Group heading="Account">
|
||||
<Command.Item
|
||||
onSelect={createNewWorkspace}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<FolderPlusIcon className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<FolderPlusIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Create new workspace
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Change interface theme...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-interface-theme"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<SettingIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Change interface theme...
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
<Command.Group heading="Help">
|
||||
<Command.Item
|
||||
@@ -703,11 +733,10 @@ export const CommandPalette: React.FC = () => {
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<RocketLaunchIcon className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<RocketLaunchIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Open keyboard shortcuts
|
||||
</div>
|
||||
</Command.Item>
|
||||
@@ -716,11 +745,10 @@ export const CommandPalette: React.FC = () => {
|
||||
setIsPaletteOpen(false);
|
||||
window.open("https://docs.plane.so/", "_blank");
|
||||
}}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<DocumentIcon className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<DocumentIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Open Plane documentation
|
||||
</div>
|
||||
</Command.Item>
|
||||
@@ -729,10 +757,9 @@ export const CommandPalette: React.FC = () => {
|
||||
setIsPaletteOpen(false);
|
||||
window.open("https://discord.com/invite/A92xrEGCge", "_blank");
|
||||
}}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<DiscordIcon className="h-4 w-4" color="#6b7280" />
|
||||
Join our Discord
|
||||
</div>
|
||||
@@ -745,10 +772,9 @@ export const CommandPalette: React.FC = () => {
|
||||
"_blank"
|
||||
);
|
||||
}}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<GithubIcon className="h-4 w-4" color="#6b7280" />
|
||||
Report a bug
|
||||
</div>
|
||||
@@ -758,11 +784,10 @@ export const CommandPalette: React.FC = () => {
|
||||
setIsPaletteOpen(false);
|
||||
(window as any).$crisp.push(["do", "chat:open"]);
|
||||
}}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<ChatBubbleOvalLeftEllipsisIcon className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<ChatBubbleOvalLeftEllipsisIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Chat with us
|
||||
</div>
|
||||
</Command.Item>
|
||||
@@ -774,51 +799,46 @@ export const CommandPalette: React.FC = () => {
|
||||
<>
|
||||
<Command.Item
|
||||
onSelect={() => goToSettings()}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<SettingIcon className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<SettingIcon className="h-4 w-4 text-brand-secondary" />
|
||||
General
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => goToSettings("members")}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<SettingIcon className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<SettingIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Members
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => goToSettings("billing")}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<SettingIcon className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<SettingIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Billings and Plans
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => goToSettings("integrations")}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<SettingIcon className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<SettingIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Integrations
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => goToSettings("import-export")}
|
||||
className="focus:bg-gray-200 focus:outline-none"
|
||||
tabIndex={0}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<SettingIcon className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<SettingIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Import/Export
|
||||
</div>
|
||||
</Command.Item>
|
||||
@@ -844,6 +864,9 @@ export const CommandPalette: React.FC = () => {
|
||||
setIsPaletteOpen={setIsPaletteOpen}
|
||||
/>
|
||||
)}
|
||||
{page === "change-interface-theme" && (
|
||||
<ChangeInterfaceTheme setIsPaletteOpen={setIsPaletteOpen} />
|
||||
)}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</Dialog.Panel>
|
||||
@@ -3,3 +3,4 @@ export * from "./shortcuts-modal";
|
||||
export * from "./change-issue-state";
|
||||
export * from "./change-issue-priority";
|
||||
export * from "./change-issue-assignee";
|
||||
export * from "./change-interface-theme";
|
||||
+17
-15
@@ -33,6 +33,8 @@ 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" },
|
||||
{
|
||||
@@ -69,7 +71,7 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
@@ -83,29 +85,29 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div className="bg-white p-5">
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-brand-surface-2 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div className="bg-brand-surface-2 p-5">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="flex w-full flex-col gap-y-4 text-center sm:text-left">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="flex justify-between text-lg font-medium leading-6 text-gray-900"
|
||||
className="flex justify-between text-lg font-medium leading-6 text-brand-base"
|
||||
>
|
||||
<span>Keyboard Shortcuts</span>
|
||||
<span>
|
||||
<button type="button" onClick={() => setIsOpen(false)}>
|
||||
<XMarkIcon
|
||||
className="h-6 w-6 text-gray-400 hover:text-gray-500"
|
||||
className="h-6 w-6 text-gray-400 hover:text-brand-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
</Dialog.Title>
|
||||
<div>
|
||||
<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" />
|
||||
<div className="flex w-full items-center justify-start gap-1 rounded border-[0.6px] border-brand-base bg-brand-surface-1 px-3 py-2">
|
||||
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-brand-secondary" />
|
||||
<Input
|
||||
className="w-full border-none bg-transparent py-1 px-2 text-xs text-gray-500 focus:outline-none"
|
||||
className="w-full border-none bg-transparent py-1 px-2 text-xs text-brand-secondary focus:outline-none"
|
||||
id="search"
|
||||
name="search"
|
||||
type="text"
|
||||
@@ -121,16 +123,16 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
<div key={shortcut.keys} className="flex w-full flex-col">
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">{shortcut.description}</p>
|
||||
<p className="text-sm text-brand-secondary">{shortcut.description}</p>
|
||||
<div className="flex items-center gap-x-2.5">
|
||||
{shortcut.keys.split(",").map((key, index) => (
|
||||
<span key={index} className="flex items-center gap-1">
|
||||
{key === "Ctrl" ? (
|
||||
<span className="flex h-full items-center rounded-sm border border-gray-200 bg-gray-100 p-2">
|
||||
<span className="flex h-full items-center rounded-sm border border-brand-base bg-brand-surface-1 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">
|
||||
<kbd className="rounded-sm border border-brand-base bg-brand-surface-1 px-2 py-1 text-sm font-medium text-gray-800">
|
||||
{key === "Ctrl" ? <MacCommandIcon /> : key}
|
||||
</kbd>
|
||||
)}
|
||||
@@ -143,7 +145,7 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-brand-secondary">
|
||||
No shortcuts found for{" "}
|
||||
<span className="font-semibold italic">
|
||||
{`"`}
|
||||
@@ -160,16 +162,16 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
<div className="flex flex-col gap-y-3">
|
||||
{shortcuts.map(({ keys, description }, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">{description}</p>
|
||||
<p className="text-sm text-brand-secondary">{description}</p>
|
||||
<div className="flex items-center gap-x-2.5">
|
||||
{keys.split(",").map((key, index) => (
|
||||
<span key={index} className="flex items-center gap-1">
|
||||
{key === "Ctrl" ? (
|
||||
<span className="flex h-full items-center rounded-sm border border-gray-200 bg-gray-100 p-2">
|
||||
<span className="flex h-full items-center rounded-sm border border-brand-base text-brand-secondary bg-brand-surface-1 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">
|
||||
<kbd className="rounded-sm border border-brand-base bg-brand-surface-1 px-2 py-1 text-sm font-medium text-brand-secondary">
|
||||
{key === "Ctrl" ? <MacCommandIcon /> : key}
|
||||
</kbd>
|
||||
)}
|
||||
+2
-2
@@ -81,7 +81,7 @@ export const AllBoards: React.FC<Props> = ({
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between gap-2 rounded bg-white p-2 shadow"
|
||||
className="flex items-center justify-between gap-2 rounded bg-brand-surface-1 p-2 shadow"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentState &&
|
||||
@@ -92,7 +92,7 @@ export const AllBoards: React.FC<Props> = ({
|
||||
: addSpaceIfCamelCase(singleGroup)}
|
||||
</h4>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">0</span>
|
||||
<span className="text-xs text-brand-secondary">0</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
+12
-8
@@ -96,17 +96,17 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
|
||||
switch (selectedGroup) {
|
||||
case "state":
|
||||
icon = currentState && getStateGroupIcon(currentState.group, "18", "18", bgColor);
|
||||
icon = currentState && getStateGroupIcon(currentState.group, "16", "16", bgColor);
|
||||
break;
|
||||
case "priority":
|
||||
icon = getPriorityIcon(groupTitle, "h-[18px] w-[18px] flex items-center");
|
||||
icon = getPriorityIcon(groupTitle, "text-lg");
|
||||
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"
|
||||
className="h-3.5 w-3.5 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: labelColor }}
|
||||
/>
|
||||
);
|
||||
@@ -123,8 +123,8 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-between items-center px-1 ${
|
||||
!isCollapsed ? "flex-col rounded-md border bg-gray-50" : ""
|
||||
className={`flex items-center justify-between px-1 ${
|
||||
!isCollapsed ? "flex-col rounded-md bg-brand-surface-1" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
|
||||
@@ -142,7 +142,11 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
>
|
||||
{getGroupTitle()}
|
||||
</h2>
|
||||
<span className="ml-0.5 rounded-full bg-gray-100 py-1 px-3 text-sm">
|
||||
<span
|
||||
className={`${
|
||||
isCollapsed ? "ml-0.5" : ""
|
||||
} min-w-[2.5rem] rounded-full bg-brand-surface-2 py-1 text-center text-xs`}
|
||||
>
|
||||
{groupedByIssues?.[groupTitle].length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
@@ -151,7 +155,7 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
<div className={`flex items-center ${!isCollapsed ? "flex-col pb-2" : ""}`}>
|
||||
<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"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 text-brand-secondary outline-none duration-300 hover:bg-brand-surface-2"
|
||||
onClick={() => {
|
||||
setIsCollapsed((prevData) => !prevData);
|
||||
}}
|
||||
@@ -165,7 +169,7 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
{!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"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 text-brand-secondary outline-none duration-300 hover:bg-brand-surface-2"
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
+12
-8
@@ -66,7 +66,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
}, [currentState]);
|
||||
|
||||
return (
|
||||
<div className={`h-full flex-shrink-0 ${!isCollapsed ? "" : "w-96 bg-gray-50"}`}>
|
||||
<div className={`h-full flex-shrink-0 ${!isCollapsed ? "" : "w-96"}`}>
|
||||
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3"}`}>
|
||||
<BoardHeader
|
||||
addIssueToState={addIssueToState}
|
||||
@@ -81,7 +81,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`relative h-full overflow-y-auto p-1 ${
|
||||
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
|
||||
snapshot.isDraggingOver ? "bg-brand-base/20" : ""
|
||||
} ${!isCollapsed ? "hidden" : "block"}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
@@ -91,15 +91,17 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
<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`}
|
||||
} pointer-events-none top-0 left-0 z-[99] h-full w-full bg-brand-surface-1 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`}
|
||||
} pointer-events-none top-1/2 left-1/2 z-[99] -translate-y-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-brand-base p-2 text-xs`}
|
||||
>
|
||||
This board is ordered by{" "}
|
||||
{replaceUnderscoreIfSnakeCase(orderBy ?? "created_at")}
|
||||
{replaceUnderscoreIfSnakeCase(
|
||||
orderBy ? (orderBy[0] === "-" ? orderBy.slice(1) : orderBy) : "created_at"
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -108,7 +110,9 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
key={issue.id}
|
||||
draggableId={issue.id}
|
||||
index={index}
|
||||
isDragDisabled={isNotAllowed || selectedGroup === "created_by"}
|
||||
isDragDisabled={
|
||||
isNotAllowed || selectedGroup === "created_by" || selectedGroup === "labels"
|
||||
}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<SingleBoardIssue
|
||||
@@ -144,7 +148,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
{type === "issue" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 font-medium text-theme outline-none"
|
||||
className="flex items-center gap-2 font-medium text-brand-accent outline-none"
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
@@ -156,7 +160,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 font-medium text-theme outline-none"
|
||||
className="flex items-center gap-2 font-medium text-brand-accent outline-none"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Issue
|
||||
+99
-24
@@ -21,11 +21,12 @@ import useToast from "hooks/use-toast";
|
||||
import {
|
||||
ViewAssigneeSelect,
|
||||
ViewDueDateSelect,
|
||||
ViewEstimateSelect,
|
||||
ViewPrioritySelect,
|
||||
ViewStateSelect,
|
||||
} from "components/issues/view-select";
|
||||
} from "components/issues";
|
||||
// ui
|
||||
import { ContextMenu, CustomMenu } from "components/ui";
|
||||
import { ContextMenu, CustomMenu, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
ClipboardDocumentCheckIcon,
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
XMarkIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
PaperClipIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { handleIssuesMutation } from "constants/issue";
|
||||
@@ -107,11 +110,17 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
>(
|
||||
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params),
|
||||
(prevData) =>
|
||||
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
|
||||
handleIssuesMutation(
|
||||
formData,
|
||||
groupTitle ?? "",
|
||||
selectedGroup,
|
||||
index,
|
||||
orderBy,
|
||||
prevData
|
||||
),
|
||||
false
|
||||
);
|
||||
|
||||
if (moduleId)
|
||||
else if (moduleId)
|
||||
mutate<
|
||||
| {
|
||||
[key: string]: IIssue[];
|
||||
@@ -120,21 +129,39 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
>(
|
||||
MODULE_ISSUES_WITH_PARAMS(moduleId as string),
|
||||
(prevData) =>
|
||||
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
|
||||
handleIssuesMutation(
|
||||
formData,
|
||||
groupTitle ?? "",
|
||||
selectedGroup,
|
||||
index,
|
||||
orderBy,
|
||||
prevData
|
||||
),
|
||||
false
|
||||
);
|
||||
else {
|
||||
mutate<
|
||||
| {
|
||||
[key: string]: IIssue[];
|
||||
}
|
||||
| IIssue[]
|
||||
>(
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
mutate<
|
||||
| {
|
||||
[key: string]: IIssue[];
|
||||
}
|
||||
| IIssue[]
|
||||
>(
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
|
||||
(prevData) =>
|
||||
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
|
||||
false
|
||||
);
|
||||
return handleIssuesMutation(
|
||||
formData,
|
||||
groupTitle ?? "",
|
||||
selectedGroup,
|
||||
index,
|
||||
orderBy,
|
||||
prevData
|
||||
);
|
||||
},
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
|
||||
@@ -151,7 +178,18 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
console.log(error);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, moduleId, issue, groupTitle, index, selectedGroup, params]
|
||||
[
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cycleId,
|
||||
moduleId,
|
||||
issue,
|
||||
groupTitle,
|
||||
index,
|
||||
selectedGroup,
|
||||
orderBy,
|
||||
params,
|
||||
]
|
||||
);
|
||||
|
||||
const getStyle = (
|
||||
@@ -212,10 +250,19 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
<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 ${
|
||||
snapshot.isDragging ? "border-2 border-theme shadow-lg" : ""
|
||||
className={`mb-3 rounded bg-brand-base shadow ${
|
||||
snapshot.isDragging ? "border-2 border-brand-accent shadow-lg" : ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
@@ -265,12 +312,12 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
|
||||
<a>
|
||||
{properties.key && (
|
||||
<div className="mb-2.5 text-xs font-medium text-gray-700">
|
||||
<div className="mb-2.5 text-xs font-medium text-brand-secondary">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
)}
|
||||
<h5
|
||||
className="break-all text-sm group-hover:text-theme"
|
||||
className="break-all text-sm group-hover:text-brand-accent"
|
||||
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
|
||||
>
|
||||
{truncateText(issue.name, 100)}
|
||||
@@ -302,7 +349,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
{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">
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded-md border border-brand-base px-3 py-1.5 text-xs shadow-sm">
|
||||
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
)}
|
||||
@@ -311,7 +358,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
{issue.label_details.map((label) => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
||||
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary"
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
@@ -333,6 +380,34 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
{properties.estimate && (
|
||||
<ViewEstimateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
{properties.link && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Link" tooltipContent={`${issue.link_count}`}>
|
||||
<div className="flex items-center gap-1 text-gray-500">
|
||||
<LinkIcon className="h-3.5 w-3.5 text-gray-500" />
|
||||
{issue.link_count}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{properties.attachment_count && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Attachment" tooltipContent={`${issue.attachment_count}`}>
|
||||
<div className="flex items-center gap-1 text-gray-500">
|
||||
<PaperClipIcon className="h-3.5 w-3.5 -rotate-45 text-gray-500" />
|
||||
{issue.attachment_count}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+8
-8
@@ -121,7 +121,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-brand-surface-2 shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
|
||||
<form>
|
||||
<Combobox
|
||||
onChange={(val: string) => {
|
||||
@@ -136,12 +136,12 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
||||
>
|
||||
<div className="relative m-1">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-brand-base text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-brand-base placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
@@ -154,7 +154,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
||||
{filteredIssues.length > 0 ? (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-brand-base">
|
||||
Select issues to delete
|
||||
</h2>
|
||||
)}
|
||||
@@ -166,7 +166,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
||||
value={issue.id}
|
||||
className={({ active }) =>
|
||||
`flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 ${
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
active ? "bg-gray-900 bg-opacity-5 text-brand-base" : ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
@@ -182,7 +182,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
<span className="flex-shrink-0 text-xs text-brand-secondary">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
<span>{issue.name}</span>
|
||||
@@ -194,9 +194,9 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
|
||||
<LayerDiagonalIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
<h3 className="text-brand-secondary">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-gray-200 px-2 py-1">C</pre>.
|
||||
<pre className="inline rounded bg-brand-surface-2 px-2 py-1">C</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
+75
-34
@@ -26,14 +26,17 @@ import {
|
||||
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";
|
||||
import { CustomMenu, Spinner } from "components/ui";
|
||||
// icon
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PlusIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// hooks
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import cyclesService from "services/cycles.service";
|
||||
@@ -49,12 +52,16 @@ import { IIssue } from "types";
|
||||
import { monthOptions, yearOptions } from "constants/calendar";
|
||||
import modulesService from "services/modules.service";
|
||||
|
||||
type Props = {
|
||||
addIssueToDate: (date: string) => void;
|
||||
};
|
||||
|
||||
interface ICalendarRange {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export const CalendarView = () => {
|
||||
export const CalendarView: React.FC<Props> = ({ addIssueToDate }) => {
|
||||
const [showWeekEnds, setShowWeekEnds] = useState<boolean>(false);
|
||||
const [currentDate, setCurrentDate] = useState<Date>(new Date());
|
||||
const [isMonthlyView, setIsMonthlyView] = useState<boolean>(true);
|
||||
@@ -62,6 +69,8 @@ export const CalendarView = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const { params } = useIssuesView();
|
||||
|
||||
const [calendarDateRange, setCalendarDateRange] = useState<ICalendarRange>({
|
||||
startDate: startOfWeek(currentDate),
|
||||
endDate: lastDayOfWeek(currentDate),
|
||||
@@ -77,11 +86,13 @@ export const CalendarView = () => {
|
||||
workspaceSlug && projectId ? PROJECT_CALENDAR_ISSUES(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () =>
|
||||
issuesService.getIssuesWithParams(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
targetDateFilter
|
||||
)
|
||||
issuesService.getIssuesWithParams(workspaceSlug as string, projectId as string, {
|
||||
...params,
|
||||
target_date: `${renderDateFormat(calendarDateRange.startDate)};after,${renderDateFormat(
|
||||
calendarDateRange.endDate
|
||||
)};before`,
|
||||
group_by: null,
|
||||
})
|
||||
: null
|
||||
);
|
||||
|
||||
@@ -95,7 +106,13 @@ export const CalendarView = () => {
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
cycleId as string,
|
||||
targetDateFilter
|
||||
{
|
||||
...params,
|
||||
target_date: `${renderDateFormat(
|
||||
calendarDateRange.startDate
|
||||
)};after,${renderDateFormat(calendarDateRange.endDate)};before`,
|
||||
group_by: null,
|
||||
}
|
||||
)
|
||||
: null
|
||||
);
|
||||
@@ -110,7 +127,13 @@ export const CalendarView = () => {
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
moduleId as string,
|
||||
targetDateFilter
|
||||
{
|
||||
...params,
|
||||
target_date: `${renderDateFormat(
|
||||
calendarDateRange.startDate
|
||||
)};after,${renderDateFormat(calendarDateRange.endDate)};before`,
|
||||
group_by: null,
|
||||
}
|
||||
)
|
||||
: null
|
||||
);
|
||||
@@ -127,7 +150,11 @@ export const CalendarView = () => {
|
||||
|
||||
const currentViewDays = showWeekEnds ? totalDate : onlyWeekDays;
|
||||
|
||||
const calendarIssues = cycleCalendarIssues ?? moduleCalendarIssues ?? projectCalendarIssues;
|
||||
const calendarIssues = cycleId
|
||||
? cycleCalendarIssues
|
||||
: moduleId
|
||||
? moduleCalendarIssues
|
||||
: projectCalendarIssues;
|
||||
|
||||
const currentViewDaysData = currentViewDays.map((date: Date) => {
|
||||
const filterIssue =
|
||||
@@ -198,17 +225,17 @@ export const CalendarView = () => {
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
return calendarIssues ? (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div className="h-full overflow-y-auto rounded-lg text-gray-600 -m-2">
|
||||
<div className="-m-2 h-full overflow-y-auto rounded-lg text-brand-secondary">
|
||||
<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 ">
|
||||
<div className="relative flex h-full w-full items-center justify-start gap-2 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`}>
|
||||
<Popover.Button className={`group flex h-full items-start gap-1 text-brand-base`}>
|
||||
<div className="flex items-center justify-center gap-2 text-2xl font-semibold">
|
||||
<span className="text-black">{formatDate(currentDate, "Month")}</span>{" "}
|
||||
<span>{formatDate(currentDate, "Month")}</span>{" "}
|
||||
<span>{formatDate(currentDate, "yyyy")}</span>
|
||||
</div>
|
||||
</Popover.Button>
|
||||
@@ -222,30 +249,30 @@ export const CalendarView = () => {
|
||||
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">
|
||||
<Popover.Panel className="absolute top-10 left-0 z-20 flex w-full max-w-xs transform flex-col overflow-hidden rounded-[10px] bg-brand-surface-2 shadow-lg">
|
||||
<div className="flex items-center justify-center gap-5 px-2 py-2 text-sm">
|
||||
{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 `}
|
||||
? "text-sm font-medium text-brand-base"
|
||||
: "text-xs text-brand-secondary "
|
||||
} hover:text-sm hover:font-medium hover:text-brand-base`}
|
||||
>
|
||||
{year.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 px-2 border-t border-gray-200">
|
||||
<div className="grid grid-cols-4 border-t border-brand-base px-2">
|
||||
{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 ${
|
||||
className={`px-2 py-2 text-xs text-brand-secondary hover:font-medium hover:text-brand-base ${
|
||||
isSameMonth(month.value, currentDate)
|
||||
? "font-medium text-gray-800"
|
||||
? "font-medium text-brand-base"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
@@ -295,9 +322,9 @@ export const CalendarView = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-2 items-center justify-end">
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
<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"
|
||||
className="group flex cursor-pointer items-center gap-2 rounded-md border border-brand-base bg-brand-surface-2 px-4 py-1.5 text-sm hover:bg-brand-surface-1 hover:text-brand-base focus:outline-none"
|
||||
onClick={() => {
|
||||
if (isMonthlyView) {
|
||||
updateDate(new Date());
|
||||
@@ -312,10 +339,11 @@ export const CalendarView = () => {
|
||||
>
|
||||
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 `}
|
||||
className={`group flex cursor-pointer items-center gap-2 rounded-md border border-brand-base bg-brand-surface-2 px-3 py-1.5 text-sm hover:bg-brand-surface-1 hover:text-brand-base focus:outline-none `}
|
||||
>
|
||||
{isMonthlyView ? "Monthly" : "Weekly"}
|
||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||
@@ -330,7 +358,7 @@ export const CalendarView = () => {
|
||||
endDate: lastDayOfWeek(currentDate),
|
||||
});
|
||||
}}
|
||||
className="w-52 text-sm text-gray-600"
|
||||
className="w-52 text-sm text-brand-secondary"
|
||||
>
|
||||
<div className="flex w-full max-w-[260px] items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2">Monthly View</span>
|
||||
@@ -349,7 +377,7 @@ export const CalendarView = () => {
|
||||
endDate: getCurrentWeekEndDate(currentDate),
|
||||
});
|
||||
}}
|
||||
className="w-52 text-sm text-gray-600"
|
||||
className="w-52 text-sm text-brand-secondary"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2">Weekly View</span>
|
||||
@@ -360,12 +388,12 @@ export const CalendarView = () => {
|
||||
/>
|
||||
</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">
|
||||
<div className="mt-1 flex w-52 items-center justify-between border-t border-brand-base py-2 px-1 text-sm text-brand-secondary">
|
||||
<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"
|
||||
showWeekEnds ? "bg-green-500" : "bg-brand-surface-2"
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={showWeekEnds}
|
||||
@@ -374,7 +402,7 @@ export const CalendarView = () => {
|
||||
<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 ${
|
||||
className={`inline-block h-2.5 w-2.5 transform rounded-full bg-brand-surface-2 shadow ring-0 transition duration-200 ease-in-out ${
|
||||
showWeekEnds ? "translate-x-2.5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
@@ -392,7 +420,7 @@ export const CalendarView = () => {
|
||||
{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 ${
|
||||
className={`flex items-center justify-start gap-2 border-brand-base bg-brand-surface-1 p-1.5 text-base font-medium text-brand-secondary ${
|
||||
!isMonthlyView
|
||||
? showWeekEnds
|
||||
? (index + 1) % 7 === 0
|
||||
@@ -424,7 +452,7 @@ export const CalendarView = () => {
|
||||
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 ${
|
||||
className={`group flex flex-col gap-1.5 border-t border-brand-base p-2.5 text-left text-sm font-medium hover:bg-brand-surface-1 ${
|
||||
showWeekEnds
|
||||
? (index + 1) % 7 === 0
|
||||
? ""
|
||||
@@ -444,7 +472,7 @@ export const CalendarView = () => {
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={`w-full cursor-pointer truncate rounded bg-white p-1.5 hover:scale-105 ${
|
||||
className={`w-full cursor-pointer truncate rounded bg-brand-surface-2 p-1.5 hover:scale-105 ${
|
||||
snapshot.isDragging ? "shadow-lg" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -458,6 +486,15 @@ export const CalendarView = () => {
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
<div className="flex items-center justify-center p-1.5 text-sm text-brand-secondary opacity-0 group-hover:opacity-100">
|
||||
<button
|
||||
className="flex items-center justify-center gap-2 text-center"
|
||||
onClick={() => addIssueToDate(date.date)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Add new issue
|
||||
</button>
|
||||
</div>
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
@@ -466,5 +503,9 @@ export const CalendarView = () => {
|
||||
</div>
|
||||
</div>
|
||||
</DragDropContext>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
|
||||
const defaultValues = {
|
||||
palette: "",
|
||||
};
|
||||
|
||||
export const ThemeForm: React.FC<any> = ({ handleFormSubmit, handleClose, status, data }) => {
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = useForm<any>({
|
||||
defaultValues,
|
||||
});
|
||||
const [darkPalette, setDarkPalette] = useState(false);
|
||||
|
||||
const handleUpdateTheme = async (formData: any) => {
|
||||
await handleFormSubmit({ ...formData, darkPalette });
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...data,
|
||||
});
|
||||
}, [data, reset]);
|
||||
|
||||
// --color-bg-base: 25, 27, 27;
|
||||
// --color-bg-surface-1: 31, 32, 35;
|
||||
// --color-bg-surface-2: 39, 42, 45;
|
||||
|
||||
// --color-border: 46, 50, 52;
|
||||
// --color-bg-sidebar: 19, 20, 22;
|
||||
// --color-accent: 60, 133, 217;
|
||||
|
||||
// --color-text-base: 255, 255, 255;
|
||||
// --color-text-secondary: 142, 148, 146;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleUpdateTheme)}>
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-lg font-medium leading-6 text-brand-base">Customize your theme</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="mt-6 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-6">
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
id="bgBase"
|
||||
label="Background"
|
||||
name="bgBase"
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={errors.bgBase}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Background color is required",
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Background color should be hex format",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
id="bgSurface1"
|
||||
label="Background surface 1"
|
||||
name="bgSurface1"
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={errors.bgSurface1}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Background surface 1 color is required",
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Background surface 1 color should be hex format",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
id="bgSurface2"
|
||||
label="Background surface 2"
|
||||
name="bgSurface1"
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={errors.bgSurface1}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Background surface 2 color is required",
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Background surface 2 color should be hex format",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
id="border"
|
||||
label="Border"
|
||||
name="border"
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={errors.border}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Border color is required",
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Border color should be hex format",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
id="sidebar"
|
||||
label="Sidebar"
|
||||
name="sidebar"
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={errors.sidebar}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Sidebar color is required",
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Sidebar color should be hex format",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
id="accent"
|
||||
label="Accent"
|
||||
name="accent"
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={errors.accent}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Accent color is required",
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Accent color should be hex format",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<Input
|
||||
id="textBase"
|
||||
label="Text primary"
|
||||
name="textBase"
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={errors.textBase}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Text primary color is required",
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Text primary color should be hex format",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<Input
|
||||
id="textSecondary"
|
||||
label="Text secondary"
|
||||
name="textSecondary"
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={errors.textSecondary}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Text secondary color is required",
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Text secondary color should be hex format",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
id="palette"
|
||||
label="All colors"
|
||||
name="palette"
|
||||
type="name"
|
||||
placeholder="Enter comma separated hex colors"
|
||||
autoComplete="off"
|
||||
error={errors.palette}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Color values is required",
|
||||
pattern: {
|
||||
value: /^(#(?:[0-9a-fA-F]{3}){1,2},){7}#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Color values should be hex format, separated by commas",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1"
|
||||
onClick={() => setDarkPalette((prevData) => !prevData)}
|
||||
>
|
||||
<span className="text-xs">Dark palette</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`pointer-events-none relative inline-flex h-4 w-7 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent ${
|
||||
darkPalette ? "bg-brand-accent" : "bg-gray-300"
|
||||
} transition-colors duration-300 ease-in-out focus:outline-none`}
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
>
|
||||
<span className="sr-only">Dark palette</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-3 w-3 ${
|
||||
darkPalette ? "translate-x-3" : "translate-x-0"
|
||||
} transform rounded-full bg-brand-surface-2 shadow ring-0 transition duration-300 ease-in-out`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{status
|
||||
? isSubmitting
|
||||
? "Updating Theme..."
|
||||
: "Update Theme"
|
||||
: isSubmitting
|
||||
? "Creating Theme..."
|
||||
: "Set Theme"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// components
|
||||
import { ThemeForm } from "./custom-theme-form";
|
||||
// helpers
|
||||
import { applyTheme } from "helpers/theme.helper";
|
||||
// fetch-keys
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const CustomThemeModal: React.FC<Props> = ({ isOpen, handleClose }) => {
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: any) => {
|
||||
applyTheme(formData.palette, formData.darkPalette);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-brand-surface-1 px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<ThemeForm
|
||||
handleClose={handleClose}
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
status={false}
|
||||
/>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
+22
-11
@@ -19,7 +19,12 @@ 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";
|
||||
import {
|
||||
CYCLE_DETAILS,
|
||||
CYCLE_ISSUES_WITH_PARAMS,
|
||||
MODULE_DETAILS,
|
||||
MODULE_ISSUES_WITH_PARAMS,
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
type FormInput = {
|
||||
issues: string[];
|
||||
@@ -76,8 +81,14 @@ 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));
|
||||
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));
|
||||
}
|
||||
|
||||
handleClose();
|
||||
|
||||
@@ -119,7 +130,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-brand-surface-2 shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
|
||||
<form>
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -128,11 +139,11 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
<Combobox as="div" {...field} multiple>
|
||||
<div className="relative m-1">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-brand-base text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-brand-base placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
@@ -145,7 +156,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
{filteredIssues.length > 0 ? (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
<h2 className="mb-2 px-3 text-xs font-semibold text-brand-base">
|
||||
Select issues to add
|
||||
</h2>
|
||||
)}
|
||||
@@ -158,7 +169,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
value={issue.id}
|
||||
className={({ active }) =>
|
||||
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 ${
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
active ? "bg-gray-900 bg-opacity-5 text-brand-base" : ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
@@ -171,7 +182,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
||||
<span className="flex-shrink-0 text-xs text-brand-secondary">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
{issue.name}
|
||||
@@ -184,9 +195,9 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
|
||||
<LayerDiagonalIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
<h3 className="text-brand-secondary">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-gray-200 px-2 py-1">C</pre>.
|
||||
<pre className="inline rounded bg-brand-surface-2 px-2 py-1">C</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
@@ -8,8 +8,12 @@ import {
|
||||
ChartBarIcon,
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
LinkIcon,
|
||||
PaperClipIcon,
|
||||
PlayIcon,
|
||||
RectangleGroupIcon,
|
||||
Squares2X2Icon,
|
||||
TrashIcon,
|
||||
UserIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "components/icons";
|
||||
@@ -51,31 +55,51 @@ const activityDetails: {
|
||||
},
|
||||
modules: {
|
||||
message: "set the module to",
|
||||
icon: <RectangleGroupIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
icon: <RectangleGroupIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
|
||||
},
|
||||
state: {
|
||||
message: "set the state to",
|
||||
icon: <Squares2X2Icon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
icon: <Squares2X2Icon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
|
||||
},
|
||||
priority: {
|
||||
message: "set the priority to",
|
||||
icon: <ChartBarIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
icon: <ChartBarIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
|
||||
},
|
||||
name: {
|
||||
message: "set the name to",
|
||||
icon: <ChatBubbleBottomCenterTextIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
icon: <ChatBubbleBottomCenterTextIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
|
||||
},
|
||||
description: {
|
||||
message: "updated the description.",
|
||||
icon: <ChatBubbleBottomCenterTextIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
icon: <ChatBubbleBottomCenterTextIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
|
||||
},
|
||||
estimate_point: {
|
||||
message: "set the estimate point to",
|
||||
icon: <PlayIcon className="h-3 w-3 text-gray-500 -rotate-90" aria-hidden="true" />,
|
||||
},
|
||||
target_date: {
|
||||
message: "set the due date to",
|
||||
icon: <CalendarDaysIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
icon: <CalendarDaysIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
|
||||
},
|
||||
parent: {
|
||||
message: "set the parent to",
|
||||
icon: <UserIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
icon: <UserIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
|
||||
},
|
||||
issue: {
|
||||
message: "deleted the issue.",
|
||||
icon: <TrashIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
|
||||
},
|
||||
estimate: {
|
||||
message: "updated the estimate",
|
||||
icon: <PlayIcon className="h-3 w-3 text-gray-500 -rotate-90" aria-hidden="true" />,
|
||||
},
|
||||
link: {
|
||||
message: "updated the link",
|
||||
icon: <LinkIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
},
|
||||
attachment: {
|
||||
message: "updated the attachment",
|
||||
icon: <PaperClipIcon className="h-3 w-3 text-gray-500 " aria-hidden="true" />,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -112,13 +136,20 @@ export const Feeds: React.FC<any> = ({ activities }) => (
|
||||
: "removed the priority";
|
||||
} else if (activity.field === "description") {
|
||||
action = "updated the";
|
||||
} else if (activity.field === "attachment") {
|
||||
action = `${activity.verb} the`;
|
||||
} else if (activity.field === "link") {
|
||||
action = `${activity.verb} 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"
|
||||
activity.field !== "modules" &&
|
||||
activity.field !== "attachment" &&
|
||||
activity.field !== "link" &&
|
||||
activity.field !== "estimate"
|
||||
) {
|
||||
const { workspace_detail, project, issue } = activity;
|
||||
value = (
|
||||
@@ -155,6 +186,14 @@ export const Feeds: React.FC<any> = ({ activities }) => (
|
||||
value = renderShortNumericDateFormat(date as string);
|
||||
} else if (activity.field === "description") {
|
||||
value = "description";
|
||||
} else if (activity.field === "attachment") {
|
||||
value = "attachment";
|
||||
} else if (activity.field === "link") {
|
||||
value = "link";
|
||||
} else if (activity.field === "estimate_point") {
|
||||
value = activity.new_value
|
||||
? activity.new_value + ` Point${parseInt(activity.new_value ?? "", 10) > 1 ? "s" : ""}`
|
||||
: "None";
|
||||
}
|
||||
|
||||
if (activity.field === "comment") {
|
||||
@@ -178,7 +217,7 @@ export const Feeds: React.FC<any> = ({ activities }) => (
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="absolute -bottom-0.5 -right-1 rounded-tl bg-white px-0.5 py-px">
|
||||
<span className="absolute -bottom-0.5 -right-1 rounded-tl bg-brand-surface-2 px-0.5 py-px">
|
||||
<ChatBubbleLeftEllipsisIcon
|
||||
className="h-3.5 w-3.5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
@@ -191,7 +230,7 @@ export const Feeds: React.FC<any> = ({ activities }) => (
|
||||
{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">
|
||||
<p className="mt-0.5 text-xs text-brand-secondary">
|
||||
Commented {timeAgo(activity.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -205,7 +244,7 @@ export const Feeds: React.FC<any> = ({ activities }) => (
|
||||
editable={false}
|
||||
onBlur={() => ({})}
|
||||
noBorder
|
||||
customClassName="text-xs bg-gray-100"
|
||||
customClassName="text-xs bg-brand-surface-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,7 +259,7 @@ export const Feeds: React.FC<any> = ({ activities }) => (
|
||||
<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"
|
||||
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-brand-surface-2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
@@ -229,7 +268,7 @@ export const Feeds: React.FC<any> = ({ activities }) => (
|
||||
<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">
|
||||
<div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-brand-surface-1 ring-white">
|
||||
{activity.field ? (
|
||||
activityDetails[activity.field as keyof typeof activityDetails]?.icon
|
||||
) : activity.actor_detail.avatar &&
|
||||
@@ -253,7 +292,7 @@ export const Feeds: React.FC<any> = ({ activities }) => (
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 py-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="text-xs text-brand-secondary">
|
||||
<span className="text-gray font-medium">
|
||||
{activity.actor_detail.first_name}
|
||||
{activity.actor_detail.is_bot
|
||||
@@ -261,7 +300,7 @@ export const Feeds: React.FC<any> = ({ activities }) => (
|
||||
: " " + activity.actor_detail.last_name}
|
||||
</span>
|
||||
<span> {action} </span>
|
||||
<span className="text-xs font-medium text-gray-900"> {value} </span>
|
||||
<span className="text-xs font-medium text-brand-base"> {value} </span>
|
||||
<span className="whitespace-nowrap">{timeAgo(activity.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,9 +57,9 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-x-2 rounded-full border bg-white px-2 py-1"
|
||||
className="flex items-center gap-x-2 rounded-full border border-brand-base bg-brand-surface-2 px-2 py-1"
|
||||
>
|
||||
<span className="font-medium capitalize text-gray-500">
|
||||
<span className="font-medium capitalize text-brand-secondary">
|
||||
{replaceUnderscoreIfSnakeCase(key)}:
|
||||
</span>
|
||||
{filters[key as keyof IIssueFilterOptions] === null ||
|
||||
@@ -131,7 +131,7 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
|
||||
? "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"
|
||||
: "bg-brand-surface-1 text-gray-700 hover:bg-brand-surface-1"
|
||||
}`}
|
||||
>
|
||||
<span>{getPriorityIcon(priority)}</span>
|
||||
@@ -309,7 +309,19 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="capitalize">{filters[key as keyof typeof filters]}</span>
|
||||
<div className="flex items-center gap-x-1 capitalize">
|
||||
{filters[key as keyof typeof filters]}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
[key]: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -319,6 +331,7 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
type: null,
|
||||
state: null,
|
||||
priority: null,
|
||||
assignees: null,
|
||||
@@ -326,7 +339,7 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
|
||||
created_by: null,
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-x-1 rounded-full border bg-white px-3 py-1.5 text-xs"
|
||||
className="flex items-center gap-x-1 rounded-full border border-brand-base bg-brand-surface-2 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<span className="font-medium">Clear all filters</span>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
+4
-4
@@ -121,16 +121,16 @@ export const GptAssistantModal: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border bg-white p-4 shadow ${
|
||||
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-brand-base bg-brand-surface-2 p-4 shadow ${
|
||||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{((content && content !== "") || htmlContent) && (
|
||||
<div className="text-sm page-block-section">
|
||||
{((content && content !== "") || htmlContent !== "<p></p>") && (
|
||||
<div className="remirror-section text-sm">
|
||||
Content:
|
||||
<RemirrorRichTextEditor
|
||||
value={htmlContent ?? <p>{content}</p>}
|
||||
customClassName="-mx-3 -my-3"
|
||||
customClassName="-m-3"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
editable={false}
|
||||
+19
-14
@@ -17,6 +17,10 @@ import { Input, Spinner, PrimaryButton } from "components/ui";
|
||||
// hooks
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
|
||||
const unsplashEnabled =
|
||||
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" ||
|
||||
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "1";
|
||||
|
||||
const tabOptions = [
|
||||
{
|
||||
key: "unsplash",
|
||||
@@ -56,10 +60,12 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
|
||||
onChange(images[0].urls.regular);
|
||||
}, [value, onChange, images]);
|
||||
|
||||
if (!unsplashEnabled) return null;
|
||||
|
||||
return (
|
||||
<Popover className="relative z-[2]" ref={ref}>
|
||||
<Popover.Button
|
||||
className="rounded-md border border-gray-500 bg-white px-2 py-1 text-xs text-gray-700"
|
||||
className="rounded-md border border-brand-base bg-brand-surface-2 px-2 py-1 text-xs text-gray-700"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
>
|
||||
{label}
|
||||
@@ -73,16 +79,16 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md bg-white shadow-lg">
|
||||
<div className="h-96 w-80 overflow-auto rounded border bg-white p-5 shadow-2xl sm:max-w-2xl md:w-96 lg:w-[40rem]">
|
||||
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md bg-brand-surface-2 shadow-lg">
|
||||
<div className="h-96 w-80 overflow-auto rounded border border-brand-base bg-brand-surface-2 p-5 shadow-2xl sm:max-w-2xl md:w-96 lg:w-[40rem]">
|
||||
<Tab.Group>
|
||||
<Tab.List as="span" className="inline-block rounded bg-gray-200 p-1">
|
||||
<Tab.List as="span" className="inline-block rounded bg-brand-surface-2 p-1">
|
||||
{tabOptions.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
className={({ selected }) =>
|
||||
`rounded py-1 px-4 text-center text-sm outline-none transition-colors ${
|
||||
selected ? "bg-theme text-white" : "text-black"
|
||||
selected ? "bg-brand-accent text-white" : "text-brand-base"
|
||||
}`
|
||||
}
|
||||
>
|
||||
@@ -92,13 +98,7 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
|
||||
</Tab.List>
|
||||
<Tab.Panels className="h-full w-full flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<Tab.Panel className="h-full w-full space-y-4">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setSearchParams(formData.search);
|
||||
}}
|
||||
className="flex gap-x-2 pt-7"
|
||||
>
|
||||
<div className="flex gap-x-2 pt-7">
|
||||
<Input
|
||||
name="search"
|
||||
className="text-sm"
|
||||
@@ -107,10 +107,15 @@ 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="button"
|
||||
onClick={() => setSearchParams(formData.search)}
|
||||
className="bg-indigo-600"
|
||||
size="sm"
|
||||
>
|
||||
Search
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
</div>
|
||||
{images ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{images.map((image) => (
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user