Compare commits
138
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6e4c6da41 | ||
|
|
8ae1e38ef9 | ||
|
|
c37264f3c8 | ||
|
|
043d8e8227 | ||
|
|
a488a3411c | ||
|
|
b8f1734738 | ||
|
|
d946c51ef1 | ||
|
|
a40517015b | ||
|
|
967ad77078 | ||
|
|
f5f683b8b8 | ||
|
|
2aef40b7c5 | ||
|
|
1eba6c24cd | ||
|
|
a46eccf300 | ||
|
|
780b239ecb | ||
|
|
a85517de99 | ||
|
|
fb68b77068 | ||
|
|
20e7dc68e6 | ||
|
|
5ef51edad7 | ||
|
|
06a664f6b9 | ||
|
|
cdab12e4b6 | ||
|
|
59f1cc1962 | ||
|
|
562e50815d | ||
|
|
463f4781ae | ||
|
|
653005bb3b | ||
|
|
f1fda4ae4a | ||
|
|
fb74875cde | ||
|
|
c96225c812 | ||
|
|
527ecd7d22 | ||
|
|
acd8f8d2d6 | ||
|
|
f4cc103238 | ||
|
|
89f2f87b97 | ||
|
|
eed047bfe3 | ||
|
|
8cf4260219 | ||
|
|
1b55411919 | ||
|
|
dc5edca34d | ||
|
|
2884b0e22e | ||
|
|
091fdb89ac | ||
|
|
a678844073 | ||
|
|
4cb5c4d9df | ||
|
|
45c9dfb3cf | ||
|
|
4c78cd7c7e | ||
|
|
c4229c9d55 | ||
|
|
42c4c46939 | ||
|
|
6918393b63 | ||
|
|
6196c750f1 | ||
|
|
ed6dd37043 | ||
|
|
efa3eda85e | ||
|
|
eb0877a3c8 | ||
|
|
73fd6e641c | ||
|
|
34dd19cb00 | ||
|
|
ecc277c571 | ||
|
|
d69f025b9a | ||
|
|
ed4a0518fc | ||
|
|
2e2747c1f9 | ||
|
|
aa09ec7cd4 | ||
|
|
037ddd8bdb | ||
|
|
d1978be778 | ||
|
|
d2717a221c | ||
|
|
cb6ecc86cc | ||
|
|
6972a520ce | ||
|
|
4f4f1d92e8 | ||
|
|
87a606446f | ||
|
|
1b79517f07 | ||
|
|
e5681534d7 | ||
|
|
03065d2c1d | ||
|
|
5d3c64752c | ||
|
|
4104f87d41 | ||
|
|
32d14d7000 | ||
|
|
1c4ca42603 | ||
|
|
3077cc410e | ||
|
|
392075a9eb | ||
|
|
c252650c9a | ||
|
|
16b43c7b02 | ||
|
|
354c3d95ce | ||
|
|
9739aa574d | ||
|
|
a6b0a7fa8e | ||
|
|
7fdaa64b90 | ||
|
|
6072c8b550 | ||
|
|
84fd1dca4b | ||
|
|
49a6c9582c | ||
|
|
245a0e92ee | ||
|
|
709d3a115b | ||
|
|
9c8b4afc20 | ||
|
|
0c880bbbc8 | ||
|
|
ea436c925a | ||
|
|
4bccbc9804 | ||
|
|
ac4bb1c1b4 | ||
|
|
0e3d5cc4eb | ||
|
|
6ac3cb9b31 | ||
|
|
e75947a5f4 | ||
|
|
e4777157a2 | ||
|
|
0605b5f60c | ||
|
|
ad27184a91 | ||
|
|
f87bb95236 | ||
|
|
f2fa6452c9 | ||
|
|
80461e6484 | ||
|
|
88165a8fdb | ||
|
|
42cceb5e65 | ||
|
|
15c7deb2db | ||
|
|
e60ef36bfe | ||
|
|
bc2c97b9c3 | ||
|
|
7f99b9a554 | ||
|
|
b74a0ea4d3 | ||
|
|
d9f11733ad | ||
|
|
87aab74579 | ||
|
|
d5dd971fb4 | ||
|
|
1789b8ddeb | ||
|
|
1caa109c16 | ||
|
|
b711fedb65 | ||
|
|
deaa63488b | ||
|
|
87737dbfbe | ||
|
|
fc1cffd524 | ||
|
|
6e574515e0 | ||
|
|
d87edede79 | ||
|
|
196724214b | ||
|
|
695892d66e | ||
|
|
a4e5138d1c | ||
|
|
f174a96ef2 | ||
|
|
5e53279734 | ||
|
|
bf852739cd | ||
|
|
f17e4c73a2 | ||
|
|
aee48f6fa4 | ||
|
|
f7d6219bd1 | ||
|
|
75d14ce1ef | ||
|
|
9659da5b31 | ||
|
|
51ade1295c | ||
|
|
ac3def2929 | ||
|
|
098a1950c7 | ||
|
|
03fd5feda6 | ||
|
|
270f7c4503 | ||
|
|
beff8536c9 | ||
|
|
5d8c5b22e8 | ||
|
|
60663821df | ||
|
|
302da646a8 | ||
|
|
e0e8ce633b | ||
|
|
f77d2d8c0a | ||
|
|
c50a0602f7 | ||
|
|
38daf72361 |
@@ -1,84 +0,0 @@
|
||||
name: Auto Merge or Create PR on Push
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "sync/**"
|
||||
|
||||
env:
|
||||
CURRENT_BRANCH: ${{ github.ref_name }}
|
||||
SOURCE_BRANCH: ${{ secrets.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce"
|
||||
TARGET_BRANCH: ${{ secrets.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
|
||||
REVIEWER: ${{ secrets.SYNC_PR_REVIEWER }}
|
||||
|
||||
jobs:
|
||||
Check_Branch:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }}
|
||||
steps:
|
||||
- name: Check if current branch matches the secret
|
||||
id: check-branch
|
||||
run: |
|
||||
if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then
|
||||
echo "MATCH=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "MATCH=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
Auto_Merge:
|
||||
if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }}
|
||||
needs: [Check_Branch]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for all branches and tags
|
||||
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email "actions@github.com"
|
||||
|
||||
- name: Setup GH CLI and Git Config
|
||||
run: |
|
||||
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install gh -y
|
||||
|
||||
- name: Check for merge conflicts
|
||||
id: conflicts
|
||||
run: |
|
||||
git fetch origin $TARGET_BRANCH
|
||||
git checkout $TARGET_BRANCH
|
||||
# Attempt to merge the main branch into the current branch
|
||||
if $(git merge --no-commit --no-ff $SOURCE_BRANCH); then
|
||||
echo "No merge conflicts detected."
|
||||
echo "HAS_CONFLICTS=false" >> $GITHUB_ENV
|
||||
else
|
||||
echo "Merge conflicts detected."
|
||||
echo "HAS_CONFLICTS=true" >> $GITHUB_ENV
|
||||
git merge --abort
|
||||
fi
|
||||
|
||||
- name: Merge Change to Target Branch
|
||||
if: env.HAS_CONFLICTS == 'false'
|
||||
run: |
|
||||
git commit -m "Merge branch '$SOURCE_BRANCH' into $TARGET_BRANCH"
|
||||
git push origin $TARGET_BRANCH
|
||||
|
||||
- name: Create PR to Target Branch
|
||||
if: env.HAS_CONFLICTS == 'true'
|
||||
run: |
|
||||
# Replace 'username' with the actual GitHub username of the reviewer.
|
||||
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "" --reviewer $REVIEWER)
|
||||
echo "Pull Request created: $PR_URL"
|
||||
@@ -1,28 +1,53 @@
|
||||
name: Create Sync Action
|
||||
name: Create PR on Sync
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- preview
|
||||
- "sync/**"
|
||||
|
||||
env:
|
||||
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
|
||||
CURRENT_BRANCH: ${{ github.ref_name }}
|
||||
SOURCE_BRANCH: ${{ vars.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce"
|
||||
TARGET_BRANCH: ${{ vars.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
|
||||
REVIEWER: ${{ vars.SYNC_PR_REVIEWER }}
|
||||
ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }}
|
||||
ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }}
|
||||
|
||||
jobs:
|
||||
sync_changes:
|
||||
Check_Branch:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }}
|
||||
steps:
|
||||
- name: Check if current branch matches the secret
|
||||
id: check-branch
|
||||
run: |
|
||||
if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then
|
||||
echo "MATCH=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "MATCH=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
Auto_Merge:
|
||||
if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }}
|
||||
needs: [Check_Branch]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
fetch-depth: 0 # Fetch all history for all branches and tags
|
||||
|
||||
- name: Setup GH CLI
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config user.name "$ACCOUNT_USER_NAME"
|
||||
git config user.email "$ACCOUNT_USER_EMAIL"
|
||||
|
||||
- name: Setup GH CLI and Git Config
|
||||
run: |
|
||||
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
@@ -31,25 +56,14 @@ jobs:
|
||||
sudo apt update
|
||||
sudo apt install gh -y
|
||||
|
||||
- name: Push Changes to Target Repo A
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
- name: Create PR to Target Branch
|
||||
run: |
|
||||
TARGET_REPO="${{ secrets.TARGET_REPO_A }}"
|
||||
TARGET_BRANCH="${{ secrets.TARGET_REPO_A_BRANCH_NAME }}"
|
||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||
|
||||
git checkout $SOURCE_BRANCH
|
||||
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
|
||||
|
||||
- name: Push Changes to Target Repo B
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
run: |
|
||||
TARGET_REPO="${{ secrets.TARGET_REPO_B }}"
|
||||
TARGET_BRANCH="${{ secrets.TARGET_REPO_B_BRANCH_NAME }}"
|
||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||
|
||||
git remote add target-origin-b "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||
git push target-origin-b $SOURCE_BRANCH:$TARGET_BRANCH
|
||||
# get all pull requests and check if there is already a PR
|
||||
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $SOURCE_BRANCH --state open --json number | jq '.[] | .number')
|
||||
if [ -n "$PR_EXISTS" ]; then
|
||||
echo "Pull Request already exists: $PR_EXISTS"
|
||||
else
|
||||
echo "Creating new pull request"
|
||||
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "")
|
||||
echo "Pull Request created: $PR_URL"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Sync Repositories
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- preview
|
||||
|
||||
env:
|
||||
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
|
||||
|
||||
jobs:
|
||||
sync_changes:
|
||||
runs-on: ubuntu-20.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup GH CLI
|
||||
run: |
|
||||
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install gh -y
|
||||
|
||||
- name: Push Changes to Target Repo
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
run: |
|
||||
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
|
||||
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
|
||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||
|
||||
git checkout $SOURCE_BRANCH
|
||||
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
|
||||
@@ -7,7 +7,7 @@
|
||||
</p>
|
||||
|
||||
<h3 align="center"><b>Plane</b></h3>
|
||||
<p align="center"><b>Open-source project management that unlocks customer value.</b></p>
|
||||
<p align="center"><b>Open-source project management that unlocks customer value</b></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.com/invite/A92xrEGCge">
|
||||
@@ -40,22 +40,22 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Meet [Plane](https://dub.sh/plane-website-readme). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind. 🧘♀️
|
||||
Meet [Plane](https://dub.sh/plane-website-readme), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘♀️
|
||||
|
||||
> 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 in our upcoming releases.
|
||||
> Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Discord](https://discord.com/invite/A92xrEGCge) or raise a GitHub issue. We read everything and respond to most.
|
||||
|
||||
## ⚡ Installation
|
||||
|
||||
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account where we offer a hosted solution for users.
|
||||
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account.
|
||||
|
||||
If you want more control over your data, prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
|
||||
If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/docker-compose).
|
||||
|
||||
| Installation Methods | Documentation Link |
|
||||
| Installation methods | Docs link |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Docker | [](https://docs.plane.so/self-hosting/methods/docker-compose) |
|
||||
| Kubernetes | [](https://docs.plane.so/kubernetes) |
|
||||
|
||||
`Instance admin` can configure instance settings using our [God-mode](https://docs.plane.so/instance-admin) feature.
|
||||
`Instance admins` can configure instance settings with [God-mode](https://docs.plane.so/instance-admin).
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.18.0"
|
||||
"version": "0.19.0"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
# Module improts
|
||||
from .base import BaseSerializer
|
||||
from .issue import IssueExpandSerializer
|
||||
from plane.db.models import InboxIssue
|
||||
|
||||
|
||||
class InboxIssueSerializer(BaseSerializer):
|
||||
|
||||
issue_detail = IssueExpandSerializer(read_only=True, source="issue")
|
||||
|
||||
class Meta:
|
||||
model = InboxIssue
|
||||
fields = "__all__"
|
||||
|
||||
@@ -6,9 +6,15 @@ from plane.api.views import (
|
||||
IssueLinkAPIEndpoint,
|
||||
IssueCommentAPIEndpoint,
|
||||
IssueActivityAPIEndpoint,
|
||||
WorkspaceIssueAPIEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/issues/<str:project__identifier>-<str:issue__identifier>/",
|
||||
WorkspaceIssueAPIEndpoint.as_view(),
|
||||
name="issue-by-identifier",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
|
||||
IssueAPIEndpoint.as_view(),
|
||||
|
||||
@@ -3,6 +3,7 @@ from .project import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint
|
||||
from .state import StateAPIEndpoint
|
||||
|
||||
from .issue import (
|
||||
WorkspaceIssueAPIEndpoint,
|
||||
IssueAPIEndpoint,
|
||||
LabelAPIEndpoint,
|
||||
IssueLinkAPIEndpoint,
|
||||
|
||||
@@ -19,7 +19,6 @@ from rest_framework.views import APIView
|
||||
# Module imports
|
||||
from plane.api.middleware.api_authentication import APIKeyAuthentication
|
||||
from plane.api.rate_limit import ApiKeyRateThrottle
|
||||
from plane.bgtasks.webhook_task import send_webhook
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.paginator import BasePaginator
|
||||
|
||||
@@ -38,40 +37,6 @@ class TimezoneMixin:
|
||||
timezone.deactivate()
|
||||
|
||||
|
||||
class WebhookMixin:
|
||||
webhook_event = None
|
||||
bulk = False
|
||||
|
||||
def finalize_response(self, request, response, *args, **kwargs):
|
||||
response = super().finalize_response(
|
||||
request, response, *args, **kwargs
|
||||
)
|
||||
|
||||
# Check for the case should webhook be sent
|
||||
if (
|
||||
self.webhook_event
|
||||
and self.request.method in ["POST", "PATCH", "DELETE"]
|
||||
and response.status_code in [200, 201, 204]
|
||||
):
|
||||
url = request.build_absolute_uri()
|
||||
parsed_url = urlparse(url)
|
||||
# Extract the scheme and netloc
|
||||
scheme = parsed_url.scheme
|
||||
netloc = parsed_url.netloc
|
||||
# Push the object to delay
|
||||
send_webhook.delay(
|
||||
event=self.webhook_event,
|
||||
payload=response.data,
|
||||
kw=self.kwargs,
|
||||
action=self.request.method,
|
||||
slug=self.workspace_slug,
|
||||
bulk=self.bulk,
|
||||
current_site=f"{scheme}://{netloc}",
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
|
||||
authentication_classes = [
|
||||
APIKeyAuthentication,
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
from django.core import serializers
|
||||
from django.db.models import Count, F, Func, OuterRef, Q, Sum
|
||||
from django.utils import timezone
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -26,10 +27,11 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
from .base import BaseAPIView, WebhookMixin
|
||||
from .base import BaseAPIView
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
|
||||
|
||||
class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
class CycleAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
This viewset automatically provides `list`, `create`, `retrieve`,
|
||||
`update` and `destroy` actions related to cycle.
|
||||
@@ -277,6 +279,16 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
project_id=project_id,
|
||||
owned_by=request.user,
|
||||
)
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
model_name="cycle",
|
||||
model_id=str(serializer.data["id"]),
|
||||
requested_data=request.data,
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED
|
||||
)
|
||||
@@ -295,6 +307,11 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
|
||||
current_instance = json.dumps(
|
||||
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
if cycle.archived_at:
|
||||
return Response(
|
||||
{"error": "Archived cycle cannot be edited"},
|
||||
@@ -344,6 +361,17 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save()
|
||||
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
model_name="cycle",
|
||||
model_id=str(serializer.data["id"]),
|
||||
requested_data=request.data,
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -515,7 +543,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
class CycleIssueAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
This viewset automatically provides `list`, `create`,
|
||||
and `destroy` actions related to cycle issues.
|
||||
|
||||
@@ -154,6 +154,13 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
||||
state=state,
|
||||
)
|
||||
|
||||
# create an inbox issue
|
||||
inbox_issue = InboxIssue.objects.create(
|
||||
inbox_id=inbox.id,
|
||||
project_id=project_id,
|
||||
issue=issue,
|
||||
source=request.data.get("source", "in-app"),
|
||||
)
|
||||
# Create an Issue Activity
|
||||
issue_activity.delay(
|
||||
type="issue.activity.created",
|
||||
@@ -163,14 +170,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
|
||||
# create an inbox issue
|
||||
inbox_issue = InboxIssue.objects.create(
|
||||
inbox_id=inbox.id,
|
||||
project_id=project_id,
|
||||
issue=issue,
|
||||
source=request.data.get("source", "in-app"),
|
||||
inbox=str(inbox_issue.id),
|
||||
)
|
||||
|
||||
serializer = InboxIssueSerializer(inbox_issue)
|
||||
@@ -260,6 +260,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
inbox=(inbox_issue.id),
|
||||
)
|
||||
issue_serializer.save()
|
||||
else:
|
||||
@@ -327,6 +328,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=False,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
inbox=str(inbox_issue.id),
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -48,10 +48,72 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
)
|
||||
|
||||
from .base import BaseAPIView, WebhookMixin
|
||||
from .base import BaseAPIView
|
||||
|
||||
|
||||
class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
class WorkspaceIssueAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
This viewset provides `retrieveByIssueId` on workspace level
|
||||
|
||||
"""
|
||||
|
||||
model = Issue
|
||||
webhook_event = "issue"
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
serializer_class = IssueSerializer
|
||||
|
||||
@property
|
||||
def project__identifier(self):
|
||||
return self.kwargs.get("project__identifier", None)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.issue_objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project__identifier=self.kwargs.get("project__identifier"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.order_by(self.kwargs.get("order_by", "-created_at"))
|
||||
).distinct()
|
||||
|
||||
def get(
|
||||
self, request, slug, project__identifier=None, issue__identifier=None
|
||||
):
|
||||
if issue__identifier and project__identifier:
|
||||
issue = Issue.issue_objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
).get(
|
||||
workspace__slug=slug,
|
||||
project__identifier=project__identifier,
|
||||
sequence_id=issue__identifier,
|
||||
)
|
||||
return Response(
|
||||
IssueSerializer(
|
||||
issue,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class IssueAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
This viewset automatically provides `list`, `create`, `retrieve`,
|
||||
`update` and `destroy` actions related to issue.
|
||||
@@ -282,7 +344,7 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
str(request.data.get("external_id"))
|
||||
request.data.get("external_id")
|
||||
and (issue.external_id != str(request.data.get("external_id")))
|
||||
and Issue.objects.filter(
|
||||
project_id=project_id,
|
||||
@@ -593,7 +655,7 @@ class IssueLinkAPIEndpoint(BaseAPIView):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
class IssueCommentAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
This viewset automatically provides `list`, `create`, `retrieve`,
|
||||
`update` and `destroy` actions related to comments of the particular issue.
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
from django.core import serializers
|
||||
from django.db.models import Count, F, Func, OuterRef, Prefetch, Q
|
||||
from django.utils import timezone
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -28,10 +29,11 @@ from plane.db.models import (
|
||||
Project,
|
||||
)
|
||||
|
||||
from .base import BaseAPIView, WebhookMixin
|
||||
from .base import BaseAPIView
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
|
||||
|
||||
class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
class ModuleAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
This viewset automatically provides `list`, `create`, `retrieve`,
|
||||
`update` and `destroy` actions related to module.
|
||||
@@ -163,6 +165,16 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save()
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
model_name="module",
|
||||
model_id=str(serializer.data["id"]),
|
||||
requested_data=request.data,
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
module = Module.objects.get(pk=serializer.data["id"])
|
||||
serializer = ModuleSerializer(module)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
@@ -172,6 +184,11 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
module = Module.objects.get(
|
||||
pk=pk, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
|
||||
current_instance = json.dumps(
|
||||
ModuleSerializer(module).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
if module.archived_at:
|
||||
return Response(
|
||||
{"error": "Archived module cannot be edited"},
|
||||
@@ -204,6 +221,18 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
serializer.save()
|
||||
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
model_name="module",
|
||||
model_id=str(serializer.data["id"]),
|
||||
requested_data=request.data,
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -260,7 +289,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
class ModuleIssueAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
This viewset automatically provides `list`, `create`, `retrieve`,
|
||||
`update` and `destroy` actions related to module issues.
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery
|
||||
from django.utils import timezone
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -23,11 +27,11 @@ from plane.db.models import (
|
||||
State,
|
||||
Workspace,
|
||||
)
|
||||
|
||||
from .base import BaseAPIView, WebhookMixin
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from .base import BaseAPIView
|
||||
|
||||
|
||||
class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
class ProjectAPIEndpoint(BaseAPIView):
|
||||
"""Project Endpoints to create, update, list, retrieve and delete endpoint"""
|
||||
|
||||
serializer_class = ProjectSerializer
|
||||
@@ -236,6 +240,17 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
.filter(pk=serializer.data["id"])
|
||||
.first()
|
||||
)
|
||||
# Model activity
|
||||
model_activity.delay(
|
||||
model_name="project",
|
||||
model_id=str(project.id),
|
||||
requested_data=request.data,
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
serializer = ProjectSerializer(project)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED
|
||||
@@ -265,7 +280,9 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
project = Project.objects.get(pk=pk)
|
||||
|
||||
current_instance = json.dumps(
|
||||
ProjectSerializer(project).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
if project.archived_at:
|
||||
return Response(
|
||||
{"error": "Archived project cannot be updated"},
|
||||
@@ -303,6 +320,17 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
||||
.filter(pk=serializer.data["id"])
|
||||
.first()
|
||||
)
|
||||
|
||||
model_activity.delay(
|
||||
model_name="project",
|
||||
model_id=str(project.id),
|
||||
requested_data=request.data,
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
serializer = ProjectSerializer(project)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
|
||||
@@ -79,6 +79,16 @@ class ProjectEntityPermission(BasePermission):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
# Handle requests based on project__identifier
|
||||
if hasattr(view, "project__identifier") and view.project__identifier:
|
||||
if request.method in SAFE_METHODS:
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project__identifier=view.project__identifier,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
## Safe Methods -> Handle the filtering logic in queryset
|
||||
if request.method in SAFE_METHODS:
|
||||
return ProjectMember.objects.filter(
|
||||
|
||||
@@ -442,7 +442,7 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
raise serializers.ValidationError("Invalid URL format.")
|
||||
|
||||
# Check URL scheme
|
||||
if not value.startswith(('http://', 'https://')):
|
||||
if not value.startswith(("http://", "https://")):
|
||||
raise serializers.ValidationError("Invalid URL scheme.")
|
||||
|
||||
return value
|
||||
@@ -636,6 +636,7 @@ class IssueInboxSerializer(DynamicBaseSerializer):
|
||||
"project_id",
|
||||
"created_at",
|
||||
"label_ids",
|
||||
"created_by",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
@@ -210,6 +210,7 @@ class ModuleSerializer(DynamicBaseSerializer):
|
||||
"backlog_issues",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"archived_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ from .user.base import (
|
||||
|
||||
from .oauth import OauthEndpoint
|
||||
|
||||
from .base import BaseAPIView, BaseViewSet, WebhookMixin
|
||||
from .base import BaseAPIView, BaseViewSet
|
||||
|
||||
from .workspace.base import (
|
||||
WorkSpaceViewSet,
|
||||
@@ -38,7 +38,7 @@ from .workspace.base import (
|
||||
WorkSpaceAvailabilityCheckEndpoint,
|
||||
UserWorkspaceDashboardEndpoint,
|
||||
WorkspaceThemeViewSet,
|
||||
ExportWorkspaceUserActivityEndpoint
|
||||
ExportWorkspaceUserActivityEndpoint,
|
||||
)
|
||||
|
||||
from .workspace.member import (
|
||||
@@ -91,12 +91,14 @@ from .cycle.base import (
|
||||
CycleDateCheckEndpoint,
|
||||
CycleFavoriteViewSet,
|
||||
TransferCycleIssueEndpoint,
|
||||
CycleArchiveUnarchiveEndpoint,
|
||||
CycleUserPropertiesEndpoint,
|
||||
)
|
||||
from .cycle.issue import (
|
||||
CycleIssueViewSet,
|
||||
)
|
||||
from .cycle.archive import (
|
||||
CycleArchiveUnarchiveEndpoint,
|
||||
)
|
||||
|
||||
from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
||||
from .issue.base import (
|
||||
@@ -170,7 +172,6 @@ from .module.base import (
|
||||
ModuleViewSet,
|
||||
ModuleLinkViewSet,
|
||||
ModuleFavoriteViewSet,
|
||||
ModuleArchiveUnarchiveEndpoint,
|
||||
ModuleUserPropertiesEndpoint,
|
||||
)
|
||||
|
||||
@@ -178,6 +179,10 @@ from .module.issue import (
|
||||
ModuleIssueViewSet,
|
||||
)
|
||||
|
||||
from .module.archive import (
|
||||
ModuleArchiveUnarchiveEndpoint,
|
||||
)
|
||||
|
||||
from .api import ApiTokenEndpoint
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
# Module imports
|
||||
from plane.bgtasks.webhook_task import send_webhook
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.paginator import BasePaginator
|
||||
|
||||
@@ -38,35 +37,6 @@ class TimezoneMixin:
|
||||
timezone.deactivate()
|
||||
|
||||
|
||||
class WebhookMixin:
|
||||
webhook_event = None
|
||||
bulk = False
|
||||
|
||||
def finalize_response(self, request, response, *args, **kwargs):
|
||||
response = super().finalize_response(
|
||||
request, response, *args, **kwargs
|
||||
)
|
||||
|
||||
# Check for the case should webhook be sent
|
||||
if (
|
||||
self.webhook_event
|
||||
and self.request.method in ["POST", "PATCH", "DELETE"]
|
||||
and response.status_code in [200, 201, 204]
|
||||
):
|
||||
# Push the object to delay
|
||||
send_webhook.delay(
|
||||
event=self.webhook_event,
|
||||
payload=response.data,
|
||||
kw=self.kwargs,
|
||||
action=self.request.method,
|
||||
slug=self.workspace_slug,
|
||||
bulk=self.bulk,
|
||||
current_site=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
|
||||
model = None
|
||||
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
Count,
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
UUIDField,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
CycleFavorite,
|
||||
Issue,
|
||||
Label,
|
||||
User,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
# Module imports
|
||||
from .. import BaseAPIView
|
||||
|
||||
|
||||
class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
favorite_subquery = CycleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
cycle_id=OuterRef("pk"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
return (
|
||||
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(archived_at__isnull=False)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(project__archived_at__isnull=True)
|
||||
.select_related("project", "workspace", "owned_by")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only(
|
||||
"avatar", "first_name", "id"
|
||||
).distinct(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__labels",
|
||||
queryset=Label.objects.only(
|
||||
"name", "color", "id"
|
||||
).distinct(),
|
||||
)
|
||||
)
|
||||
.annotate(is_favorite=Exists(favorite_subquery))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="cancelled",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="unstarted",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="backlog",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
Q(start_date__lte=timezone.now())
|
||||
& Q(end_date__gte=timezone.now()),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(
|
||||
start_date__gt=timezone.now(), then=Value("UPCOMING")
|
||||
),
|
||||
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
||||
When(
|
||||
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
||||
then=Value("DRAFT"),
|
||||
),
|
||||
default=Value("DRAFT"),
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue_cycle__issue__assignees__id",
|
||||
distinct=True,
|
||||
filter=~Q(
|
||||
issue_cycle__issue__assignees__id__isnull=True
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "name")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def get(self, request, slug, project_id, pk=None):
|
||||
if pk is None:
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.values(
|
||||
# necessary fields
|
||||
"id",
|
||||
"workspace_id",
|
||||
"project_id",
|
||||
# model fields
|
||||
"name",
|
||||
"description",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"owned_by_id",
|
||||
"view_props",
|
||||
"sort_order",
|
||||
"external_source",
|
||||
"external_id",
|
||||
"progress_snapshot",
|
||||
# meta fields
|
||||
"total_issues",
|
||||
"is_favorite",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
"unstarted_issues",
|
||||
"backlog_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
"archived_at",
|
||||
)
|
||||
).order_by("-is_favorite", "-created_at")
|
||||
return Response(queryset, status=status.HTTP_200_OK)
|
||||
else:
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.filter(archived_at__isnull=False)
|
||||
.filter(pk=pk)
|
||||
)
|
||||
data = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.annotate(
|
||||
sub_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
parent__isnull=False,
|
||||
issue_cycle__cycle_id=pk,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.values(
|
||||
# necessary fields
|
||||
"id",
|
||||
"workspace_id",
|
||||
"project_id",
|
||||
# model fields
|
||||
"name",
|
||||
"description",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"owned_by_id",
|
||||
"view_props",
|
||||
"sort_order",
|
||||
"external_source",
|
||||
"external_id",
|
||||
"progress_snapshot",
|
||||
"sub_issues",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
"unstarted_issues",
|
||||
"backlog_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
queryset = queryset.first()
|
||||
|
||||
if data is None:
|
||||
return Response(
|
||||
{"error": "Cycle does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Assignee Distribution
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(first_name=F("assignees__first_name"))
|
||||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.values(
|
||||
"first_name",
|
||||
"last_name",
|
||||
"assignee_id",
|
||||
"avatar",
|
||||
"display_name",
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("first_name", "last_name")
|
||||
)
|
||||
|
||||
# Label Distribution
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
data["distribution"] = {
|
||||
"assignees": assignee_distribution,
|
||||
"labels": label_distribution,
|
||||
"completion_chart": {},
|
||||
}
|
||||
|
||||
if queryset.start_date and queryset.end_date:
|
||||
data["distribution"]["completion_chart"] = burndown_plot(
|
||||
queryset=queryset,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
cycle_id=pk,
|
||||
)
|
||||
|
||||
return Response(
|
||||
data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def post(self, request, slug, project_id, cycle_id):
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
|
||||
if cycle.end_date >= timezone.now().date():
|
||||
return Response(
|
||||
{"error": "Only completed cycles can be archived"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cycle.archived_at = timezone.now()
|
||||
cycle.save()
|
||||
return Response(
|
||||
{"archived_at": str(cycle.archived_at)},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def delete(self, request, slug, project_id, cycle_id):
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
cycle.archived_at = None
|
||||
cycle.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1,10 +1,9 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
@@ -21,11 +20,11 @@ from django.db.models import (
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
ProjectLitePermission,
|
||||
@@ -49,10 +48,11 @@ from plane.db.models import (
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
# Module imports
|
||||
from .. import BaseAPIView, BaseViewSet, WebhookMixin
|
||||
from .. import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
|
||||
|
||||
class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
class CycleViewSet(BaseViewSet):
|
||||
serializer_class = CycleSerializer
|
||||
model = Cycle
|
||||
webhook_event = "cycle"
|
||||
@@ -414,6 +414,17 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
model_name="cycle",
|
||||
model_id=str(cycle["id"]),
|
||||
requested_data=request.data,
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(cycle, status=status.HTTP_201_CREATED)
|
||||
return Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
@@ -436,6 +447,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
{"error": "Archived cycle cannot be updated"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
current_instance = json.dumps(
|
||||
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
request_data = request.data
|
||||
|
||||
if (
|
||||
@@ -489,6 +505,18 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
"assignee_ids",
|
||||
"status",
|
||||
).first()
|
||||
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
model_name="cycle",
|
||||
model_id=str(cycle["id"]),
|
||||
requested_data=request.data,
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
return Response(cycle, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -686,380 +714,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
favorite_subquery = CycleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
cycle_id=OuterRef("pk"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
return (
|
||||
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(archived_at__isnull=False)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(project__archived_at__isnull=True)
|
||||
.select_related("project", "workspace", "owned_by")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only(
|
||||
"avatar", "first_name", "id"
|
||||
).distinct(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__labels",
|
||||
queryset=Label.objects.only(
|
||||
"name", "color", "id"
|
||||
).distinct(),
|
||||
)
|
||||
)
|
||||
.annotate(is_favorite=Exists(favorite_subquery))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="cancelled",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="unstarted",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="backlog",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
Q(start_date__lte=timezone.now())
|
||||
& Q(end_date__gte=timezone.now()),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(
|
||||
start_date__gt=timezone.now(), then=Value("UPCOMING")
|
||||
),
|
||||
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
||||
When(
|
||||
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
||||
then=Value("DRAFT"),
|
||||
),
|
||||
default=Value("DRAFT"),
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue_cycle__issue__assignees__id",
|
||||
distinct=True,
|
||||
filter=~Q(
|
||||
issue_cycle__issue__assignees__id__isnull=True
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "name")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def get(self, request, slug, project_id, pk=None):
|
||||
if pk is None:
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.values(
|
||||
# necessary fields
|
||||
"id",
|
||||
"workspace_id",
|
||||
"project_id",
|
||||
# model fields
|
||||
"name",
|
||||
"description",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"owned_by_id",
|
||||
"view_props",
|
||||
"sort_order",
|
||||
"external_source",
|
||||
"external_id",
|
||||
"progress_snapshot",
|
||||
# meta fields
|
||||
"total_issues",
|
||||
"is_favorite",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
"unstarted_issues",
|
||||
"backlog_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
"archived_at",
|
||||
)
|
||||
).order_by("-is_favorite", "-created_at")
|
||||
return Response(queryset, status=status.HTTP_200_OK)
|
||||
else:
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.filter(archived_at__isnull=False)
|
||||
.filter(pk=pk)
|
||||
)
|
||||
data = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.annotate(
|
||||
sub_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
parent__isnull=False,
|
||||
issue_cycle__cycle_id=pk,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.values(
|
||||
# necessary fields
|
||||
"id",
|
||||
"workspace_id",
|
||||
"project_id",
|
||||
# model fields
|
||||
"name",
|
||||
"description",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"owned_by_id",
|
||||
"view_props",
|
||||
"sort_order",
|
||||
"external_source",
|
||||
"external_id",
|
||||
"progress_snapshot",
|
||||
"sub_issues",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
"unstarted_issues",
|
||||
"backlog_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
queryset = queryset.first()
|
||||
|
||||
if data is None:
|
||||
return Response(
|
||||
{"error": "Cycle does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Assignee Distribution
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(first_name=F("assignees__first_name"))
|
||||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.values(
|
||||
"first_name",
|
||||
"last_name",
|
||||
"assignee_id",
|
||||
"avatar",
|
||||
"display_name",
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("first_name", "last_name")
|
||||
)
|
||||
|
||||
# Label Distribution
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
data["distribution"] = {
|
||||
"assignees": assignee_distribution,
|
||||
"labels": label_distribution,
|
||||
"completion_chart": {},
|
||||
}
|
||||
|
||||
if queryset.start_date and queryset.end_date:
|
||||
data["distribution"]["completion_chart"] = burndown_plot(
|
||||
queryset=queryset,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
cycle_id=pk,
|
||||
)
|
||||
|
||||
return Response(
|
||||
data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def post(self, request, slug, project_id, cycle_id):
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
|
||||
if cycle.end_date >= timezone.now().date():
|
||||
return Response(
|
||||
{"error": "Only completed cycles can be archived"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cycle.archived_at = timezone.now()
|
||||
cycle.save()
|
||||
return Response(
|
||||
{"archived_at": str(cycle.archived_at)},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def delete(self, request, slug, project_id, cycle_id):
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
cycle.archived_at = None
|
||||
cycle.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class CycleDateCheckEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
|
||||
@@ -23,7 +23,7 @@ from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, WebhookMixin
|
||||
from .. import BaseViewSet
|
||||
from plane.app.serializers import (
|
||||
IssueSerializer,
|
||||
CycleIssueSerializer,
|
||||
@@ -38,9 +38,9 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
|
||||
|
||||
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
class CycleIssueViewSet(BaseViewSet):
|
||||
serializer_class = CycleIssueSerializer
|
||||
model = CycleIssue
|
||||
|
||||
@@ -191,6 +191,11 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
)
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
issues = user_timezone_converter(
|
||||
issues, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
def create(self, request, slug, project_id, cycle_id):
|
||||
@@ -249,6 +254,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
update_cycle_issue_activity = []
|
||||
# Iterate over each cycle_issue in cycle_issues
|
||||
for cycle_issue in cycle_issues:
|
||||
old_cycle_id = cycle_issue.cycle_id
|
||||
# Update the cycle_issue's cycle_id
|
||||
cycle_issue.cycle_id = cycle_id
|
||||
# Add the modified cycle_issue to the records_to_update list
|
||||
@@ -256,7 +262,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
# Record the update activity
|
||||
update_cycle_issue_activity.append(
|
||||
{
|
||||
"old_cycle_id": str(cycle_issue.cycle_id),
|
||||
"old_cycle_id": str(old_cycle_id),
|
||||
"new_cycle_id": str(cycle_id),
|
||||
"issue_id": str(cycle_issue.issue_id),
|
||||
}
|
||||
|
||||
@@ -571,14 +571,16 @@ def dashboard_recent_collaborators(self, request, slug):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=project_members_with_activities,
|
||||
controller=self.get_results_controller,
|
||||
controller=lambda qs: self.get_results_controller(qs, slug),
|
||||
)
|
||||
|
||||
|
||||
class DashboardEndpoint(BaseAPIView):
|
||||
def get_results_controller(self, project_members_with_activities):
|
||||
def get_results_controller(self, project_members_with_activities, slug):
|
||||
user_active_issue_counts = (
|
||||
User.objects.filter(id__in=project_members_with_activities)
|
||||
User.objects.filter(
|
||||
id__in=project_members_with_activities,
|
||||
)
|
||||
.annotate(
|
||||
active_issue_count=Count(
|
||||
Case(
|
||||
@@ -587,10 +589,13 @@ class DashboardEndpoint(BaseAPIView):
|
||||
"unstarted",
|
||||
"started",
|
||||
],
|
||||
then=1,
|
||||
issue_assignee__issue__workspace__slug=slug,
|
||||
issue_assignee__issue__project__project_projectmember__is_active=True,
|
||||
then=F("issue_assignee__issue__id"),
|
||||
),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
),
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
.values("active_issue_count", user_id=F("id"))
|
||||
|
||||
@@ -251,6 +251,16 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
inbox_id = Inbox.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
# create an inbox issue
|
||||
inbox_issue = InboxIssue.objects.create(
|
||||
inbox_id=inbox_id.id,
|
||||
project_id=project_id,
|
||||
issue_id=serializer.data["id"],
|
||||
source=request.data.get("source", "in-app"),
|
||||
)
|
||||
# Create an Issue Activity
|
||||
issue_activity.delay(
|
||||
type="issue.activity.created",
|
||||
@@ -262,16 +272,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
inbox_id = Inbox.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
# create an inbox issue
|
||||
inbox_issue = InboxIssue.objects.create(
|
||||
inbox_id=inbox_id.id,
|
||||
project_id=project_id,
|
||||
issue_id=serializer.data["id"],
|
||||
source=request.data.get("source", "in-app"),
|
||||
inbox=str(inbox_issue.id),
|
||||
)
|
||||
inbox_issue = (
|
||||
InboxIssue.objects.select_related("issue")
|
||||
@@ -339,7 +340,24 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
# Get issue data
|
||||
issue_data = request.data.pop("issue", False)
|
||||
if bool(issue_data):
|
||||
issue = Issue.objects.get(
|
||||
issue = Issue.objects.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=~Q(labels__id__isnull=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=~Q(assignees__id__isnull=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
).get(
|
||||
pk=inbox_issue.issue_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
@@ -379,6 +397,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
inbox=str(inbox_issue.id),
|
||||
)
|
||||
issue_serializer.save()
|
||||
else:
|
||||
@@ -444,6 +463,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=False,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
inbox=(inbox_issue.id),
|
||||
)
|
||||
|
||||
inbox_issue = (
|
||||
@@ -480,7 +500,8 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
output_field=ArrayField(UUIDField()),
|
||||
),
|
||||
),
|
||||
).first()
|
||||
)
|
||||
.first()
|
||||
)
|
||||
serializer = InboxIssueDetailSerializer(inbox_issue).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -47,7 +47,7 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
|
||||
class IssueArchiveViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
@@ -239,6 +239,11 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
)
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
issues = user_timezone_converter(
|
||||
issues, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
|
||||
@@ -50,9 +50,10 @@ from plane.db.models import (
|
||||
Project,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
|
||||
# Module imports
|
||||
from .. import BaseAPIView, BaseViewSet, WebhookMixin
|
||||
from .. import BaseAPIView, BaseViewSet
|
||||
|
||||
|
||||
class IssueListEndpoint(BaseAPIView):
|
||||
@@ -241,10 +242,14 @@ class IssueListEndpoint(BaseAPIView):
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
)
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
issues = user_timezone_converter(
|
||||
issues, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
class IssueViewSet(BaseViewSet):
|
||||
def get_serializer_class(self):
|
||||
return (
|
||||
IssueCreateSerializer
|
||||
@@ -440,6 +445,10 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
)
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
issues = user_timezone_converter(
|
||||
issues, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
@@ -503,6 +512,10 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
.first()
|
||||
)
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
issue = user_timezone_converter(
|
||||
issue, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
return Response(issue, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, WebhookMixin
|
||||
from .. import BaseViewSet
|
||||
from plane.app.serializers import (
|
||||
IssueCommentSerializer,
|
||||
CommentReactionSerializer,
|
||||
@@ -25,7 +25,7 @@ from plane.db.models import (
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
|
||||
|
||||
class IssueCommentViewSet(WebhookMixin, BaseViewSet):
|
||||
class IssueCommentViewSet(BaseViewSet):
|
||||
serializer_class = IssueCommentSerializer
|
||||
model = IssueComment
|
||||
webhook_event = "issue_comment"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
@@ -19,14 +20,12 @@ from django.db.models import (
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from rest_framework import status
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
@@ -46,6 +45,7 @@ from plane.db.models import (
|
||||
Project,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
@@ -230,6 +230,10 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
)
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
issues = user_timezone_converter(
|
||||
issues, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
|
||||
@@ -31,6 +31,7 @@ from plane.db.models import (
|
||||
IssueAttachment,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
@@ -132,6 +133,10 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
)
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
sub_issues = user_timezone_converter(
|
||||
sub_issues, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"sub_issues": sub_issues,
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import (
|
||||
Count,
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
IntegerField,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
Subquery,
|
||||
UUIDField,
|
||||
Value,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
ModuleDetailSerializer,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
Module,
|
||||
ModuleFavorite,
|
||||
ModuleLink,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
|
||||
|
||||
# Module imports
|
||||
from .. import BaseAPIView
|
||||
|
||||
|
||||
class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
favorite_subquery = ModuleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
module_id=OuterRef("pk"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
cancelled_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
state__group="cancelled",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
completed_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
state__group="completed",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
started_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
state__group="started",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
unstarted_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
state__group="unstarted",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
backlog_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
state__group="backlog",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
total_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
return (
|
||||
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(archived_at__isnull=False)
|
||||
.annotate(is_favorite=Exists(favorite_subquery))
|
||||
.select_related("workspace", "project", "lead")
|
||||
.prefetch_related("members")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"link_module",
|
||||
queryset=ModuleLink.objects.select_related(
|
||||
"module", "created_by"
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Coalesce(
|
||||
Subquery(completed_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Coalesce(
|
||||
Subquery(cancelled_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Coalesce(
|
||||
Subquery(started_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Coalesce(
|
||||
Subquery(unstarted_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Coalesce(
|
||||
Subquery(backlog_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Coalesce(
|
||||
Subquery(total_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
member_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"members__id",
|
||||
distinct=True,
|
||||
filter=~Q(members__id__isnull=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "-created_at")
|
||||
)
|
||||
|
||||
def get(self, request, slug, project_id, pk=None):
|
||||
if pk is None:
|
||||
queryset = self.get_queryset()
|
||||
modules = queryset.values( # Required fields
|
||||
"id",
|
||||
"workspace_id",
|
||||
"project_id",
|
||||
# Model fields
|
||||
"name",
|
||||
"description",
|
||||
"description_text",
|
||||
"description_html",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"status",
|
||||
"lead_id",
|
||||
"member_ids",
|
||||
"view_props",
|
||||
"sort_order",
|
||||
"external_source",
|
||||
"external_id",
|
||||
# computed fields
|
||||
"total_issues",
|
||||
"is_favorite",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
"unstarted_issues",
|
||||
"backlog_issues",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"archived_at",
|
||||
)
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
modules = user_timezone_converter(
|
||||
modules, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
return Response(modules, status=status.HTTP_200_OK)
|
||||
else:
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.annotate(
|
||||
sub_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
parent__isnull=False,
|
||||
issue_module__module_id=pk,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(first_name=F("assignees__first_name"))
|
||||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values(
|
||||
"first_name",
|
||||
"last_name",
|
||||
"assignee_id",
|
||||
"avatar",
|
||||
"display_name",
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("first_name", "last_name")
|
||||
)
|
||||
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
data = ModuleDetailSerializer(queryset.first()).data
|
||||
data["distribution"] = {
|
||||
"assignees": assignee_distribution,
|
||||
"labels": label_distribution,
|
||||
"completion_chart": {},
|
||||
}
|
||||
|
||||
# Fetch the modules
|
||||
modules = queryset.first()
|
||||
if modules and modules.start_date and modules.target_date:
|
||||
data["distribution"]["completion_chart"] = burndown_plot(
|
||||
queryset=modules,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
module_id=pk,
|
||||
)
|
||||
|
||||
return Response(
|
||||
data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def post(self, request, slug, project_id, module_id):
|
||||
module = Module.objects.get(
|
||||
pk=module_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
if module.status not in ["completed", "cancelled"]:
|
||||
return Response(
|
||||
{
|
||||
"error": "Only completed or cancelled modules can be archived"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
module.archived_at = timezone.now()
|
||||
module.save()
|
||||
return Response(
|
||||
{"archived_at": str(module.archived_at)},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def delete(self, request, slug, project_id, module_id):
|
||||
module = Module.objects.get(
|
||||
pk=module_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
module.archived_at = None
|
||||
module.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1,6 +1,7 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django Imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import (
|
||||
@@ -17,14 +18,14 @@ from django.db.models import (
|
||||
Value,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Django Imports
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
ProjectLitePermission,
|
||||
@@ -48,12 +49,12 @@ from plane.db.models import (
|
||||
Project,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
# Module imports
|
||||
from .. import BaseAPIView, BaseViewSet, WebhookMixin
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from .. import BaseAPIView, BaseViewSet
|
||||
|
||||
|
||||
class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
class ModuleViewSet(BaseViewSet):
|
||||
model = Module
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
@@ -236,6 +237,20 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
"updated_at",
|
||||
)
|
||||
).first()
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
model_name="module",
|
||||
model_id=str(module["id"]),
|
||||
requested_data=request.data,
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
module = user_timezone_converter(
|
||||
module, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
return Response(module, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -277,6 +292,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
modules = user_timezone_converter(
|
||||
modules, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
return Response(modules, status=status.HTTP_200_OK)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
@@ -418,6 +437,9 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
{"error": "Archived module cannot be updated"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
current_instance = json.dumps(
|
||||
ModuleSerializer(module.first()).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
serializer = ModuleWriteSerializer(
|
||||
module.first(), data=request.data, partial=True
|
||||
)
|
||||
@@ -454,6 +476,22 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
).first()
|
||||
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
model_name="module",
|
||||
model_id=str(module["id"]),
|
||||
requested_data=request.data,
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
module = user_timezone_converter(
|
||||
module, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
return Response(module, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -515,325 +553,6 @@ class ModuleLinkViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
|
||||
class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
favorite_subquery = ModuleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
module_id=OuterRef("pk"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
cancelled_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
state__group="cancelled",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
completed_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
state__group="completed",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
started_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
state__group="started",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
unstarted_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
state__group="unstarted",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
backlog_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
state__group="backlog",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
total_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
return (
|
||||
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(archived_at__isnull=False)
|
||||
.annotate(is_favorite=Exists(favorite_subquery))
|
||||
.select_related("workspace", "project", "lead")
|
||||
.prefetch_related("members")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"link_module",
|
||||
queryset=ModuleLink.objects.select_related(
|
||||
"module", "created_by"
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Coalesce(
|
||||
Subquery(completed_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Coalesce(
|
||||
Subquery(cancelled_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Coalesce(
|
||||
Subquery(started_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Coalesce(
|
||||
Subquery(unstarted_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Coalesce(
|
||||
Subquery(backlog_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Coalesce(
|
||||
Subquery(total_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
member_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"members__id",
|
||||
distinct=True,
|
||||
filter=~Q(members__id__isnull=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "-created_at")
|
||||
)
|
||||
|
||||
def get(self, request, slug, project_id, pk=None):
|
||||
if pk is None:
|
||||
queryset = self.get_queryset()
|
||||
modules = queryset.values( # Required fields
|
||||
"id",
|
||||
"workspace_id",
|
||||
"project_id",
|
||||
# Model fields
|
||||
"name",
|
||||
"description",
|
||||
"description_text",
|
||||
"description_html",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"status",
|
||||
"lead_id",
|
||||
"member_ids",
|
||||
"view_props",
|
||||
"sort_order",
|
||||
"external_source",
|
||||
"external_id",
|
||||
# computed fields
|
||||
"total_issues",
|
||||
"is_favorite",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
"unstarted_issues",
|
||||
"backlog_issues",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"archived_at",
|
||||
)
|
||||
return Response(modules, status=status.HTTP_200_OK)
|
||||
else:
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.annotate(
|
||||
sub_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
parent__isnull=False,
|
||||
issue_module__module_id=pk,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
assignee_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(first_name=F("assignees__first_name"))
|
||||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values(
|
||||
"first_name",
|
||||
"last_name",
|
||||
"assignee_id",
|
||||
"avatar",
|
||||
"display_name",
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("first_name", "last_name")
|
||||
)
|
||||
|
||||
label_distribution = (
|
||||
Issue.objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
data = ModuleDetailSerializer(queryset.first()).data
|
||||
data["distribution"] = {
|
||||
"assignees": assignee_distribution,
|
||||
"labels": label_distribution,
|
||||
"completion_chart": {},
|
||||
}
|
||||
|
||||
# Fetch the modules
|
||||
modules = queryset.first()
|
||||
if modules and modules.start_date and modules.target_date:
|
||||
data["distribution"]["completion_chart"] = burndown_plot(
|
||||
queryset=modules,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
module_id=pk,
|
||||
)
|
||||
|
||||
return Response(
|
||||
data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def post(self, request, slug, project_id, module_id):
|
||||
module = Module.objects.get(
|
||||
pk=module_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
if module.status not in ["completed", "cancelled"]:
|
||||
return Response(
|
||||
{
|
||||
"error": "Only completed or cancelled modules can be archived"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
module.archived_at = timezone.now()
|
||||
module.save()
|
||||
return Response(
|
||||
{"archived_at": str(module.archived_at)},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def delete(self, request, slug, project_id, module_id):
|
||||
module = Module.objects.get(
|
||||
pk=module_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
module.archived_at = None
|
||||
module.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class ModuleFavoriteViewSet(BaseViewSet):
|
||||
serializer_class = ModuleFavoriteSerializer
|
||||
model = ModuleFavorite
|
||||
|
||||
@@ -16,7 +16,7 @@ from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, WebhookMixin
|
||||
from .. import BaseViewSet
|
||||
from plane.app.serializers import (
|
||||
ModuleIssueSerializer,
|
||||
IssueSerializer,
|
||||
@@ -31,9 +31,9 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
|
||||
|
||||
class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
class ModuleIssueViewSet(BaseViewSet):
|
||||
serializer_class = ModuleIssueSerializer
|
||||
model = ModuleIssue
|
||||
webhook_event = "module_issue"
|
||||
@@ -150,6 +150,11 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
)
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
issues = user_timezone_converter(
|
||||
issues, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
# create multiple issues inside a module
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Python imports
|
||||
import boto3
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
@@ -14,6 +15,7 @@ from django.db.models import (
|
||||
)
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -22,7 +24,7 @@ from rest_framework import serializers
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseViewSet, BaseAPIView, WebhookMixin
|
||||
from plane.app.views.base import BaseViewSet, BaseAPIView
|
||||
from plane.app.serializers import (
|
||||
ProjectSerializer,
|
||||
ProjectListSerializer,
|
||||
@@ -50,9 +52,10 @@ from plane.db.models import (
|
||||
Issue,
|
||||
)
|
||||
from plane.utils.cache import cache_response
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
|
||||
|
||||
class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
class ProjectViewSet(BaseViewSet):
|
||||
serializer_class = ProjectListSerializer
|
||||
model = Project
|
||||
webhook_event = "project"
|
||||
@@ -185,7 +188,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
.annotate(
|
||||
total_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
parent__isnull=True,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -204,7 +206,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
archived_issues=Issue.objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
archived_at__isnull=False,
|
||||
parent__isnull=True,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -224,7 +225,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
draft_issues=Issue.objects.filter(
|
||||
project_id=self.kwargs.get("pk"),
|
||||
is_draft=True,
|
||||
parent__isnull=True,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -337,6 +337,17 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
.filter(pk=serializer.data["id"])
|
||||
.first()
|
||||
)
|
||||
|
||||
model_activity.delay(
|
||||
model_name="project",
|
||||
model_id=str(project.id),
|
||||
requested_data=request.data,
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
serializer = ProjectListSerializer(project)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED
|
||||
@@ -367,7 +378,9 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
project = Project.objects.get(pk=pk)
|
||||
|
||||
current_instance = json.dumps(
|
||||
ProjectSerializer(project).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
if project.archived_at:
|
||||
return Response(
|
||||
{"error": "Archived projects cannot be updated"},
|
||||
@@ -405,6 +418,16 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
.filter(pk=serializer.data["id"])
|
||||
.first()
|
||||
)
|
||||
|
||||
model_activity.delay(
|
||||
model_name="project",
|
||||
model_id=str(project.id),
|
||||
requested_data=request.data,
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
serializer = ProjectListSerializer(project)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
|
||||
@@ -49,7 +49,12 @@ class UserEndpoint(BaseViewSet):
|
||||
{"is_instance_admin": is_admin}, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
@invalidate_cache(path="/api/users/me/")
|
||||
@invalidate_cache(
|
||||
path="/api/users/me/",
|
||||
)
|
||||
@invalidate_cache(
|
||||
path="/api/users/me/settings/",
|
||||
)
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ from plane.db.models import (
|
||||
IssueAttachment,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
|
||||
class GlobalViewViewSet(BaseViewSet):
|
||||
serializer_class = IssueViewSerializer
|
||||
@@ -255,6 +255,10 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
)
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
issues = user_timezone_converter(
|
||||
issues, datetime_fields, request.user.user_timezone
|
||||
)
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
|
||||
@@ -151,8 +151,8 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/", user=False)
|
||||
@invalidate_cache(path="/api/users/me/workspaces/", multiple=True)
|
||||
@invalidate_cache(path="/api/users/me/settings/", multiple=True)
|
||||
@invalidate_cache(path="/api/users/me/workspaces/", multiple=True, user=False)
|
||||
@invalidate_cache(path="/api/users/me/settings/", multiple=True, user=False)
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.filter(archived_at__isnull=False)
|
||||
.filter(archived_at__isnull=True)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
|
||||
@@ -30,7 +30,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
|
||||
.select_related("workspace")
|
||||
.select_related("lead")
|
||||
.prefetch_related("members")
|
||||
.filter(archived_at__isnull=False)
|
||||
.filter(archived_at__isnull=True)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"link_module",
|
||||
|
||||
@@ -21,6 +21,7 @@ class WorkspaceStatesEndpoint(BaseAPIView):
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
is_triage=False,
|
||||
)
|
||||
serializer = StateSerializer(states, many=True).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from plane.db.models import APIActivityLog
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_api_logs():
|
||||
# Get the logs older than 30 days to delete
|
||||
logs_to_delete = APIActivityLog.objects.filter(
|
||||
created_at__lte=timezone.now() - timedelta(days=30)
|
||||
)
|
||||
|
||||
# Delete the logs
|
||||
logs_to_delete._raw_delete(logs_to_delete.db)
|
||||
@@ -152,7 +152,7 @@ def process_mention(mention_component):
|
||||
soup = BeautifulSoup(mention_component, "html.parser")
|
||||
mentions = soup.find_all("mention-component")
|
||||
for mention in mentions:
|
||||
user_id = mention["id"]
|
||||
user_id = mention["entity_identifier"]
|
||||
user = User.objects.get(pk=user_id)
|
||||
user_name = user.display_name
|
||||
highlighted_name = f"@{user_name}"
|
||||
|
||||
@@ -31,6 +31,7 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.settings.redis import redis_instance
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.bgtasks.webhook_task import webhook_activity
|
||||
|
||||
|
||||
# Track Changes in name
|
||||
@@ -1296,7 +1297,7 @@ def create_issue_vote_activity(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor_id=actor_id,
|
||||
verb="created",
|
||||
verb="updated",
|
||||
old_value=None,
|
||||
new_value=requested_data.get("vote"),
|
||||
field="vote",
|
||||
@@ -1365,7 +1366,7 @@ def create_issue_relation_activity(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor_id=actor_id,
|
||||
verb="created",
|
||||
verb="updated",
|
||||
old_value="",
|
||||
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
|
||||
field=requested_data.get("relation_type"),
|
||||
@@ -1380,7 +1381,7 @@ def create_issue_relation_activity(
|
||||
IssueActivity(
|
||||
issue_id=related_issue,
|
||||
actor_id=actor_id,
|
||||
verb="created",
|
||||
verb="updated",
|
||||
old_value="",
|
||||
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
|
||||
field=(
|
||||
@@ -1606,6 +1607,7 @@ def issue_activity(
|
||||
subscriber=True,
|
||||
notification=False,
|
||||
origin=None,
|
||||
inbox=None,
|
||||
):
|
||||
try:
|
||||
issue_activities = []
|
||||
@@ -1692,6 +1694,41 @@ def issue_activity(
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
|
||||
for activity in issue_activities_created:
|
||||
webhook_activity.delay(
|
||||
event=(
|
||||
"issue_comment"
|
||||
if activity.field == "comment"
|
||||
else "inbox_issue" if inbox else "issue"
|
||||
),
|
||||
event_id=(
|
||||
activity.issue_comment_id
|
||||
if activity.field == "comment"
|
||||
else inbox if inbox else activity.issue_id
|
||||
),
|
||||
verb=activity.verb,
|
||||
field=(
|
||||
"description"
|
||||
if activity.field == "comment"
|
||||
else activity.field
|
||||
),
|
||||
old_value=(
|
||||
activity.old_value
|
||||
if activity.old_value != ""
|
||||
else None
|
||||
),
|
||||
new_value=(
|
||||
activity.new_value
|
||||
if activity.new_value != ""
|
||||
else None
|
||||
),
|
||||
actor_id=activity.actor_id,
|
||||
current_site=origin,
|
||||
slug=activity.workspace.slug,
|
||||
old_identifier=activity.old_identifier,
|
||||
new_identifier=activity.new_identifier,
|
||||
)
|
||||
|
||||
if notification:
|
||||
notifications.delay(
|
||||
type=type,
|
||||
|
||||
@@ -128,7 +128,7 @@ def extract_mentions(issue_instance):
|
||||
"mention-component", attrs={"target": "users"}
|
||||
)
|
||||
|
||||
mentions = [mention_tag["id"] for mention_tag in mention_tags]
|
||||
mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags]
|
||||
|
||||
return list(set(mentions))
|
||||
except Exception:
|
||||
@@ -144,7 +144,7 @@ def extract_comment_mentions(comment_value):
|
||||
"mention-component", attrs={"target": "users"}
|
||||
)
|
||||
for mention_tag in mentions_tags:
|
||||
mentions.append(mention_tag["id"])
|
||||
mentions.append(mention_tag["entity_identifier"])
|
||||
return list(set(mentions))
|
||||
except Exception:
|
||||
return []
|
||||
@@ -663,9 +663,7 @@ def notifications(
|
||||
"old_value": str(
|
||||
last_activity.old_value
|
||||
),
|
||||
"activity_time": issue_activity.get(
|
||||
"created_at"
|
||||
),
|
||||
"activity_time": str(last_activity.created_at),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
# Module imports
|
||||
from plane.api.serializers import (
|
||||
@@ -25,6 +26,8 @@ from plane.api.serializers import (
|
||||
ModuleIssueSerializer,
|
||||
ModuleSerializer,
|
||||
ProjectSerializer,
|
||||
UserLiteSerializer,
|
||||
InboxIssueSerializer,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
@@ -37,6 +40,7 @@ from plane.db.models import (
|
||||
User,
|
||||
Webhook,
|
||||
WebhookLog,
|
||||
InboxIssue,
|
||||
)
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
@@ -49,6 +53,8 @@ SERIALIZER_MAPPER = {
|
||||
"cycle_issue": CycleIssueSerializer,
|
||||
"module_issue": ModuleIssueSerializer,
|
||||
"issue_comment": IssueCommentSerializer,
|
||||
"user": UserLiteSerializer,
|
||||
"inbox_issue": InboxIssueSerializer,
|
||||
}
|
||||
|
||||
MODEL_MAPPER = {
|
||||
@@ -59,6 +65,8 @@ MODEL_MAPPER = {
|
||||
"cycle_issue": CycleIssue,
|
||||
"module_issue": ModuleIssue,
|
||||
"issue_comment": IssueComment,
|
||||
"user": User,
|
||||
"inbox_issue": InboxIssue,
|
||||
}
|
||||
|
||||
|
||||
@@ -179,64 +187,6 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site):
|
||||
return
|
||||
|
||||
|
||||
@shared_task()
|
||||
def send_webhook(event, payload, kw, action, slug, bulk, current_site):
|
||||
try:
|
||||
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
|
||||
|
||||
if event == "project":
|
||||
webhooks = webhooks.filter(project=True)
|
||||
|
||||
if event == "issue":
|
||||
webhooks = webhooks.filter(issue=True)
|
||||
|
||||
if event == "module" or event == "module_issue":
|
||||
webhooks = webhooks.filter(module=True)
|
||||
|
||||
if event == "cycle" or event == "cycle_issue":
|
||||
webhooks = webhooks.filter(cycle=True)
|
||||
|
||||
if event == "issue_comment":
|
||||
webhooks = webhooks.filter(issue_comment=True)
|
||||
|
||||
if webhooks:
|
||||
if action in ["POST", "PATCH"]:
|
||||
if bulk and event in ["cycle_issue", "module_issue"]:
|
||||
return
|
||||
else:
|
||||
event_data = [
|
||||
get_model_data(
|
||||
event=event,
|
||||
event_id=(
|
||||
payload.get("id")
|
||||
if isinstance(payload, dict)
|
||||
else kw.get("pk")
|
||||
),
|
||||
many=False,
|
||||
)
|
||||
]
|
||||
|
||||
if action == "DELETE":
|
||||
event_data = [{"id": kw.get("pk")}]
|
||||
|
||||
for webhook in webhooks:
|
||||
for data in event_data:
|
||||
webhook_task.delay(
|
||||
webhook=webhook.id,
|
||||
slug=slug,
|
||||
event=event,
|
||||
event_data=data,
|
||||
action=action,
|
||||
current_site=current_site,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_webhook_deactivation_email(
|
||||
webhook_id, receiver_id, current_site, reason
|
||||
@@ -294,3 +244,245 @@ def send_webhook_deactivation_email(
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
autoretry_for=(requests.RequestException,),
|
||||
retry_backoff=600,
|
||||
max_retries=5,
|
||||
retry_jitter=True,
|
||||
)
|
||||
def webhook_send_task(
|
||||
self,
|
||||
webhook,
|
||||
slug,
|
||||
event,
|
||||
event_data,
|
||||
action,
|
||||
current_site,
|
||||
activity,
|
||||
):
|
||||
try:
|
||||
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Autopilot",
|
||||
"X-Plane-Delivery": str(uuid.uuid4()),
|
||||
"X-Plane-Event": event,
|
||||
}
|
||||
|
||||
# # Your secret key
|
||||
event_data = (
|
||||
json.loads(json.dumps(event_data, cls=DjangoJSONEncoder))
|
||||
if event_data is not None
|
||||
else None
|
||||
)
|
||||
|
||||
activity = (
|
||||
json.loads(json.dumps(activity, cls=DjangoJSONEncoder))
|
||||
if activity is not None
|
||||
else None
|
||||
)
|
||||
|
||||
action = {
|
||||
"POST": "create",
|
||||
"PATCH": "update",
|
||||
"PUT": "update",
|
||||
"DELETE": "delete",
|
||||
}.get(action, action)
|
||||
|
||||
payload = {
|
||||
"event": event,
|
||||
"action": action,
|
||||
"webhook_id": str(webhook.id),
|
||||
"workspace_id": str(webhook.workspace_id),
|
||||
"data": event_data,
|
||||
"activity": activity,
|
||||
}
|
||||
|
||||
# Use HMAC for generating signature
|
||||
if webhook.secret_key:
|
||||
hmac_signature = hmac.new(
|
||||
webhook.secret_key.encode("utf-8"),
|
||||
json.dumps(payload).encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
)
|
||||
signature = hmac_signature.hexdigest()
|
||||
headers["X-Plane-Signature"] = signature
|
||||
|
||||
# Send the webhook event
|
||||
response = requests.post(
|
||||
webhook.url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# Log the webhook request
|
||||
WebhookLog.objects.create(
|
||||
workspace_id=str(webhook.workspace_id),
|
||||
webhook_id=str(webhook.id),
|
||||
event_type=str(event),
|
||||
request_method=str(action),
|
||||
request_headers=str(headers),
|
||||
request_body=str(payload),
|
||||
response_status=str(response.status_code),
|
||||
response_headers=str(response.headers),
|
||||
response_body=str(response.text),
|
||||
retry_count=str(self.request.retries),
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
# Log the failed webhook request
|
||||
WebhookLog.objects.create(
|
||||
workspace_id=str(webhook.workspace_id),
|
||||
webhook_id=str(webhook.id),
|
||||
event_type=str(event),
|
||||
request_method=str(action),
|
||||
request_headers=str(headers),
|
||||
request_body=str(payload),
|
||||
response_status=500,
|
||||
response_headers="",
|
||||
response_body=str(e),
|
||||
retry_count=str(self.request.retries),
|
||||
)
|
||||
# Retry logic
|
||||
if self.request.retries >= self.max_retries:
|
||||
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
|
||||
if webhook:
|
||||
# send email for the deactivation of the webhook
|
||||
send_webhook_deactivation_email(
|
||||
webhook_id=webhook.id,
|
||||
receiver_id=webhook.created_by_id,
|
||||
reason=str(e),
|
||||
current_site=current_site,
|
||||
)
|
||||
return
|
||||
raise requests.RequestException()
|
||||
|
||||
except Exception as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def webhook_activity(
|
||||
event,
|
||||
verb,
|
||||
field,
|
||||
old_value,
|
||||
new_value,
|
||||
actor_id,
|
||||
slug,
|
||||
current_site,
|
||||
event_id,
|
||||
old_identifier,
|
||||
new_identifier,
|
||||
):
|
||||
try:
|
||||
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
|
||||
|
||||
if event == "project":
|
||||
webhooks = webhooks.filter(project=True)
|
||||
|
||||
if event == "issue":
|
||||
webhooks = webhooks.filter(issue=True)
|
||||
|
||||
if event == "module" or event == "module_issue":
|
||||
webhooks = webhooks.filter(module=True)
|
||||
|
||||
if event == "cycle" or event == "cycle_issue":
|
||||
webhooks = webhooks.filter(cycle=True)
|
||||
|
||||
if event == "issue_comment":
|
||||
webhooks = webhooks.filter(issue_comment=True)
|
||||
|
||||
for webhook in webhooks:
|
||||
webhook_send_task.delay(
|
||||
webhook=webhook.id,
|
||||
slug=slug,
|
||||
event=event,
|
||||
event_data=get_model_data(
|
||||
event=event,
|
||||
event_id=event_id,
|
||||
),
|
||||
action=verb,
|
||||
current_site=current_site,
|
||||
activity={
|
||||
"field": field,
|
||||
"new_value": new_value,
|
||||
"old_value": old_value,
|
||||
"actor": get_model_data(event="user", event_id=actor_id),
|
||||
"old_identifier": old_identifier,
|
||||
"new_identifier": new_identifier,
|
||||
},
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
# Return if a does not exist error occurs
|
||||
if isinstance(e, ObjectDoesNotExist):
|
||||
return
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def model_activity(
|
||||
model_name,
|
||||
model_id,
|
||||
requested_data,
|
||||
current_instance,
|
||||
actor_id,
|
||||
slug,
|
||||
origin=None,
|
||||
):
|
||||
"""Function takes in two json and computes differences between keys of both the json"""
|
||||
if current_instance is None:
|
||||
webhook_activity.delay(
|
||||
event=model_name,
|
||||
verb="created",
|
||||
field=None,
|
||||
old_value=None,
|
||||
new_value=None,
|
||||
actor_id=actor_id,
|
||||
slug=slug,
|
||||
current_site=origin,
|
||||
event_id=model_id,
|
||||
old_identifier=None,
|
||||
new_identifier=None,
|
||||
)
|
||||
return
|
||||
|
||||
# Load the current instance
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
|
||||
# Loop through all keys in requested data and check the current value and requested value
|
||||
for key in requested_data:
|
||||
# Check if key is present in current instance or not
|
||||
if key in current_instance:
|
||||
current_value = current_instance.get(key, None)
|
||||
requested_value = requested_data.get(key, None)
|
||||
if current_value != requested_value:
|
||||
webhook_activity.delay(
|
||||
event=model_name,
|
||||
verb="updated",
|
||||
field=key,
|
||||
old_value=current_value,
|
||||
new_value=requested_value,
|
||||
actor_id=actor_id,
|
||||
slug=slug,
|
||||
current_site=origin,
|
||||
event_id=model_id,
|
||||
old_identifier=None,
|
||||
new_identifier=None,
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
@@ -32,6 +32,10 @@ app.conf.beat_schedule = {
|
||||
"task": "plane.bgtasks.email_notification_task.stack_email_notification",
|
||||
"schedule": crontab(minute="*/5"),
|
||||
},
|
||||
"check-every-day-to-delete-api-logs": {
|
||||
"task": "plane.bgtasks.api_logs_task.delete_api_logs",
|
||||
"schedule": crontab(hour=0, minute=0),
|
||||
},
|
||||
}
|
||||
|
||||
# Load task modules from all registered Django app configs.
|
||||
|
||||
@@ -15,7 +15,7 @@ class Command(BaseCommand):
|
||||
receiver_email = options.get("to_email")
|
||||
|
||||
if not receiver_email:
|
||||
raise CommandError("Reciever email is required")
|
||||
raise CommandError("Receiver email is required")
|
||||
|
||||
(
|
||||
EMAIL_HOST,
|
||||
@@ -54,7 +54,7 @@ class Command(BaseCommand):
|
||||
connection=connection,
|
||||
)
|
||||
msg.send()
|
||||
self.stdout.write(self.style.SUCCESS("Email succesfully sent"))
|
||||
self.stdout.write(self.style.SUCCESS("Email successfully sent"))
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
|
||||
@@ -46,7 +46,7 @@ class Command(BaseCommand):
|
||||
}
|
||||
|
||||
instance = Instance.objects.create(
|
||||
instance_name="Plane Free",
|
||||
instance_name="Plane Community Edition",
|
||||
instance_id=secrets.token_hex(12),
|
||||
license_key=None,
|
||||
api_key=secrets.token_hex(8),
|
||||
|
||||
@@ -293,6 +293,7 @@ CELERY_IMPORTS = (
|
||||
"plane.bgtasks.exporter_expired_task",
|
||||
"plane.bgtasks.file_asset_task",
|
||||
"plane.bgtasks.email_notification_task",
|
||||
"plane.bgtasks.api_logs_task",
|
||||
# management tasks
|
||||
"plane.bgtasks.dummy_data_task",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import pytz
|
||||
|
||||
def user_timezone_converter(queryset, datetime_fields, user_timezone):
|
||||
# Create a timezone object for the user's timezone
|
||||
user_tz = pytz.timezone(user_timezone)
|
||||
|
||||
# Check if queryset is a dictionary (single item) or a list of dictionaries
|
||||
if isinstance(queryset, dict):
|
||||
queryset_values = [queryset]
|
||||
else:
|
||||
queryset_values = list(queryset)
|
||||
|
||||
# Iterate over the dictionaries in the list
|
||||
for item in queryset_values:
|
||||
# Iterate over the datetime fields
|
||||
for field in datetime_fields:
|
||||
# Convert the datetime field to the user's timezone
|
||||
if field in item and item[field]:
|
||||
item[field] = item[field].astimezone(user_tz)
|
||||
|
||||
# If queryset was a single item, return a single item
|
||||
if isinstance(queryset, dict):
|
||||
return queryset_values[0]
|
||||
else:
|
||||
return queryset_values
|
||||
@@ -1,3 +1,3 @@
|
||||
-r base.txt
|
||||
|
||||
gunicorn==21.2.0
|
||||
gunicorn==22.0.0
|
||||
|
||||
@@ -1 +1 @@
|
||||
python-3.11.8
|
||||
python-3.11.9
|
||||
@@ -180,7 +180,7 @@ services:
|
||||
|
||||
plane-redis:
|
||||
container_name: plane-redis
|
||||
image: redis:6.2.7-alpine
|
||||
image: redis:7.2.4-alpine
|
||||
restart: always
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
|
||||
@@ -10,7 +10,7 @@ volumes:
|
||||
|
||||
services:
|
||||
plane-redis:
|
||||
image: redis:6.2.7-alpine
|
||||
image: redis:7.2.4-alpine
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- dev_env
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ services:
|
||||
|
||||
plane-redis:
|
||||
container_name: plane-redis
|
||||
image: redis:6.2.7-alpine
|
||||
image: redis:7.2.4-alpine
|
||||
restart: always
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor-core",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -34,7 +34,7 @@ interface CustomEditorProps {
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
placeholder?: string | ((isFocused: boolean) => string);
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
@@ -142,11 +142,11 @@ export const useEditor = ({
|
||||
executeMenuItemCommand: (itemName: EditorMenuItemNames) => {
|
||||
const editorItems = getEditorMenuItems(editorRef.current, uploadFile);
|
||||
|
||||
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName);
|
||||
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
|
||||
|
||||
const item = getEditorMenuItem(itemName);
|
||||
if (item) {
|
||||
if (item.name === "image") {
|
||||
if (item.key === "image") {
|
||||
item.command(savedSelection);
|
||||
} else {
|
||||
item.command();
|
||||
@@ -158,7 +158,7 @@ export const useEditor = ({
|
||||
isMenuItemActive: (itemName: EditorMenuItemNames): boolean => {
|
||||
const editorItems = getEditorMenuItems(editorRef.current, uploadFile);
|
||||
|
||||
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName);
|
||||
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
|
||||
const item = getEditorMenuItem(itemName);
|
||||
return item ? item.isActive() : false;
|
||||
},
|
||||
|
||||
@@ -4,6 +4,11 @@ import { findTableAncestor } from "src/lib/utils";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { UploadImage } from "src/types/upload-image";
|
||||
|
||||
export const setText = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().run();
|
||||
else editor.chain().focus().clearNodes().run();
|
||||
};
|
||||
|
||||
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level: 1 }).run();
|
||||
@@ -19,6 +24,21 @@ export const toggleHeadingThree = (editor: Editor, range?: Range) => {
|
||||
else editor.chain().focus().toggleHeading({ level: 3 }).run();
|
||||
};
|
||||
|
||||
export const toggleHeadingFour = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 4 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level: 4 }).run();
|
||||
};
|
||||
|
||||
export const toggleHeadingFive = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 5 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level: 5 }).run();
|
||||
};
|
||||
|
||||
export const toggleHeadingSix = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 6 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level: 6 }).run();
|
||||
};
|
||||
|
||||
export const toggleBold = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleBold().run();
|
||||
else editor.chain().focus().toggleBold().run();
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
.ProseMirror {
|
||||
--font-size-h1: 1.5rem;
|
||||
--font-size-h2: 1.3125rem;
|
||||
--font-size-h3: 1.125rem;
|
||||
--font-size-h4: 0.9375rem;
|
||||
--font-size-h5: 0.8125rem;
|
||||
--font-size-h6: 0.75rem;
|
||||
--font-size-regular: 0.9375rem;
|
||||
--font-size-list: var(--font-size-regular);
|
||||
}
|
||||
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
@@ -56,7 +67,7 @@
|
||||
|
||||
/* to-do list */
|
||||
ul[data-type="taskList"] li {
|
||||
font-size: 1rem;
|
||||
font-size: var(--font-size-list);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -162,7 +173,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||
cursor: text;
|
||||
line-height: 1.2;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-size: var(--font-size-regular);
|
||||
color: inherit;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
@@ -310,15 +321,15 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
|
||||
.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 4px;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-h1);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
margin-top: 1.4rem;
|
||||
margin-bottom: 1px;
|
||||
font-size: 1.5rem;
|
||||
font-size: var(--font-size-h2);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
@@ -326,21 +337,46 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
|
||||
.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1px;
|
||||
font-size: 1.25rem;
|
||||
font-size: var(--font-size-h3);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1px;
|
||||
font-size: var(--font-size-h4);
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1px;
|
||||
font-size: var(--font-size-h5);
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1px;
|
||||
font-size: var(--font-size-h6);
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 1px;
|
||||
padding: 3px 2px;
|
||||
font-size: 1rem;
|
||||
padding: 3px 0;
|
||||
font-size: var(--font-size-regular);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p,
|
||||
.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p {
|
||||
font-size: 1rem;
|
||||
font-size: var(--font-size-list);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ type TArguments = {
|
||||
cancelUploadImage?: () => void;
|
||||
uploadFile: UploadImage;
|
||||
};
|
||||
placeholder?: string | ((isFocused: boolean) => string);
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
@@ -147,7 +147,7 @@ export const CoreEditorExtensions = ({
|
||||
|
||||
if (placeholder) {
|
||||
if (typeof placeholder === "string") return placeholder;
|
||||
else return placeholder(editor.isFocused);
|
||||
else return placeholder(editor.isFocused, editor.getHTML());
|
||||
}
|
||||
|
||||
return "Press '/' for commands...";
|
||||
|
||||
@@ -13,16 +13,24 @@ import {
|
||||
UnderlineIcon,
|
||||
StrikethroughIcon,
|
||||
CodeIcon,
|
||||
Heading4,
|
||||
Heading5,
|
||||
Heading6,
|
||||
CaseSensitive,
|
||||
} from "lucide-react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import {
|
||||
insertImageCommand,
|
||||
insertTableCommand,
|
||||
setText,
|
||||
toggleBlockquote,
|
||||
toggleBold,
|
||||
toggleBulletList,
|
||||
toggleCodeBlock,
|
||||
toggleHeadingFive,
|
||||
toggleHeadingFour,
|
||||
toggleHeadingOne,
|
||||
toggleHeadingSix,
|
||||
toggleHeadingThree,
|
||||
toggleHeadingTwo,
|
||||
toggleItalic,
|
||||
@@ -36,15 +44,26 @@ import { UploadImage } from "src/types/upload-image";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
|
||||
export interface EditorMenuItem {
|
||||
key: string;
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
icon: LucideIconType;
|
||||
}
|
||||
|
||||
export const TextItem = (editor: Editor) =>
|
||||
({
|
||||
key: "text",
|
||||
name: "Text",
|
||||
isActive: () => editor.isActive("paragraph"),
|
||||
command: () => setText(editor),
|
||||
icon: CaseSensitive,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const HeadingOneItem = (editor: Editor) =>
|
||||
({
|
||||
name: "H1",
|
||||
key: "h1",
|
||||
name: "Heading 1",
|
||||
isActive: () => editor.isActive("heading", { level: 1 }),
|
||||
command: () => toggleHeadingOne(editor),
|
||||
icon: Heading1,
|
||||
@@ -52,7 +71,8 @@ export const HeadingOneItem = (editor: Editor) =>
|
||||
|
||||
export const HeadingTwoItem = (editor: Editor) =>
|
||||
({
|
||||
name: "H2",
|
||||
key: "h2",
|
||||
name: "Heading 2",
|
||||
isActive: () => editor.isActive("heading", { level: 2 }),
|
||||
command: () => toggleHeadingTwo(editor),
|
||||
icon: Heading2,
|
||||
@@ -60,15 +80,44 @@ export const HeadingTwoItem = (editor: Editor) =>
|
||||
|
||||
export const HeadingThreeItem = (editor: Editor) =>
|
||||
({
|
||||
name: "H3",
|
||||
key: "h3",
|
||||
name: "Heading 3",
|
||||
isActive: () => editor.isActive("heading", { level: 3 }),
|
||||
command: () => toggleHeadingThree(editor),
|
||||
icon: Heading3,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const HeadingFourItem = (editor: Editor) =>
|
||||
({
|
||||
key: "h4",
|
||||
name: "Heading 4",
|
||||
isActive: () => editor.isActive("heading", { level: 4 }),
|
||||
command: () => toggleHeadingFour(editor),
|
||||
icon: Heading4,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const HeadingFiveItem = (editor: Editor) =>
|
||||
({
|
||||
key: "h5",
|
||||
name: "Heading 5",
|
||||
isActive: () => editor.isActive("heading", { level: 5 }),
|
||||
command: () => toggleHeadingFive(editor),
|
||||
icon: Heading5,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const HeadingSixItem = (editor: Editor) =>
|
||||
({
|
||||
key: "h6",
|
||||
name: "Heading 6",
|
||||
isActive: () => editor.isActive("heading", { level: 6 }),
|
||||
command: () => toggleHeadingSix(editor),
|
||||
icon: Heading6,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const BoldItem = (editor: Editor) =>
|
||||
({
|
||||
name: "bold",
|
||||
key: "bold",
|
||||
name: "Bold",
|
||||
isActive: () => editor?.isActive("bold"),
|
||||
command: () => toggleBold(editor),
|
||||
icon: BoldIcon,
|
||||
@@ -76,7 +125,8 @@ export const BoldItem = (editor: Editor) =>
|
||||
|
||||
export const ItalicItem = (editor: Editor) =>
|
||||
({
|
||||
name: "italic",
|
||||
key: "italic",
|
||||
name: "Italic",
|
||||
isActive: () => editor?.isActive("italic"),
|
||||
command: () => toggleItalic(editor),
|
||||
icon: ItalicIcon,
|
||||
@@ -84,7 +134,8 @@ export const ItalicItem = (editor: Editor) =>
|
||||
|
||||
export const UnderLineItem = (editor: Editor) =>
|
||||
({
|
||||
name: "underline",
|
||||
key: "underline",
|
||||
name: "Underline",
|
||||
isActive: () => editor?.isActive("underline"),
|
||||
command: () => toggleUnderline(editor),
|
||||
icon: UnderlineIcon,
|
||||
@@ -92,7 +143,8 @@ export const UnderLineItem = (editor: Editor) =>
|
||||
|
||||
export const StrikeThroughItem = (editor: Editor) =>
|
||||
({
|
||||
name: "strike",
|
||||
key: "strikethrough",
|
||||
name: "Strikethrough",
|
||||
isActive: () => editor?.isActive("strike"),
|
||||
command: () => toggleStrike(editor),
|
||||
icon: StrikethroughIcon,
|
||||
@@ -100,47 +152,53 @@ export const StrikeThroughItem = (editor: Editor) =>
|
||||
|
||||
export const BulletListItem = (editor: Editor) =>
|
||||
({
|
||||
name: "bullet-list",
|
||||
key: "bulleted-list",
|
||||
name: "Bulleted list",
|
||||
isActive: () => editor?.isActive("bulletList"),
|
||||
command: () => toggleBulletList(editor),
|
||||
icon: ListIcon,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const TodoListItem = (editor: Editor) =>
|
||||
({
|
||||
name: "To-do List",
|
||||
isActive: () => editor.isActive("taskItem"),
|
||||
command: () => toggleTaskList(editor),
|
||||
icon: CheckSquare,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const CodeItem = (editor: Editor) =>
|
||||
({
|
||||
name: "code",
|
||||
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
|
||||
command: () => toggleCodeBlock(editor),
|
||||
icon: CodeIcon,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const NumberedListItem = (editor: Editor) =>
|
||||
({
|
||||
name: "ordered-list",
|
||||
key: "numbered-list",
|
||||
name: "Numbered list",
|
||||
isActive: () => editor?.isActive("orderedList"),
|
||||
command: () => toggleOrderedList(editor),
|
||||
icon: ListOrderedIcon,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const TodoListItem = (editor: Editor) =>
|
||||
({
|
||||
key: "to-do-list",
|
||||
name: "To-do list",
|
||||
isActive: () => editor.isActive("taskItem"),
|
||||
command: () => toggleTaskList(editor),
|
||||
icon: CheckSquare,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const QuoteItem = (editor: Editor) =>
|
||||
({
|
||||
name: "quote",
|
||||
key: "quote",
|
||||
name: "Quote",
|
||||
isActive: () => editor?.isActive("blockquote"),
|
||||
command: () => toggleBlockquote(editor),
|
||||
icon: QuoteIcon,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const CodeItem = (editor: Editor) =>
|
||||
({
|
||||
key: "code",
|
||||
name: "Code",
|
||||
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
|
||||
command: () => toggleCodeBlock(editor),
|
||||
icon: CodeIcon,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const TableItem = (editor: Editor) =>
|
||||
({
|
||||
name: "table",
|
||||
key: "table",
|
||||
name: "Table",
|
||||
isActive: () => editor?.isActive("table"),
|
||||
command: () => insertTableCommand(editor),
|
||||
icon: TableIcon,
|
||||
@@ -148,7 +206,8 @@ export const TableItem = (editor: Editor) =>
|
||||
|
||||
export const ImageItem = (editor: Editor, uploadFile: UploadImage) =>
|
||||
({
|
||||
name: "image",
|
||||
key: "image",
|
||||
name: "Image",
|
||||
isActive: () => editor?.isActive("image"),
|
||||
command: (savedSelection: Selection | null) => insertImageCommand(editor, uploadFile, savedSelection),
|
||||
icon: ImageIcon,
|
||||
@@ -159,9 +218,13 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
TextItem(editor),
|
||||
HeadingOneItem(editor),
|
||||
HeadingTwoItem(editor),
|
||||
HeadingThreeItem(editor),
|
||||
HeadingFourItem(editor),
|
||||
HeadingFiveItem(editor),
|
||||
HeadingSixItem(editor),
|
||||
BoldItem(editor),
|
||||
ItalicItem(editor),
|
||||
UnderLineItem(editor),
|
||||
@@ -177,7 +240,7 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
|
||||
}
|
||||
|
||||
export type EditorMenuItemNames = ReturnType<typeof getEditorMenuItems> extends (infer U)[]
|
||||
? U extends { name: infer N }
|
||||
? U extends { key: infer N }
|
||||
? N
|
||||
: never
|
||||
: never;
|
||||
|
||||
@@ -158,7 +158,7 @@ export async function startImageUpload(
|
||||
const transaction = view.state.tr.insert(pos - 1, node).setMeta(uploadKey, { remove: { id } });
|
||||
|
||||
view.dispatch(transaction);
|
||||
view.focus();
|
||||
if (view.hasFocus()) view.focus();
|
||||
editor.storage.image.uploadInProgress = false;
|
||||
} catch (error) {
|
||||
removePlaceholder(editor, view, id);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/document-editor",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"description": "Package that powers Plane's Pages Editor",
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
|
||||
@@ -31,7 +31,7 @@ interface IDocumentEditor {
|
||||
suggestions: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
tabIndex?: number;
|
||||
placeholder?: string | ((isFocused: boolean) => string);
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
}
|
||||
|
||||
const DocumentEditor = (props: IDocumentEditor) => {
|
||||
|
||||
@@ -67,11 +67,13 @@ export default function BlockMenu(props: BlockMenuProps) {
|
||||
popup.current?.hide();
|
||||
};
|
||||
document.addEventListener("click", handleClickDragHandle);
|
||||
document.addEventListener("contextmenu", handleClickDragHandle);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("scroll", handleScroll, true); // Using capture phase
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickDragHandle);
|
||||
document.removeEventListener("contextmenu", handleClickDragHandle);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("scroll", handleScroll, true);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor-extensions",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"description": "Package that powers Plane's Editor with extensions",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -225,6 +225,9 @@ function DragHandle(options: DragHandleOptions) {
|
||||
dragHandleElement.addEventListener("click", (e) => {
|
||||
handleClick(e, view);
|
||||
});
|
||||
dragHandleElement.addEventListener("contextmenu", (e) => {
|
||||
handleClick(e, view);
|
||||
});
|
||||
|
||||
dragHandleElement.addEventListener("drag", (e) => {
|
||||
hideDragHandle();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/lite-text-editor",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"description": "Package that powers Plane's Comment Editor",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -32,7 +32,8 @@ export interface ILiteTextEditor {
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
tabIndex?: number;
|
||||
placeholder?: string | ((isFocused: boolean) => string);
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||
@@ -48,12 +49,14 @@ const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||
tabIndex,
|
||||
mentionHandler,
|
||||
placeholder = "Add comment...",
|
||||
id = "",
|
||||
} = props;
|
||||
|
||||
const editor = useEditor({
|
||||
onChange,
|
||||
initialValue,
|
||||
value,
|
||||
id,
|
||||
editorClassName,
|
||||
restoreFile: fileHandler.restore,
|
||||
uploadFile: fileHandler.upload,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/rich-text-editor",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"description": "Rich Text Editor that powers Plane",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -35,7 +35,7 @@ export type IRichTextEditor = {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
placeholder?: string | ((isFocused: boolean) => string);
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@plane/editor-core";
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
key: string;
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
|
||||
@@ -8,9 +8,13 @@ import {
|
||||
QuoteItem,
|
||||
CodeItem,
|
||||
TodoListItem,
|
||||
TextItem,
|
||||
HeadingFourItem,
|
||||
HeadingFiveItem,
|
||||
HeadingSixItem,
|
||||
} from "@plane/editor-core";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { Check, ChevronDown, TextIcon } from "lucide-react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { Dispatch, FC, SetStateAction } from "react";
|
||||
|
||||
import { BubbleMenuItem } from ".";
|
||||
@@ -23,18 +27,16 @@ interface NodeSelectorProps {
|
||||
|
||||
export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
|
||||
const items: BubbleMenuItem[] = [
|
||||
{
|
||||
name: "Text",
|
||||
icon: TextIcon,
|
||||
command: () => editor.chain().focus().clearNodes().run(),
|
||||
isActive: () => editor.isActive("paragraph") && !editor.isActive("bulletList") && !editor.isActive("orderedList"),
|
||||
},
|
||||
TextItem(editor),
|
||||
HeadingOneItem(editor),
|
||||
HeadingTwoItem(editor),
|
||||
HeadingThreeItem(editor),
|
||||
TodoListItem(editor),
|
||||
HeadingFourItem(editor),
|
||||
HeadingFiveItem(editor),
|
||||
HeadingSixItem(editor),
|
||||
BulletListItem(editor),
|
||||
NumberedListItem(editor),
|
||||
TodoListItem(editor),
|
||||
QuoteItem(editor),
|
||||
CodeItem(editor),
|
||||
];
|
||||
@@ -58,7 +60,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 p-1 shadow-xl animate-in fade-in slide-in-from-top-1">
|
||||
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
@@ -69,19 +71,17 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",
|
||||
"flex items-center justify-between rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80",
|
||||
{
|
||||
"bg-custom-primary-100/5 text-custom-text-100": activeItem.name === item.name,
|
||||
"bg-custom-background-80": activeItem.name === item.name,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="rounded-sm border border-custom-border-300 p-1">
|
||||
<item.icon className="h-3 w-3" />
|
||||
</div>
|
||||
<item.icon className="size-3 flex-shrink-0" />
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{activeItem.name === item.name && <Check className="h-4 w-4" />}
|
||||
{activeItem.name === item.name && <Check className="size-3 text-custom-text-300 flex-shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eslint-config-custom",
|
||||
"private": true,
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"devDependencies": {},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tailwind-config-custom",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"description": "common tailwind configuration across monorepo",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tsconfig",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"private": true,
|
||||
"files": [
|
||||
"base.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/types",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"private": true,
|
||||
"main": "./src/index.d.ts"
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -20,7 +20,7 @@ export type TInboxIssueCurrentTab = EInboxIssueCurrentTab;
|
||||
export type TInboxIssueStatus = EInboxIssueStatus;
|
||||
|
||||
// filters
|
||||
export type TInboxIssueFilterMemberKeys = "assignee" | "created_by";
|
||||
export type TInboxIssueFilterMemberKeys = "assignees" | "created_by";
|
||||
|
||||
export type TInboxIssueFilterDateKeys = "created_at" | "updated_at";
|
||||
|
||||
|
||||
Vendored
-5
@@ -16,14 +16,9 @@ export type TPage = {
|
||||
project: string | undefined;
|
||||
updated_at: Date | undefined;
|
||||
updated_by: string | undefined;
|
||||
view_props: TPageViewProps | undefined;
|
||||
workspace: string | undefined;
|
||||
};
|
||||
|
||||
export type TPageViewProps = {
|
||||
full_width?: boolean;
|
||||
};
|
||||
|
||||
// page filters
|
||||
export type TPageNavigationTabs = "public" | "private" | "archived";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@plane/ui",
|
||||
"description": "UI components shared across multiple apps internally",
|
||||
"private": true,
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
// ui
|
||||
import { Tooltip } from "../tooltip";
|
||||
// helpers
|
||||
import { cn } from "../../helpers";
|
||||
// types
|
||||
import { TAvatarSize, getSizeInfo, isAValidNumber } from "./avatar";
|
||||
|
||||
@@ -55,7 +57,7 @@ export const AvatarGroup: React.FC<Props> = (props) => {
|
||||
const sizeInfo = getSizeInfo(size);
|
||||
|
||||
return (
|
||||
<div className={`flex ${sizeInfo.spacing}`}>
|
||||
<div className={cn("flex", sizeInfo.spacing)}>
|
||||
{avatarsWithUpdatedProps.map((avatar, index) => (
|
||||
<div key={index} className="rounded-full ring-1 ring-custom-background-100">
|
||||
{avatar}
|
||||
@@ -64,9 +66,12 @@ export const AvatarGroup: React.FC<Props> = (props) => {
|
||||
{maxAvatarsToRender < totalAvatars && (
|
||||
<Tooltip tooltipContent={`${totalAvatars} total`} disabled={!showTooltip}>
|
||||
<div
|
||||
className={`${
|
||||
!isAValidNumber(size) ? sizeInfo.avatarSize : ""
|
||||
} grid place-items-center rounded-full bg-custom-primary-10 text-[9px] text-custom-primary-100 ring-1 ring-custom-background-100`}
|
||||
className={cn(
|
||||
"grid place-items-center rounded-full bg-custom-primary-10 text-[9px] text-custom-primary-100 ring-1 ring-custom-background-100",
|
||||
{
|
||||
[sizeInfo.avatarSize]: !isAValidNumber(size),
|
||||
}
|
||||
)}
|
||||
style={
|
||||
isAValidNumber(size)
|
||||
? {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
// ui
|
||||
import { Tooltip } from "../tooltip";
|
||||
// helpers
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
export type TAvatarSize = "sm" | "md" | "base" | "lg" | number;
|
||||
|
||||
@@ -130,9 +132,9 @@ export const Avatar: React.FC<Props> = (props) => {
|
||||
return (
|
||||
<Tooltip tooltipContent={fallbackText ?? name ?? "?"} disabled={!showTooltip}>
|
||||
<div
|
||||
className={`${
|
||||
!isAValidNumber(size) ? sizeInfo.avatarSize : ""
|
||||
} grid place-items-center overflow-hidden ${getBorderRadius(shape)}`}
|
||||
className={cn("grid place-items-center overflow-hidden", getBorderRadius(shape), {
|
||||
[sizeInfo.avatarSize]: !isAValidNumber(size),
|
||||
})}
|
||||
style={
|
||||
isAValidNumber(size)
|
||||
? {
|
||||
@@ -144,12 +146,15 @@ export const Avatar: React.FC<Props> = (props) => {
|
||||
tabIndex={-1}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} className={`h-full w-full ${getBorderRadius(shape)} ${className}`} alt={name} />
|
||||
<img src={src} className={cn("h-full w-full", getBorderRadius(shape), className)} alt={name} />
|
||||
) : (
|
||||
<div
|
||||
className={`${sizeInfo.fontSize} grid h-full w-full place-items-center ${getBorderRadius(
|
||||
shape
|
||||
)} ${className}`}
|
||||
className={cn(
|
||||
sizeInfo.fontSize,
|
||||
"grid h-full w-full place-items-center",
|
||||
getBorderRadius(shape),
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fallbackBackgroundColor ?? "rgba(var(--color-primary-500))",
|
||||
color: fallbackTextColor ?? "#ffffff",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
// helpers
|
||||
import { getIconStyling, getBadgeStyling, TBadgeVariant, TBadgeSizes } from "./helper";
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
export interface BadgeProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: TBadgeVariant;
|
||||
@@ -31,7 +32,7 @@ const Badge = React.forwardRef<HTMLButtonElement, BadgeProps>((props, ref) => {
|
||||
const buttonIconStyle = getIconStyling(size);
|
||||
|
||||
return (
|
||||
<button ref={ref} type={type} className={`${buttonStyle} ${className}`} disabled={disabled || loading} {...rest}>
|
||||
<button ref={ref} type={type} className={cn(buttonStyle, className)} disabled={disabled || loading} {...rest}>
|
||||
{prependIcon && <div className={buttonIconStyle}>{React.cloneElement(prependIcon, { strokeWidth: 2 })}</div>}
|
||||
{children}
|
||||
{appendIcon && <div className={buttonIconStyle}>{React.cloneElement(appendIcon, { strokeWidth: 2 })}</div>}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Switch } from "@headlessui/react";
|
||||
// helpers
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
interface IToggleSwitchProps {
|
||||
value: boolean;
|
||||
@@ -19,22 +20,32 @@ const ToggleSwitch: React.FC<IToggleSwitchProps> = (props) => {
|
||||
checked={value}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
className={`relative inline-flex flex-shrink-0 ${
|
||||
size === "sm" ? "h-4 w-6" : size === "md" ? "h-5 w-8" : "h-6 w-10"
|
||||
} flex-shrink-0 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
value ? "bg-custom-primary-100" : "bg-gray-700"
|
||||
} ${className || ""} ${disabled ? "cursor-not-allowed" : ""}`}
|
||||
className={cn(
|
||||
"relative inline-flex flex-shrink-0 h-6 w-10 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none bg-gray-700",
|
||||
{
|
||||
"h-4 w-6": size === "sm",
|
||||
"h-5 w-8": size === "md",
|
||||
"bg-custom-primary-100": value,
|
||||
"cursor-not-allowed": disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block self-center ${
|
||||
size === "sm" ? "h-2 w-2" : size === "md" ? "h-3 w-3" : "h-4 w-4"
|
||||
} transform rounded-full shadow ring-0 transition duration-200 ease-in-out ${
|
||||
value
|
||||
? (size === "sm" ? "translate-x-3" : size === "md" ? "translate-x-4" : "translate-x-5") + " bg-white"
|
||||
: "translate-x-0.5 bg-custom-background-90"
|
||||
} ${disabled ? "cursor-not-allowed" : ""}`}
|
||||
className={cn(
|
||||
"inline-block self-center h-4 w-4 transform rounded-full shadow ring-0 transition duration-200 ease-in-out",
|
||||
{
|
||||
"translate-x-5 bg-white": value,
|
||||
"h-2 w-2": size === "sm",
|
||||
"h-3 w-3": size === "md",
|
||||
"translate-x-3": value && size === "sm",
|
||||
"translate-x-4": value && size === "md",
|
||||
"translate-x-0.5 bg-custom-background-90": !value,
|
||||
"cursor-not-allowed": disabled,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
|
||||
@@ -6,10 +6,11 @@ export type TControlLink = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
children: React.ReactNode;
|
||||
target?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ControlLink: React.FC<TControlLink> = (props) => {
|
||||
const { href, onClick, children, target = "_self", disabled = false, ...rest } = props;
|
||||
export const ControlLink = React.forwardRef<HTMLAnchorElement, TControlLink>((props, ref) => {
|
||||
const { href, onClick, children, target = "_self", disabled = false, className, ...rest } = props;
|
||||
const LEFT_CLICK_EVENT_CODE = 0;
|
||||
|
||||
const handleOnClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||
@@ -20,11 +21,20 @@ export const ControlLink: React.FC<TControlLink> = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
// if disabled but still has a ref or a className then it has to be rendered without a href
|
||||
if (disabled && (ref || className))
|
||||
return (
|
||||
<a ref={ref} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
|
||||
// else if just disabled return without the parent wrapper
|
||||
if (disabled) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<a href={href} target={target} onClick={handleOnClick} {...rest}>
|
||||
<a href={href} target={target} onClick={handleOnClick} {...rest} ref={ref} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./item";
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
// helpers
|
||||
import { cn } from "../../../helpers";
|
||||
// types
|
||||
import { TContextMenuItem } from "./root";
|
||||
|
||||
type ContextMenuItemProps = {
|
||||
handleActiveItem: () => void;
|
||||
handleClose: () => void;
|
||||
isActive: boolean;
|
||||
item: TContextMenuItem;
|
||||
};
|
||||
|
||||
export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
|
||||
const { handleActiveItem, handleClose, isActive, item } = props;
|
||||
|
||||
if (item.shouldRender === false) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-1 py-1.5 text-left text-custom-text-200 rounded text-xs select-none",
|
||||
{
|
||||
"bg-custom-background-90": isActive,
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
if (item.closeOnClick !== false) handleClose();
|
||||
}}
|
||||
onMouseEnter={handleActiveItem}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
// components
|
||||
import { ContextMenuItem } from "./item";
|
||||
// helpers
|
||||
import { cn } from "../../../helpers";
|
||||
// hooks
|
||||
import useOutsideClickDetector from "../../hooks/use-outside-click-detector";
|
||||
|
||||
export type TContextMenuItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.FC<any>;
|
||||
action: () => void;
|
||||
shouldRender?: boolean;
|
||||
closeOnClick?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
type ContextMenuProps = {
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
items: TContextMenuItem[];
|
||||
};
|
||||
|
||||
const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
|
||||
const { parentRef, items } = props;
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [position, setPosition] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const [activeItemIndex, setActiveItemIndex] = useState<number>(0);
|
||||
// refs
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
// derived values
|
||||
const renderedItems = items.filter((item) => item.shouldRender !== false);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setActiveItemIndex(0);
|
||||
};
|
||||
|
||||
// calculate position of context menu
|
||||
useEffect(() => {
|
||||
const parentElement = parentRef.current;
|
||||
const contextMenu = contextMenuRef.current;
|
||||
if (!parentElement || !contextMenu) return;
|
||||
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const contextMenuWidth = contextMenu.clientWidth;
|
||||
const contextMenuHeight = contextMenu.clientHeight;
|
||||
|
||||
const clickX = e?.pageX || 0;
|
||||
const clickY = e?.pageY || 0;
|
||||
|
||||
// check if there's enough space at the bottom, otherwise show at the top
|
||||
let top = clickY;
|
||||
if (clickY + contextMenuHeight > window.innerHeight) top = clickY - contextMenuHeight;
|
||||
|
||||
// check if there's enough space on the right, otherwise show on the left
|
||||
let left = clickX;
|
||||
if (clickX + contextMenuWidth > window.innerWidth) left = clickX - contextMenuWidth;
|
||||
|
||||
setPosition({ x: left, y: top });
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const hideContextMenu = (e: KeyboardEvent) => {
|
||||
if (isOpen && e.key === "Escape") handleClose();
|
||||
};
|
||||
|
||||
parentElement.addEventListener("contextmenu", handleContextMenu);
|
||||
window.addEventListener("keydown", hideContextMenu);
|
||||
|
||||
return () => {
|
||||
parentElement.removeEventListener("contextmenu", handleContextMenu);
|
||||
window.removeEventListener("keydown", hideContextMenu);
|
||||
};
|
||||
}, [contextMenuRef, isOpen, parentRef, setIsOpen, setPosition]);
|
||||
|
||||
// handle keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveItemIndex((prev) => (prev + 1) % renderedItems.length);
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveItemIndex((prev) => (prev - 1 + renderedItems.length) % renderedItems.length);
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const item = renderedItems[activeItemIndex];
|
||||
if (!item.disabled) {
|
||||
renderedItems[activeItemIndex].action();
|
||||
if (item.closeOnClick !== false) handleClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [activeItemIndex, isOpen, renderedItems, setIsOpen]);
|
||||
|
||||
// close on clicking outside
|
||||
useOutsideClickDetector(contextMenuRef, handleClose);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed h-screen w-screen top-0 left-0 cursor-default z-20 opacity-0 pointer-events-none transition-opacity",
|
||||
{
|
||||
"opacity-100 pointer-events-auto": isOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="fixed border-[0.5px] border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-rg rounded-md px-2 py-2.5 max-h-72 min-w-[12rem] overflow-y-scroll vertical-scrollbar scrollbar-sm"
|
||||
style={{
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
}}
|
||||
>
|
||||
{renderedItems.map((item, index) => (
|
||||
<ContextMenuItem
|
||||
key={item.key}
|
||||
handleActiveItem={() => setActiveItemIndex(index)}
|
||||
handleClose={handleClose}
|
||||
isActive={index === activeItemIndex}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
let contextMenu = <ContextMenuWithoutPortal {...props} />;
|
||||
const portal = document.querySelector("#context-menu-portal");
|
||||
if (portal) contextMenu = ReactDOM.createPortal(contextMenu, portal);
|
||||
return contextMenu;
|
||||
};
|
||||
@@ -66,6 +66,11 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (isOpen) closeDropdown();
|
||||
else openDropdown();
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
@@ -90,7 +95,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
||||
? "cursor-not-allowed text-custom-text-200"
|
||||
: "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${customButtonClassName}`}
|
||||
onClick={openDropdown}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{customButton}
|
||||
</button>
|
||||
@@ -107,7 +112,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
||||
? "cursor-not-allowed text-custom-text-200"
|
||||
: "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
onClick={openDropdown}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
|
||||
|
||||
@@ -47,6 +47,11 @@ const CustomSelect = (props: ICustomSelectProps) => {
|
||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (isOpen) closeDropdown();
|
||||
else openDropdown();
|
||||
};
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
as="div"
|
||||
@@ -67,7 +72,7 @@ const CustomSelect = (props: ICustomSelectProps) => {
|
||||
className={`flex items-center justify-between gap-1 text-xs ${
|
||||
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${customButtonClassName}`}
|
||||
onClick={openDropdown}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{customButton}
|
||||
</button>
|
||||
@@ -82,7 +87,7 @@ const CustomSelect = (props: ICustomSelectProps) => {
|
||||
} ${
|
||||
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
onClick={openDropdown}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./context-menu";
|
||||
export * from "./custom-menu";
|
||||
export * from "./custom-select";
|
||||
export * from "./custom-search-select";
|
||||
|
||||
@@ -71,7 +71,7 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={cn("grid place-items-center h-4 w-4 rounded-full border border-transparent", {
|
||||
className={cn("grid place-items-center h-5 w-5 rounded-full border border-transparent", {
|
||||
"border-custom-border-400": !showHexInput,
|
||||
})}
|
||||
onClick={() => {
|
||||
@@ -91,7 +91,7 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
|
||||
<button
|
||||
key={icon.name}
|
||||
type="button"
|
||||
className="h-6 w-6 select-none text-lg grid place-items-center rounded hover:bg-custom-background-80"
|
||||
className="h-9 w-9 select-none text-lg grid place-items-center rounded hover:bg-custom-background-80"
|
||||
onClick={() => {
|
||||
onChange({
|
||||
name: icon.name,
|
||||
@@ -99,7 +99,7 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span style={{ color: activeColor }} className="material-symbols-rounded text-base">
|
||||
<span style={{ color: activeColor }} className="material-symbols-rounded text-[20px]">
|
||||
{icon.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as React from "react";
|
||||
// helpers
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
intermediate?: boolean;
|
||||
@@ -9,32 +11,30 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>((props, ref)
|
||||
const { id, name, checked, intermediate = false, disabled, className = "", ...rest } = props;
|
||||
|
||||
return (
|
||||
<div className={`relative w-full flex gap-2 ${className}`}>
|
||||
<div className={cn("relative w-full flex gap-2", className)}>
|
||||
<input
|
||||
id={id}
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
name={name}
|
||||
checked={checked}
|
||||
className={`
|
||||
appearance-none shrink-0 w-4 h-4 border rounded-[3px] focus:outline-1 focus:outline-offset-4 focus:outline-custom-primary-50
|
||||
${
|
||||
disabled
|
||||
? "border-custom-border-200 bg-custom-background-80 cursor-not-allowed"
|
||||
: `cursor-pointer ${
|
||||
checked || intermediate
|
||||
? "border-custom-primary-40 bg-custom-primary-100 hover:bg-custom-primary-200"
|
||||
: "border-custom-border-300 hover:border-custom-border-400 bg-white"
|
||||
}`
|
||||
className={cn(
|
||||
"appearance-none shrink-0 w-4 h-4 border rounded-[3px] focus:outline-1 focus:outline-offset-4 focus:outline-custom-primary-50",
|
||||
{
|
||||
"border-custom-border-200 bg-custom-background-80 cursor-not-allowed": disabled,
|
||||
"cursor-pointer border-custom-border-300 hover:border-custom-border-400 bg-white": !disabled,
|
||||
"border-custom-primary-40 bg-custom-primary-100 hover:bg-custom-primary-200":
|
||||
!disabled && (checked || intermediate),
|
||||
}
|
||||
`}
|
||||
)}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
<svg
|
||||
className={`absolute w-4 h-4 p-0.5 pointer-events-none outline-none ${
|
||||
disabled ? "stroke-custom-text-400 opacity-40" : "stroke-white"
|
||||
} ${checked ? "block" : "hidden"}`}
|
||||
className={cn("absolute w-4 h-4 p-0.5 pointer-events-none outline-none hidden stroke-white", {
|
||||
block: checked,
|
||||
"stroke-custom-text-400 opacity-40": disabled,
|
||||
})}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -46,9 +46,10 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>((props, ref)
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
<svg
|
||||
className={`absolute w-4 h-4 p-0.5 pointer-events-none outline-none ${
|
||||
disabled ? "stroke-custom-text-400 opacity-40" : "stroke-white"
|
||||
} ${intermediate && !checked ? "block" : "hidden"}`}
|
||||
className={cn("absolute w-4 h-4 p-0.5 pointer-events-none outline-none stroke-white hidden", {
|
||||
"stroke-custom-text-400 opacity-40": disabled,
|
||||
block: intermediate && !checked,
|
||||
})}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 8 8"
|
||||
fill="none"
|
||||
|
||||
@@ -5,6 +5,8 @@ import { ColorResult, SketchPicker } from "react-color";
|
||||
import { Input } from "./input";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Button } from "../button";
|
||||
// helpers
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
export interface InputColorPickerProps {
|
||||
hasError: boolean;
|
||||
@@ -45,7 +47,7 @@ export const InputColorPicker: React.FC<InputColorPickerProps> = (props) => {
|
||||
onChange={handleInputChange}
|
||||
hasError={hasError}
|
||||
placeholder={placeholder}
|
||||
className={`border-[0.5px] border-custom-border-200 ${className}`}
|
||||
className={cn("border-[0.5px] border-custom-border-200", className)}
|
||||
style={style}
|
||||
/>
|
||||
|
||||
|
||||
@@ -19,17 +19,16 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
||||
type={type}
|
||||
name={name}
|
||||
className={cn(
|
||||
`block rounded-md bg-transparent text-sm placeholder-custom-text-400 focus:outline-none ${
|
||||
mode === "primary"
|
||||
? "rounded-md border-[0.5px] border-custom-border-200"
|
||||
: mode === "transparent"
|
||||
? "rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-custom-primary"
|
||||
: mode === "true-transparent"
|
||||
? "rounded border-none bg-transparent ring-0"
|
||||
: ""
|
||||
} ${hasError ? "border-red-500" : ""} ${hasError && mode === "primary" ? "bg-red-500/20" : ""} ${
|
||||
inputSize === "sm" ? "px-3 py-2" : inputSize === "md" ? "p-3" : ""
|
||||
}`,
|
||||
"block rounded-md bg-transparent text-sm placeholder-custom-text-400 focus:outline-none",
|
||||
{
|
||||
"rounded-md border-[0.5px] border-custom-border-200": mode === "primary",
|
||||
"rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-custom-primary":
|
||||
mode === "transparent",
|
||||
"rounded border-none bg-transparent ring-0": mode === "true-transparent",
|
||||
"border-red-500": hasError,
|
||||
"px-3 py-2": inputSize === "sm",
|
||||
"p-3": inputSize === "md",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
|
||||
@@ -11,21 +11,11 @@ export interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextArea
|
||||
}
|
||||
|
||||
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>((props, ref) => {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
value = "",
|
||||
rows = 1,
|
||||
cols = 1,
|
||||
mode = "primary",
|
||||
hasError = false,
|
||||
className = "",
|
||||
...rest
|
||||
} = props;
|
||||
const { id, name, value = "", mode = "primary", hasError = false, className = "", ...rest } = props;
|
||||
// refs
|
||||
const textAreaRef = useRef<any>(ref);
|
||||
// auto re-size
|
||||
useAutoResizeTextArea(textAreaRef);
|
||||
useAutoResizeTextArea(textAreaRef, value);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
@@ -33,8 +23,6 @@ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>((props, re
|
||||
name={name}
|
||||
ref={textAreaRef}
|
||||
value={value}
|
||||
rows={rows}
|
||||
cols={cols}
|
||||
className={cn(
|
||||
"no-scrollbar w-full bg-transparent px-3 py-2 placeholder-custom-text-400 outline-none",
|
||||
{
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLayoutEffect } from "react";
|
||||
|
||||
export const useAutoResizeTextArea = (textAreaRef: React.RefObject<HTMLTextAreaElement>) => {
|
||||
useEffect(() => {
|
||||
export const useAutoResizeTextArea = (
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>,
|
||||
value: string | number | readonly string[]
|
||||
) => {
|
||||
useLayoutEffect(() => {
|
||||
const textArea = textAreaRef.current;
|
||||
if (!textArea) return;
|
||||
|
||||
const resizeTextArea = () => {
|
||||
textArea.style.height = "auto";
|
||||
const computedHeight = textArea.scrollHeight + "px";
|
||||
textArea.style.height = computedHeight;
|
||||
};
|
||||
|
||||
const handleInput = () => resizeTextArea();
|
||||
|
||||
// resize on mount
|
||||
resizeTextArea();
|
||||
|
||||
textArea.addEventListener("input", handleInput);
|
||||
return () => {
|
||||
textArea.removeEventListener("input", handleInput);
|
||||
};
|
||||
}, [textAreaRef]);
|
||||
// We need to reset the height momentarily to get the correct scrollHeight for the textarea
|
||||
textArea.style.height = "0px";
|
||||
const scrollHeight = textArea.scrollHeight;
|
||||
textArea.style.height = scrollHeight + "px";
|
||||
}, [textAreaRef, value]);
|
||||
};
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const AdminProfileIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={`${className} stroke-2`}
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...rest}
|
||||
>
|
||||
<path d="M12 22C12 22 20 18 20 12V5L12 2L4 5V12C4 18 12 22 12 22Z" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path
|
||||
d="M8 19V18C8 16.9391 8.42143 15.9217 9.17157 15.1716C9.92172 14.4214 10.9391 14 12 14C13.0609 14 14.0783 14.4214 14.8284 15.1716C15.5786 15.9217 16 16.9391 16 18V19"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 14C13.6569 14 15 12.6569 15 11C15 9.34315 13.6569 8 12 8C10.3431 8 9 9.34315 9 11C9 12.6569 10.3431 14 12 14Z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -1,25 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const CopyIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={`${className} stroke-2`}
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...rest}
|
||||
>
|
||||
<path
|
||||
d="M20 8H10C8.89543 8 8 8.89543 8 10V20C8 21.1046 8.89543 22 10 22H20C21.1046 22 22 21.1046 22 20V10C22 8.89543 21.1046 8 20 8Z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 16C2.9 16 2 15.1 2 14V4C2 2.9 2.9 2 4 2H14C15.1 2 16 2.9 16 4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const ExternalLinkIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={`${className} stroke-[1.5]`}
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...rest}
|
||||
>
|
||||
<path
|
||||
d="M18 13V19C18 19.5304 17.7893 20.0391 17.4142 20.4142C17.0391 20.7893 16.5304 21 16 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V8C3 7.46957 3.21071 6.96086 3.58579 6.58579C3.96086 6.21071 4.46957 6 5 6H11"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M15 3H21V9" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M10 14L21 3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user