mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
169 Commits
feat/redir
...
f095594b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d09f410f21 | ||
|
|
98e6a1366c | ||
|
|
754142afa2 | ||
|
|
42fceb4dcd | ||
|
|
e949c4e130 | ||
|
|
78c1a64690 | ||
|
|
3d5fcbd4ce | ||
|
|
f9cd1b1352 | ||
|
|
18f66805cb | ||
|
|
382a1343ea | ||
|
|
c05eb9e240 | ||
|
|
40d2990565 | ||
|
|
684df96969 | ||
|
|
1f3fdd5d0a | ||
|
|
6f2a38ad66 | ||
|
|
c127353281 | ||
|
|
705371eaf3 | ||
|
|
fae9d8cdc1 | ||
|
|
b6c0ddac50 | ||
|
|
557e96c68e | ||
|
|
7eae6b4c9e | ||
|
|
77e05a3599 | ||
|
|
2511a284eb | ||
|
|
e3da80755c | ||
|
|
58d1d8f132 | ||
|
|
50060a0bf9 | ||
|
|
bffc6a60e7 | ||
|
|
799cf230b7 | ||
|
|
37442f482b | ||
|
|
c234f2a087 | ||
|
|
857879f5ed | ||
|
|
44f8ba407d | ||
|
|
33db616767 | ||
|
|
c949c4d244 | ||
|
|
c8caa925b1 | ||
|
|
26ba4d71c3 | ||
|
|
022960d7e3 | ||
|
|
dce3c3f008 | ||
|
|
ffc6077e9b | ||
|
|
23d08a2ad1 | ||
|
|
1c98f2dca9 | ||
|
|
053ebc031d | ||
|
|
c9dee593eb | ||
|
|
bc8430220b | ||
|
|
030558c026 | ||
|
|
d8c367c51e | ||
|
|
1afb3ba4d2 | ||
|
|
8252b1ccde | ||
|
|
394f0bf555 | ||
|
|
4ce0ac6ea1 | ||
|
|
cd821a934d | ||
|
|
f80b3f1eb1 | ||
|
|
b6321438ce | ||
|
|
16604dd31b | ||
|
|
11b28048bf | ||
|
|
7e5b26ea82 | ||
|
|
5beb50fa76 | ||
|
|
af2d7d6f75 | ||
|
|
e608b58e70 | ||
|
|
a16514ed11 | ||
|
|
74329a49cc | ||
|
|
def391cb76 | ||
|
|
e526a01295 | ||
|
|
0fb4a87454 | ||
|
|
0bd6e53b44 | ||
|
|
f095594be9 | ||
|
|
2e638b28b6 | ||
|
|
aaffe37fbe | ||
|
|
5c632921f8 | ||
|
|
7c9035182b | ||
|
|
b5325f14aa | ||
|
|
4238f89cec | ||
|
|
a44cddb0fc | ||
|
|
83a0c8163f | ||
|
|
c8c195eab4 | ||
|
|
0acee1fe66 | ||
|
|
e5f6be54e0 | ||
|
|
15a846ce06 | ||
|
|
a01e241523 | ||
|
|
cba62f07c0 | ||
|
|
c1f8766571 | ||
|
|
af13a1b00a | ||
|
|
4439740768 | ||
|
|
98a223e5e1 | ||
|
|
e081395857 | ||
|
|
8b527f27d0 | ||
|
|
ae67dc6074 | ||
|
|
e1e9a5ed96 | ||
|
|
9ccc35d181 | ||
|
|
012486df11 | ||
|
|
1fed5f7846 | ||
|
|
8cbe6c4b36 | ||
|
|
d9642eee82 | ||
|
|
ab273f6be3 | ||
|
|
b1f26f322f | ||
|
|
2e4f936dfa | ||
|
|
ddeafc0695 | ||
|
|
406f02737f | ||
|
|
8e9afd459a | ||
|
|
186b5b5500 | ||
|
|
f2c8bdba34 | ||
|
|
e162c88f03 | ||
|
|
7f5fdb9589 | ||
|
|
e3a114cd69 | ||
|
|
ae1eb9527a | ||
|
|
6da4247400 | ||
|
|
4ce5a450d9 | ||
|
|
06618006c2 | ||
|
|
bb79c9de96 | ||
|
|
09cffd5498 | ||
|
|
5916d6e749 | ||
|
|
c3d520aefd | ||
|
|
d41250c1ce | ||
|
|
27626fb16f | ||
|
|
ab695a309f | ||
|
|
3427652c22 | ||
|
|
559b0cc9c8 | ||
|
|
c49b0d6151 | ||
|
|
d99f85ce05 | ||
|
|
e696a3741c | ||
|
|
d575e8ec6b | ||
|
|
c060f7db30 | ||
|
|
84a0f6f77e | ||
|
|
c6d78b5e6a | ||
|
|
8c707cc544 | ||
|
|
abe021071c | ||
|
|
8d6082183e | ||
|
|
7b52fb885d | ||
|
|
d28bc41fbd | ||
|
|
63075a6a0d | ||
|
|
9fdc56db0f | ||
|
|
290318603d | ||
|
|
dbbd9add99 | ||
|
|
37bb183bf0 | ||
|
|
512b8c104d | ||
|
|
bf865f399f | ||
|
|
6a78948113 | ||
|
|
6e9235e5fe | ||
|
|
f2a68874f1 | ||
|
|
9cb3794dde | ||
|
|
1329145173 | ||
|
|
44d49b5500 | ||
|
|
1a534a3c19 | ||
|
|
d7928f853d | ||
|
|
a0553722c9 | ||
|
|
34f4580b94 | ||
|
|
a1d7a4ea55 | ||
|
|
abaa65b4b7 | ||
|
|
fb165d080e | ||
|
|
083562b24c | ||
|
|
88d3fa549a | ||
|
|
c7deb00f2a | ||
|
|
4f2b106852 | ||
|
|
df96d40cfa | ||
|
|
4884ecd668 | ||
|
|
c16c5b1cb2 | ||
|
|
a69593a9e8 | ||
|
|
a1de3f581f | ||
|
|
443878994a | ||
|
|
86cb23777e | ||
|
|
93c105c495 | ||
|
|
b34cf0c471 | ||
|
|
fd96c54b43 | ||
|
|
993cf3faba | ||
|
|
1bf1b63fff | ||
|
|
336220bd98 | ||
|
|
5b0dc43bae | ||
|
|
e0bec31586 | ||
|
|
563bb12b9b |
60
.env.example
60
.env.example
@@ -1,20 +1,68 @@
|
||||
# Replace with your instance Public IP
|
||||
# Frontend
|
||||
# Extra image domains that need to be added for Next Image
|
||||
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
|
||||
# Google Client ID for Google OAuth
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID=""
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME=""
|
||||
# Github ID for Github OAuth
|
||||
NEXT_PUBLIC_GITHUB_ID=""
|
||||
# Github App Name for GitHub Integration
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME=""
|
||||
# Sentry DSN for error monitoring
|
||||
NEXT_PUBLIC_SENTRY_DSN=""
|
||||
# Enable/Disable OAUTH - default 0 for selfhosted instance
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||
# Enable/Disable sentry
|
||||
NEXT_PUBLIC_ENABLE_SENTRY=0
|
||||
# Enable/Disable session recording
|
||||
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
|
||||
# Enable/Disable event tracking
|
||||
NEXT_PUBLIC_TRACK_EVENTS=0
|
||||
# Slack for Slack Integration
|
||||
NEXT_PUBLIC_SLACK_CLIENT_ID=""
|
||||
|
||||
# Backend
|
||||
|
||||
# Database Settings
|
||||
PGUSER="plane"
|
||||
PGPASSWORD="plane"
|
||||
PGHOST="plane-db"
|
||||
PGDATABASE="plane"
|
||||
|
||||
# Email Settings
|
||||
EMAIL_HOST=""
|
||||
EMAIL_HOST_USER=""
|
||||
EMAIL_HOST_PASSWORD=""
|
||||
EMAIL_PORT=587
|
||||
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
|
||||
EMAIL_USE_TLS="1"
|
||||
|
||||
# AWS Settings
|
||||
AWS_REGION=""
|
||||
AWS_ACCESS_KEY_ID=""
|
||||
AWS_SECRET_ACCESS_KEY=""
|
||||
AWS_S3_BUCKET_NAME=""
|
||||
AWS_ACCESS_KEY_ID="access-key"
|
||||
AWS_SECRET_ACCESS_KEY="secret-key"
|
||||
AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
|
||||
# Changing this requires change in the nginx.conf for uploads if using minio setup
|
||||
AWS_S3_BUCKET_NAME="uploads"
|
||||
# Maximum file upload limit
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
|
||||
# GPT settings
|
||||
OPENAI_API_KEY=""
|
||||
GPT_ENGINE=""
|
||||
GPT_ENGINE=""
|
||||
|
||||
# Github
|
||||
GITHUB_CLIENT_SECRET="" # For fetching release notes
|
||||
|
||||
# Settings related to Docker
|
||||
DOCKERIZED=1
|
||||
# set to 1 If using the pre-configured minio setup
|
||||
USE_MINIO=1
|
||||
|
||||
# Nginx Configuration
|
||||
NGINX_PORT=80
|
||||
|
||||
# Default Creds
|
||||
DEFAULT_EMAIL="captain@plane.so"
|
||||
DEFAULT_PASSWORD="password123"
|
||||
|
||||
# Auto generated and Required that will be generated from setup.sh
|
||||
23
Dockerfile
23
Dockerfile
@@ -1,6 +1,5 @@
|
||||
FROM node:18-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
|
||||
@@ -13,9 +12,7 @@ RUN turbo prune --scope=app --docker
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM node:18-alpine AS installer
|
||||
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
# First install the dependencies (as they change less often)
|
||||
@@ -44,10 +41,12 @@ FROM python:3.11.1-alpine3.17 AS backend
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ENV DJANGO_SETTINGS_MODULE plane.settings.production
|
||||
ENV DOCKERIZED 1
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
RUN apk --update --no-cache add \
|
||||
RUN apk --no-cache add \
|
||||
"libpq~=15" \
|
||||
"libxslt~=1.1" \
|
||||
"nodejs-current~=19" \
|
||||
@@ -59,8 +58,8 @@ RUN apk --update --no-cache add \
|
||||
|
||||
COPY apiserver/requirements.txt ./
|
||||
COPY apiserver/requirements ./requirements
|
||||
RUN apk add libffi-dev
|
||||
RUN apk --update --no-cache --virtual .build-deps add \
|
||||
RUN apk add --no-cache libffi-dev
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
"bash~=5.2" \
|
||||
"g++~=12.2" \
|
||||
"gcc~=12.2" \
|
||||
@@ -81,18 +80,13 @@ COPY apiserver/plane plane/
|
||||
COPY apiserver/templates templates/
|
||||
|
||||
COPY apiserver/gunicorn.config.py ./
|
||||
RUN apk --update --no-cache add "bash~=5.2"
|
||||
RUN apk --no-cache add "bash~=5.2"
|
||||
COPY apiserver/bin ./bin/
|
||||
|
||||
RUN chmod +x ./bin/takeoff ./bin/worker
|
||||
RUN chmod -R 777 /code
|
||||
|
||||
# Expose container port and run entry point script
|
||||
EXPOSE 8000
|
||||
EXPOSE 3000
|
||||
EXPOSE 80
|
||||
|
||||
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -126,9 +120,6 @@ COPY start.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
RUN chmod +x /usr/local/bin/start.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["supervisord","-c","/code/supervisor.conf"]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
82
README.md
82
README.md
@@ -15,18 +15,25 @@
|
||||
</a>
|
||||
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
|
||||
</p>
|
||||
<br />
|
||||
|
||||
<p>
|
||||
<a href="https://app.plane.so/" target="_blank">
|
||||
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
|
||||
<img
|
||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680599798/Plane/plane_1_1_tnb32j.png"
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Screen.png?updatedAt=1684942001069"
|
||||
alt="Plane Screens"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
<a href="https://app.plane.so/#gh-dark-mode-only" target="_blank">
|
||||
<img
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Screens_Dark_Mode.png?updatedAt=1684942388044"
|
||||
alt="Plane Screens"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Meet Plane. An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘♀️.
|
||||
Meet [Plane](https://plane.so). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘♀️.
|
||||
|
||||
|
||||
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
|
||||
@@ -38,22 +45,18 @@ The easiest way to get started with Plane is by creating a [Plane Cloud](https:/
|
||||
|
||||
### Docker Compose Setup
|
||||
|
||||
- Clone the Repository
|
||||
- Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/makeplane/plane
|
||||
```
|
||||
|
||||
- Change Directory
|
||||
|
||||
```bash
|
||||
cd plane
|
||||
chmod +x setup.sh
|
||||
```
|
||||
|
||||
- Run setup.sh
|
||||
|
||||
```bash
|
||||
./setup.sh localhost
|
||||
./setup.sh http://localhost
|
||||
```
|
||||
|
||||
> If running in a cloud env replace localhost with public facing IP address of the VM
|
||||
@@ -69,7 +72,7 @@ set +a
|
||||
- Run Docker compose up
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose-hub.yml up
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
<strong>You can use the default email and password for your first login `captain@plane.so` and `password123`.</strong>
|
||||
@@ -89,41 +92,62 @@ docker-compose -f docker-compose-hub.yml up
|
||||
## 📸 Screenshots
|
||||
|
||||
<p>
|
||||
<a href="https://app.plane.so/" target="_blank">
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601719/Plane/plane_2_iqao52.png"
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Views_Dark_Mode.png?updatedAt=1684943050275"
|
||||
alt="Plane Views"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Issue_Detail_Dark_Mode.png?updatedAt=1684943050202"
|
||||
alt="Plane Issue Details"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://app.plane.so/" target="_blank">
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680604273/Plane/plane_5_1_nwsl3a.png"
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Cycles___Modules_Dark_Mode.png?updatedAt=1684943050281"
|
||||
alt="Plane Cycles and Modules"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://app.plane.so/" target="_blank">
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601713/Plane/plane_4_cqm0g8.png"
|
||||
alt="Plane Quick Lists"
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Analytics_Dark_Mode.png?updatedAt=1684944596824"
|
||||
alt="Plane Analytics"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://app.plane.so/" target="_blank">
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601712/Plane/plane_3_1_cu4fsc.png"
|
||||
alt="Plane Command K"
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Pages_Dark_Mode.png?updatedAt=1684943050202"
|
||||
alt="Plane Pages"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Commad_K_Dark_Mode.png?updatedAt=1684943050312"
|
||||
alt="Plane Command Menu"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
|
||||
## 📚Documentation
|
||||
|
||||
@@ -135,7 +159,7 @@ To see how to Contribute, visit [here](https://github.com/makeplane/plane/blob/m
|
||||
|
||||
The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects.
|
||||
|
||||
To chat with other community members you can join the [Plane Discord](https://discord.com/invite/q9HKAdau).
|
||||
To chat with other community members you can join the [Plane Discord](https://discord.com/invite/A92xrEGCge).
|
||||
|
||||
Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community channels.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
RUN apk --update --no-cache add \
|
||||
RUN apk --no-cache add \
|
||||
"libpq~=15" \
|
||||
"libxslt~=1.1" \
|
||||
"nodejs-current~=19" \
|
||||
@@ -15,8 +15,8 @@ RUN apk --update --no-cache add \
|
||||
|
||||
COPY requirements.txt ./
|
||||
COPY requirements ./requirements
|
||||
RUN apk add libffi-dev
|
||||
RUN apk --update --no-cache --virtual .build-deps add \
|
||||
RUN apk add --no-cache libffi-dev
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
"bash~=5.2" \
|
||||
"g++~=12.2" \
|
||||
"gcc~=12.2" \
|
||||
@@ -46,7 +46,7 @@ COPY templates templates/
|
||||
|
||||
COPY gunicorn.config.py ./
|
||||
USER root
|
||||
RUN apk --update --no-cache add "bash~=5.2"
|
||||
RUN apk --no-cache add "bash~=5.2"
|
||||
COPY ./bin ./bin/
|
||||
|
||||
RUN chmod +x ./bin/takeoff ./bin/worker
|
||||
|
||||
@@ -204,7 +204,21 @@ def update_integration_verified():
|
||||
Integration.objects.bulk_update(
|
||||
updated_integrations, ["verified"], batch_size=10
|
||||
)
|
||||
print("Sucess")
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
|
||||
def update_start_date():
|
||||
try:
|
||||
issues = Issue.objects.filter(state__group__in=["started", "completed"])
|
||||
updated_issues = []
|
||||
for issue in issues:
|
||||
issue.start_date = issue.created_at.date()
|
||||
updated_issues.append(issue)
|
||||
Issue.objects.bulk_update(updated_issues, ["start_date"], batch_size=500)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Failed")
|
||||
|
||||
@@ -70,3 +70,5 @@ from .importer import ImporterSerializer
|
||||
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
|
||||
|
||||
from .estimate import EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer
|
||||
|
||||
from .analytic import AnalyticViewSerializer
|
||||
|
||||
30
apiserver/plane/api/serializers/analytic.py
Normal file
30
apiserver/plane/api/serializers/analytic.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import AnalyticView
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class AnalyticViewSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = AnalyticView
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"query",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
query_params = validated_data.get("query_dict", {})
|
||||
if bool(query_params):
|
||||
validated_data["query"] = issue_filters(query_params, "POST")
|
||||
else:
|
||||
validated_data["query"] = dict()
|
||||
return AnalyticView.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
query_params = validated_data.get("query_data", {})
|
||||
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)
|
||||
@@ -19,10 +19,32 @@ class CycleSerializer(BaseSerializer):
|
||||
started_issues = serializers.IntegerField(read_only=True)
|
||||
unstarted_issues = serializers.IntegerField(read_only=True)
|
||||
backlog_issues = serializers.IntegerField(read_only=True)
|
||||
assignees = serializers.SerializerMethodField(read_only=True)
|
||||
total_estimates = serializers.IntegerField(read_only=True)
|
||||
completed_estimates = serializers.IntegerField(read_only=True)
|
||||
started_estimates = serializers.IntegerField(read_only=True)
|
||||
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
|
||||
def get_assignees(self, obj):
|
||||
members = [
|
||||
{
|
||||
"avatar": assignee.avatar,
|
||||
"first_name": assignee.first_name,
|
||||
"id": assignee.id,
|
||||
}
|
||||
for issue_cycle in obj.issue_cycle.all()
|
||||
for assignee in issue_cycle.issue.assignees.all()
|
||||
]
|
||||
# Use a set comprehension to return only the unique objects
|
||||
unique_objects = {frozenset(item.items()) for item in members}
|
||||
|
||||
# Convert the set back to a list of dictionaries
|
||||
unique_list = [dict(item) for item in unique_objects]
|
||||
|
||||
return unique_list
|
||||
|
||||
class Meta:
|
||||
model = Cycle
|
||||
fields = "__all__"
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
from .base import BaseSerializer
|
||||
|
||||
from plane.db.models import Estimate, EstimatePoint
|
||||
from plane.api.serializers import WorkspaceLiteSerializer, ProjectLiteSerializer
|
||||
|
||||
|
||||
class EstimateSerializer(BaseSerializer):
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
|
||||
class Meta:
|
||||
model = Estimate
|
||||
fields = "__all__"
|
||||
@@ -27,6 +31,8 @@ class EstimatePointSerializer(BaseSerializer):
|
||||
|
||||
class EstimateReadSerializer(BaseSerializer):
|
||||
points = EstimatePointSerializer(read_only=True, many=True)
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
|
||||
class Meta:
|
||||
model = Estimate
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
from .base import BaseSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
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)
|
||||
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Importer
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
@@ -251,6 +254,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
instance.updated_at = timezone.now()
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
|
||||
@@ -82,6 +82,9 @@ class ProjectDetailSerializer(BaseSerializer):
|
||||
default_assignee = UserLiteSerializer(read_only=True)
|
||||
project_lead = UserLiteSerializer(read_only=True)
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
total_cycles = serializers.IntegerField(read_only=True)
|
||||
total_modules = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
|
||||
@@ -25,6 +25,10 @@ class UserSerializer(BaseSerializer):
|
||||
]
|
||||
extra_kwargs = {"password": {"write_only": True}}
|
||||
|
||||
# If the user has already filled first name or last name then he is onboarded
|
||||
def get_is_onboarded(self, obj):
|
||||
return bool(obj.first_name) or bool(obj.last_name)
|
||||
|
||||
|
||||
class UserLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
|
||||
@@ -44,6 +44,8 @@ class WorkSpaceMemberSerializer(BaseSerializer):
|
||||
|
||||
class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
||||
workspace = WorkSpaceSerializer(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
|
||||
|
||||
class Meta:
|
||||
model = WorkspaceMemberInvite
|
||||
|
||||
@@ -96,12 +96,8 @@ from plane.api.views import (
|
||||
CycleViewSet,
|
||||
CycleIssueViewSet,
|
||||
CycleDateCheckEndpoint,
|
||||
CurrentUpcomingCyclesEndpoint,
|
||||
CompletedCyclesEndpoint,
|
||||
CycleFavoriteViewSet,
|
||||
DraftCyclesEndpoint,
|
||||
TransferCycleIssueEndpoint,
|
||||
InCompleteCyclesEndpoint,
|
||||
## End Cycles
|
||||
# Modules
|
||||
ModuleViewSet,
|
||||
@@ -115,10 +111,6 @@ from plane.api.views import (
|
||||
PageBlockViewSet,
|
||||
PageFavoriteViewSet,
|
||||
CreateIssueFromPageBlockEndpoint,
|
||||
RecentPagesEndpoint,
|
||||
FavoritePagesEndpoint,
|
||||
MyPagesEndpoint,
|
||||
CreatedbyOtherPagesEndpoint,
|
||||
## End Pages
|
||||
# Api Tokens
|
||||
ApiTokenEndpoint,
|
||||
@@ -148,6 +140,13 @@ from plane.api.views import (
|
||||
# Release Notes
|
||||
ReleaseNotesEndpoint,
|
||||
## End Release Notes
|
||||
# Analytics
|
||||
AnalyticsEndpoint,
|
||||
AnalyticViewViewset,
|
||||
SavedAnalyticEndpoint,
|
||||
ExportAnalyticsEndpoint,
|
||||
DefaultAnalyticsEndpoint,
|
||||
## End Analytics
|
||||
)
|
||||
|
||||
|
||||
@@ -171,7 +170,7 @@ urlpatterns = [
|
||||
),
|
||||
# Password Manipulation
|
||||
path(
|
||||
"password-reset/<uidb64>/<token>/",
|
||||
"reset-password/<uidb64>/<token>/",
|
||||
ResetPasswordEndpoint.as_view(),
|
||||
name="password-reset",
|
||||
),
|
||||
@@ -308,7 +307,6 @@ urlpatterns = [
|
||||
"workspaces/<str:slug>/members/<uuid:pk>/",
|
||||
WorkSpaceMemberViewSet.as_view(
|
||||
{
|
||||
"put": "update",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
"get": "retrieve",
|
||||
@@ -418,7 +416,6 @@ urlpatterns = [
|
||||
ProjectMemberViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"put": "update",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
@@ -659,21 +656,6 @@ urlpatterns = [
|
||||
CycleDateCheckEndpoint.as_view(),
|
||||
name="project-cycle",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/current-upcoming-cycles/",
|
||||
CurrentUpcomingCyclesEndpoint.as_view(),
|
||||
name="project-cycle-upcoming",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/completed-cycles/",
|
||||
CompletedCyclesEndpoint.as_view(),
|
||||
name="project-cycle-completed",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/draft-cycles/",
|
||||
DraftCyclesEndpoint.as_view(),
|
||||
name="project-cycle-draft",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-cycles/",
|
||||
CycleFavoriteViewSet.as_view(
|
||||
@@ -698,11 +680,6 @@ urlpatterns = [
|
||||
TransferCycleIssueEndpoint.as_view(),
|
||||
name="transfer-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/incomplete-cycles/",
|
||||
InCompleteCyclesEndpoint.as_view(),
|
||||
name="transfer-issues",
|
||||
),
|
||||
## End Cycles
|
||||
# Issue
|
||||
path(
|
||||
@@ -1072,26 +1049,6 @@ urlpatterns = [
|
||||
CreateIssueFromPageBlockEndpoint.as_view(),
|
||||
name="page-block-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/recent-pages/",
|
||||
RecentPagesEndpoint.as_view(),
|
||||
name="recent-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/favorite-pages/",
|
||||
FavoritePagesEndpoint.as_view(),
|
||||
name="recent-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/my-pages/",
|
||||
MyPagesEndpoint.as_view(),
|
||||
name="user-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/created-by-other-pages/",
|
||||
CreatedbyOtherPagesEndpoint.as_view(),
|
||||
name="created-by-other-pages",
|
||||
),
|
||||
## End Pages
|
||||
# API Tokens
|
||||
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),
|
||||
@@ -1285,4 +1242,38 @@ urlpatterns = [
|
||||
name="release-notes",
|
||||
),
|
||||
## End Release Notes
|
||||
# Analytics
|
||||
path(
|
||||
"workspaces/<str:slug>/analytics/",
|
||||
AnalyticsEndpoint.as_view(),
|
||||
name="plane-analytics",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/analytic-view/",
|
||||
AnalyticViewViewset.as_view({"get": "list", "post": "create"}),
|
||||
name="analytic-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/analytic-view/<uuid:pk>/",
|
||||
AnalyticViewViewset.as_view(
|
||||
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
|
||||
),
|
||||
name="analytic-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/saved-analytic-view/<uuid:analytic_id>/",
|
||||
SavedAnalyticEndpoint.as_view(),
|
||||
name="saved-analytic-view",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/export-analytics/",
|
||||
ExportAnalyticsEndpoint.as_view(),
|
||||
name="export-analytics",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/default-analytics/",
|
||||
DefaultAnalyticsEndpoint.as_view(),
|
||||
name="default-analytics",
|
||||
),
|
||||
## End Analytics
|
||||
]
|
||||
|
||||
@@ -49,12 +49,8 @@ from .cycle import (
|
||||
CycleViewSet,
|
||||
CycleIssueViewSet,
|
||||
CycleDateCheckEndpoint,
|
||||
CurrentUpcomingCyclesEndpoint,
|
||||
CompletedCyclesEndpoint,
|
||||
CycleFavoriteViewSet,
|
||||
DraftCyclesEndpoint,
|
||||
TransferCycleIssueEndpoint,
|
||||
InCompleteCyclesEndpoint,
|
||||
)
|
||||
from .asset import FileAssetEndpoint, UserAssetsEndpoint
|
||||
from .issue import (
|
||||
@@ -122,10 +118,6 @@ from .page import (
|
||||
PageBlockViewSet,
|
||||
PageFavoriteViewSet,
|
||||
CreateIssueFromPageBlockEndpoint,
|
||||
RecentPagesEndpoint,
|
||||
FavoritePagesEndpoint,
|
||||
MyPagesEndpoint,
|
||||
CreatedbyOtherPagesEndpoint,
|
||||
)
|
||||
|
||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||
@@ -140,3 +132,11 @@ from .estimate import (
|
||||
|
||||
|
||||
from .release import ReleaseNotesEndpoint
|
||||
|
||||
from .analytic import (
|
||||
AnalyticsEndpoint,
|
||||
AnalyticViewViewset,
|
||||
SavedAnalyticEndpoint,
|
||||
ExportAnalyticsEndpoint,
|
||||
DefaultAnalyticsEndpoint,
|
||||
)
|
||||
|
||||
295
apiserver/plane/api/views/analytic.py
Normal file
295
apiserver/plane/api/views/analytic.py
Normal file
@@ -0,0 +1,295 @@
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Count,
|
||||
Sum,
|
||||
F,
|
||||
)
|
||||
from django.db.models.functions import ExtractMonth
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.api.views import BaseAPIView, BaseViewSet
|
||||
from plane.api.permissions import WorkSpaceAdminPermission
|
||||
from plane.db.models import Issue, AnalyticView, Workspace, State, Label
|
||||
from plane.api.serializers import AnalyticViewSerializer
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.bgtasks.analytic_plot_export import analytic_export_task
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class AnalyticsEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
x_axis = request.GET.get("x_axis", False)
|
||||
y_axis = request.GET.get("y_axis", False)
|
||||
|
||||
if not x_axis or not y_axis:
|
||||
return Response(
|
||||
{"error": "x-axis and y-axis dimensions are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
segment = request.GET.get("segment", False)
|
||||
filters = issue_filters(request.GET, "GET")
|
||||
|
||||
queryset = Issue.objects.filter(workspace__slug=slug, **filters)
|
||||
|
||||
total_issues = queryset.count()
|
||||
distribution = build_graph_plot(
|
||||
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
|
||||
)
|
||||
|
||||
colors = dict()
|
||||
if x_axis in ["state__name", "state__group"] or segment in [
|
||||
"state__name",
|
||||
"state__group",
|
||||
]:
|
||||
if x_axis in ["state__name", "state__group"]:
|
||||
key = "name" if x_axis == "state__name" else "group"
|
||||
else:
|
||||
key = "name" if segment == "state__name" else "group"
|
||||
|
||||
colors = (
|
||||
State.objects.filter(
|
||||
workspace__slug=slug, project_id__in=filters.get("project__in")
|
||||
).values(key, "color")
|
||||
if filters.get("project__in", False)
|
||||
else State.objects.filter(workspace__slug=slug).values(key, "color")
|
||||
)
|
||||
|
||||
if x_axis in ["labels__name"] or segment in ["labels__name"]:
|
||||
colors = (
|
||||
Label.objects.filter(
|
||||
workspace__slug=slug, project_id__in=filters.get("project__in")
|
||||
).values("name", "color")
|
||||
if filters.get("project__in", False)
|
||||
else Label.objects.filter(workspace__slug=slug).values(
|
||||
"name", "color"
|
||||
)
|
||||
)
|
||||
|
||||
assignee_details = {}
|
||||
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
|
||||
assignee_details = (
|
||||
Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||
.order_by("assignees__id")
|
||||
.distinct("assignees__id")
|
||||
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
|
||||
)
|
||||
|
||||
|
||||
return Response(
|
||||
{
|
||||
"total": total_issues,
|
||||
"distribution": distribution,
|
||||
"extras": {"colors": colors, "assignee_details": assignee_details},
|
||||
},
|
||||
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 AnalyticViewViewset(BaseViewSet):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
model = AnalyticView
|
||||
serializer_class = AnalyticViewSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
|
||||
serializer.save(workspace_id=workspace.id)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super().get_queryset().filter(workspace__slug=self.kwargs.get("slug"))
|
||||
)
|
||||
|
||||
|
||||
class SavedAnalyticEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, analytic_id):
|
||||
try:
|
||||
analytic_view = AnalyticView.objects.get(
|
||||
pk=analytic_id, workspace__slug=slug
|
||||
)
|
||||
|
||||
filter = analytic_view.query
|
||||
queryset = Issue.objects.filter(**filter)
|
||||
|
||||
x_axis = analytic_view.query_dict.get("x_axis", False)
|
||||
y_axis = analytic_view.query_dict.get("y_axis", False)
|
||||
|
||||
if not x_axis or not y_axis:
|
||||
return Response(
|
||||
{"error": "x-axis and y-axis dimensions are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
segment = request.GET.get("segment", False)
|
||||
distribution = build_graph_plot(
|
||||
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
|
||||
)
|
||||
total_issues = queryset.count()
|
||||
return Response(
|
||||
{"total": total_issues, "distribution": distribution},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except AnalyticView.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Analytic View Does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ExportAnalyticsEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug):
|
||||
try:
|
||||
x_axis = request.data.get("x_axis", False)
|
||||
y_axis = request.data.get("y_axis", False)
|
||||
|
||||
if not x_axis or not y_axis:
|
||||
return Response(
|
||||
{"error": "x-axis and y-axis dimensions are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
analytic_export_task.delay(
|
||||
email=request.user.email, data=request.data, slug=slug
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": f"Once the export is ready it will be emailed to you at {str(request.user.email)}"
|
||||
},
|
||||
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 DefaultAnalyticsEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
filters = issue_filters(request.GET, "GET")
|
||||
|
||||
queryset = Issue.objects.filter(workspace__slug=slug, **filters)
|
||||
|
||||
total_issues = queryset.count()
|
||||
|
||||
total_issues_classified = (
|
||||
queryset.annotate(state_group=F("state__group"))
|
||||
.values("state_group")
|
||||
.annotate(state_count=Count("state_group"))
|
||||
.order_by("state_group")
|
||||
)
|
||||
|
||||
open_issues = queryset.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"]
|
||||
).count()
|
||||
|
||||
open_issues_classified = (
|
||||
queryset.filter(state__group__in=["backlog", "unstarted", "started"])
|
||||
.annotate(state_group=F("state__group"))
|
||||
.values("state_group")
|
||||
.annotate(state_count=Count("state_group"))
|
||||
.order_by("state_group")
|
||||
)
|
||||
|
||||
issue_completed_month_wise = (
|
||||
queryset.filter(completed_at__isnull=False)
|
||||
.annotate(month=ExtractMonth("completed_at"))
|
||||
.values("month")
|
||||
.annotate(count=Count("*"))
|
||||
.order_by("month")
|
||||
)
|
||||
most_issue_created_user = (
|
||||
queryset.exclude(created_by=None)
|
||||
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__email")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)[:5]
|
||||
|
||||
most_issue_closed_user = (
|
||||
queryset.filter(completed_at__isnull=False, assignees__isnull=False)
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)[:5]
|
||||
|
||||
pending_issue_user = (
|
||||
queryset.filter(completed_at__isnull=True)
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)
|
||||
|
||||
open_estimate_sum = (
|
||||
queryset.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"]
|
||||
).aggregate(open_estimate_sum=Sum("estimate_point"))
|
||||
)["open_estimate_sum"]
|
||||
print(open_estimate_sum)
|
||||
|
||||
total_estimate_sum = queryset.aggregate(
|
||||
total_estimate_sum=Sum("estimate_point")
|
||||
)["total_estimate_sum"]
|
||||
|
||||
return Response(
|
||||
{
|
||||
"total_issues": total_issues,
|
||||
"total_issues_classified": total_issues_classified,
|
||||
"open_issues": open_issues,
|
||||
"open_issues_classified": open_issues_classified,
|
||||
"issue_completed_month_wise": issue_completed_month_wise,
|
||||
"most_issue_created_user": most_issue_created_user,
|
||||
"most_issue_closed_user": most_issue_closed_user,
|
||||
"pending_issue_user": pending_issue_user,
|
||||
"open_estimate_sum": open_estimate_sum,
|
||||
"total_estimate_sum": total_estimate_sum,
|
||||
},
|
||||
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,
|
||||
)
|
||||
@@ -3,10 +3,10 @@ from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
from django.conf import settings
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models import FileAsset
|
||||
from plane.db.models import FileAsset, Workspace
|
||||
from plane.api.serializers import FileAssetSerializer
|
||||
|
||||
|
||||
@@ -27,15 +27,13 @@ class FileAssetEndpoint(BaseAPIView):
|
||||
try:
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
if request.user.last_workspace_id is None:
|
||||
return Response(
|
||||
{"error": "Workspace id is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer.save(workspace_id=request.user.last_workspace_id)
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer.save(workspace_id=workspace.id)
|
||||
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(
|
||||
|
||||
@@ -3,7 +3,17 @@ import json
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef, Count, Prefetch
|
||||
from django.db.models import (
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Exists,
|
||||
OuterRef,
|
||||
Count,
|
||||
Prefetch,
|
||||
Sum,
|
||||
)
|
||||
from django.core import serializers
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
@@ -24,6 +34,7 @@ from plane.api.serializers import (
|
||||
)
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
Issue,
|
||||
@@ -118,10 +129,98 @@ class CycleViewSet(BaseViewSet):
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "name")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
queryset = self.get_queryset()
|
||||
cycle_view = request.GET.get("cycle_view", False)
|
||||
if not cycle_view:
|
||||
return Response(
|
||||
{"error": "Cycle View parameter is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# All Cycles
|
||||
if cycle_view == "all":
|
||||
return Response(
|
||||
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
# Current Cycle
|
||||
if cycle_view == "current":
|
||||
queryset = queryset.filter(
|
||||
start_date__lte=timezone.now(),
|
||||
end_date__gte=timezone.now(),
|
||||
)
|
||||
return Response(
|
||||
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
# Upcoming Cycles
|
||||
if cycle_view == "upcoming":
|
||||
queryset = queryset.filter(start_date__gt=timezone.now())
|
||||
return Response(
|
||||
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
# Completed Cycles
|
||||
if cycle_view == "completed":
|
||||
queryset = queryset.filter(end_date__lt=timezone.now())
|
||||
return Response(
|
||||
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
# Draft Cycles
|
||||
if cycle_view == "draft":
|
||||
queryset = queryset.filter(
|
||||
end_date=None,
|
||||
start_date=None,
|
||||
)
|
||||
return Response(
|
||||
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
# Incomplete Cycles
|
||||
if cycle_view == "incomplete":
|
||||
queryset = queryset.filter(
|
||||
Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True),
|
||||
)
|
||||
return Response(
|
||||
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"error": "No matching view found"}, 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 create(self, request, slug, project_id):
|
||||
try:
|
||||
if (
|
||||
@@ -413,7 +512,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
try:
|
||||
start_date = request.data.get("start_date", False)
|
||||
end_date = request.data.get("end_date", False)
|
||||
|
||||
cycle_id = request.data.get("cycle_id")
|
||||
if not start_date or not end_date:
|
||||
return Response(
|
||||
{"error": "Start date and end date both are required"},
|
||||
@@ -421,12 +520,14 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
cycles = Cycle.objects.filter(
|
||||
Q(start_date__lte=start_date, end_date__gte=start_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,
|
||||
)
|
||||
Q(workspace__slug=slug)
|
||||
& Q(project_id=project_id)
|
||||
& (
|
||||
Q(start_date__lte=start_date, end_date__gte=start_date)
|
||||
| Q(start_date__lte=end_date, end_date__gte=end_date)
|
||||
| Q(start_date__gte=start_date, end_date__lte=end_date)
|
||||
)
|
||||
).exclude(pk=cycle_id)
|
||||
|
||||
if cycles.exists():
|
||||
return Response(
|
||||
@@ -446,268 +547,6 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
|
||||
class CurrentUpcomingCyclesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = CycleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
cycle_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
current_cycle = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
start_date__lte=timezone.now(),
|
||||
end_date__gte=timezone.now(),
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(total_issues=Count("issue_cycle"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
upcoming_cycle = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
start_date__gt=timezone.now(),
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(total_issues=Count("issue_cycle"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"current_cycle": CycleSerializer(current_cycle, many=True).data,
|
||||
"upcoming_cycle": CycleSerializer(upcoming_cycle, many=True).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class CompletedCyclesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = CycleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
cycle_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
completed_cycles = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
end_date__lt=timezone.now(),
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(total_issues=Count("issue_cycle"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"completed_cycles": CycleSerializer(
|
||||
completed_cycles, many=True
|
||||
).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class DraftCyclesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = CycleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
cycle_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
draft_cycles = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
end_date=None,
|
||||
start_date=None,
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(total_issues=Count("issue_cycle"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"draft_cycles": CycleSerializer(draft_cycles, many=True).data},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class CycleFavoriteViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
@@ -832,22 +671,3 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class InCompleteCyclesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
cycles = Cycle.objects.filter(
|
||||
Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
).select_related("owned_by")
|
||||
|
||||
serializer = CycleSerializer(cycles, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -53,11 +53,11 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
try:
|
||||
estimates = Estimate.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).prefetch_related("points")
|
||||
).prefetch_related("points").select_related("workspace", "project")
|
||||
serializer = EstimateReadSerializer(estimates, many=True)
|
||||
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"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -211,7 +211,7 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
|
||||
try:
|
||||
EstimatePoint.objects.bulk_update(
|
||||
updated_estimate_points, ["value"], batch_size=10
|
||||
updated_estimate_points, ["value"], batch_size=10,
|
||||
)
|
||||
except IntegrityError as e:
|
||||
return Response(
|
||||
|
||||
@@ -363,6 +363,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
start_date=issue_data.get("start_date", None),
|
||||
target_date=issue_data.get("target_date", None),
|
||||
priority=issue_data.get("priority", None),
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -400,7 +401,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for label_id in labels_list
|
||||
]
|
||||
@@ -420,7 +420,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for assignee_id in assignees_list
|
||||
]
|
||||
@@ -439,6 +438,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
workspace_id=project.workspace_id,
|
||||
comment=f"{request.user.email} importer the issue from {service}",
|
||||
verb="created",
|
||||
created_by=request.user,
|
||||
)
|
||||
for issue in issues
|
||||
],
|
||||
@@ -457,7 +457,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for comment in comments_list
|
||||
]
|
||||
@@ -474,7 +473,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for issue, issue_data in zip(issues, issues_data)
|
||||
]
|
||||
@@ -512,7 +510,6 @@ class BulkImportModulesEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for module in modules_data
|
||||
],
|
||||
@@ -536,7 +533,6 @@ class BulkImportModulesEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for module, module_data in zip(modules, modules_data)
|
||||
],
|
||||
@@ -554,7 +550,6 @@ class BulkImportModulesEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for issue in module_issues_list
|
||||
]
|
||||
|
||||
@@ -4,11 +4,23 @@ import random
|
||||
from itertools import chain
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Prefetch, OuterRef, Func, F, Q, Count
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Count,
|
||||
Case,
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
)
|
||||
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
|
||||
from django.conf import settings
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -144,9 +156,13 @@ class IssueViewSet(BaseViewSet):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
show_sub_issues = request.GET.get("show_sub_issues", "true")
|
||||
|
||||
# Custom ordering for priority
|
||||
priority_order = ["urgent", "high", "medium", "low", None]
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.order_by(request.GET.get("order_by", "created_at"))
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__id"))
|
||||
.annotate(module_id=F("issue_module__id"))
|
||||
@@ -166,6 +182,19 @@ class IssueViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
|
||||
if order_by_param == "priority":
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
priority_order=Case(
|
||||
*[
|
||||
When(priority=p, then=Value(i))
|
||||
for i, p in enumerate(priority_order)
|
||||
],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
issue_queryset = (
|
||||
issue_queryset
|
||||
if show_sub_issues == "true"
|
||||
|
||||
@@ -125,7 +125,57 @@ class PageViewSet(BaseViewSet):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
queryset = self.get_queryset()
|
||||
page_view = request.GET.get("page_view", False)
|
||||
|
||||
if not page_view:
|
||||
return Response({"error": "Page View parameter is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# All Pages
|
||||
if page_view == "all":
|
||||
return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK)
|
||||
|
||||
# Recent pages
|
||||
if page_view == "recent":
|
||||
current_time = date.today()
|
||||
day_before = current_time - timedelta(days=1)
|
||||
todays_pages = queryset.filter(updated_at__date=date.today())
|
||||
yesterdays_pages = queryset.filter(updated_at__date=day_before)
|
||||
earlier_this_week = queryset.filter( updated_at__date__range=(
|
||||
(timezone.now() - timedelta(days=7)),
|
||||
(timezone.now() - timedelta(days=2)),
|
||||
))
|
||||
return Response(
|
||||
{
|
||||
"today": PageSerializer(todays_pages, many=True).data,
|
||||
"yesterday": PageSerializer(yesterdays_pages, many=True).data,
|
||||
"earlier_this_week": PageSerializer(earlier_this_week, many=True).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
# Favorite Pages
|
||||
if page_view == "favorite":
|
||||
queryset = queryset.filter(is_favorite=True)
|
||||
return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK)
|
||||
|
||||
# My pages
|
||||
if page_view == "created_by_me":
|
||||
queryset = queryset.filter(owned_by=request.user)
|
||||
return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK)
|
||||
|
||||
# Created by other Pages
|
||||
if page_view == "created_by_other":
|
||||
queryset = queryset.filter(~Q(owned_by=request.user), access=0)
|
||||
return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK)
|
||||
|
||||
return Response({"error": "No matching view found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class PageBlockViewSet(BaseViewSet):
|
||||
serializer_class = PageBlockSerializer
|
||||
@@ -269,249 +319,3 @@ class CreateIssueFromPageBlockEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class RecentPagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = PageFavorite.objects.filter(
|
||||
user=request.user,
|
||||
page_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
current_time = date.today()
|
||||
day_before = current_time - timedelta(days=1)
|
||||
|
||||
todays_pages = (
|
||||
Page.objects.filter(
|
||||
updated_at__date=date.today(),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.filter(project__project_projectmember__member=request.user)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "-updated_at")
|
||||
)
|
||||
|
||||
yesterdays_pages = (
|
||||
Page.objects.filter(
|
||||
updated_at__date=day_before,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.filter(project__project_projectmember__member=request.user)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "-updated_at")
|
||||
)
|
||||
|
||||
earlier_this_week = (
|
||||
Page.objects.filter(
|
||||
updated_at__date__range=(
|
||||
(timezone.now() - timedelta(days=7)),
|
||||
(timezone.now() - timedelta(days=2)),
|
||||
),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.filter(project__project_projectmember__member=request.user)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "-updated_at")
|
||||
)
|
||||
todays_pages_serializer = PageSerializer(todays_pages, many=True)
|
||||
yesterday_pages_serializer = PageSerializer(yesterdays_pages, many=True)
|
||||
earlier_this_week_serializer = PageSerializer(earlier_this_week, many=True)
|
||||
return Response(
|
||||
{
|
||||
"today": todays_pages_serializer.data,
|
||||
"yesterday": yesterday_pages_serializer.data,
|
||||
"earlier_this_week": earlier_this_week_serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class FavoritePagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = PageFavorite.objects.filter(
|
||||
user=request.user,
|
||||
page_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
pages = (
|
||||
Page.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.filter(project__project_projectmember__member=request.user)
|
||||
.filter(is_favorite=True)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("name", "-is_favorite")
|
||||
)
|
||||
|
||||
serializer = PageSerializer(pages, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class MyPagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = PageFavorite.objects.filter(
|
||||
user=request.user,
|
||||
page_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
pages = (
|
||||
Page.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, owned_by=request.user
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.filter(project__project_projectmember__member=request.user)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "name")
|
||||
)
|
||||
serializer = PageSerializer(pages, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class CreatedbyOtherPagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
subquery = PageFavorite.objects.filter(
|
||||
user=request.user,
|
||||
page_id=OuterRef("pk"),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
pages = (
|
||||
Page.objects.filter(
|
||||
~Q(owned_by=request.user),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
access=0,
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.prefetch_related("labels")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"blocks",
|
||||
queryset=PageBlock.objects.select_related(
|
||||
"page", "issue", "workspace", "project"
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "name")
|
||||
)
|
||||
serializer = PageSerializer(pages, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -31,36 +31,61 @@ class UserEndpoint(BaseViewSet):
|
||||
|
||||
def retrieve(self, request):
|
||||
try:
|
||||
workspace = Workspace.objects.get(pk=request.user.last_workspace_id)
|
||||
workspace = Workspace.objects.get(
|
||||
pk=request.user.last_workspace_id, workspace_member__member=request.user
|
||||
)
|
||||
workspace_invites = WorkspaceMemberInvite.objects.filter(
|
||||
email=request.user.email
|
||||
).count()
|
||||
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
|
||||
|
||||
serialized_data = UserSerializer(request.user).data
|
||||
serialized_data["workspace"] = {
|
||||
"last_workspace_id": request.user.last_workspace_id,
|
||||
"last_workspace_slug": workspace.slug,
|
||||
"fallback_workspace_id": request.user.last_workspace_id,
|
||||
"fallback_workspace_slug": workspace.slug,
|
||||
"invites": workspace_invites,
|
||||
}
|
||||
serialized_data.setdefault("issues", {})["assigned_issues"] = assigned_issues
|
||||
|
||||
return Response(
|
||||
{
|
||||
"user": UserSerializer(request.user).data,
|
||||
"slug": workspace.slug,
|
||||
"workspace_invites": workspace_invites,
|
||||
"assigned_issues": assigned_issues,
|
||||
},
|
||||
serialized_data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Workspace.DoesNotExist:
|
||||
# This exception will be hit even when the `last_workspace_id` is None
|
||||
|
||||
workspace_invites = WorkspaceMemberInvite.objects.filter(
|
||||
email=request.user.email
|
||||
).count()
|
||||
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
|
||||
|
||||
fallback_workspace = Workspace.objects.filter(
|
||||
workspace_member__member=request.user
|
||||
).order_by("created_at").first()
|
||||
|
||||
serialized_data = UserSerializer(request.user).data
|
||||
|
||||
serialized_data["workspace"] = {
|
||||
"last_workspace_id": None,
|
||||
"last_workspace_slug": None,
|
||||
"fallback_workspace_id": fallback_workspace.id
|
||||
if fallback_workspace is not None
|
||||
else None,
|
||||
"fallback_workspace_slug": fallback_workspace.slug
|
||||
if fallback_workspace is not None
|
||||
else None,
|
||||
"invites": workspace_invites,
|
||||
}
|
||||
serialized_data.setdefault("issues", {})["assigned_issues"] = assigned_issues
|
||||
|
||||
return Response(
|
||||
{
|
||||
"user": UserSerializer(request.user).data,
|
||||
"slug": None,
|
||||
"workspace_invites": workspace_invites,
|
||||
"assigned_issues": assigned_issues,
|
||||
},
|
||||
serialized_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,
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
||||
# Django imports
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Q, Exists, OuterRef
|
||||
from django.db.models import Q, Exists, OuterRef, Func, F
|
||||
from django.core.validators import validate_email
|
||||
from django.conf import settings
|
||||
|
||||
@@ -37,16 +37,19 @@ from plane.db.models import (
|
||||
State,
|
||||
TeamMember,
|
||||
ProjectFavorite,
|
||||
ProjectIdentifier,
|
||||
Module,
|
||||
Cycle,
|
||||
CycleFavorite,
|
||||
ModuleFavorite,
|
||||
PageFavorite,
|
||||
IssueViewFavorite,
|
||||
Page,
|
||||
IssueAssignee,
|
||||
ModuleMember,
|
||||
)
|
||||
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
Workspace,
|
||||
ProjectMemberInvite,
|
||||
User,
|
||||
ProjectIdentifier,
|
||||
)
|
||||
|
||||
from plane.bgtasks.project_invitation_task import project_invitation
|
||||
|
||||
|
||||
@@ -92,6 +95,26 @@ class ProjectViewSet(BaseViewSet):
|
||||
self.get_queryset()
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.order_by("-is_favorite", "name")
|
||||
.annotate(
|
||||
total_members=ProjectMember.objects.filter(
|
||||
project_id=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
total_cycles=Cycle.objects.filter(project_id=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
total_modules=Module.objects.filter(project_id=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
return Response(ProjectDetailSerializer(projects, many=True).data)
|
||||
except Exception as e:
|
||||
@@ -111,12 +134,12 @@ class ProjectViewSet(BaseViewSet):
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
## Add the user as Administrator to the project
|
||||
# Add the user as Administrator to the project
|
||||
ProjectMember.objects.create(
|
||||
project_id=serializer.data["id"], member=request.user, role=20
|
||||
)
|
||||
|
||||
## Default states
|
||||
# Default states
|
||||
states = [
|
||||
{
|
||||
"name": "Backlog",
|
||||
@@ -161,6 +184,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
workspace=serializer.instance.workspace,
|
||||
group=state["group"],
|
||||
default=state.get("default", False),
|
||||
created_by=request.user,
|
||||
)
|
||||
for state in states
|
||||
]
|
||||
@@ -344,12 +368,13 @@ class UserProjectInvitationsViewset(BaseViewSet):
|
||||
workspace=invitation.project.workspace,
|
||||
member=request.user,
|
||||
role=invitation.role,
|
||||
created_by=request.user,
|
||||
)
|
||||
for invitation in project_invitations
|
||||
]
|
||||
)
|
||||
|
||||
## Delete joined project invites
|
||||
# Delete joined project invites
|
||||
project_invitations.delete()
|
||||
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
@@ -385,6 +410,112 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
.select_related("workspace", "workspace__owner")
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
try:
|
||||
project_member = ProjectMember.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
if request.user.id == project_member.member_id:
|
||||
return Response(
|
||||
{"error": "You cannot update your own role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Check while updating user roles
|
||||
requested_project_member = ProjectMember.objects.get(
|
||||
project_id=project_id, workspace__slug=slug, member=request.user
|
||||
)
|
||||
if (
|
||||
"role" in request.data
|
||||
and int(request.data.get("role", project_member.role))
|
||||
> requested_project_member.role
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot update a role that is higher than your own role"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = ProjectMemberSerializer(
|
||||
project_member, data=request.data, partial=True
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except ProjectMember.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project Member 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, pk):
|
||||
try:
|
||||
project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
# check requesting user role
|
||||
requesting_project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug, member=request.user, project_id=project_id
|
||||
)
|
||||
if requesting_project_member.role < project_member.role:
|
||||
return Response(
|
||||
{"error": "You cannot remove a user having role higher than yourself"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Remove all favorites
|
||||
ProjectFavorite.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, user=project_member.member
|
||||
).delete()
|
||||
CycleFavorite.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, user=project_member.member
|
||||
).delete()
|
||||
ModuleFavorite.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, user=project_member.member
|
||||
).delete()
|
||||
PageFavorite.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, user=project_member.member
|
||||
).delete()
|
||||
IssueViewFavorite.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, user=project_member.member
|
||||
).delete()
|
||||
# Also remove issue from issue assigned
|
||||
IssueAssignee.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
assignee=project_member.member,
|
||||
).delete()
|
||||
|
||||
# Remove if module member
|
||||
ModuleMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=project_member.member,
|
||||
).delete()
|
||||
# Delete owned Pages
|
||||
Page.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
owned_by=project_member.member,
|
||||
).delete()
|
||||
project_member.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except ProjectMember.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project Member does not exist"}, status=status.HTTP_400
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response({"error": "Something went wrong please try again later"})
|
||||
|
||||
|
||||
class AddMemberToProjectEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
@@ -465,6 +596,7 @@ class AddTeamToProjectEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
member_id=member,
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -612,6 +744,7 @@ class ProjectJoinEndpoint(BaseAPIView):
|
||||
if workspace_role >= 15
|
||||
else (15 if workspace_role == 10 else workspace_role),
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
)
|
||||
for project_id in project_ids
|
||||
],
|
||||
|
||||
@@ -210,13 +210,15 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
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(
|
||||
issues = Issue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
)
|
||||
|
||||
if query:
|
||||
issues = search_issues(query, issues)
|
||||
|
||||
if parent == "true" and issue_id:
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
@@ -227,7 +229,12 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
if blocker_blocked_by == "true" and issue_id:
|
||||
issues = issues.filter(blocker_issues=issue_id, blocked_issues=issue_id)
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id),
|
||||
~Q(blocked_issues__block=issue),
|
||||
~Q(blocker_issues__blocked_by=issue),
|
||||
)
|
||||
|
||||
return Response(
|
||||
issues.values(
|
||||
|
||||
@@ -18,10 +18,6 @@ from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
IssueView,
|
||||
Issue,
|
||||
IssueBlocker,
|
||||
IssueLink,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
IssueViewFavorite,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
@@ -50,6 +50,14 @@ from plane.db.models import (
|
||||
IssueActivity,
|
||||
Issue,
|
||||
WorkspaceTheme,
|
||||
IssueAssignee,
|
||||
ProjectFavorite,
|
||||
CycleFavorite,
|
||||
ModuleMember,
|
||||
ModuleFavorite,
|
||||
PageFavorite,
|
||||
Page,
|
||||
IssueViewFavorite,
|
||||
)
|
||||
from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission
|
||||
from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
||||
@@ -223,6 +231,7 @@ class InviteWorkspaceEndpoint(BaseAPIView):
|
||||
algorithm="HS256",
|
||||
),
|
||||
role=email.get("role", 10),
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
except ValidationError:
|
||||
@@ -352,7 +361,7 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "workspace__owner")
|
||||
.select_related("workspace", "workspace__owner", "created_by")
|
||||
)
|
||||
|
||||
|
||||
@@ -365,7 +374,8 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(email=self.request.user.email)
|
||||
.select_related("workspace", "workspace__owner")
|
||||
.select_related("workspace", "workspace__owner", "created_by")
|
||||
.annotate(total_members=Count("workspace__workspace_member"))
|
||||
)
|
||||
|
||||
def create(self, request):
|
||||
@@ -381,6 +391,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
|
||||
workspace=invitation.workspace,
|
||||
member=request.user,
|
||||
role=invitation.role,
|
||||
created_by=request.user,
|
||||
)
|
||||
for invitation in workspace_invitations
|
||||
],
|
||||
@@ -421,6 +432,116 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
.select_related("member")
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, pk):
|
||||
try:
|
||||
workspace_member = WorkspaceMember.objects.get(pk=pk, workspace__slug=slug)
|
||||
if request.user.id == workspace_member.member_id:
|
||||
return Response(
|
||||
{"error": "You cannot update your own role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the requested user role
|
||||
requested_workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=request.user
|
||||
)
|
||||
# Check if role is being updated
|
||||
# One cannot update role higher than his own role
|
||||
if (
|
||||
"role" in request.data
|
||||
and int(request.data.get("role", workspace_member.role))
|
||||
> requested_workspace_member.role
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot update a role that is higher than your own role"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = WorkSpaceMemberSerializer(
|
||||
workspace_member, data=request.data, partial=True
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except WorkspaceMember.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace Member 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, pk):
|
||||
try:
|
||||
# Check the user role who is deleting the user
|
||||
workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, pk=pk)
|
||||
|
||||
# check requesting user role
|
||||
requesting_workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=request.user
|
||||
)
|
||||
if requesting_workspace_member.role < workspace_member.role:
|
||||
return Response(
|
||||
{"error": "You cannot remove a user having role higher than you"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Delete the user also from all the projects
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug, member=workspace_member.member
|
||||
).delete()
|
||||
# Remove all favorites
|
||||
ProjectFavorite.objects.filter(
|
||||
workspace__slug=slug, user=workspace_member.member
|
||||
).delete()
|
||||
CycleFavorite.objects.filter(
|
||||
workspace__slug=slug, user=workspace_member.member
|
||||
).delete()
|
||||
ModuleFavorite.objects.filter(
|
||||
workspace__slug=slug, user=workspace_member.member
|
||||
).delete()
|
||||
PageFavorite.objects.filter(
|
||||
workspace__slug=slug, user=workspace_member.member
|
||||
).delete()
|
||||
IssueViewFavorite.objects.filter(
|
||||
workspace__slug=slug, user=workspace_member.member
|
||||
).delete()
|
||||
# Also remove issue from issue assigned
|
||||
IssueAssignee.objects.filter(
|
||||
workspace__slug=slug, assignee=workspace_member.member
|
||||
).delete()
|
||||
|
||||
# Remove if module member
|
||||
ModuleMember.objects.filter(
|
||||
workspace__slug=slug, member=workspace_member.member
|
||||
).delete()
|
||||
# Delete owned Pages
|
||||
Page.objects.filter(
|
||||
workspace__slug=slug, owned_by=workspace_member.member
|
||||
).delete()
|
||||
|
||||
workspace_member.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except WorkspaceMember.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace Member does not 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,
|
||||
)
|
||||
|
||||
|
||||
class TeamMemberViewSet(BaseViewSet):
|
||||
serializer_class = TeamSerializer
|
||||
@@ -783,4 +904,3 @@ class WorkspaceThemeViewSet(BaseViewSet):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
174
apiserver/plane/bgtasks/analytic_plot_export.py
Normal file
174
apiserver/plane/bgtasks/analytic_plot_export.py
Normal file
@@ -0,0 +1,174 @@
|
||||
# Python imports
|
||||
import csv
|
||||
import io
|
||||
|
||||
# Django imports
|
||||
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 celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
row_mapping = {
|
||||
"state__name": "State",
|
||||
"state__group": "State Group",
|
||||
"labels__name": "Label",
|
||||
"assignees__email": "Assignee Name",
|
||||
"start_date": "Start Date",
|
||||
"target_date": "Due Date",
|
||||
"completed_at": "Completed At",
|
||||
"created_at": "Created At",
|
||||
"issue_count": "Issue Count",
|
||||
"priority": "Priority",
|
||||
"estimate": "Estimate",
|
||||
}
|
||||
|
||||
|
||||
@shared_task
|
||||
def analytic_export_task(email, data, slug):
|
||||
try:
|
||||
filters = issue_filters(data, "POST")
|
||||
queryset = Issue.objects.filter(**filters, workspace__slug=slug)
|
||||
|
||||
x_axis = data.get("x_axis", False)
|
||||
y_axis = data.get("y_axis", False)
|
||||
segment = data.get("segment", False)
|
||||
|
||||
distribution = build_graph_plot(
|
||||
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
|
||||
)
|
||||
|
||||
key = "count" if y_axis == "issue_count" else "estimate"
|
||||
|
||||
segmented = segment
|
||||
|
||||
assignee_details = {}
|
||||
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
|
||||
assignee_details = (
|
||||
Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||
.order_by("assignees__id")
|
||||
.distinct("assignees__id")
|
||||
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
|
||||
)
|
||||
|
||||
if segment:
|
||||
segment_zero = []
|
||||
for item in distribution:
|
||||
current_dict = distribution.get(item)
|
||||
for current in current_dict:
|
||||
segment_zero.append(current.get("segment"))
|
||||
|
||||
segment_zero = list(set(segment_zero))
|
||||
row_zero = (
|
||||
[
|
||||
row_mapping.get(x_axis, "X-Axis"),
|
||||
]
|
||||
+ [
|
||||
row_mapping.get(y_axis, "Y-Axis"),
|
||||
]
|
||||
+ segment_zero
|
||||
)
|
||||
rows = []
|
||||
for item in distribution:
|
||||
generated_row = [
|
||||
item,
|
||||
]
|
||||
|
||||
data = distribution.get(item)
|
||||
# Add y axis values
|
||||
generated_row.append(sum(obj.get(key) for obj in data if obj.get(key, None) is not None))
|
||||
|
||||
for segment in segment_zero:
|
||||
value = [x for x in data if x.get("segment") == segment]
|
||||
if len(value):
|
||||
generated_row.append(value[0].get(key))
|
||||
else:
|
||||
generated_row.append("0")
|
||||
# x-axis replacement for names
|
||||
if x_axis in ["assignees__email"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
|
||||
if len(assignee):
|
||||
generated_row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
rows.append(tuple(generated_row))
|
||||
|
||||
# If segment is ["assignees__email"] then replace segment_zero rows with first and last names
|
||||
if segmented in ["assignees__email"]:
|
||||
for index, segm in enumerate(row_zero[2:]):
|
||||
# find the name of the user
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(segm)]
|
||||
if len(assignee):
|
||||
row_zero[index] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
|
||||
rows = [tuple(row_zero)] + rows
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||
|
||||
# Write CSV data to the buffer
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
|
||||
subject = "Your Export is ready"
|
||||
|
||||
html_content = render_to_string("emails/exports/analytics.html", {})
|
||||
|
||||
text_content = strip_tags(html_content)
|
||||
csv_buffer.seek(0)
|
||||
msg = EmailMultiAlternatives(
|
||||
subject, text_content, settings.EMAIL_FROM, [email]
|
||||
)
|
||||
msg.attach(f"{slug}-analytics.csv", csv_buffer.read())
|
||||
msg.send(fail_silently=False)
|
||||
|
||||
else:
|
||||
row_zero = [
|
||||
row_mapping.get(x_axis, "X-Axis"),
|
||||
row_mapping.get(y_axis, "Y-Axis"),
|
||||
]
|
||||
rows = []
|
||||
for item in distribution:
|
||||
row = [
|
||||
item,
|
||||
distribution.get(item)[0].get("count")
|
||||
if y_axis == "issue_count"
|
||||
else distribution.get(item)[0].get("estimate "),
|
||||
]
|
||||
# x-axis replacement to names
|
||||
if x_axis in ["assignees__email"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
|
||||
if len(assignee):
|
||||
row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
|
||||
rows.append(tuple(row))
|
||||
rows = [tuple(row_zero)] + rows
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||
|
||||
# Write CSV data to the buffer
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
|
||||
subject = "Your Export is ready"
|
||||
|
||||
html_content = render_to_string("emails/exports/analytics.html", {})
|
||||
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
csv_buffer.seek(0)
|
||||
msg = EmailMultiAlternatives(
|
||||
subject, text_content, settings.EMAIL_FROM, [email]
|
||||
)
|
||||
msg.attach(f"{slug}-analytics.csv", csv_buffer.read())
|
||||
msg.send(fail_silently=False)
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
@@ -19,7 +19,7 @@ def email_verification(first_name, email, token, current_site):
|
||||
|
||||
try:
|
||||
realtivelink = "/request-email-verification/" + "?token=" + str(token)
|
||||
abs_url = "http://" + current_site + realtivelink
|
||||
abs_url = current_site + realtivelink
|
||||
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
|
||||
@@ -16,12 +16,12 @@ from plane.db.models import User
|
||||
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
|
||||
realtivelink = f"/reset-password/?uidb64={uidb64}&token={token}"
|
||||
abs_url = current_site + realtivelink
|
||||
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"Verify your Email!"
|
||||
subject = f"Reset Your Password - Plane"
|
||||
|
||||
context = {
|
||||
"first_name": first_name,
|
||||
|
||||
@@ -27,7 +27,7 @@ from plane.db.models import (
|
||||
User,
|
||||
)
|
||||
from .workspace_invitation_task import workspace_invitation
|
||||
from plane.bgtasks.user_welcome_task import send_welcome_email
|
||||
from plane.bgtasks.user_welcome_task import send_welcome_slack
|
||||
|
||||
|
||||
@shared_task
|
||||
@@ -58,7 +58,7 @@ def service_importer(service, importer_id):
|
||||
)
|
||||
|
||||
[
|
||||
send_welcome_email.delay(
|
||||
send_welcome_slack.delay(
|
||||
str(user.id),
|
||||
True,
|
||||
f"{user.email} was imported to Plane from {service}",
|
||||
@@ -78,7 +78,11 @@ def service_importer(service, importer_id):
|
||||
# Add new users to Workspace and project automatically
|
||||
WorkspaceMember.objects.bulk_create(
|
||||
[
|
||||
WorkspaceMember(member=user, workspace_id=importer.workspace_id)
|
||||
WorkspaceMember(
|
||||
member=user,
|
||||
workspace_id=importer.workspace_id,
|
||||
created_by=importer.created_by,
|
||||
)
|
||||
for user in workspace_users
|
||||
],
|
||||
batch_size=100,
|
||||
@@ -91,6 +95,7 @@ def service_importer(service, importer_id):
|
||||
project_id=importer.project_id,
|
||||
workspace_id=importer.workspace_id,
|
||||
member=user,
|
||||
created_by=importer.created_by,
|
||||
)
|
||||
for user in workspace_users
|
||||
],
|
||||
|
||||
@@ -136,7 +136,6 @@ 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
|
||||
|
||||
@@ -13,7 +13,7 @@ from sentry_sdk import capture_exception
|
||||
def magic_link(email, key, token, current_site):
|
||||
try:
|
||||
realtivelink = f"/magic-sign-in/?password={token}&key={key}"
|
||||
abs_url = "http://" + current_site + realtivelink
|
||||
abs_url = current_site + realtivelink
|
||||
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
|
||||
@@ -21,7 +21,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
|
||||
abs_url = current_site + relativelink
|
||||
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -15,31 +12,11 @@ from plane.db.models import User
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_welcome_email(user_id, created, message):
|
||||
def send_welcome_slack(user_id, created, message):
|
||||
try:
|
||||
instance = User.objects.get(pk=user_id)
|
||||
|
||||
if created and not instance.is_bot:
|
||||
first_name = instance.first_name.capitalize()
|
||||
to_email = instance.email
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"Welcome to Plane ✈️!"
|
||||
|
||||
context = {"first_name": first_name, "email": instance.email}
|
||||
|
||||
html_content = render_to_string(
|
||||
"emails/auth/user_welcome_email.html", context
|
||||
)
|
||||
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject, text_content, from_email_string, [to_email]
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
|
||||
# Send message on slack as well
|
||||
if settings.SLACK_BOT_TOKEN:
|
||||
client = WebClient(token=settings.SLACK_BOT_TOKEN)
|
||||
|
||||
@@ -23,9 +23,9 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
)
|
||||
|
||||
realtivelink = (
|
||||
f"/workspace-member-invitation/{workspace_member_invite.id}?email={email}"
|
||||
f"/workspace-member-invitation/?invitation_id={workspace_member_invite.id}&email={email}"
|
||||
)
|
||||
abs_url = "http://" + current_site + realtivelink
|
||||
abs_url = current_site + realtivelink
|
||||
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.2.18 on 2023-05-05 14:17
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0029_auto_20230502_0126'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='estimatepoint',
|
||||
unique_together=set(),
|
||||
),
|
||||
]
|
||||
37
apiserver/plane/db/migrations/0031_analyticview.py
Normal file
37
apiserver/plane/db/migrations/0031_analyticview.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 3.2.18 on 2023-05-12 11:31
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0030_alter_estimatepoint_unique_together'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AnalyticView',
|
||||
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)),
|
||||
('query', models.JSONField()),
|
||||
('query_dict', models.JSONField(default=dict)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='analyticview_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='analyticview_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='analytics', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Analytic',
|
||||
'verbose_name_plural': 'Analytics',
|
||||
'db_table': 'analytic_views',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
]
|
||||
23
apiserver/plane/db/migrations/0032_auto_20230520_2015.py
Normal file
23
apiserver/plane/db/migrations/0032_auto_20230520_2015.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.19 on 2023-05-20 14:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0031_analyticview'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='project',
|
||||
old_name='icon',
|
||||
new_name='emoji',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='icon_prop',
|
||||
field=models.JSONField(null=True),
|
||||
),
|
||||
]
|
||||
@@ -67,3 +67,5 @@ from .importer import Importer
|
||||
from .page import Page, PageBlock, PageFavorite, PageLabel
|
||||
|
||||
from .estimate import Estimate, EstimatePoint
|
||||
|
||||
from .analytic import AnalyticView
|
||||
25
apiserver/plane/db/models/analytic.py
Normal file
25
apiserver/plane/db/models/analytic.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Django models
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class AnalyticView(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", related_name="analytics", on_delete=models.CASCADE
|
||||
)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
query = models.JSONField()
|
||||
query_dict = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Analytic"
|
||||
verbose_name_plural = "Analytics"
|
||||
db_table = "analytic_views"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the analytic view"""
|
||||
return f"{self.name} <{self.workspace.name}>"
|
||||
@@ -4,6 +4,7 @@ from uuid import uuid4
|
||||
# Django import
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
|
||||
# Module import
|
||||
from . import BaseModel
|
||||
@@ -16,8 +17,7 @@ def get_upload_path(instance, filename):
|
||||
|
||||
|
||||
def file_size(value):
|
||||
limit = 5 * 1024 * 1024
|
||||
if value.size > limit:
|
||||
if value.size > settings.FILE_SIZE_LIMIT:
|
||||
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
||||
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ class EstimatePoint(ProjectBaseModel):
|
||||
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"
|
||||
|
||||
@@ -85,8 +85,13 @@ class Issue(ProjectBaseModel):
|
||||
).first()
|
||||
# if there is no default state assign any random state
|
||||
if default_state is None:
|
||||
self.state = State.objects.filter(project=self.project).first()
|
||||
random_state = State.objects.filter(project=self.project).first()
|
||||
self.state = random_state
|
||||
if random_state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
else:
|
||||
if default_state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
self.state = default_state
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -94,18 +99,15 @@ class Issue(ProjectBaseModel):
|
||||
try:
|
||||
from plane.db.models import State, PageBlock
|
||||
|
||||
# Get the completed states of the project
|
||||
completed_states = State.objects.filter(
|
||||
group="completed", project=self.project
|
||||
).values_list("pk", flat=True)
|
||||
# Check if the current issue state and completed state id are same
|
||||
if self.state.id in completed_states:
|
||||
if self.state.group == "completed":
|
||||
self.completed_at = timezone.now()
|
||||
# check if there are any page blocks
|
||||
PageBlock.objects.filter(issue_id=self.id).filter().update(
|
||||
completed_at=timezone.now()
|
||||
)
|
||||
|
||||
elif self.state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
else:
|
||||
PageBlock.objects.filter(issue_id=self.id).filter().update(
|
||||
completed_at=None
|
||||
@@ -116,7 +118,6 @@ class Issue(ProjectBaseModel):
|
||||
pass
|
||||
if self._state.adding:
|
||||
# Get the maximum display_id value from the database
|
||||
|
||||
last_id = IssueSequence.objects.filter(project=self.project).aggregate(
|
||||
largest=models.Max("sequence")
|
||||
)["largest"]
|
||||
@@ -131,6 +132,9 @@ class Issue(ProjectBaseModel):
|
||||
if largest_sort_order is not None:
|
||||
self.sort_order = largest_sort_order + 10000
|
||||
|
||||
# If adding it to started state
|
||||
if self.state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
@@ -206,8 +210,8 @@ def get_upload_path(instance, filename):
|
||||
|
||||
|
||||
def file_size(value):
|
||||
limit = 5 * 1024 * 1024
|
||||
if value.size > limit:
|
||||
# File limit check is only for cloud hosted
|
||||
if value.size > settings.FILE_SIZE_LIMIT:
|
||||
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
||||
|
||||
|
||||
|
||||
@@ -63,7 +63,8 @@ class Project(BaseModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
icon = models.CharField(max_length=255, null=True, blank=True)
|
||||
emoji = models.CharField(max_length=255, null=True, blank=True)
|
||||
icon_prop = models.JSONField(null=True)
|
||||
module_view = models.BooleanField(default=True)
|
||||
cycle_view = models.BooleanField(default=True)
|
||||
issue_views_view = models.BooleanField(default=True)
|
||||
|
||||
@@ -104,29 +104,9 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def send_welcome_email(sender, instance, created, **kwargs):
|
||||
def send_welcome_slack(sender, instance, created, **kwargs):
|
||||
try:
|
||||
if created and not instance.is_bot:
|
||||
first_name = instance.first_name.capitalize()
|
||||
to_email = instance.email
|
||||
from_email_string = settings.EMAIL_FROM
|
||||
|
||||
subject = f"Welcome to Plane ✈️!"
|
||||
|
||||
context = {"first_name": first_name, "email": instance.email}
|
||||
|
||||
html_content = render_to_string(
|
||||
"emails/auth/user_welcome_email.html", context
|
||||
)
|
||||
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject, text_content, from_email_string, [to_email]
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
|
||||
# Send message on slack as well
|
||||
if settings.SLACK_BOT_TOKEN:
|
||||
client = WebClient(token=settings.SLACK_BOT_TOKEN)
|
||||
|
||||
@@ -25,7 +25,13 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
DOCKERIZED = os.environ.get("DOCKERIZED", False)
|
||||
DOCKERIZED = int(os.environ.get(
|
||||
"DOCKERIZED", 0
|
||||
)) == 1
|
||||
|
||||
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
|
||||
|
||||
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
|
||||
|
||||
if DOCKERIZED:
|
||||
DATABASES["default"] = dj_database_url.config()
|
||||
@@ -68,7 +74,7 @@ MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
|
||||
if DOCKERIZED:
|
||||
REDIS_URL = os.environ.get("REDIS_URL")
|
||||
|
||||
WEB_URL = os.environ.get("WEB_URL", "localhost:3000")
|
||||
WEB_URL = os.environ.get("WEB_URL", "http://localhost:3000")
|
||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
|
||||
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
|
||||
@@ -84,5 +90,4 @@ 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)
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
|
||||
@@ -29,9 +29,12 @@ DATABASES = {
|
||||
DATABASES["default"] = dj_database_url.config()
|
||||
SITE_ID = 1
|
||||
|
||||
DOCKERIZED = os.environ.get(
|
||||
"DOCKERIZED", False
|
||||
) # Set the variable true if running in docker-compose environment
|
||||
# Set the variable true if running in docker environment
|
||||
DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1
|
||||
|
||||
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
|
||||
|
||||
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
|
||||
|
||||
# Enable Connection Pooling (if desired)
|
||||
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
|
||||
@@ -69,7 +72,7 @@ CORS_ALLOW_CREDENTIALS = True
|
||||
# Simplified static file serving.
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
if os.environ.get("SENTRY_DSN", False):
|
||||
if bool(os.environ.get("SENTRY_DSN", False)):
|
||||
sentry_sdk.init(
|
||||
dsn=os.environ.get("SENTRY_DSN", ""),
|
||||
integrations=[DjangoIntegration(), RedisIntegration()],
|
||||
@@ -80,12 +83,27 @@ if os.environ.get("SENTRY_DSN", False):
|
||||
environment="production",
|
||||
)
|
||||
|
||||
if (
|
||||
os.environ.get("AWS_REGION", False)
|
||||
and os.environ.get("AWS_ACCESS_KEY_ID", False)
|
||||
and os.environ.get("AWS_SECRET_ACCESS_KEY", False)
|
||||
and os.environ.get("AWS_S3_BUCKET_NAME", False)
|
||||
):
|
||||
if DOCKERIZED and USE_MINIO:
|
||||
INSTALLED_APPS += ("storages",)
|
||||
DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
|
||||
# The AWS access key to use.
|
||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
|
||||
# The AWS secret access key to use.
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
|
||||
# The name of the bucket to store files in.
|
||||
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
|
||||
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "http://plane-minio:9000")
|
||||
# Default permissions
|
||||
AWS_DEFAULT_ACL = "public-read"
|
||||
AWS_QUERYSTRING_AUTH = False
|
||||
AWS_S3_FILE_OVERWRITE = False
|
||||
|
||||
# Custom Domain settings
|
||||
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
|
||||
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
|
||||
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
|
||||
else:
|
||||
# The AWS region to connect to.
|
||||
AWS_REGION = os.environ.get("AWS_REGION", "")
|
||||
|
||||
@@ -99,7 +117,7 @@ if (
|
||||
# AWS_SESSION_TOKEN = ""
|
||||
|
||||
# The name of the bucket to store files in.
|
||||
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "")
|
||||
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
|
||||
|
||||
# How to construct S3 URLs ("auto", "path", "virtual").
|
||||
AWS_S3_ADDRESSING_STYLE = "auto"
|
||||
@@ -166,14 +184,8 @@ if (
|
||||
# extra characters appended.
|
||||
AWS_S3_FILE_OVERWRITE = False
|
||||
|
||||
# AWS Settings End
|
||||
|
||||
DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
|
||||
|
||||
else:
|
||||
MEDIA_URL = "/uploads/"
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
|
||||
|
||||
# AWS Settings End
|
||||
|
||||
# Enable Connection Pooling (if desired)
|
||||
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
|
||||
@@ -218,14 +230,8 @@ else:
|
||||
}
|
||||
}
|
||||
|
||||
RQ_QUEUES = {
|
||||
"default": {
|
||||
"USE_REDIS_CACHE": "default",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
WEB_URL = os.environ.get("WEB_URL")
|
||||
WEB_URL = os.environ.get("WEB_URL", "https://app.plane.so")
|
||||
|
||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
|
||||
|
||||
@@ -49,6 +49,12 @@ CORS_ALLOW_ALL_ORIGINS = True
|
||||
# Simplified static file serving.
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
# Make true if running in a docker environment
|
||||
DOCKERIZED = int(os.environ.get(
|
||||
"DOCKERIZED", 0
|
||||
)) == 1
|
||||
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
|
||||
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=os.environ.get("SENTRY_DSN"),
|
||||
@@ -165,7 +171,6 @@ CSRF_COOKIE_SECURE = True
|
||||
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL")
|
||||
DOCKERIZED = os.environ.get("DOCKERIZED", False)
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.urls import path
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls import include, url
|
||||
from django.conf.urls import include, url, static
|
||||
|
||||
# from django.conf.urls.static import static
|
||||
|
||||
@@ -17,9 +17,8 @@ urlpatterns = [
|
||||
path("api/", include("plane.api.urls")),
|
||||
path("", include("plane.web.urls")),
|
||||
]
|
||||
# + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
# + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
urlpatterns = urlpatterns + static.static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
if settings.DEBUG:
|
||||
import debug_toolbar
|
||||
|
||||
76
apiserver/plane/utils/analytics_plot.py
Normal file
76
apiserver/plane/utils/analytics_plot.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# Python imports
|
||||
from itertools import groupby
|
||||
|
||||
# Django import
|
||||
from django.db import models
|
||||
from django.db.models import Count, F, Sum, Value, Case, When, CharField
|
||||
from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear, Concat
|
||||
|
||||
|
||||
def build_graph_plot(queryset, x_axis, y_axis, segment=None):
|
||||
|
||||
temp_axis = x_axis
|
||||
|
||||
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
|
||||
year = ExtractYear(x_axis)
|
||||
month = ExtractMonth(x_axis)
|
||||
dimension = Concat(year, Value("-"), month, output_field=CharField())
|
||||
queryset = queryset.annotate(dimension=dimension)
|
||||
x_axis = "dimension"
|
||||
else:
|
||||
queryset = queryset.annotate(dimension=F(x_axis))
|
||||
x_axis = "dimension"
|
||||
|
||||
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
|
||||
queryset = queryset.exclude(x_axis__is_null=True)
|
||||
|
||||
if segment in ["created_at", "start_date", "target_date", "completed_at"]:
|
||||
year = ExtractYear(segment)
|
||||
month = ExtractMonth(segment)
|
||||
dimension = Concat(year, Value("-"), month, output_field=CharField())
|
||||
queryset = queryset.annotate(segmented=dimension)
|
||||
segment = "segmented"
|
||||
|
||||
queryset = queryset.values(x_axis)
|
||||
|
||||
# Group queryset by x_axis field
|
||||
|
||||
if y_axis == "issue_count":
|
||||
queryset = queryset.annotate(
|
||||
is_null=Case(
|
||||
When(dimension__isnull=True, then=Value("None")),
|
||||
default=Value("not_null"),
|
||||
output_field=models.CharField(max_length=8),
|
||||
),
|
||||
dimension_ex=Coalesce("dimension", Value("null")),
|
||||
).values("dimension")
|
||||
if segment:
|
||||
queryset = queryset.annotate(segment=F(segment)).values(
|
||||
"dimension", "segment"
|
||||
)
|
||||
else:
|
||||
queryset = queryset.values("dimension")
|
||||
|
||||
queryset = queryset.annotate(count=Count("*")).order_by("dimension")
|
||||
|
||||
if y_axis == "estimate":
|
||||
queryset = queryset.annotate(estimate=Sum("estimate_point")).order_by(x_axis)
|
||||
if segment:
|
||||
queryset = queryset.annotate(segment=F(segment)).values(
|
||||
"dimension", "segment", "estimate"
|
||||
)
|
||||
else:
|
||||
queryset = queryset.values("dimension", "estimate")
|
||||
|
||||
result_values = list(queryset)
|
||||
grouped_data = {}
|
||||
for key, items in groupby(result_values, key=lambda x: x[str("dimension")]):
|
||||
grouped_data[str(key)] = list(items)
|
||||
|
||||
sorted_data = grouped_data
|
||||
if temp_axis == "priority":
|
||||
order = ["low", "medium", "high", "urgent", "None"]
|
||||
sorted_data = {key: grouped_data[key] for key in order if key in grouped_data}
|
||||
else:
|
||||
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0])))
|
||||
return sorted_data
|
||||
@@ -198,6 +198,39 @@ def filter_issue_state_type(params, filter, method):
|
||||
return filter
|
||||
|
||||
|
||||
def filter_project(params, filter, method):
|
||||
if method == "GET":
|
||||
projects = params.get("project").split(",")
|
||||
if len(projects) and "" not in projects:
|
||||
filter["project__in"] = projects
|
||||
else:
|
||||
if params.get("project", None) and len(params.get("project")):
|
||||
filter["project__in"] = params.get("project")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_cycle(params, filter, method):
|
||||
if method == "GET":
|
||||
cycles = params.get("cycle").split(",")
|
||||
if len(cycles) and "" not in cycles:
|
||||
filter["issue_cycle__cycle_id__in"] = cycles
|
||||
else:
|
||||
if params.get("cycle", None) and len(params.get("cycle")):
|
||||
filter["issue_cycle__cycle_id__in"] = params.get("cycle")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_module(params, filter, method):
|
||||
if method == "GET":
|
||||
modules = params.get("module").split(",")
|
||||
if len(modules) and "" not in modules:
|
||||
filter["issue_module__module_id__in"] = modules
|
||||
else:
|
||||
if params.get("module", None) and len(params.get("module")):
|
||||
filter["issue_module__module_id__in"] = params.get("module")
|
||||
return filter
|
||||
|
||||
|
||||
def issue_filters(query_params, method):
|
||||
filter = dict()
|
||||
|
||||
@@ -216,6 +249,9 @@ def issue_filters(query_params, method):
|
||||
"target_date": filter_target_date,
|
||||
"completed_at": filter_completed_at,
|
||||
"type": filter_issue_state_type,
|
||||
"project": filter_project,
|
||||
"cycle": filter_cycle,
|
||||
"module": filter_module,
|
||||
}
|
||||
|
||||
for key, value in ISSUE_FILTER.items():
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.db.models import Q
|
||||
from plane.db.models import Issue
|
||||
|
||||
|
||||
def search_issues(query):
|
||||
def search_issues(query, queryset):
|
||||
fields = ["name", "sequence_id"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
@@ -18,6 +18,6 @@ def search_issues(query):
|
||||
q |= Q(**{"sequence_id": sequence_id})
|
||||
else:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
return Issue.objects.filter(
|
||||
return queryset.filter(
|
||||
q,
|
||||
).distinct()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# base requirements
|
||||
|
||||
Django==3.2.18
|
||||
Django==3.2.19
|
||||
django-braces==1.15.0
|
||||
django-taggit==3.1.0
|
||||
psycopg2==2.9.5
|
||||
|
||||
@@ -4,7 +4,7 @@ dj-database-url==1.2.0
|
||||
gunicorn==20.1.0
|
||||
whitenoise==6.3.0
|
||||
django-storages==1.13.2
|
||||
boto==2.49.0
|
||||
boto3==1.26.136
|
||||
django-anymail==9.0
|
||||
twilio==7.16.2
|
||||
django-debug-toolbar==3.8.1
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<p>
|
||||
|
||||
<body>
|
||||
<p>
|
||||
Dear {{first_name}},<br /><br />
|
||||
Welcome! Your account has been created.
|
||||
Verify your email by clicking on the link below <br />
|
||||
{{forgot_password_url}}
|
||||
successfully.<br /><br />
|
||||
</p>
|
||||
We received a request to reset your password for your Plane account.
|
||||
<br /><br />
|
||||
To proceed with resetting your password, please click on the link below:
|
||||
<br />
|
||||
<a href="{{forgot_password_url}}">{{forgot_password_url}}</a>
|
||||
<br /><br />
|
||||
If you didn't request to reset your password, please ignore this email. Your account will remain secure.
|
||||
<br /><br />
|
||||
If you have any questions or need further assistance, please contact our support team.
|
||||
<br /><br />
|
||||
Thank you for using Plane.
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,481 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Welcome to Plane ✈️!</title>
|
||||
<style type="text/css" emogrify="no">#outlook a { padding:0; } .ExternalClass { width:100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } table td { border-collapse: collapse; mso-line-height-rule: exactly; } .editable.image { font-size: 0 !important; line-height: 0 !important; } .nl2go_preheader { display: none !important; mso-hide:all !important; mso-line-height-rule: exactly; visibility: hidden !important; line-height: 0px !important; font-size: 0px !important; } body { width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0; } img { outline:none; text-decoration:none; -ms-interpolation-mode: bicubic; } a img { border:none; } table { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; } th { font-weight: normal; text-align: left; } *[class="gmail-fix"] { display: none !important; } </style>
|
||||
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} } </style>
|
||||
<style type="text/css" emogrify="no">@media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} .r0-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 320px !important } .r1-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important } .r2-i { background-color: #ffffff !important } .r3-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important } .r4-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important } .r5-i { background-color: #f8f9fa !important; padding-bottom: 20px !important; padding-left: 10px !important; padding-right: 10px !important; padding-top: 20px !important } .r6-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important } .r7-o { border-style: solid !important; width: 100% !important } .r8-i { padding-left: 0px !important; padding-right: 0px !important } .r9-i { padding-bottom: 15px !important; padding-top: 15px !important } .r10-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important } .r11-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important } .r12-i { padding-left: 20px !important; padding-right: 20px !important; padding-top: 0px !important; text-align: left !important } .r13-i { padding-bottom: 15px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 15px !important; text-align: left !important } .r14-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 15px !important; margin-top: 15px !important; width: 100% !important } .r15-i { text-align: center !important } .r16-r { background-color: #ffffff !important; border-color: #3f76ff !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important } .r17-r { background-color: #ffffff !important; border-color: #000000 !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important } .r18-i { background-color: #eff2f7 !important; padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important } .r19-i { color: #3b3f44 !important; padding-bottom: 0px !important; padding-top: 0px !important; text-align: center !important } .r20-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important } .r21-c { box-sizing: border-box !important; width: 100% !important } .r22-i { font-size: 0px !important; padding-bottom: 15px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 15px !important } .r23-c { box-sizing: border-box !important; width: 32px !important } .r24-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important } .r25-i { padding-bottom: 5px !important; padding-top: 5px !important } .r26-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important } .r27-i { color: #3b3f44 !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: center !important } .r28-i { padding-bottom: 15px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 0px !important } .r29-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 129px !important } .r30-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 129px !important } body { -webkit-text-size-adjust: none } .nl2go-responsive-hide { display: none } .nl2go-body-table { min-width: unset !important } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important } .resp-table { display: inline-table !important } .magic-resp { display: table-cell !important } } </style>
|
||||
<!--[if !mso]><!-->
|
||||
<style type="text/css" emogrify="no">@import url("https://fonts.googleapis.com/css2?family=Bitter&family=Roboto"); </style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">p, h1, h2, h3, h4, ol, ul { margin: 0; } a, a:link { color: #0092ff; text-decoration: underline } .nl2go-default-textstyle { color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5 } .default-button { border-radius: 4px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; width: 50% } .default-heading1 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 36px } .default-heading2 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 32px } .default-heading3 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 24px } .default-heading4 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 18px } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
|
||||
<!--[if mso]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
<![endif]-->
|
||||
<style type="text/css">a:link{color: #0092ff; text-decoration: underline;}</style>
|
||||
</head>
|
||||
<body bgcolor="#ffffff" text="#3b3f44" link="#0092ff" yahoo="fix" style="background-color: #ffffff; padding-bottom: 0px; padding-top: 0px;">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" class="nl2go-body-table" width="100%" style="background-color: #ffffff; width: 100%;">
|
||||
<tr>
|
||||
<td align="center" class="r0-c">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="600" class="r1-o" style="table-layout: fixed; width: 600px;">
|
||||
<tr>
|
||||
<td valign="top" class="r2-i" style="background-color: #ffffff;">
|
||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
||||
<tr>
|
||||
<td class="r3-c" align="center">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r4-o" style="table-layout: fixed; width: 100%;">
|
||||
<!-- -->
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #f8f9fa;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r5-i" style="background-color: #f8f9fa;">
|
||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
||||
<tr>
|
||||
<th width="100%" valign="top" class="r6-c" style="font-weight: normal;">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r7-o" style="table-layout: fixed; width: 100%;">
|
||||
<!-- -->
|
||||
<tr>
|
||||
<td valign="top" class="r8-i">
|
||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
||||
<tr>
|
||||
<td class="r3-c" align="center">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="120" class="r4-o" style="table-layout: fixed; width: 120px;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r9-i" style="font-size: 0px; line-height: 0px;"> <img src="https://ik.imagekit.io/w2okwbtu2/Plane_Logo_pIhtbyIoa.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670873447444" width="120" border="0" class="" style="display: block; width: 100%;"></td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r10-c" align="left">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
||||
<tr>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
<td align="left" valign="top" class="r12-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
||||
<div>
|
||||
<h3 class="default-heading3" style="margin: 0; color: #1f2d3d; font-family: arial,helvetica,sans-serif; font-size: 24px;">Welcome to Plane!</h3>
|
||||
</div>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r10-c" align="left">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
||||
<div>
|
||||
<p style="margin: 0;">We're thrilled you're here. We know this is the beginning of a long and exciting<br>journey, and we want to be there every step of the way.</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r10-c" align="left">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
||||
<div>
|
||||
<p style="margin: 0;"><strong>Plane is an open-source issue planning and tracking tool</strong> that allows teams to collaborate on projects and prioritize tasks. With Plane, you can easily create and assign issues, set deadlines, and track progress.</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r10-c" align="left">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
||||
<div>
|
||||
<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/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>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r3-c" align="center">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="300" class="r14-o" style="table-layout: fixed; width: 300px;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://app.plane.so/" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
|
||||
<w:anchorlock/>
|
||||
<div style="display:none;">
|
||||
<center class="default-button">
|
||||
<p><span style="color:#3F76FF;font-size:14px;">Open Plane</span></p>
|
||||
</center>
|
||||
</div>
|
||||
</v:roundrect>
|
||||
<![endif]--> <!--[if !mso]><!-- -->
|
||||
<a href="https://app.plane.so/" class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 5px; padding-right: 5px; padding-top: 7px; width: 288px;">
|
||||
<p style="margin: 0;"><span style="color: #3F76FF; font-size: 14px;">Open Plane</span></p>
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r10-c" align="left">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
||||
<div>
|
||||
<p style="margin: 0;">Also, if you like Plane, please consider starring us on GitHub. This helps us to grow our community and make Plane even better.</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r3-c" align="center">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="300" class="r14-o" style="table-layout: fixed; width: 300px;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://github.com/makeplane/plane" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#000000" strokeweight="1px" data-btn="2">
|
||||
<w:anchorlock/>
|
||||
<div style="display:none;">
|
||||
<center class="default-button">
|
||||
<p><span style="color:#000000;font-size:14px;">⭐ Star us on GitHub</span></p>
|
||||
</center>
|
||||
</div>
|
||||
</v:roundrect>
|
||||
<![endif]--> <!--[if !mso]><!-- -->
|
||||
<a href="https://github.com/makeplane/plane" class="r17-r default-button" target="_blank" data-btn="2" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #000000; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 5px; padding-right: 5px; padding-top: 7px; width: 288px;">
|
||||
<p style="margin: 0;"><span style="color: #000000; font-size: 14px;">⭐ Star us on GitHub</span></p>
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r10-c" align="left">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
||||
<div>
|
||||
<p style="margin: 0;"><span style="font-size: 12px;">Note: 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 </span><a href="https://discord.gg/A92xrEGCge" target="_blank" style="color: #0092ff; text-decoration: underline;"><span style="font-size: 12px;">Discord</span></a><span style="font-size: 12px;"> or </span><a href="https://github.com/makeplane/plane" target="_blank" style="color: #0092ff; text-decoration: underline;"><span style="font-size: 12px;">GitHub</span></a><span style="font-size: 12px;">, and we will use your feedback to improve on our upcoming releases.</span></p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</th>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #f8f9fa;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r3-c" align="center">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r4-o" style="table-layout: fixed; width: 100%;">
|
||||
<!-- -->
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #eff2f7;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r18-i" style="background-color: #eff2f7;">
|
||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
||||
<tr>
|
||||
<th width="100%" valign="top" class="r6-c" style="font-weight: normal;">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r7-o" style="table-layout: fixed; width: 100%;">
|
||||
<!-- -->
|
||||
<tr>
|
||||
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
<td valign="top" class="r8-i">
|
||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
||||
<tr>
|
||||
<td class="r3-c" align="center">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="57" class="r4-o" style="table-layout: fixed; width: 57px;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r9-i" style="font-size: 0px; line-height: 0px;"> <img src="https://ik.imagekit.io/w2okwbtu2/115727700_n9t8rrnwT.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670872429989" width="57" border="0" class="" style="display: block; width: 100%;"></td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r10-c" align="left">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
||||
<tr>
|
||||
<td align="center" valign="top" class="r19-i nl2go-default-textstyle" style="font-family: arial,helvetica,sans-serif; color: #3b3f44; font-size: 18px; line-height: 1.5; text-align: center;">
|
||||
<div>
|
||||
<p style="margin: 0; font-size: 14px;">Proudly made on <strong>Planet Earth 🌍</strong>.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r20-c" align="center">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="570" class="r4-o" style="table-layout: fixed; width: 570px;">
|
||||
<!-- -->
|
||||
<tr>
|
||||
<td valign="top" class="">
|
||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
||||
<tr>
|
||||
<td class="r21-c" style="display: inline-block;">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="570" class="r7-o" style="table-layout: fixed; width: 570px;">
|
||||
<!-- -->
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="209" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="209" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nl2go-responsive-hide" width="209" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
<td class="r22-i">
|
||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
||||
<tr>
|
||||
<th width="40" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r24-o" style="table-layout: fixed; width: 100%;">
|
||||
<!-- -->
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://github.com/makeplane/plane" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/github_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
|
||||
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</th>
|
||||
<th width="40" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r24-o" style="table-layout: fixed; width: 100%;">
|
||||
<!-- -->
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://discord.gg/A92xrEGCge" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/discord_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
|
||||
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</th>
|
||||
<th width="40" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r24-o" style="table-layout: fixed; width: 100%;">
|
||||
<!-- -->
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://twitter.com/planepowers" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/twitter_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
|
||||
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</th>
|
||||
<th width="32" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r26-o" style="table-layout: fixed; width: 100%;">
|
||||
<!-- -->
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/linkedin_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</th>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="209" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" width="209" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
<td height="15" width="209" style="font-size: 15px; line-height: 15px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r10-c" align="left">
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" class="r27-i nl2go-default-textstyle" style="font-family: arial,helvetica,sans-serif; color: #3b3f44; font-size: 18px; line-height: 1.5; text-align: center;">
|
||||
<div>
|
||||
<p style="margin: 0; font-size: 14px;"><a href="{{ mirror }}" style="color: #0092ff; text-decoration: underline;">View in browser</a> | <a href="{{ unsubscribe }}" style="color: #0092ff; text-decoration: underline;">Unsubscribe</a></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</th>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #eff2f7;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
8
apiserver/templates/emails/exports/analytics.html
Normal file
8
apiserver/templates/emails/exports/analytics.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html>
|
||||
Hey there,<br/>
|
||||
Your requested data export from Plane Analytics is now ready. The information has been compiled into a CSV format for your convenience.<br/>
|
||||
Please find the attachment and download the CSV file. This file can easily be imported into any spreadsheet program for further analysis.<br/>
|
||||
If you require any assistance or have any questions, please do not hesitate to contact us.<br/>
|
||||
Thank you
|
||||
</html>
|
||||
26
app.json
26
app.json
@@ -37,6 +37,14 @@
|
||||
"description": "Email host to send emails from",
|
||||
"value": ""
|
||||
},
|
||||
"EMAIL_FROM": {
|
||||
"description": "Email Sender",
|
||||
"value": ""
|
||||
},
|
||||
"EMAIL_PORT": {
|
||||
"description": "The default Email PORT to use",
|
||||
"value": "587"
|
||||
},
|
||||
"AWS_REGION": {
|
||||
"description": "AWS Region to use for S3",
|
||||
"value": "false"
|
||||
@@ -49,30 +57,22 @@
|
||||
"description": "AWS Secret Access Key to use for S3",
|
||||
"value": ""
|
||||
},
|
||||
"SENTRY_DSN": {
|
||||
"description": "",
|
||||
"value": ""
|
||||
},
|
||||
"AWS_S3_BUCKET_NAME": {
|
||||
"description": "AWS Bucket Name to use for S3",
|
||||
"value": ""
|
||||
},
|
||||
"SENTRY_DSN": {
|
||||
"description": "",
|
||||
"value": ""
|
||||
},
|
||||
"WEB_URL": {
|
||||
"description": "Web URL for Plane",
|
||||
"description": "Web URL for Plane this will be used for redirections in the emails",
|
||||
"value": ""
|
||||
},
|
||||
"GITHUB_CLIENT_SECRET": {
|
||||
"description": "Github Client Secret",
|
||||
"value": ""
|
||||
},
|
||||
"NEXT_PUBLIC_GITHUB_ID": {
|
||||
"description": "Next Public Github ID",
|
||||
"value": ""
|
||||
},
|
||||
"NEXT_PUBLIC_GOOGLE_CLIENTID": {
|
||||
"description": "Next Public Google Client ID",
|
||||
"value": ""
|
||||
},
|
||||
"NEXT_PUBLIC_API_BASE_URL": {
|
||||
"description": "Next Public API Base URL",
|
||||
"value": ""
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["custom"],
|
||||
rules: {
|
||||
"@next/next/no-img-element": "off",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
FROM node:18-alpine
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
FROM node:18-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
|
||||
@@ -14,7 +13,6 @@ RUN turbo prune --scope=app --docker
|
||||
FROM node:18-alpine AS installer
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
const [codeResent, setCodeResent] = useState(false);
|
||||
const [isCodeResending, setIsCodeResending] = useState(false);
|
||||
const [errorResendingCode, setErrorResendingCode] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer();
|
||||
@@ -64,22 +65,19 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
};
|
||||
|
||||
const handleSignin = async (formData: EmailCodeFormValues) => {
|
||||
await authenticationService
|
||||
.magicSignIn(formData)
|
||||
.then((response) => {
|
||||
onSuccess(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
setToastAlert({
|
||||
title: "Oops!",
|
||||
type: "error",
|
||||
message: error?.response?.data?.error ?? "Enter the correct code to sign in",
|
||||
});
|
||||
setError("token" as keyof EmailCodeFormValues, {
|
||||
type: "manual",
|
||||
message: error.error,
|
||||
});
|
||||
setIsLoading(true);
|
||||
await authenticationService.magicSignIn(formData).catch((error) => {
|
||||
setIsLoading(false);
|
||||
setToastAlert({
|
||||
title: "Oops!",
|
||||
type: "error",
|
||||
message: error?.response?.data?.error ?? "Enter the correct code to sign in",
|
||||
});
|
||||
setError("token" as keyof EmailCodeFormValues, {
|
||||
type: "manual",
|
||||
message: error.error,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const emailOld = getValues("email");
|
||||
@@ -88,6 +86,25 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
setErrorResendingCode(false);
|
||||
}, [emailOld]);
|
||||
|
||||
useEffect(() => {
|
||||
const submitForm = (e: KeyboardEvent) => {
|
||||
if (!codeSent && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit(onSubmit)().then(() => {
|
||||
setResendCodeTimer(30);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!codeSent) {
|
||||
window.addEventListener("keydown", submitForm);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", submitForm);
|
||||
};
|
||||
}, [handleSubmit, codeSent]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className="space-y-5 py-5 px-5">
|
||||
@@ -177,9 +194,9 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
size="md"
|
||||
onClick={handleSubmit(handleSignin)}
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isSubmitting}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
|
||||
// react hook form
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -8,6 +6,8 @@ import { useForm } from "react-hook-form";
|
||||
import authenticationService from "services/authentication.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { EmailResetPasswordForm } from "components/account";
|
||||
// ui
|
||||
import { Input, SecondaryButton } from "components/ui";
|
||||
// types
|
||||
@@ -17,8 +17,11 @@ type EmailPasswordFormValues = {
|
||||
medium?: string;
|
||||
};
|
||||
|
||||
export const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
export const EmailPasswordForm = ({ handleSignIn }: any) => {
|
||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -38,7 +41,7 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
authenticationService
|
||||
.emailLogin(formData)
|
||||
.then((response) => {
|
||||
onSuccess(response);
|
||||
if (handleSignIn) handleSignIn(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
@@ -58,59 +61,66 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className="mt-5 py-5 px-5" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Email ID is required",
|
||||
validate: (value) =>
|
||||
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
|
||||
value
|
||||
) || "Email ID is not valid",
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter your Email ID"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Password is required",
|
||||
}}
|
||||
error={errors.password}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="ml-auto text-sm">
|
||||
<Link href={"/forgot-password"}>
|
||||
<a className="font-medium text-brand-accent hover:text-brand-accent">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</Link>
|
||||
{isResettingPassword ? (
|
||||
<EmailResetPasswordForm setIsResettingPassword={setIsResettingPassword} />
|
||||
) : (
|
||||
<form className="mt-5 py-5 px-5" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Email ID is required",
|
||||
validate: (value) =>
|
||||
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
|
||||
value
|
||||
) || "Email ID is not valid",
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter your Email ID"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<SecondaryButton
|
||||
type="submit"
|
||||
className="w-full text-center"
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign In"}
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-5">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Password is required",
|
||||
}}
|
||||
error={errors.password}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="ml-auto text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsResettingPassword(true)}
|
||||
className="font-medium text-brand-accent hover:text-brand-accent"
|
||||
>
|
||||
Forgot your password?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<SecondaryButton
|
||||
type="submit"
|
||||
className="w-full text-center"
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign In"}
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
93
apps/app/components/account/email-reset-password-form.tsx
Normal file
93
apps/app/components/account/email-reset-password-form.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from "react";
|
||||
|
||||
// react hook form
|
||||
import { useForm } from "react-hook-form";
|
||||
// services
|
||||
import userService from "services/user.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// types
|
||||
type Props = {
|
||||
setIsResettingPassword: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const EmailResetPasswordForm: React.FC<Props> = ({ setIsResettingPassword }) => {
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const forgotPassword = async (formData: any) => {
|
||||
const payload = {
|
||||
email: formData.email,
|
||||
};
|
||||
|
||||
await userService
|
||||
.forgotPassword(payload)
|
||||
.then(() =>
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Password reset link has been sent to your email address.",
|
||||
})
|
||||
)
|
||||
.catch((err) => {
|
||||
if (err.status === 400)
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Please check the Email ID entered.",
|
||||
});
|
||||
else
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="mt-5 py-5 px-5" onSubmit={handleSubmit(forgotPassword)}>
|
||||
<div>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Email ID is required",
|
||||
validate: (value) =>
|
||||
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
|
||||
value
|
||||
) || "Email ID is not valid",
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter registered Email ID"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<SecondaryButton
|
||||
className="w-full text-center"
|
||||
onClick={() => setIsResettingPassword(false)}
|
||||
>
|
||||
Go Back
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="submit" className="w-full text-center" loading={isSubmitting}>
|
||||
{isSubmitting ? "Sending link..." : "Send reset link"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { useState, FC } from "react";
|
||||
import { KeyIcon } from "@heroicons/react/24/outline";
|
||||
// components
|
||||
import { EmailCodeForm, EmailPasswordForm } from "components/account";
|
||||
|
||||
export interface EmailSignInFormProps {
|
||||
handleSuccess: () => void;
|
||||
}
|
||||
|
||||
export const EmailSignInForm: FC<EmailSignInFormProps> = (props) => {
|
||||
const { handleSuccess } = props;
|
||||
// states
|
||||
const [useCode, setUseCode] = useState(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
{useCode ? (
|
||||
<EmailCodeForm onSuccess={handleSuccess} />
|
||||
) : (
|
||||
<EmailPasswordForm onSuccess={handleSuccess} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -29,7 +29,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
|
||||
useEffect(() => {
|
||||
const origin =
|
||||
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
setLoginCallBackURL(`${origin}/signin` as any);
|
||||
setLoginCallBackURL(`${origin}/` as any);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from "./google-login";
|
||||
export * from "./email-code-form";
|
||||
export * from "./email-password-form";
|
||||
export * from "./email-reset-password-form";
|
||||
export * from "./github-login-button";
|
||||
export * from "./email-signin-form";
|
||||
export * from "./google-login";
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import analyticsService from "services/analytics.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
|
||||
// types
|
||||
import { IAnalyticsParams, ISaveAnalyticsFormData } from "types";
|
||||
|
||||
// types
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
params?: IAnalyticsParams;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
name: "",
|
||||
description: "",
|
||||
};
|
||||
|
||||
export const CreateUpdateAnalyticsModal: React.FC<Props> = ({ isOpen, handleClose, params }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = useForm<FormValues>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
reset(defaultValues);
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: FormValues) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
const payload: ISaveAnalyticsFormData = {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
query_dict: {
|
||||
x_axis: "priority",
|
||||
y_axis: "issue_count",
|
||||
...params,
|
||||
project: params?.project ?? [],
|
||||
},
|
||||
};
|
||||
|
||||
await analyticsService
|
||||
.saveAnalytics(workspaceSlug.toString(), payload)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Analytics saved successfully.",
|
||||
});
|
||||
onClose();
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Analytics could not be saved. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-30" onClose={onClose}>
|
||||
<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-brand-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 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 border border-brand-base bg-brand-base px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-brand-base">
|
||||
Save Analytics
|
||||
</Dialog.Title>
|
||||
<div className="mt-5">
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Title"
|
||||
autoComplete="off"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
width="full"
|
||||
validations={{
|
||||
required: "Title is required",
|
||||
}}
|
||||
/>
|
||||
<TextArea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Description"
|
||||
className="mt-3 h-32 resize-none text-sm"
|
||||
error={errors.description}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save Analytics"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Control, UseFormSetValue } from "react-hook-form";
|
||||
// hooks
|
||||
import useProjects from "hooks/use-projects";
|
||||
// components
|
||||
import {
|
||||
AnalyticsGraph,
|
||||
AnalyticsSelectBar,
|
||||
AnalyticsSidebar,
|
||||
AnalyticsTable,
|
||||
} from "components/analytics";
|
||||
// ui
|
||||
import { Loader, PrimaryButton } from "components/ui";
|
||||
// helpers
|
||||
import { convertResponseToBarGraphData } from "helpers/analytics.helper";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse, ICurrentUserResponse } from "types";
|
||||
// fetch-keys
|
||||
import { ANALYTICS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
analytics: IAnalyticsResponse | undefined;
|
||||
analyticsError: any;
|
||||
params: IAnalyticsParams;
|
||||
control: Control<IAnalyticsParams, any>;
|
||||
setValue: UseFormSetValue<IAnalyticsParams>;
|
||||
fullScreen: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
export const CustomAnalytics: React.FC<Props> = ({
|
||||
analytics,
|
||||
analyticsError,
|
||||
params,
|
||||
control,
|
||||
setValue,
|
||||
fullScreen,
|
||||
user,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const isProjectLevel = projectId ? true : false;
|
||||
|
||||
const yAxisKey = params.y_axis === "issue_count" ? "count" : "estimate";
|
||||
const barGraphData = convertResponseToBarGraphData(analytics?.distribution, params);
|
||||
|
||||
const { projects } = useProjects();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden flex flex-col-reverse ${
|
||||
fullScreen ? "md:grid md:grid-cols-4 md:h-full" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="col-span-3 flex flex-col h-full overflow-hidden">
|
||||
<AnalyticsSelectBar
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
projects={projects}
|
||||
params={params}
|
||||
fullScreen={fullScreen}
|
||||
isProjectLevel={isProjectLevel}
|
||||
/>
|
||||
{!analyticsError ? (
|
||||
analytics ? (
|
||||
analytics.total > 0 ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<AnalyticsGraph
|
||||
analytics={analytics}
|
||||
barGraphData={barGraphData}
|
||||
params={params}
|
||||
yAxisKey={yAxisKey}
|
||||
fullScreen={fullScreen}
|
||||
/>
|
||||
<AnalyticsTable
|
||||
analytics={analytics}
|
||||
barGraphData={barGraphData}
|
||||
params={params}
|
||||
yAxisKey={yAxisKey}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center p-5">
|
||||
<div className="space-y-4 text-brand-secondary">
|
||||
<p className="text-sm">No matching issues found. Try changing the parameters.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-6 p-5">
|
||||
<Loader.Item height="300px" />
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</Loader>
|
||||
</Loader>
|
||||
)
|
||||
) : (
|
||||
<div className="grid h-full place-items-center p-5">
|
||||
<div className="space-y-4 text-brand-secondary">
|
||||
<p className="text-sm">There was some error in fetching the data.</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
mutate(ANALYTICS(workspaceSlug.toString(), params));
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AnalyticsSidebar
|
||||
analytics={analytics}
|
||||
params={params}
|
||||
fullScreen={fullScreen}
|
||||
isProjectLevel={isProjectLevel}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
// nivo
|
||||
import { BarTooltipProps } from "@nivo/bar";
|
||||
import { DATE_KEYS } from "constants/analytics";
|
||||
import { renderMonthAndYear } from "helpers/analytics.helper";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||
|
||||
type Props = {
|
||||
datum: BarTooltipProps<any>;
|
||||
analytics: IAnalyticsResponse;
|
||||
params: IAnalyticsParams;
|
||||
};
|
||||
|
||||
export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) => {
|
||||
let tooltipValue: string | number = "";
|
||||
|
||||
if (params.segment) {
|
||||
if (DATE_KEYS.includes(params.segment)) tooltipValue = renderMonthAndYear(datum.id);
|
||||
else if (params.segment === "assignees__email") {
|
||||
const assignee = analytics.extras.assignee_details.find(
|
||||
(a) => a.assignees__email === datum.id
|
||||
);
|
||||
|
||||
if (assignee)
|
||||
tooltipValue = assignee.assignees__first_name + " " + assignee.assignees__last_name;
|
||||
else tooltipValue = "No assignees";
|
||||
} else tooltipValue = datum.id;
|
||||
} else {
|
||||
if (DATE_KEYS.includes(params.x_axis)) tooltipValue = datum.indexValue;
|
||||
else tooltipValue = datum.id === "count" ? "Issue count" : "Estimate";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
|
||||
<span
|
||||
className="h-3 w-3 rounded"
|
||||
style={{
|
||||
backgroundColor: datum.color,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`font-medium text-brand-secondary ${
|
||||
params.segment
|
||||
? params.segment === "priority" || params.segment === "state__group"
|
||||
? "capitalize"
|
||||
: ""
|
||||
: params.x_axis === "priority" || params.x_axis === "state__group"
|
||||
? "capitalize"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{tooltipValue}:
|
||||
</span>
|
||||
<span>{datum.value}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
119
apps/app/components/analytics/custom-analytics/graph/index.tsx
Normal file
119
apps/app/components/analytics/custom-analytics/graph/index.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
// nivo
|
||||
import { BarDatum } from "@nivo/bar";
|
||||
// components
|
||||
import { CustomTooltip } from "./custom-tooltip";
|
||||
// ui
|
||||
import { BarGraph } from "components/ui";
|
||||
// helpers
|
||||
import { findStringWithMostCharacters } from "helpers/array.helper";
|
||||
import { generateBarColor } from "helpers/analytics.helper";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
analytics: IAnalyticsResponse;
|
||||
barGraphData: {
|
||||
data: BarDatum[];
|
||||
xAxisKeys: string[];
|
||||
};
|
||||
params: IAnalyticsParams;
|
||||
yAxisKey: "count" | "estimate";
|
||||
fullScreen: boolean;
|
||||
};
|
||||
|
||||
export const AnalyticsGraph: React.FC<Props> = ({
|
||||
analytics,
|
||||
barGraphData,
|
||||
params,
|
||||
yAxisKey,
|
||||
fullScreen,
|
||||
}) => {
|
||||
const generateYAxisTickValues = () => {
|
||||
if (!analytics) return [];
|
||||
|
||||
let data: number[] = [];
|
||||
|
||||
if (params.segment)
|
||||
// find the total no of issues in each segment
|
||||
data = Object.keys(analytics.distribution).map((segment) => {
|
||||
let totalSegmentIssues = 0;
|
||||
|
||||
analytics.distribution[segment].map((s) => {
|
||||
totalSegmentIssues += s[yAxisKey] as number;
|
||||
});
|
||||
|
||||
return totalSegmentIssues;
|
||||
});
|
||||
else data = barGraphData.data.map((d) => d[yAxisKey] as number);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const longestXAxisLabel = findStringWithMostCharacters(barGraphData.data.map((d) => `${d.name}`));
|
||||
|
||||
return (
|
||||
<BarGraph
|
||||
data={barGraphData.data}
|
||||
indexBy="name"
|
||||
keys={barGraphData.xAxisKeys}
|
||||
colors={(datum) =>
|
||||
generateBarColor(
|
||||
params.segment ? `${datum.id}` : `${datum.indexValue}`,
|
||||
analytics,
|
||||
params,
|
||||
params.segment ? "segment" : "x_axis"
|
||||
)
|
||||
}
|
||||
customYAxisTickValues={generateYAxisTickValues()}
|
||||
tooltip={(datum) => <CustomTooltip datum={datum} analytics={analytics} params={params} />}
|
||||
height={fullScreen ? "400px" : "300px"}
|
||||
margin={{
|
||||
right: 20,
|
||||
bottom: params.x_axis === "assignees__email" ? 50 : longestXAxisLabel.length * 5 + 20,
|
||||
}}
|
||||
axisBottom={{
|
||||
tickSize: 0,
|
||||
tickPadding: 10,
|
||||
tickRotation: barGraphData.data.length > 7 ? -45 : 0,
|
||||
renderTick:
|
||||
params.x_axis === "assignees__email"
|
||||
? (datum) => {
|
||||
const avatar = analytics.extras.assignee_details?.find(
|
||||
(a) => a?.assignees__email === datum?.value
|
||||
)?.assignees__avatar;
|
||||
|
||||
if (avatar && avatar !== "")
|
||||
return (
|
||||
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||
<image
|
||||
x={-8}
|
||||
y={10}
|
||||
width={16}
|
||||
height={16}
|
||||
xlinkHref={avatar}
|
||||
style={{ clipPath: "circle(50%)" }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||
<circle cy={18} r={8} fill="#374151" />
|
||||
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
|
||||
{datum.value && datum.value !== "None"
|
||||
? `${datum.value}`.toUpperCase()[0]
|
||||
: "?"}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
theme={{
|
||||
background: "rgb(var(--color-bg-base))",
|
||||
axis: {},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
6
apps/app/components/analytics/custom-analytics/index.ts
Normal file
6
apps/app/components/analytics/custom-analytics/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./graph";
|
||||
export * from "./create-update-analytics-modal";
|
||||
export * from "./custom-analytics";
|
||||
export * from "./select-bar";
|
||||
export * from "./sidebar";
|
||||
export * from "./table";
|
||||
@@ -0,0 +1,80 @@
|
||||
// react-hook-form
|
||||
import { Control, Controller, UseFormSetValue } from "react-hook-form";
|
||||
// components
|
||||
import { SelectProject, SelectSegment, SelectXAxis, SelectYAxis } from "components/analytics";
|
||||
// types
|
||||
import { IAnalyticsParams, IProject } from "types";
|
||||
|
||||
type Props = {
|
||||
control: Control<IAnalyticsParams, any>;
|
||||
setValue: UseFormSetValue<IAnalyticsParams>;
|
||||
projects: IProject[];
|
||||
params: IAnalyticsParams;
|
||||
fullScreen: boolean;
|
||||
isProjectLevel: boolean;
|
||||
};
|
||||
|
||||
export const AnalyticsSelectBar: React.FC<Props> = ({
|
||||
control,
|
||||
setValue,
|
||||
projects,
|
||||
params,
|
||||
fullScreen,
|
||||
isProjectLevel,
|
||||
}) => (
|
||||
<div
|
||||
className={`grid items-center gap-4 px-5 py-2.5 ${
|
||||
isProjectLevel ? "grid-cols-3" : "grid-cols-2"
|
||||
} ${fullScreen ? "lg:grid-cols-4 md:py-5" : ""}`}
|
||||
>
|
||||
{!isProjectLevel && (
|
||||
<div>
|
||||
<h6 className="text-xs text-brand-secondary">Project</h6>
|
||||
<Controller
|
||||
name="project"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectProject value={value} onChange={onChange} projects={projects} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h6 className="text-xs text-brand-secondary">Measure (y-axis)</h6>
|
||||
<Controller
|
||||
name="y_axis"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectYAxis value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="text-xs text-brand-secondary">Dimension (x-axis)</h6>
|
||||
<Controller
|
||||
name="x_axis"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectXAxis
|
||||
value={value}
|
||||
onChange={(val: string) => {
|
||||
if (params.segment === val) setValue("segment", null);
|
||||
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="text-xs text-brand-secondary">Group</h6>
|
||||
<Controller
|
||||
name="segment"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectSegment value={value} onChange={onChange} params={params} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
397
apps/app/components/analytics/custom-analytics/sidebar.tsx
Normal file
397
apps/app/components/analytics/custom-analytics/sidebar.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import analyticsService from "services/analytics.service";
|
||||
import projectService from "services/project.service";
|
||||
import cyclesService from "services/cycles.service";
|
||||
import modulesService from "services/modules.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
// hooks
|
||||
import useProjects from "hooks/use-projects";
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
ArrowPathIcon,
|
||||
CalendarDaysIcon,
|
||||
UserGroupIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ContrastIcon, LayerDiagonalIcon } from "components/icons";
|
||||
// helpers
|
||||
import { renderShortDate } from "helpers/date-time.helper";
|
||||
// types
|
||||
import {
|
||||
IAnalyticsParams,
|
||||
IAnalyticsResponse,
|
||||
ICurrentUserResponse,
|
||||
IExportAnalyticsFormData,
|
||||
IProject,
|
||||
IWorkspace,
|
||||
} from "types";
|
||||
// fetch-keys
|
||||
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { NETWORK_CHOICES } from "constants/project";
|
||||
|
||||
type Props = {
|
||||
analytics: IAnalyticsResponse | undefined;
|
||||
params: IAnalyticsParams;
|
||||
fullScreen: boolean;
|
||||
isProjectLevel: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
analytics,
|
||||
params,
|
||||
fullScreen,
|
||||
isProjectLevel = false,
|
||||
user,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const { projects } = useProjects();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { data: projectDetails } = useSWR(
|
||||
workspaceSlug && projectId && !(cycleId || moduleId)
|
||||
? PROJECT_DETAILS(projectId.toString())
|
||||
: null,
|
||||
workspaceSlug && projectId && !(cycleId || moduleId)
|
||||
? () => projectService.getProject(workspaceSlug.toString(), projectId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: cycleDetails } = useSWR(
|
||||
workspaceSlug && projectId && cycleId ? CYCLE_DETAILS(cycleId.toString()) : null,
|
||||
workspaceSlug && projectId && cycleId
|
||||
? () =>
|
||||
cyclesService.getCycleDetails(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
cycleId.toString()
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: moduleDetails } = useSWR(
|
||||
workspaceSlug && projectId && moduleId ? MODULE_DETAILS(moduleId.toString()) : null,
|
||||
workspaceSlug && projectId && moduleId
|
||||
? () =>
|
||||
modulesService.getModuleDetails(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
moduleId.toString()
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const trackExportAnalytics = () => {
|
||||
const eventPayload: any = {
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
params: {
|
||||
x_axis: params.x_axis,
|
||||
y_axis: params.y_axis,
|
||||
group: params.segment,
|
||||
project: params.project,
|
||||
},
|
||||
};
|
||||
|
||||
if (projectDetails) {
|
||||
const workspaceDetails = projectDetails.workspace as IWorkspace;
|
||||
|
||||
eventPayload.workspaceId = workspaceDetails.id;
|
||||
eventPayload.workspaceName = workspaceDetails.name;
|
||||
eventPayload.projectId = projectDetails.id;
|
||||
eventPayload.projectIdentifier = projectDetails.identifier;
|
||||
eventPayload.projectName = projectDetails.name;
|
||||
}
|
||||
|
||||
if (cycleDetails || moduleDetails) {
|
||||
const details = cycleDetails || moduleDetails;
|
||||
|
||||
eventPayload.workspaceId = details?.workspace_detail?.id;
|
||||
eventPayload.workspaceName = details?.workspace_detail?.name;
|
||||
eventPayload.projectId = details?.project_detail.id;
|
||||
eventPayload.projectIdentifier = details?.project_detail.identifier;
|
||||
eventPayload.projectName = details?.project_detail.name;
|
||||
}
|
||||
|
||||
if (cycleDetails) {
|
||||
eventPayload.cycleId = cycleDetails.id;
|
||||
eventPayload.cycleName = cycleDetails.name;
|
||||
}
|
||||
|
||||
if (moduleDetails) {
|
||||
eventPayload.moduleId = moduleDetails.id;
|
||||
eventPayload.moduleName = moduleDetails.name;
|
||||
}
|
||||
|
||||
trackEventServices.trackAnalyticsEvent(
|
||||
eventPayload,
|
||||
cycleId
|
||||
? "CYCLE_ANALYTICS_EXPORT"
|
||||
: moduleId
|
||||
? "MODULE_ANALYTICS_EXPORT"
|
||||
: projectId
|
||||
? "PROJECT_ANALYTICS_EXPORT"
|
||||
: "WORKSPACE_ANALYTICS_EXPORT",
|
||||
user
|
||||
);
|
||||
};
|
||||
|
||||
const exportAnalytics = () => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
const data: IExportAnalyticsFormData = {
|
||||
x_axis: params.x_axis,
|
||||
y_axis: params.y_axis,
|
||||
};
|
||||
|
||||
if (params.segment) data.segment = params.segment;
|
||||
if (params.project) data.project = params.project;
|
||||
|
||||
analyticsService
|
||||
.exportAnalytics(workspaceSlug.toString(), data)
|
||||
.then((res) => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: res.message,
|
||||
});
|
||||
|
||||
trackExportAnalytics();
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "There was some error in exporting the analytics. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const selectedProjects =
|
||||
params.project && params.project.length > 0 ? params.project : projects.map((p) => p.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-5 py-2.5 flex items-center justify-between space-y-2 ${
|
||||
fullScreen
|
||||
? "border-l border-brand-base md:h-full md:border-l md:border-brand-base md:space-y-4 overflow-hidden md:flex-col md:items-start md:py-5"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-1 bg-brand-surface-2 rounded-md px-3 py-1 text-brand-secondary text-xs">
|
||||
<LayerDiagonalIcon height={14} width={14} />
|
||||
{analytics ? analytics.total : "..."} Issues
|
||||
</div>
|
||||
{isProjectLevel && (
|
||||
<div className="flex items-center gap-1 bg-brand-surface-2 rounded-md px-3 py-1 text-brand-secondary text-xs">
|
||||
<CalendarDaysIcon className="h-3.5 w-3.5" />
|
||||
{renderShortDate(
|
||||
(cycleId
|
||||
? cycleDetails?.created_at
|
||||
: moduleId
|
||||
? moduleDetails?.created_at
|
||||
: projectDetails?.created_at) ?? ""
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-full overflow-hidden">
|
||||
{fullScreen ? (
|
||||
<>
|
||||
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
|
||||
<div className="hidden h-full overflow-hidden md:flex md:flex-col">
|
||||
<h4 className="font-medium">Selected Projects</h4>
|
||||
<div className="space-y-6 mt-4 h-full overflow-y-auto">
|
||||
{selectedProjects.map((projectId) => {
|
||||
const project: IProject = projects.find((p) => p.id === projectId);
|
||||
|
||||
return (
|
||||
<div key={project.id}>
|
||||
<div className="text-sm flex items-center gap-1">
|
||||
{project.emoji ? (
|
||||
<span className="grid h-6 w-6 flex-shrink-0 place-items-center">
|
||||
{String.fromCodePoint(parseInt(project.emoji))}
|
||||
</span>
|
||||
) : project.icon_prop ? (
|
||||
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
|
||||
<span
|
||||
style={{ color: project.icon_prop.color }}
|
||||
className="material-symbols-rounded text-lg"
|
||||
>
|
||||
{project.icon_prop.name}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{project?.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<h5 className="break-all">
|
||||
{project.name}
|
||||
<span className="text-brand-secondary text-xs ml-1">
|
||||
({project.identifier})
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3 pl-2">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<h6>Total members</h6>
|
||||
</div>
|
||||
<span className="text-brand-secondary">{project.total_members}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<ContrastIcon height={16} width={16} />
|
||||
<h6>Total cycles</h6>
|
||||
</div>
|
||||
<span className="text-brand-secondary">{project.total_cycles}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<h6>Total modules</h6>
|
||||
</div>
|
||||
<span className="text-brand-secondary">{project.total_modules}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{projectId ? (
|
||||
cycleId && cycleDetails ? (
|
||||
<div className="hidden md:block h-full overflow-y-auto">
|
||||
<h4 className="font-medium break-all">Analytics for {cycleDetails.name}</h4>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Lead</h6>
|
||||
<span>
|
||||
{cycleDetails.owned_by?.first_name} {cycleDetails.owned_by?.last_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Start Date</h6>
|
||||
<span>
|
||||
{cycleDetails.start_date && cycleDetails.start_date !== ""
|
||||
? renderShortDate(cycleDetails.start_date)
|
||||
: "No start date"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Target Date</h6>
|
||||
<span>
|
||||
{cycleDetails.end_date && cycleDetails.end_date !== ""
|
||||
? renderShortDate(cycleDetails.end_date)
|
||||
: "No end date"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : moduleId && moduleDetails ? (
|
||||
<div className="hidden md:block h-full overflow-y-auto">
|
||||
<h4 className="font-medium break-all">Analytics for {moduleDetails.name}</h4>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Lead</h6>
|
||||
<span>
|
||||
{moduleDetails.lead_detail?.first_name}{" "}
|
||||
{moduleDetails.lead_detail?.last_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Start Date</h6>
|
||||
<span>
|
||||
{moduleDetails.start_date && moduleDetails.start_date !== ""
|
||||
? renderShortDate(moduleDetails.start_date)
|
||||
: "No start date"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Target Date</h6>
|
||||
<span>
|
||||
{moduleDetails.target_date && moduleDetails.target_date !== ""
|
||||
? renderShortDate(moduleDetails.target_date)
|
||||
: "No end date"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden md:flex md:flex-col h-full overflow-y-auto">
|
||||
<div className="flex items-center gap-1">
|
||||
{projectDetails?.emoji ? (
|
||||
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">
|
||||
{String.fromCodePoint(parseInt(projectDetails.emoji))}
|
||||
</div>
|
||||
) : projectDetails?.icon_prop ? (
|
||||
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
|
||||
<span
|
||||
style={{ color: projectDetails.icon_prop.color }}
|
||||
className="material-symbols-rounded text-lg"
|
||||
>
|
||||
{projectDetails.icon_prop.name}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{projectDetails?.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<h4 className="font-medium break-all">{projectDetails?.name}</h4>
|
||||
</div>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Network</h6>
|
||||
<span>
|
||||
{
|
||||
NETWORK_CHOICES[
|
||||
`${projectDetails?.network}` as keyof typeof NETWORK_CHOICES
|
||||
]
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap justify-self-end">
|
||||
<SecondaryButton
|
||||
onClick={() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
mutate(ANALYTICS(workspaceSlug.toString(), params));
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 -my-1">
|
||||
<ArrowPathIcon className="h-3.5 w-3.5" />
|
||||
Refresh
|
||||
</div>
|
||||
</SecondaryButton>
|
||||
<PrimaryButton onClick={exportAnalytics}>
|
||||
<div className="flex items-center gap-2 -my-1">
|
||||
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
|
||||
Export as CSV
|
||||
</div>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
135
apps/app/components/analytics/custom-analytics/table.tsx
Normal file
135
apps/app/components/analytics/custom-analytics/table.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
// nivo
|
||||
import { BarDatum } from "@nivo/bar";
|
||||
// icons
|
||||
import { getPriorityIcon } from "components/icons";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// helpers
|
||||
import { generateBarColor, renderMonthAndYear } from "helpers/analytics.helper";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||
// constants
|
||||
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, DATE_KEYS } from "constants/analytics";
|
||||
|
||||
type Props = {
|
||||
analytics: IAnalyticsResponse;
|
||||
barGraphData: {
|
||||
data: BarDatum[];
|
||||
xAxisKeys: string[];
|
||||
};
|
||||
params: IAnalyticsParams;
|
||||
yAxisKey: "count" | "estimate";
|
||||
};
|
||||
|
||||
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => {
|
||||
const renderAssigneeName = (email: string): string => {
|
||||
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__email === email);
|
||||
|
||||
if (!assignee) return "No assignee";
|
||||
|
||||
if (assignee.assignees__first_name !== "")
|
||||
return assignee.assignees__first_name + " " + assignee.assignees__last_name;
|
||||
|
||||
return email;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flow-root">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full align-middle">
|
||||
<table className="min-w-full divide-y divide-brand-base whitespace-nowrap border-y border-brand-base">
|
||||
<thead className="bg-brand-surface-2">
|
||||
<tr className="divide-x divide-brand-base text-sm text-brand-base">
|
||||
<th scope="col" className="py-3 px-2.5 text-left font-medium">
|
||||
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === params.x_axis)?.label}
|
||||
</th>
|
||||
{params.segment ? (
|
||||
barGraphData.xAxisKeys.map((key) => (
|
||||
<th
|
||||
key={`segment-${key}`}
|
||||
scope="col"
|
||||
className={`px-2.5 py-3 text-left font-medium ${
|
||||
params.segment === "priority" || params.segment === "state__group"
|
||||
? "capitalize"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{params.segment === "priority" ? (
|
||||
getPriorityIcon(key)
|
||||
) : (
|
||||
<span
|
||||
className="h-3 w-3 flex-shrink-0 rounded"
|
||||
style={{
|
||||
backgroundColor: generateBarColor(key, analytics, params, "segment"),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{DATE_KEYS.includes(params.segment ?? "")
|
||||
? renderMonthAndYear(key)
|
||||
: params.segment === "assignees__email"
|
||||
? renderAssigneeName(key)
|
||||
: key}
|
||||
</div>
|
||||
</th>
|
||||
))
|
||||
) : (
|
||||
<th scope="col" className="py-3 px-2.5 text-left font-medium sm:pr-0">
|
||||
{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === params.y_axis)?.label}
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-brand-base">
|
||||
{barGraphData.data.map((item, index) => (
|
||||
<tr
|
||||
key={`table-row-${index}`}
|
||||
className="divide-x divide-brand-base text-xs text-brand-secondary"
|
||||
>
|
||||
<td
|
||||
className={`flex items-center gap-2 whitespace-nowrap py-2 px-2.5 font-medium ${
|
||||
params.x_axis === "priority" || params.x_axis === "state__group"
|
||||
? "capitalize"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{params.x_axis === "priority" ? (
|
||||
getPriorityIcon(`${item.name}`)
|
||||
) : (
|
||||
<span
|
||||
className="h-3 w-3 rounded"
|
||||
style={{
|
||||
backgroundColor: generateBarColor(
|
||||
`${item.name}`,
|
||||
analytics,
|
||||
params,
|
||||
"x_axis"
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{params.x_axis === "assignees__email"
|
||||
? renderAssigneeName(`${item.name}`)
|
||||
: addSpaceIfCamelCase(`${item.name}`)}
|
||||
</td>
|
||||
{params.segment ? (
|
||||
barGraphData.xAxisKeys.map((key, index) => (
|
||||
<td
|
||||
key={`segment-value-${index}`}
|
||||
className="whitespace-nowrap py-2 px-2.5 sm:pr-0"
|
||||
>
|
||||
{item[key] ?? 0}
|
||||
</td>
|
||||
))
|
||||
) : (
|
||||
<td className="whitespace-nowrap py-2 px-2.5 sm:pr-0">{item[yAxisKey]}</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
4
apps/app/components/analytics/index.ts
Normal file
4
apps/app/components/analytics/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./custom-analytics";
|
||||
export * from "./scope-and-demand";
|
||||
export * from "./select";
|
||||
export * from "./project-modal";
|
||||
225
apps/app/components/analytics/project-modal.tsx
Normal file
225
apps/app/components/analytics/project-modal.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import React, { Fragment, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Tab } from "@headlessui/react";
|
||||
// services
|
||||
import analyticsService from "services/analytics.service";
|
||||
import projectService from "services/project.service";
|
||||
import cyclesService from "services/cycles.service";
|
||||
import modulesService from "services/modules.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
// components
|
||||
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
|
||||
// icons
|
||||
import {
|
||||
ArrowsPointingInIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IAnalyticsParams, IWorkspace } from "types";
|
||||
// fetch-keys
|
||||
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const defaultValues: IAnalyticsParams = {
|
||||
x_axis: "priority",
|
||||
y_axis: "issue_count",
|
||||
segment: null,
|
||||
project: null,
|
||||
};
|
||||
|
||||
const tabsList = ["Scope and Demand", "Custom Analytics"];
|
||||
|
||||
export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
|
||||
const [fullScreen, setFullScreen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const { user } = useUserAuth();
|
||||
|
||||
const { control, watch, setValue } = useForm<IAnalyticsParams>({ defaultValues });
|
||||
|
||||
const params: IAnalyticsParams = {
|
||||
x_axis: watch("x_axis"),
|
||||
y_axis: watch("y_axis"),
|
||||
segment: watch("segment"),
|
||||
project: projectId ? [projectId.toString()] : watch("project"),
|
||||
cycle: cycleId ? cycleId.toString() : null,
|
||||
module: moduleId ? moduleId.toString() : null,
|
||||
};
|
||||
|
||||
const { data: analytics, error: analyticsError } = useSWR(
|
||||
workspaceSlug ? ANALYTICS(workspaceSlug.toString(), params) : null,
|
||||
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
|
||||
);
|
||||
|
||||
const { data: projectDetails } = useSWR(
|
||||
workspaceSlug && projectId && !(cycleId || moduleId)
|
||||
? PROJECT_DETAILS(projectId.toString())
|
||||
: null,
|
||||
workspaceSlug && projectId && !(cycleId || moduleId)
|
||||
? () => projectService.getProject(workspaceSlug.toString(), projectId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: cycleDetails } = useSWR(
|
||||
workspaceSlug && projectId && cycleId ? CYCLE_DETAILS(cycleId.toString()) : null,
|
||||
workspaceSlug && projectId && cycleId
|
||||
? () =>
|
||||
cyclesService.getCycleDetails(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
cycleId.toString()
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: moduleDetails } = useSWR(
|
||||
workspaceSlug && projectId && moduleId ? MODULE_DETAILS(moduleId.toString()) : null,
|
||||
workspaceSlug && projectId && moduleId
|
||||
? () =>
|
||||
modulesService.getModuleDetails(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
moduleId.toString()
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const trackAnalyticsEvent = (tab: string) => {
|
||||
const eventPayload: any = {
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
};
|
||||
|
||||
if (projectDetails) {
|
||||
const workspaceDetails = projectDetails.workspace as IWorkspace;
|
||||
|
||||
eventPayload.workspaceId = workspaceDetails.id;
|
||||
eventPayload.workspaceName = workspaceDetails.name;
|
||||
eventPayload.projectId = projectDetails.id;
|
||||
eventPayload.projectIdentifier = projectDetails.identifier;
|
||||
eventPayload.projectName = projectDetails.name;
|
||||
}
|
||||
|
||||
if (cycleDetails || moduleDetails) {
|
||||
const details = cycleDetails || moduleDetails;
|
||||
|
||||
eventPayload.workspaceId = details?.workspace_detail?.id;
|
||||
eventPayload.workspaceName = details?.workspace_detail?.name;
|
||||
eventPayload.projectId = details?.project_detail.id;
|
||||
eventPayload.projectIdentifier = details?.project_detail.identifier;
|
||||
eventPayload.projectName = details?.project_detail.name;
|
||||
}
|
||||
|
||||
if (cycleDetails) {
|
||||
eventPayload.cycleId = cycleDetails.id;
|
||||
eventPayload.cycleName = cycleDetails.name;
|
||||
}
|
||||
|
||||
if (moduleDetails) {
|
||||
eventPayload.moduleId = moduleDetails.id;
|
||||
eventPayload.moduleName = moduleDetails.name;
|
||||
}
|
||||
|
||||
const eventType =
|
||||
tab === "Scope and Demand" ? "SCOPE_AND_DEMAND_ANALYTICS" : "CUSTOM_ANALYTICS";
|
||||
|
||||
trackEventServices.trackAnalyticsEvent(
|
||||
eventPayload,
|
||||
cycleId ? `CYCLE_${eventType}` : moduleId ? `MODULE_${eventType}` : `PROJECT_${eventType}`,
|
||||
user
|
||||
);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute top-0 z-30 h-full bg-brand-surface-1 ${
|
||||
fullScreen ? "p-2 w-full" : "w-1/2"
|
||||
} ${isOpen ? "right-0" : "-right-full"} duration-300 transition-all`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-full flex-col overflow-hidden border-brand-base bg-brand-base text-left ${
|
||||
fullScreen ? "rounded-lg border" : "border-l"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 bg-brand-base px-5 py-4 text-sm">
|
||||
<h3 className="break-all">
|
||||
Analytics for{" "}
|
||||
{cycleId ? cycleDetails?.name : moduleId ? moduleDetails?.name : projectDetails?.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center p-1 text-brand-secondary hover:text-brand-base"
|
||||
onClick={() => setFullScreen((prevData) => !prevData)}
|
||||
>
|
||||
{fullScreen ? (
|
||||
<ArrowsPointingInIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowsPointingOutIcon className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center p-1 text-brand-secondary hover:text-brand-base"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Tab.Group as={Fragment}>
|
||||
<Tab.List as="div" className="space-x-2 border-b border-brand-base p-5 pt-0">
|
||||
{tabsList.map((tab) => (
|
||||
<Tab
|
||||
key={tab}
|
||||
className={({ selected }) =>
|
||||
`rounded-3xl border border-brand-base px-4 py-2 text-xs hover:bg-brand-surface-2 ${
|
||||
selected ? "bg-brand-surface-2" : ""
|
||||
}`
|
||||
}
|
||||
onClick={() => trackAnalyticsEvent(tab)}
|
||||
>
|
||||
{tab}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
{/* <h4 className="p-5 pb-0">Analytics for</h4> */}
|
||||
<Tab.Panels as={Fragment}>
|
||||
<Tab.Panel as={Fragment}>
|
||||
<ScopeAndDemand fullScreen={fullScreen} />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel as={Fragment}>
|
||||
<CustomAnalytics
|
||||
analytics={analytics}
|
||||
analyticsError={analyticsError}
|
||||
params={params}
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
fullScreen={fullScreen}
|
||||
user={user}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
63
apps/app/components/analytics/scope-and-demand/demand.tsx
Normal file
63
apps/app/components/analytics/scope-and-demand/demand.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
// icons
|
||||
import { PlayIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IDefaultAnalyticsResponse } from "types";
|
||||
// constants
|
||||
import { STATE_GROUP_COLORS } from "constants/state";
|
||||
|
||||
type Props = {
|
||||
defaultAnalytics: IDefaultAnalyticsResponse;
|
||||
};
|
||||
|
||||
export const AnalyticsDemand: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||
<div className="space-y-3 rounded-[10px] border border-brand-base p-3">
|
||||
<h5 className="text-xs text-red-500">DEMAND</h5>
|
||||
<div>
|
||||
<h4 className="text-brand-bas text-base font-medium">Total open tasks</h4>
|
||||
<h3 className="mt-1 text-xl font-semibold">{defaultAnalytics.open_issues}</h3>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{defaultAnalytics?.open_issues_classified.map((group) => {
|
||||
const percentage = ((group.state_count / defaultAnalytics.total_issues) * 100).toFixed(0);
|
||||
|
||||
return (
|
||||
<div key={group.state_group} className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor: STATE_GROUP_COLORS[group.state_group],
|
||||
}}
|
||||
/>
|
||||
<h6 className="capitalize">{group.state_group}</h6>
|
||||
<span className="ml-1 rounded-3xl bg-brand-surface-2 px-2 py-0.5 text-[0.65rem] text-brand-secondary">
|
||||
{group.state_count}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-brand-secondary">{percentage}%</p>
|
||||
</div>
|
||||
<div className="bar relative h-1 w-full rounded bg-brand-surface-2">
|
||||
<div
|
||||
className="absolute top-0 left-0 h-1 rounded duration-300"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
backgroundColor: STATE_GROUP_COLORS[group.state_group],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="!mt-6 flex w-min items-center gap-2 whitespace-nowrap rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
|
||||
<p className="flex items-center gap-1 text-brand-secondary">
|
||||
<PlayIcon className="h-4 w-4 -rotate-90" aria-hidden="true" />
|
||||
<span>Estimate Demand:</span>
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{defaultAnalytics.open_estimate_sum}/{defaultAnalytics.total_estimate_sum}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
5
apps/app/components/analytics/scope-and-demand/index.ts
Normal file
5
apps/app/components/analytics/scope-and-demand/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./demand";
|
||||
export * from "./leaderboard";
|
||||
export * from "./scope-and-demand";
|
||||
export * from "./scope";
|
||||
export * from "./year-wise-issues";
|
||||
@@ -0,0 +1,48 @@
|
||||
type Props = {
|
||||
users: {
|
||||
avatar: string | null;
|
||||
email: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
count: number;
|
||||
}[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
|
||||
<div className="p-3 border border-brand-base rounded-[10px]">
|
||||
<h6 className="text-base font-medium">{title}</h6>
|
||||
{users.length > 0 ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.email ?? "None"}
|
||||
className="flex items-start justify-between gap-4 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{user && user.avatar && user.avatar !== "" ? (
|
||||
<div className="relative rounded-full h-4 w-4 flex-shrink-0">
|
||||
<img
|
||||
src={user.avatar}
|
||||
className="absolute top-0 left-0 h-full w-full object-cover rounded-full"
|
||||
alt={user.email ?? "None"}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid place-items-center flex-shrink-0 rounded-full bg-gray-700 text-[11px] capitalize text-white h-4 w-4">
|
||||
{user.firstName !== "" ? user.firstName[0] : "?"}
|
||||
</div>
|
||||
)}
|
||||
<span className="break-all text-brand-secondary">
|
||||
{user.firstName !== "" ? `${user.firstName} ${user.lastName}` : "No assignee"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex-shrink-0">{user.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-brand-secondary text-center text-sm py-8">No matching data found.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import analyticsService from "services/analytics.service";
|
||||
// components
|
||||
import {
|
||||
AnalyticsDemand,
|
||||
AnalyticsLeaderboard,
|
||||
AnalyticsScope,
|
||||
AnalyticsYearWiseIssues,
|
||||
} from "components/analytics";
|
||||
// ui
|
||||
import { Loader, PrimaryButton } from "components/ui";
|
||||
// fetch-keys
|
||||
import { DEFAULT_ANALYTICS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
fullScreen?: boolean;
|
||||
};
|
||||
|
||||
export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const isProjectLevel = projectId ? true : false;
|
||||
|
||||
const params = isProjectLevel
|
||||
? {
|
||||
project: projectId ? [projectId.toString()] : null,
|
||||
cycle: cycleId ? cycleId.toString() : null,
|
||||
module: moduleId ? moduleId.toString() : null,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
data: defaultAnalytics,
|
||||
error: defaultAnalyticsError,
|
||||
mutate: mutateDefaultAnalytics,
|
||||
} = useSWR(
|
||||
workspaceSlug ? DEFAULT_ANALYTICS(workspaceSlug.toString(), params) : null,
|
||||
workspaceSlug
|
||||
? () => analyticsService.getDefaultAnalytics(workspaceSlug.toString(), params)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!defaultAnalyticsError ? (
|
||||
defaultAnalytics ? (
|
||||
<div className="h-full overflow-y-auto p-5 text-sm">
|
||||
<div className={`grid grid-cols-1 gap-5 ${fullScreen ? "md:grid-cols-2" : ""}`}>
|
||||
<AnalyticsDemand defaultAnalytics={defaultAnalytics} />
|
||||
<AnalyticsScope defaultAnalytics={defaultAnalytics} />
|
||||
<AnalyticsLeaderboard
|
||||
users={defaultAnalytics.most_issue_created_user?.map((user) => ({
|
||||
avatar: user?.created_by__avatar,
|
||||
email: user?.created_by__email,
|
||||
firstName: user?.created_by__first_name,
|
||||
lastName: user?.created_by__last_name,
|
||||
count: user?.count,
|
||||
}))}
|
||||
title="Most issues created"
|
||||
/>
|
||||
<AnalyticsLeaderboard
|
||||
users={defaultAnalytics.most_issue_closed_user?.map((user) => ({
|
||||
avatar: user?.assignees__avatar,
|
||||
email: user?.assignees__email,
|
||||
firstName: user?.assignees__first_name,
|
||||
lastName: user?.assignees__last_name,
|
||||
count: user?.count,
|
||||
}))}
|
||||
title="Most issues closed"
|
||||
/>
|
||||
<div className={fullScreen ? "md:col-span-2" : ""}>
|
||||
<AnalyticsYearWiseIssues defaultAnalytics={defaultAnalytics} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="grid grid-cols-1 gap-5 p-5 lg:grid-cols-2">
|
||||
<Loader.Item height="250px" />
|
||||
<Loader.Item height="250px" />
|
||||
<Loader.Item height="250px" />
|
||||
<Loader.Item height="250px" />
|
||||
</Loader>
|
||||
)
|
||||
) : (
|
||||
<div className="grid h-full place-items-center p-5">
|
||||
<div className="space-y-4 text-brand-secondary">
|
||||
<p className="text-sm">There was some error in fetching the data.</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<PrimaryButton onClick={() => mutateDefaultAnalytics()}>Refresh</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
84
apps/app/components/analytics/scope-and-demand/scope.tsx
Normal file
84
apps/app/components/analytics/scope-and-demand/scope.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
// ui
|
||||
import { BarGraph } from "components/ui";
|
||||
// types
|
||||
import { IDefaultAnalyticsResponse } from "types";
|
||||
|
||||
type Props = {
|
||||
defaultAnalytics: IDefaultAnalyticsResponse;
|
||||
};
|
||||
|
||||
export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||
<div className="rounded-[10px] border border-brand-base">
|
||||
<h5 className="p-3 text-xs text-green-500">SCOPE</h5>
|
||||
<div className="divide-y divide-brand-base">
|
||||
<div>
|
||||
<h6 className="px-3 text-base font-medium">Pending issues</h6>
|
||||
{defaultAnalytics.pending_issue_user.length > 0 ? (
|
||||
<BarGraph
|
||||
data={defaultAnalytics.pending_issue_user}
|
||||
indexBy="assignees__email"
|
||||
keys={["count"]}
|
||||
height="250px"
|
||||
colors={() => `#f97316`}
|
||||
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)}
|
||||
tooltip={(datum) => {
|
||||
const assignee = defaultAnalytics.pending_issue_user.find(
|
||||
(a) => a.assignees__email === `${datum.indexValue}`
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
|
||||
<span className="font-medium text-brand-secondary">
|
||||
{assignee
|
||||
? assignee.assignees__first_name + " " + assignee.assignees__last_name
|
||||
: "No assignee"}
|
||||
:{" "}
|
||||
</span>
|
||||
{datum.value}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
axisBottom={{
|
||||
renderTick: (datum) => {
|
||||
const avatar =
|
||||
defaultAnalytics.pending_issue_user[datum.tickIndex]?.assignees__avatar ?? "";
|
||||
|
||||
if (avatar && avatar !== "")
|
||||
return (
|
||||
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||
<image
|
||||
x={-8}
|
||||
y={10}
|
||||
width={16}
|
||||
height={16}
|
||||
xlinkHref={avatar}
|
||||
style={{ clipPath: "circle(50%)" }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||
<circle cy={18} r={8} fill="#374151" />
|
||||
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
|
||||
{datum.value ? `${datum.value}`.toUpperCase()[0] : "?"}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
},
|
||||
}}
|
||||
margin={{ top: 20 }}
|
||||
theme={{
|
||||
background: "rgb(var(--color-bg-base))",
|
||||
axis: {},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-brand-secondary text-center text-sm py-8">
|
||||
No matching data found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
// ui
|
||||
import { LineGraph } from "components/ui";
|
||||
// types
|
||||
import { IDefaultAnalyticsResponse } from "types";
|
||||
// constants
|
||||
import { MONTHS_LIST } from "constants/calendar";
|
||||
|
||||
type Props = {
|
||||
defaultAnalytics: IDefaultAnalyticsResponse;
|
||||
};
|
||||
|
||||
export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) => {
|
||||
const currentMonth = new Date().getMonth();
|
||||
const startMonth = Math.floor(currentMonth / 3) * 3 + 1;
|
||||
const quarterMonthsList = [startMonth, startMonth + 1, startMonth + 2];
|
||||
|
||||
return (
|
||||
<div className="py-3 border border-brand-base rounded-[10px]">
|
||||
<h1 className="px-3 text-base font-medium">Issues closed in a year</h1>
|
||||
{defaultAnalytics.issue_completed_month_wise.length > 0 ? (
|
||||
<LineGraph
|
||||
data={[
|
||||
{
|
||||
id: "issues_closed",
|
||||
color: "rgb(var(--color-accent))",
|
||||
data: MONTHS_LIST.map((month) => ({
|
||||
x: month.label.substring(0, 3),
|
||||
y:
|
||||
defaultAnalytics.issue_completed_month_wise.find(
|
||||
(data) => data.month === month.value
|
||||
)?.count || 0,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
customYAxisTickValues={defaultAnalytics.issue_completed_month_wise.map((data) => {
|
||||
if (quarterMonthsList.includes(data.month)) return data.count;
|
||||
|
||||
return 0;
|
||||
})}
|
||||
height="300px"
|
||||
colors={(datum) => datum.color}
|
||||
curve="monotoneX"
|
||||
margin={{ top: 20 }}
|
||||
theme={{
|
||||
background: "rgb(var(--color-bg-base))",
|
||||
}}
|
||||
enableArea
|
||||
/>
|
||||
) : (
|
||||
<div className="text-brand-secondary text-center text-sm py-8">No matching data found.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
4
apps/app/components/analytics/select/index.ts
Normal file
4
apps/app/components/analytics/select/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./project";
|
||||
export * from "./segment";
|
||||
export * from "./x-axis";
|
||||
export * from "./y-axis";
|
||||
41
apps/app/components/analytics/select/project.tsx
Normal file
41
apps/app/components/analytics/select/project.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
// ui
|
||||
import { CustomSearchSelect } from "components/ui";
|
||||
// types
|
||||
import { IProject } from "types";
|
||||
|
||||
type Props = {
|
||||
value: string[] | null | undefined;
|
||||
onChange: (val: string[] | null) => void;
|
||||
projects: IProject[];
|
||||
};
|
||||
|
||||
export const SelectProject: React.FC<Props> = ({ value, onChange, projects }) => {
|
||||
const options = projects?.map((project) => ({
|
||||
value: project.id,
|
||||
query: project.name + project.identifier,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-brand-secondary text-[0.65rem]">{project.identifier}</span>
|
||||
{project.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
value={value ?? []}
|
||||
onChange={(val: string[]) => onChange(val)}
|
||||
options={options}
|
||||
label={
|
||||
value && value.length > 0
|
||||
? projects
|
||||
.filter((p) => value.includes(p.id))
|
||||
.map((p) => p.identifier)
|
||||
.join(", ")
|
||||
: "All projects"
|
||||
}
|
||||
optionsClassName="min-w-full"
|
||||
multiple
|
||||
/>
|
||||
);
|
||||
};
|
||||
48
apps/app/components/analytics/select/segment.tsx
Normal file
48
apps/app/components/analytics/select/segment.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// ui
|
||||
import { CustomSelect } from "components/ui";
|
||||
// types
|
||||
import { IAnalyticsParams, TXAxisValues } from "types";
|
||||
// constants
|
||||
import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics";
|
||||
|
||||
type Props = {
|
||||
value: TXAxisValues | null | undefined;
|
||||
onChange: () => void;
|
||||
params: IAnalyticsParams;
|
||||
};
|
||||
|
||||
export const SelectSegment: React.FC<Props> = ({ value, onChange, params }) => {
|
||||
const router = useRouter();
|
||||
const { cycleId, moduleId } = router.query;
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={
|
||||
<span>
|
||||
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label ?? (
|
||||
<span className="text-brand-secondary">No value</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
onChange={onChange}
|
||||
width="w-full"
|
||||
maxHeight="lg"
|
||||
>
|
||||
<CustomSelect.Option value={null}>No value</CustomSelect.Option>
|
||||
{ANALYTICS_X_AXIS_VALUES.map((item) => {
|
||||
if (params.x_axis === item.value) return null;
|
||||
if (cycleId && item.value === "issue_cycle__cycle__name") return null;
|
||||
if (moduleId && item.value === "issue_module__module__name") return null;
|
||||
|
||||
return (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
);
|
||||
};
|
||||
39
apps/app/components/analytics/select/x-axis.tsx
Normal file
39
apps/app/components/analytics/select/x-axis.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// ui
|
||||
import { CustomSelect } from "components/ui";
|
||||
// types
|
||||
import { TXAxisValues } from "types";
|
||||
// constants
|
||||
import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics";
|
||||
|
||||
type Props = {
|
||||
value: TXAxisValues;
|
||||
onChange: (val: string) => void;
|
||||
};
|
||||
|
||||
export const SelectXAxis: React.FC<Props> = ({ value, onChange }) => {
|
||||
const router = useRouter();
|
||||
const { cycleId, moduleId } = router.query;
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={<span>{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label}</span>}
|
||||
onChange={onChange}
|
||||
width="w-full"
|
||||
maxHeight="lg"
|
||||
>
|
||||
{ANALYTICS_X_AXIS_VALUES.map((item) => {
|
||||
if (cycleId && item.value === "issue_cycle__cycle__name") return null;
|
||||
if (moduleId && item.value === "issue_module__module__name") return null;
|
||||
|
||||
return (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
);
|
||||
};
|
||||
26
apps/app/components/analytics/select/y-axis.tsx
Normal file
26
apps/app/components/analytics/select/y-axis.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
// ui
|
||||
import { CustomSelect } from "components/ui";
|
||||
// types
|
||||
import { TYAxisValues } from "types";
|
||||
// constants
|
||||
import { ANALYTICS_Y_AXIS_VALUES } from "constants/analytics";
|
||||
|
||||
type Props = {
|
||||
value: TYAxisValues;
|
||||
onChange: () => void;
|
||||
};
|
||||
|
||||
export const SelectYAxis: React.FC<Props> = ({ value, onChange }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={<span>{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}</span>}
|
||||
onChange={onChange}
|
||||
width="w-full"
|
||||
>
|
||||
{ANALYTICS_Y_AXIS_VALUES.map((item) => (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
);
|
||||
@@ -21,13 +21,8 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
|
||||
const { asPath: currentPath } = useRouter();
|
||||
|
||||
return (
|
||||
<DefaultLayout
|
||||
meta={{
|
||||
title: "Plane - Not Authorized",
|
||||
description: "You are not authorized to view this page",
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
|
||||
<DefaultLayout>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-brand-surface-1 text-center">
|
||||
<div className="h-44 w-72">
|
||||
<Image
|
||||
src={type === "project" ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg}
|
||||
@@ -36,13 +31,15 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
|
||||
alt="ProjectSettingImg"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-xl font-medium">Oops! You are not authorized to view this page</h1>
|
||||
<h1 className="text-xl font-medium text-brand-base">
|
||||
Oops! You are not authorized to view this page
|
||||
</h1>
|
||||
|
||||
<div className="w-full max-w-md text-base text-brand-secondary">
|
||||
{user ? (
|
||||
<p>
|
||||
You have signed in as {user.email}. <br />
|
||||
<Link href={`/signin?next=${currentPath}`}>
|
||||
<Link href={`/?next=${currentPath}`}>
|
||||
<a className="font-medium text-brand-base">Sign in</a>
|
||||
</Link>{" "}
|
||||
with different account that has access to this page.
|
||||
@@ -50,7 +47,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
|
||||
) : (
|
||||
<p>
|
||||
You need to{" "}
|
||||
<Link href={`/signin?next=${currentPath}`}>
|
||||
<Link href={`/?next=${currentPath}`}>
|
||||
<a className="font-medium text-brand-base">Sign in</a>
|
||||
</Link>{" "}
|
||||
with an account that has access to this page.
|
||||
|
||||
@@ -41,11 +41,11 @@ export const JoinProject: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-brand-surface-1 text-center">
|
||||
<div className="h-44 w-72">
|
||||
<Image src={JoinProjectImg} height="176" width="288" alt="JoinProject" />
|
||||
</div>
|
||||
<h1 className="text-xl font-medium">You are not a member of this project</h1>
|
||||
<h1 className="text-xl font-medium text-brand-base">You are not a member of this project</h1>
|
||||
|
||||
<div className="w-full max-w-md text-base text-brand-secondary">
|
||||
<p className="mx-auto w-full text-sm md:w-3/4">
|
||||
|
||||
@@ -1,44 +1,34 @@
|
||||
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="mx-auto w-1/2 text-sm text-brand-secondary">
|
||||
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 justify-center gap-2">
|
||||
<Link href="/invitations">
|
||||
<a>
|
||||
<SecondaryButton>Check pending invites</SecondaryButton>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="/create-workspace">
|
||||
<a>
|
||||
<PrimaryButton>Create new workspace</PrimaryButton>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
export const NotAWorkspaceMember = () => (
|
||||
<DefaultLayout>
|
||||
<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="mx-auto w-1/2 text-sm text-brand-secondary">
|
||||
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 justify-center gap-2">
|
||||
<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>
|
||||
</DefaultLayout>
|
||||
);
|
||||
};
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
// icons
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||
import { Icon } from "components/ui";
|
||||
|
||||
type BreadcrumbsProps = {
|
||||
children: any;
|
||||
@@ -16,10 +17,13 @@ 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-brand-base text-center text-sm hover:bg-brand-surface-1"
|
||||
className="group grid h-7 w-7 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" />
|
||||
<Icon
|
||||
iconName="keyboard_backspace"
|
||||
className="text-base leading-4 text-brand-secondary group-hover:text-brand-base"
|
||||
/>
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Command } from "cmdk";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
// constants
|
||||
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
// icons
|
||||
@@ -18,9 +18,10 @@ import { Avatar } from "components/ui";
|
||||
type Props = {
|
||||
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
|
||||
issue: IIssue;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
|
||||
export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue, user }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
@@ -57,18 +58,21 @@ export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue }
|
||||
async (formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
mutate(
|
||||
mutate<IIssue>(
|
||||
ISSUE_DETAILS(issueId as string),
|
||||
(prevData: IIssue) => ({
|
||||
...prevData,
|
||||
...formData,
|
||||
}),
|
||||
async (prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
return {
|
||||
...prevData,
|
||||
...formData,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const payload = { ...formData };
|
||||
await issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
|
||||
.then(() => {
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||
})
|
||||
@@ -80,7 +84,7 @@ export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue }
|
||||
);
|
||||
|
||||
const handleIssueAssignees = (assignee: string) => {
|
||||
const updatedAssignees = issue.assignees ?? [];
|
||||
const updatedAssignees = issue.assignees_list ?? [];
|
||||
|
||||
if (updatedAssignees.includes(assignee)) {
|
||||
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Command } from "cmdk";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
// constants
|
||||
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
import { PRIORITIES } from "constants/project";
|
||||
@@ -17,9 +17,10 @@ import { CheckIcon, getPriorityIcon } from "components/icons";
|
||||
type Props = {
|
||||
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
|
||||
issue: IIssue;
|
||||
user: ICurrentUserResponse;
|
||||
};
|
||||
|
||||
export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
|
||||
export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue, user }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
@@ -27,18 +28,22 @@ export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue }
|
||||
async (formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
mutate(
|
||||
mutate<IIssue>(
|
||||
ISSUE_DETAILS(issueId as string),
|
||||
(prevData: IIssue) => ({
|
||||
...prevData,
|
||||
...formData,
|
||||
}),
|
||||
async (prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
...formData,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const payload = { ...formData };
|
||||
await issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
|
||||
.then(() => {
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getStatesList } from "helpers/state.helper";
|
||||
import issuesService from "services/issues.service";
|
||||
import stateService from "services/state.service";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
// fetch keys
|
||||
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, STATES_LIST } from "constants/fetch-keys";
|
||||
// icons
|
||||
@@ -21,9 +21,10 @@ import { CheckIcon, getStateGroupIcon } from "components/icons";
|
||||
type Props = {
|
||||
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
|
||||
issue: IIssue;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
|
||||
export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue, user }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
@@ -39,18 +40,21 @@ export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue }) =
|
||||
async (formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
mutate(
|
||||
mutate<IIssue>(
|
||||
ISSUE_DETAILS(issueId as string),
|
||||
(prevData: IIssue) => ({
|
||||
...prevData,
|
||||
...formData,
|
||||
}),
|
||||
async (prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
return {
|
||||
...prevData,
|
||||
...formData,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const payload = { ...formData };
|
||||
await issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
|
||||
.then(() => {
|
||||
mutateIssueDetails();
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||
|
||||
@@ -120,18 +120,23 @@ export const CommandPalette: React.FC = () => {
|
||||
async (formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
mutate(
|
||||
mutate<IIssue>(
|
||||
ISSUE_DETAILS(issueId as string),
|
||||
(prevData: IIssue) => ({
|
||||
...prevData,
|
||||
...formData,
|
||||
}),
|
||||
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
...formData,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const payload = { ...formData };
|
||||
await issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
|
||||
.then(() => {
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||
mutate(ISSUE_DETAILS(issueId as string));
|
||||
@@ -325,25 +330,33 @@ export const CommandPalette: React.FC = () => {
|
||||
<>
|
||||
<ShortcutsModal isOpen={isShortcutsModalOpen} setIsOpen={setIsShortcutsModalOpen} />
|
||||
{workspaceSlug && (
|
||||
<CreateProjectModal isOpen={isProjectModalOpen} setIsOpen={setIsProjectModalOpen} />
|
||||
<CreateProjectModal
|
||||
isOpen={isProjectModalOpen}
|
||||
setIsOpen={setIsProjectModalOpen}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{projectId && (
|
||||
<>
|
||||
<CreateUpdateCycleModal
|
||||
isOpen={isCreateCycleModalOpen}
|
||||
handleClose={() => setIsCreateCycleModalOpen(false)}
|
||||
user={user}
|
||||
/>
|
||||
<CreateUpdateModuleModal
|
||||
isOpen={isCreateModuleModalOpen}
|
||||
setIsOpen={setIsCreateModuleModalOpen}
|
||||
user={user}
|
||||
/>
|
||||
<CreateUpdateViewModal
|
||||
handleClose={() => setIsCreateViewModalOpen(false)}
|
||||
isOpen={isCreateViewModalOpen}
|
||||
user={user}
|
||||
/>
|
||||
<CreateUpdatePageModal
|
||||
isOpen={isCreateUpdatePageModalOpen}
|
||||
handleClose={() => setIsCreateUpdatePageModalOpen(false)}
|
||||
user={user}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -352,6 +365,7 @@ export const CommandPalette: React.FC = () => {
|
||||
handleClose={() => setDeleteIssueModal(false)}
|
||||
isOpen={deleteIssueModal}
|
||||
data={issueDetails}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -362,6 +376,7 @@ export const CommandPalette: React.FC = () => {
|
||||
<BulkDeleteIssuesModal
|
||||
isOpen={isBulkDeleteIssuesModalOpen}
|
||||
setIsOpen={setIsBulkDeleteIssuesModalOpen}
|
||||
user={user}
|
||||
/>
|
||||
<Transition.Root
|
||||
show={isPaletteOpen}
|
||||
@@ -821,7 +836,7 @@ export const CommandPalette: React.FC = () => {
|
||||
>
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<SettingIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Billings and Plans
|
||||
Billing and Plans
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
@@ -839,7 +854,7 @@ export const CommandPalette: React.FC = () => {
|
||||
>
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<SettingIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Import/Export
|
||||
Import/ Export
|
||||
</div>
|
||||
</Command.Item>
|
||||
</>
|
||||
@@ -849,6 +864,7 @@ export const CommandPalette: React.FC = () => {
|
||||
<ChangeIssueState
|
||||
issue={issueDetails}
|
||||
setIsPaletteOpen={setIsPaletteOpen}
|
||||
user={user}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -856,12 +872,14 @@ export const CommandPalette: React.FC = () => {
|
||||
<ChangeIssuePriority
|
||||
issue={issueDetails}
|
||||
setIsPaletteOpen={setIsPaletteOpen}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{page === "change-issue-assignee" && issueDetails && (
|
||||
<ChangeIssueAssignee
|
||||
issue={issueDetails}
|
||||
setIsPaletteOpen={setIsPaletteOpen}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{page === "change-interface-theme" && (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SingleBoard } from "components/core/board-view/single-board";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, IState, UserAuth } from "types";
|
||||
import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types";
|
||||
import { getStateGroupIcon } from "components/icons";
|
||||
|
||||
type Props = {
|
||||
@@ -19,6 +19,7 @@ type Props = {
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
|
||||
isCompleted?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
@@ -33,6 +34,7 @@ export const AllBoards: React.FC<Props> = ({
|
||||
handleTrashBox,
|
||||
removeIssue,
|
||||
isCompleted = false,
|
||||
user,
|
||||
userAuth,
|
||||
}) => {
|
||||
const {
|
||||
@@ -44,7 +46,7 @@ export const AllBoards: React.FC<Props> = ({
|
||||
return (
|
||||
<>
|
||||
{groupedByIssues ? (
|
||||
<div className="horizontal-scroll-enable flex h-[calc(100vh-140px)] gap-x-4">
|
||||
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8">
|
||||
{Object.keys(groupedByIssues).map((singleGroup, index) => {
|
||||
const currentState =
|
||||
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
|
||||
@@ -65,6 +67,7 @@ export const AllBoards: React.FC<Props> = ({
|
||||
handleTrashBox={handleTrashBox}
|
||||
removeIssue={removeIssue}
|
||||
isCompleted={isCompleted}
|
||||
user={user}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -17,7 +17,7 @@ import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, IState, UserAuth } from "types";
|
||||
import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types";
|
||||
|
||||
type Props = {
|
||||
type?: "issue" | "cycle" | "module";
|
||||
@@ -31,6 +31,7 @@ type Props = {
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
|
||||
isCompleted?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
@@ -46,6 +47,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
handleTrashBox,
|
||||
removeIssue,
|
||||
isCompleted = false,
|
||||
user,
|
||||
userAuth,
|
||||
}) => {
|
||||
// collapse/expand
|
||||
@@ -60,51 +62,46 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentState?.group === "completed" || currentState?.group === "cancelled")
|
||||
setIsCollapsed(false);
|
||||
}, [currentState]);
|
||||
|
||||
return (
|
||||
<div className={`h-full flex-shrink-0 ${!isCollapsed ? "" : "w-96"}`}>
|
||||
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3"}`}>
|
||||
<BoardHeader
|
||||
addIssueToState={addIssueToState}
|
||||
currentState={currentState}
|
||||
groupTitle={groupTitle}
|
||||
isCollapsed={isCollapsed}
|
||||
setIsCollapsed={setIsCollapsed}
|
||||
isCompleted={isCompleted}
|
||||
/>
|
||||
{isCollapsed && (
|
||||
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`relative h-full overflow-y-auto p-1 ${
|
||||
snapshot.isDraggingOver ? "bg-brand-base/20" : ""
|
||||
} ${!isCollapsed ? "hidden" : "block"}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{orderBy !== "sort_order" && (
|
||||
<>
|
||||
<div
|
||||
className={`absolute ${
|
||||
snapshot.isDraggingOver ? "block" : "hidden"
|
||||
} pointer-events-none top-0 left-0 z-[99] h-full w-full bg-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-brand-base p-2 text-xs`}
|
||||
>
|
||||
This board is ordered by{" "}
|
||||
{replaceUnderscoreIfSnakeCase(
|
||||
orderBy ? (orderBy[0] === "-" ? orderBy.slice(1) : orderBy) : "created_at"
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}>
|
||||
<BoardHeader
|
||||
addIssueToState={addIssueToState}
|
||||
currentState={currentState}
|
||||
groupTitle={groupTitle}
|
||||
isCollapsed={isCollapsed}
|
||||
setIsCollapsed={setIsCollapsed}
|
||||
isCompleted={isCompleted}
|
||||
/>
|
||||
{isCollapsed && (
|
||||
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`relative h-full ${
|
||||
orderBy !== "sort_order" && snapshot.isDraggingOver ? "bg-brand-base/20" : ""
|
||||
} ${!isCollapsed ? "hidden" : "flex flex-col"}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{orderBy !== "sort_order" && (
|
||||
<>
|
||||
<div
|
||||
className={`absolute ${
|
||||
snapshot.isDraggingOver ? "block" : "hidden"
|
||||
} pointer-events-none top-0 left-0 z-[99] h-full w-full bg-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-brand-base p-2 text-xs`}
|
||||
>
|
||||
This board is ordered by{" "}
|
||||
{replaceUnderscoreIfSnakeCase(
|
||||
orderBy ? (orderBy[0] === "-" ? orderBy.slice(1) : orderBy) : "created_at"
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="pt-3 overflow-hidden overflow-y-scroll">
|
||||
{groupedByIssues?.[groupTitle].map((issue, index) => (
|
||||
<Draggable
|
||||
key={issue.id}
|
||||
@@ -134,6 +131,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
removeIssue(issue.bridge_id, issue.id);
|
||||
}}
|
||||
isCompleted={isCompleted}
|
||||
user={user}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
)}
|
||||
@@ -146,10 +144,12 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
>
|
||||
{provided.placeholder}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{type === "issue" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 font-medium text-brand-accent outline-none"
|
||||
className="flex items-center gap-2 font-medium text-brand-accent outline-none p-1"
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
@@ -167,7 +167,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
Add Issue
|
||||
</button>
|
||||
}
|
||||
optionsPosition="left"
|
||||
position="left"
|
||||
noBorder
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={addIssueToState}>
|
||||
@@ -182,10 +182,10 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user