Merge branch 'develop' of github.com:makeplane/plane into chore/search-empty-state-and-empty-state-improvement

This commit is contained in:
Anmol Singh Bhatia 2024-03-01 11:40:44 +05:30
commit 3eed5a0159
373 changed files with 9353 additions and 7236 deletions

View File

@ -2,7 +2,7 @@ name: Bug report
description: Create a bug report to help us improve Plane description: Create a bug report to help us improve Plane
title: "[bug]: " title: "[bug]: "
labels: [🐛bug] labels: [🐛bug]
assignees: [srinivaspendem, pushya-plane] assignees: [srinivaspendem, pushya22]
body: body:
- type: markdown - type: markdown
attributes: attributes:
@ -45,7 +45,7 @@ body:
- Deploy preview - Deploy preview
validations: validations:
required: true required: true
type: dropdown - type: dropdown
id: browser id: browser
attributes: attributes:
label: Browser label: Browser

View File

@ -2,7 +2,7 @@ name: Feature request
description: Suggest a feature to improve Plane description: Suggest a feature to improve Plane
title: "[feature]: " title: "[feature]: "
labels: [✨feature] labels: [✨feature]
assignees: [srinivaspendem, pushya-plane] assignees: [srinivaspendem, pushya22]
body: body:
- type: markdown - type: markdown
attributes: attributes:

View File

@ -23,6 +23,10 @@ jobs:
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }} gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
build_frontend: ${{ steps.changed_files.outputs.frontend_any_changed }}
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
build_backend: ${{ steps.changed_files.outputs.backend_any_changed }}
build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }}
steps: steps:
- id: set_env_variables - id: set_env_variables
@ -41,7 +45,36 @@ jobs:
fi fi
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
- name: Get changed files
id: changed_files
uses: tj-actions/changed-files@v42
with:
files_yaml: |
frontend:
- web/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
space:
- space/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
backend:
- apiserver/**
proxy:
- nginx/**
branch_build_push_frontend: branch_build_push_frontend:
if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
@ -55,9 +88,9 @@ jobs:
- name: Set Frontend Docker Tag - name: Set Frontend Docker Tag
run: | run: |
if [ "${{ github.event_name }}" == "release" ]; then if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }} TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest
else else
TAG=${{ env.FRONTEND_TAG }} TAG=${{ env.FRONTEND_TAG }}
fi fi
@ -77,7 +110,7 @@ jobs:
endpoint: ${{ env.BUILDX_ENDPOINT }} endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4
- name: Build and Push Frontend to Docker Container Registry - name: Build and Push Frontend to Docker Container Registry
uses: docker/build-push-action@v5.1.0 uses: docker/build-push-action@v5.1.0
@ -93,6 +126,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_space: branch_build_push_space:
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
@ -106,9 +140,9 @@ jobs:
- name: Set Space Docker Tag - name: Set Space Docker Tag
run: | run: |
if [ "${{ github.event_name }}" == "release" ]; then if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }} TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest
else else
TAG=${{ env.SPACE_TAG }} TAG=${{ env.SPACE_TAG }}
fi fi
@ -128,7 +162,7 @@ jobs:
endpoint: ${{ env.BUILDX_ENDPOINT }} endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4
- name: Build and Push Space to Docker Hub - name: Build and Push Space to Docker Hub
uses: docker/build-push-action@v5.1.0 uses: docker/build-push-action@v5.1.0
@ -144,6 +178,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_backend: branch_build_push_backend:
if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
@ -157,9 +192,9 @@ jobs:
- name: Set Backend Docker Tag - name: Set Backend Docker Tag
run: | run: |
if [ "${{ github.event_name }}" == "release" ]; then if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }} TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest
else else
TAG=${{ env.BACKEND_TAG }} TAG=${{ env.BACKEND_TAG }}
fi fi
@ -179,7 +214,7 @@ jobs:
endpoint: ${{ env.BUILDX_ENDPOINT }} endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4
- name: Build and Push Backend to Docker Hub - name: Build and Push Backend to Docker Hub
uses: docker/build-push-action@v5.1.0 uses: docker/build-push-action@v5.1.0
@ -194,8 +229,8 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_proxy: branch_build_push_proxy:
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
@ -209,9 +244,9 @@ jobs:
- name: Set Proxy Docker Tag - name: Set Proxy Docker Tag
run: | run: |
if [ "${{ github.event_name }}" == "release" ]; then if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }} TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest
else else
TAG=${{ env.PROXY_TAG }} TAG=${{ env.PROXY_TAG }}
fi fi
@ -231,7 +266,7 @@ jobs:
endpoint: ${{ env.BUILDX_ENDPOINT }} endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4
- name: Build and Push Plane-Proxy to Docker Hub - name: Build and Push Plane-Proxy to Docker Hub
uses: docker/build-push-action@v5.1.0 uses: docker/build-push-action@v5.1.0

View File

@ -2,7 +2,7 @@ name: Create Sync Action
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: branches:
- preview - preview
@ -17,7 +17,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0
@ -31,14 +31,25 @@ jobs:
sudo apt update sudo apt update
sudo apt install gh -y sudo apt install gh -y
- name: Push Changes to Target Repo - name: Push Changes to Target Repo A
env: env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: | run: |
TARGET_REPO="${{ secrets.SYNC_TARGET_REPO_NAME }}" TARGET_REPO="${{ secrets.TARGET_REPO_A }}"
TARGET_BRANCH="${{ secrets.SYNC_TARGET_BRANCH_NAME }}" TARGET_BRANCH="${{ secrets.TARGET_REPO_A_BRANCH_NAME }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH git checkout $SOURCE_BRANCH
git remote add target-origin "https://$GH_TOKEN@github.com/$TARGET_REPO.git" git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target-origin $SOURCE_BRANCH:$TARGET_BRANCH git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
- name: Push Changes to Target Repo B
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
TARGET_REPO="${{ secrets.TARGET_REPO_B }}"
TARGET_BRANCH="${{ secrets.TARGET_REPO_B_BRANCH_NAME }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git remote add target-origin-b "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target-origin-b $SOURCE_BRANCH:$TARGET_BRANCH

View File

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

View File

@ -45,7 +45,10 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
return ( return (
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("owned_by") .select_related("owned_by")
@ -390,7 +393,10 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
) )
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(cycle_id=self.kwargs.get("cycle_id")) .filter(cycle_id=self.kwargs.get("cycle_id"))
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")

View File

@ -352,7 +352,10 @@ class LabelAPIEndpoint(BaseAPIView):
return ( return (
Label.objects.filter(workspace__slug=self.kwargs.get("slug")) Label.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("parent") .select_related("parent")
@ -481,7 +484,10 @@ class IssueLinkAPIEndpoint(BaseAPIView):
IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug")) IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.order_by(self.kwargs.get("order_by", "-created_at")) .order_by(self.kwargs.get("order_by", "-created_at"))
.distinct() .distinct()
) )
@ -607,11 +613,11 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
) )
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
.select_related("project") project__project_projectmember__member=self.request.user,
.select_related("workspace") project__project_projectmember__is_active=True,
.select_related("issue") )
.select_related("actor") .select_related("workspace", "project", "issue", "actor")
.annotate( .annotate(
is_member=Exists( is_member=Exists(
ProjectMember.objects.filter( ProjectMember.objects.filter(
@ -647,6 +653,33 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
) )
def post(self, request, slug, project_id, issue_id): def post(self, request, slug, project_id, issue_id):
# Validation check if the issue already exists
if (
request.data.get("external_id")
and request.data.get("external_source")
and IssueComment.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).exists()
):
issue_comment = IssueComment.objects.filter(
workspace__slug=slug,
project_id=project_id,
external_id=request.data.get("external_id"),
external_source=request.data.get("external_source"),
).first()
return Response(
{
"error": "Issue Comment with the same external id and external source already exists",
"id": str(issue_comment.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer = IssueCommentSerializer(data=request.data) serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
serializer.save( serializer.save(
@ -680,6 +713,29 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
IssueCommentSerializer(issue_comment).data, IssueCommentSerializer(issue_comment).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
) )
# Validation check if the issue already exists
if (
str(request.data.get("external_id"))
and (issue_comment.external_id != str(request.data.get("external_id")))
and Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get(
"external_source", issue_comment.external_source
),
external_id=request.data.get("external_id"),
).exists()
):
return Response(
{
"error": "Issue Comment with the same external id and external source already exists",
"id": str(issue_comment.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer = IssueCommentSerializer( serializer = IssueCommentSerializer(
issue_comment, data=request.data, partial=True issue_comment, data=request.data, partial=True
) )
@ -734,6 +790,7 @@ class IssueActivityAPIEndpoint(BaseAPIView):
.filter( .filter(
~Q(field__in=["comment", "vote", "reaction", "draft"]), ~Q(field__in=["comment", "vote", "reaction", "draft"]),
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
) )
.select_related("actor", "workspace", "issue", "project") .select_related("actor", "workspace", "issue", "project")
).order_by(request.GET.get("order_by", "created_at")) ).order_by(request.GET.get("order_by", "created_at"))

View File

@ -273,7 +273,10 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(module_id=self.kwargs.get("module_id")) .filter(module_id=self.kwargs.get("module_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("module") .select_related("module")

View File

@ -1,7 +1,5 @@
# Python imports
from itertools import groupby
# Django imports # Django imports
from django.db import IntegrityError
from django.db.models import Q from django.db.models import Q
# Third party imports # Third party imports
@ -26,7 +24,10 @@ class StateAPIEndpoint(BaseAPIView):
return ( return (
State.objects.filter(workspace__slug=self.kwargs.get("slug")) State.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(~Q(name="Triage")) .filter(~Q(name="Triage"))
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
@ -34,37 +35,51 @@ class StateAPIEndpoint(BaseAPIView):
) )
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
serializer = StateSerializer( try:
data=request.data, context={"project_id": project_id} serializer = StateSerializer(
) data=request.data, context={"project_id": project_id}
if serializer.is_valid(): )
if ( if serializer.is_valid():
request.data.get("external_id") if (
and request.data.get("external_source") request.data.get("external_id")
and State.objects.filter( and request.data.get("external_source")
project_id=project_id, and State.objects.filter(
workspace__slug=slug, project_id=project_id,
external_source=request.data.get("external_source"), workspace__slug=slug,
external_id=request.data.get("external_id"), external_source=request.data.get("external_source"),
).exists() external_id=request.data.get("external_id"),
): ).exists()
state = State.objects.filter( ):
workspace__slug=slug, state = State.objects.filter(
project_id=project_id, workspace__slug=slug,
external_id=request.data.get("external_id"), project_id=project_id,
external_source=request.data.get("external_source"), external_id=request.data.get("external_id"),
).first() external_source=request.data.get("external_source"),
return Response( ).first()
{ return Response(
"error": "State with the same external id and external source already exists", {
"id": str(state.id), "error": "State with the same external id and external source already exists",
}, "id": str(state.id),
status=status.HTTP_409_CONFLICT, },
) status=status.HTTP_409_CONFLICT,
)
serializer.save(project_id=project_id) serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError as e:
state = State.objects.filter(
workspace__slug=slug,
project_id=project_id,
name=request.data.get("name"),
).first()
return Response(
{
"error": "State with the same name already exists in the project",
"id": str(state.id),
},
status=status.HTTP_409_CONFLICT,
)
def get(self, request, slug, project_id, state_id=None): def get(self, request, slug, project_id, state_id=None):
if state_id: if state_id:

View File

@ -69,6 +69,9 @@ from .issue import (
RelatedIssueSerializer, RelatedIssueSerializer,
IssuePublicSerializer, IssuePublicSerializer,
IssueDetailSerializer, IssueDetailSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
) )
from .module import ( from .module import (

View File

@ -58,9 +58,12 @@ class DynamicBaseSerializer(BaseSerializer):
IssueSerializer, IssueSerializer,
LabelSerializer, LabelSerializer,
CycleIssueSerializer, CycleIssueSerializer,
IssueFlatSerializer, IssueLiteSerializer,
IssueRelationSerializer, IssueRelationSerializer,
InboxIssueLiteSerializer InboxIssueLiteSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
) )
# Expansion mapper # Expansion mapper
@ -79,12 +82,34 @@ class DynamicBaseSerializer(BaseSerializer):
"assignees": UserLiteSerializer, "assignees": UserLiteSerializer,
"labels": LabelSerializer, "labels": LabelSerializer,
"issue_cycle": CycleIssueSerializer, "issue_cycle": CycleIssueSerializer,
"parent": IssueSerializer, "parent": IssueLiteSerializer,
"issue_relation": IssueRelationSerializer, "issue_relation": IssueRelationSerializer,
"issue_inbox" : InboxIssueLiteSerializer, "issue_inbox": InboxIssueLiteSerializer,
"issue_reactions": IssueReactionLiteSerializer,
"issue_attachment": IssueAttachmentLiteSerializer,
"issue_link": IssueLinkLiteSerializer,
"sub_issues": IssueLiteSerializer,
} }
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation", "issue_inbox"] else False) self.fields[field] = expansion[field](
many=(
True
if field
in [
"members",
"assignees",
"labels",
"issue_cycle",
"issue_relation",
"issue_inbox",
"issue_reactions",
"issue_attachment",
"issue_link",
"sub_issues",
]
else False
)
)
return self.fields return self.fields
@ -105,7 +130,11 @@ class DynamicBaseSerializer(BaseSerializer):
LabelSerializer, LabelSerializer,
CycleIssueSerializer, CycleIssueSerializer,
IssueRelationSerializer, IssueRelationSerializer,
InboxIssueLiteSerializer InboxIssueLiteSerializer,
IssueLiteSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
) )
# Expansion mapper # Expansion mapper
@ -124,9 +153,13 @@ class DynamicBaseSerializer(BaseSerializer):
"assignees": UserLiteSerializer, "assignees": UserLiteSerializer,
"labels": LabelSerializer, "labels": LabelSerializer,
"issue_cycle": CycleIssueSerializer, "issue_cycle": CycleIssueSerializer,
"parent": IssueSerializer, "parent": IssueLiteSerializer,
"issue_relation": IssueRelationSerializer, "issue_relation": IssueRelationSerializer,
"issue_inbox" : InboxIssueLiteSerializer, "issue_inbox": InboxIssueLiteSerializer,
"issue_reactions": IssueReactionLiteSerializer,
"issue_attachment": IssueAttachmentLiteSerializer,
"issue_link": IssueLinkLiteSerializer,
"sub_issues": IssueLiteSerializer,
} }
# Check if field in expansion then expand the field # Check if field in expansion then expand the field
if expand in expansion: if expand in expansion:

View File

@ -444,6 +444,22 @@ class IssueLinkSerializer(BaseSerializer):
return IssueLink.objects.create(**validated_data) return IssueLink.objects.create(**validated_data)
class IssueLinkLiteSerializer(BaseSerializer):
class Meta:
model = IssueLink
fields = [
"id",
"issue_id",
"title",
"url",
"metadata",
"created_by_id",
"created_at",
]
read_only_fields = fields
class IssueAttachmentSerializer(BaseSerializer): class IssueAttachmentSerializer(BaseSerializer):
class Meta: class Meta:
model = IssueAttachment model = IssueAttachment
@ -459,6 +475,21 @@ class IssueAttachmentSerializer(BaseSerializer):
] ]
class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
class Meta:
model = IssueAttachment
fields = [
"id",
"asset",
"attributes",
"issue_id",
"updated_at",
"updated_by_id",
]
read_only_fields = fields
class IssueReactionSerializer(BaseSerializer): class IssueReactionSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor") actor_detail = UserLiteSerializer(read_only=True, source="actor")
@ -473,6 +504,18 @@ class IssueReactionSerializer(BaseSerializer):
] ]
class IssueReactionLiteSerializer(DynamicBaseSerializer):
class Meta:
model = IssueReaction
fields = [
"id",
"actor_id",
"issue_id",
"reaction",
]
class CommentReactionSerializer(BaseSerializer): class CommentReactionSerializer(BaseSerializer):
class Meta: class Meta:
model = CommentReaction model = CommentReaction
@ -503,9 +546,7 @@ class IssueCommentSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer( workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace" read_only=True, source="workspace"
) )
comment_reactions = CommentReactionSerializer( comment_reactions = CommentReactionSerializer(read_only=True, many=True)
read_only=True, many=True
)
is_member = serializers.BooleanField(read_only=True) is_member = serializers.BooleanField(read_only=True)
class Meta: class Meta:
@ -558,18 +599,17 @@ class IssueStateSerializer(DynamicBaseSerializer):
class IssueSerializer(DynamicBaseSerializer): class IssueSerializer(DynamicBaseSerializer):
# ids # ids
project_id = serializers.PrimaryKeyRelatedField(read_only=True)
state_id = serializers.PrimaryKeyRelatedField(read_only=True)
parent_id = serializers.PrimaryKeyRelatedField(read_only=True)
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
module_ids = serializers.SerializerMethodField() module_ids = serializers.ListField(
child=serializers.UUIDField(), required=False,
)
# Many to many # Many to many
label_ids = serializers.PrimaryKeyRelatedField( label_ids = serializers.ListField(
read_only=True, many=True, source="labels" child=serializers.UUIDField(), required=False,
) )
assignee_ids = serializers.PrimaryKeyRelatedField( assignee_ids = serializers.ListField(
read_only=True, many=True, source="assignees" child=serializers.UUIDField(), required=False,
) )
# Count items # Count items
@ -577,9 +617,6 @@ class IssueSerializer(DynamicBaseSerializer):
attachment_count = serializers.IntegerField(read_only=True) attachment_count = serializers.IntegerField(read_only=True)
link_count = serializers.IntegerField(read_only=True) link_count = serializers.IntegerField(read_only=True)
# is_subscribed
is_subscribed = serializers.BooleanField(read_only=True)
class Meta: class Meta:
model = Issue model = Issue
fields = [ fields = [
@ -606,57 +643,45 @@ class IssueSerializer(DynamicBaseSerializer):
"updated_by", "updated_by",
"attachment_count", "attachment_count",
"link_count", "link_count",
"is_subscribed",
"is_draft", "is_draft",
"archived_at", "archived_at",
] ]
read_only_fields = fields read_only_fields = fields
def get_module_ids(self, obj):
# Access the prefetched modules and extract module IDs
return [module for module in obj.issue_module.values_list("module_id", flat=True)]
class IssueDetailSerializer(IssueSerializer): class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField() description_html = serializers.CharField()
is_subscribed = serializers.BooleanField(read_only=True)
class Meta(IssueSerializer.Meta): class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + ['description_html'] fields = IssueSerializer.Meta.fields + [
"description_html",
"is_subscribed",
]
class IssueLiteSerializer(DynamicBaseSerializer): class IssueLiteSerializer(DynamicBaseSerializer):
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateLiteSerializer(read_only=True, source="state")
label_details = LabelLiteSerializer(
read_only=True, source="labels", many=True
)
assignee_details = UserLiteSerializer(
read_only=True, source="assignees", many=True
)
sub_issues_count = serializers.IntegerField(read_only=True)
cycle_id = serializers.UUIDField(read_only=True)
module_id = serializers.UUIDField(read_only=True)
attachment_count = serializers.IntegerField(read_only=True)
link_count = serializers.IntegerField(read_only=True)
issue_reactions = IssueReactionSerializer(read_only=True, many=True)
class Meta: class Meta:
model = Issue model = Issue
fields = "__all__" fields = [
read_only_fields = [ "id",
"start_date", "sequence_id",
"target_date", "project_id",
"completed_at",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
] ]
read_only_fields = fields
class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField()
is_subscribed = serializers.BooleanField()
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + [
"description_html",
"is_subscribed",
]
read_only_fields = fields
class IssuePublicSerializer(BaseSerializer): class IssuePublicSerializer(BaseSerializer):

View File

@ -90,11 +90,13 @@ urlpatterns = [
BulkImportIssuesEndpoint.as_view(), BulkImportIssuesEndpoint.as_view(),
name="project-issues-bulk", name="project-issues-bulk",
), ),
# deprecated endpoint TODO: remove once confirmed
path( path(
"workspaces/<str:slug>/my-issues/", "workspaces/<str:slug>/my-issues/",
UserWorkSpaceIssues.as_view(), UserWorkSpaceIssues.as_view(),
name="workspace-issues", name="workspace-issues",
), ),
##
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/",
SubIssuesEndpoint.as_view(), SubIssuesEndpoint.as_view(),
@ -257,23 +259,15 @@ urlpatterns = [
name="project-issue-archive", name="project-issue-archive",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/archive/",
IssueArchiveViewSet.as_view( IssueArchiveViewSet.as_view(
{ {
"get": "retrieve", "get": "retrieve",
"delete": "destroy", "post": "archive",
"delete": "unarchive",
} }
), ),
name="project-issue-archive", name="project-issue-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/unarchive/<uuid:pk>/",
IssueArchiveViewSet.as_view(
{
"post": "unarchive",
}
),
name="project-issue-archive",
), ),
## End Issue Archives ## End Issue Archives
## Issue Relation ## Issue Relation

View File

@ -22,6 +22,8 @@ from plane.app.views import (
WorkspaceUserPropertiesEndpoint, WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint, WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint, WorkspaceEstimatesEndpoint,
WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint,
) )
@ -219,4 +221,14 @@ urlpatterns = [
WorkspaceEstimatesEndpoint.as_view(), WorkspaceEstimatesEndpoint.as_view(),
name="workspace-estimate", name="workspace-estimate",
), ),
path(
"workspaces/<str:slug>/modules/",
WorkspaceModulesEndpoint.as_view(),
name="workspace-modules",
),
path(
"workspaces/<str:slug>/cycles/",
WorkspaceCyclesEndpoint.as_view(),
name="workspace-cycles",
),
] ]

View File

@ -49,6 +49,8 @@ from .workspace import (
WorkspaceUserPropertiesEndpoint, WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint, WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint, WorkspaceEstimatesEndpoint,
WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint,
) )
from .state import StateViewSet from .state import StateViewSet
from .view import ( from .view import (

View File

@ -66,15 +66,15 @@ class ConfigurationEndpoint(BaseAPIView):
}, },
{ {
"key": "SLACK_CLIENT_ID", "key": "SLACK_CLIENT_ID",
"default": os.environ.get("SLACK_CLIENT_ID", "1"), "default": os.environ.get("SLACK_CLIENT_ID", None),
}, },
{ {
"key": "POSTHOG_API_KEY", "key": "POSTHOG_API_KEY",
"default": os.environ.get("POSTHOG_API_KEY", "1"), "default": os.environ.get("POSTHOG_API_KEY", None),
}, },
{ {
"key": "POSTHOG_HOST", "key": "POSTHOG_HOST",
"default": os.environ.get("POSTHOG_HOST", "1"), "default": os.environ.get("POSTHOG_HOST", None),
}, },
{ {
"key": "UNSPLASH_ACCESS_KEY", "key": "UNSPLASH_ACCESS_KEY",
@ -181,11 +181,11 @@ class MobileConfigurationEndpoint(BaseAPIView):
}, },
{ {
"key": "POSTHOG_API_KEY", "key": "POSTHOG_API_KEY",
"default": os.environ.get("POSTHOG_API_KEY", "1"), "default": os.environ.get("POSTHOG_API_KEY", None),
}, },
{ {
"key": "POSTHOG_HOST", "key": "POSTHOG_HOST",
"default": os.environ.get("POSTHOG_HOST", "1"), "default": os.environ.get("POSTHOG_HOST", None),
}, },
{ {
"key": "UNSPLASH_ACCESS_KEY", "key": "UNSPLASH_ACCESS_KEY",

View File

@ -85,7 +85,10 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project", "workspace", "owned_by") .select_related("project", "workspace", "owned_by")
.prefetch_related( .prefetch_related(
Prefetch( Prefetch(
@ -689,7 +692,10 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
) )
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(cycle_id=self.kwargs.get("cycle_id")) .filter(cycle_id=self.kwargs.get("cycle_id"))
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
@ -708,10 +714,11 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
] ]
order_by = request.GET.get("order_by", "created_at") order_by = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
issues = ( queryset = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
.filter(project_id=project_id) .filter(project_id=project_id)
.filter(workspace__slug=slug) .filter(workspace__slug=slug)
.filter(**filters)
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
.prefetch_related( .prefetch_related(
"assignees", "assignees",
@ -721,7 +728,6 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
) )
.order_by(order_by) .order_by(order_by)
.filter(**filters) .filter(**filters)
.annotate(module_ids=F("issue_module__module_id"))
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate( .annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id")) link_count=IssueLink.objects.filter(issue=OuterRef("id"))
@ -745,11 +751,67 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.order_by(order_by)
) )
serializer = IssueSerializer( if self.fields:
issues, many=True, fields=fields if fields else None issues = IssueSerializer(
) queryset, many=True, fields=fields if fields else None
return Response(serializer.data, status=status.HTTP_200_OK) ).data
else:
issues = queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id, cycle_id): def create(self, request, slug, project_id, cycle_id):
issues = request.data.get("issues", []) issues = request.data.get("issues", [])
@ -1121,6 +1183,21 @@ class TransferCycleIssueEndpoint(BaseAPIView):
) )
.order_by("label_name") .order_by("label_name")
) )
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": (
str(item["assignee_id"]) if item["assignee_id"] else None
),
"avatar": item["avatar"],
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Label distribution serilization # Label distribution serilization
label_distribution_data = [ label_distribution_data = [
{ {
@ -1147,9 +1224,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
"started_issues": old_cycle.first().started_issues, "started_issues": old_cycle.first().started_issues,
"unstarted_issues": old_cycle.first().unstarted_issues, "unstarted_issues": old_cycle.first().unstarted_issues,
"backlog_issues": old_cycle.first().backlog_issues, "backlog_issues": old_cycle.first().backlog_issues,
"total_estimates": old_cycle.first().total_estimates,
"completed_estimates": old_cycle.first().completed_estimates,
"started_estimates": old_cycle.first().started_estimates,
"distribution": { "distribution": {
"labels": label_distribution_data, "labels": label_distribution_data,
"assignees": assignee_distribution_data, "assignees": assignee_distribution_data,

View File

@ -15,6 +15,10 @@ from django.db.models import (
Func, Func,
Prefetch, Prefetch,
) )
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
from django.utils import timezone from django.utils import timezone
# Third Party imports # Third Party imports
@ -54,6 +58,7 @@ def dashboard_overview_stats(self, request, slug):
pending_issues_count = Issue.issue_objects.filter( pending_issues_count = Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]), ~Q(state__group__in=["completed", "cancelled"]),
target_date__lt=timezone.now().date(),
project__project_projectmember__is_active=True, project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
workspace__slug=slug, workspace__slug=slug,
@ -130,7 +135,32 @@ def dashboard_assigned_issues(self, request, slug):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.order_by("created_at") .annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
) )
# Priority Ordering # Priority Ordering
@ -259,6 +289,32 @@ def dashboard_created_issues(self, request, slug):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.order_by("created_at") .order_by("created_at")
) )

View File

@ -3,8 +3,12 @@ import json
# Django import # Django import
from django.utils import timezone from django.utils import timezone
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch from django.db.models import Q, Count, OuterRef, Func, F, Prefetch, Exists
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
@ -21,12 +25,14 @@ from plane.db.models import (
IssueLink, IssueLink,
IssueAttachment, IssueAttachment,
ProjectMember, ProjectMember,
IssueReaction,
IssueSubscriber,
) )
from plane.app.serializers import ( from plane.app.serializers import (
IssueCreateSerializer,
IssueSerializer, IssueSerializer,
InboxSerializer, InboxSerializer,
InboxIssueSerializer, InboxIssueSerializer,
IssueCreateSerializer,
IssueDetailSerializer, IssueDetailSerializer,
) )
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
@ -92,7 +98,7 @@ class InboxIssueViewSet(BaseViewSet):
Issue.objects.filter( Issue.objects.filter(
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
issue_inbox__inbox_id=self.kwargs.get("inbox_id") issue_inbox__inbox_id=self.kwargs.get("inbox_id"),
) )
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related("assignees", "labels", "issue_module__module")
@ -127,14 +133,75 @@ class InboxIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct() ).distinct()
def list(self, request, slug, project_id, inbox_id): def list(self, request, slug, project_id, inbox_id):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters).order_by("issue_inbox__snoozed_till", "issue_inbox__status") issue_queryset = (
issues_data = IssueSerializer(issue_queryset, expand=self.expand, many=True).data self.get_queryset()
.filter(**filters)
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
)
if self.expand:
issues = IssueSerializer(
issue_queryset, expand=self.expand, many=True
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
return Response( return Response(
issues_data, issues,
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@ -199,8 +266,8 @@ class InboxIssueViewSet(BaseViewSet):
source=request.data.get("source", "in-app"), source=request.data.get("source", "in-app"),
) )
issue = (self.get_queryset().filter(pk=issue.id).first()) issue = self.get_queryset().filter(pk=issue.id).first()
serializer = IssueSerializer(issue ,expand=self.expand) serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, inbox_id, issue_id): def partial_update(self, request, slug, project_id, inbox_id, issue_id):
@ -230,11 +297,7 @@ class InboxIssueViewSet(BaseViewSet):
issue_data = request.data.pop("issue", False) issue_data = request.data.pop("issue", False)
if bool(issue_data): if bool(issue_data):
issue = Issue.objects.get( issue = self.get_queryset().filter(pk=inbox_issue.issue_id).first()
pk=inbox_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
)
# Only allow guests and viewers to edit name and description # Only allow guests and viewers to edit name and description
if project_member.role <= 10: if project_member.role <= 10:
# viewers and guests since only viewers and guests # viewers and guests since only viewers and guests
@ -320,20 +383,54 @@ class InboxIssueViewSet(BaseViewSet):
if state is not None: if state is not None:
issue.state = state issue.state = state
issue.save() issue.save()
issue = (self.get_queryset().filter(pk=issue_id).first()) return Response(status=status.HTTP_204_NO_CONTENT)
serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response( return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST serializer.errors, status=status.HTTP_400_BAD_REQUEST
) )
else: else:
issue = (self.get_queryset().filter(pk=issue_id).first()) issue = self.get_queryset().filter(pk=issue_id).first()
serializer = IssueSerializer(issue ,expand=self.expand) serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, inbox_id, issue_id): def retrieve(self, request, slug, project_id, inbox_id, issue_id):
issue = self.get_queryset().filter(pk=issue_id).first() issue = (
serializer = IssueDetailSerializer(issue, expand=self.expand,) self.get_queryset()
.filter(pk=issue_id)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related(
"issue", "actor"
),
)
)
.prefetch_related(
Prefetch(
"issue_attachment",
queryset=IssueAttachment.objects.select_related("issue"),
)
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("created_by"),
)
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=OuterRef("pk"),
subscriber=request.user,
)
)
)
).first()
if issue is None:
return Response({"error": "Requested object was not found"}, status=status.HTTP_404_NOT_FOUND)
serializer = IssueDetailSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, inbox_id, issue_id): def destroy(self, request, slug, project_id, inbox_id, issue_id):

View File

@ -36,7 +36,10 @@ class SlackProjectSyncViewSet(BaseViewSet):
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
) )
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
) )
def create(self, request, slug, project_id, workspace_integration_id): def create(self, request, slug, project_id, workspace_integration_id):

View File

@ -298,12 +298,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate( .annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id")) link_count=IssueLink.objects.filter(issue=OuterRef("id"))
@ -327,12 +321,40 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct() ).distinct()
@method_decorator(gzip_page) @method_decorator(gzip_page)
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters)
# Custom ordering for priority and state # Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"] priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [ state_order = [
@ -343,10 +365,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
"cancelled", "cancelled",
] ]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters)
# Priority Ordering # Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority": if order_by_param == "priority" or order_by_param == "-priority":
priority_order = ( priority_order = (
@ -407,9 +425,42 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
else: else:
issue_queryset = issue_queryset.order_by(order_by_param) issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueSerializer( # Only use serializer when expand or fields else return by values
issue_queryset, many=True, fields=self.fields, expand=self.expand if self.expand or self.fields:
).data issues = IssueSerializer(
issue_queryset,
many=True,
fields=self.fields,
expand=self.expand,
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
@ -442,28 +493,97 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),
) )
issue = ( issue = (
self.get_queryset().filter(pk=serializer.data["id"]).first() self.get_queryset()
.filter(pk=serializer.data["id"])
.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
.first()
) )
serializer = IssueSerializer(issue) return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk=None): def retrieve(self, request, slug, project_id, pk=None):
issue = self.get_queryset().filter(pk=pk).first() issue = (
return Response( self.get_queryset()
IssueDetailSerializer( .filter(pk=pk)
issue, fields=self.fields, expand=self.expand .prefetch_related(
).data, Prefetch(
status=status.HTTP_200_OK, "issue_reactions",
) queryset=IssueReaction.objects.select_related(
"issue", "actor"
),
)
)
.prefetch_related(
Prefetch(
"issue_attachment",
queryset=IssueAttachment.objects.select_related("issue"),
)
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("created_by"),
)
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=OuterRef("pk"),
subscriber=request.user,
)
)
)
).first()
if not issue:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, pk=None): def partial_update(self, request, slug, project_id, pk=None):
issue = Issue.objects.get( issue = self.get_queryset().filter(pk=pk).first()
workspace__slug=slug, project_id=project_id, pk=pk
) if not issue:
return Response(
{"error": "Issue not found"},
status=status.HTTP_404_NOT_FOUND,
)
current_instance = json.dumps( current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder IssueSerializer(issue).data, cls=DjangoJSONEncoder
) )
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
serializer = IssueCreateSerializer( serializer = IssueCreateSerializer(
issue, data=request.data, partial=True issue, data=request.data, partial=True
@ -482,18 +602,13 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),
) )
issue = self.get_queryset().filter(pk=pk).first() issue = self.get_queryset().filter(pk=pk).first()
return Response( return Response(status=status.HTTP_204_NO_CONTENT)
IssueSerializer(issue).data, status=status.HTTP_200_OK
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, pk=None): def destroy(self, request, slug, project_id, pk=None):
issue = Issue.objects.get( issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk workspace__slug=slug, project_id=project_id, pk=pk
) )
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
issue.delete() issue.delete()
issue_activity.delay( issue_activity.delay(
type="issue.activity.deleted", type="issue.activity.deleted",
@ -501,7 +616,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
actor_id=str(request.user.id), actor_id=str(request.user.id),
issue_id=str(pk), issue_id=str(pk),
project_id=str(project_id), project_id=str(project_id),
current_instance=current_instance, current_instance={},
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
notification=True, notification=True,
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),
@ -509,6 +624,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
# TODO: deprecated remove once confirmed
class UserWorkSpaceIssues(BaseAPIView): class UserWorkSpaceIssues(BaseAPIView):
@method_decorator(gzip_page) @method_decorator(gzip_page)
def get(self, request, slug): def get(self, request, slug):
@ -563,12 +679,6 @@ class UserWorkSpaceIssues(BaseAPIView):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters) .filter(**filters)
).distinct() ).distinct()
@ -653,6 +763,7 @@ class UserWorkSpaceIssues(BaseAPIView):
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)
# TODO: deprecated remove once confirmed
class WorkSpaceIssuesEndpoint(BaseAPIView): class WorkSpaceIssuesEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
WorkSpaceAdminPermission, WorkSpaceAdminPermission,
@ -662,7 +773,10 @@ class WorkSpaceIssuesEndpoint(BaseAPIView):
def get(self, request, slug): def get(self, request, slug):
issues = ( issues = (
Issue.issue_objects.filter(workspace__slug=slug) Issue.issue_objects.filter(workspace__slug=slug)
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.order_by("-created_at") .order_by("-created_at")
) )
serializer = IssueSerializer(issues, many=True) serializer = IssueSerializer(issues, many=True)
@ -685,6 +799,7 @@ class IssueActivityEndpoint(BaseAPIView):
.filter( .filter(
~Q(field__in=["comment", "vote", "reaction", "draft"]), ~Q(field__in=["comment", "vote", "reaction", "draft"]),
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug, workspace__slug=slug,
) )
.filter(**filters) .filter(**filters)
@ -694,6 +809,7 @@ class IssueActivityEndpoint(BaseAPIView):
IssueComment.objects.filter(issue_id=issue_id) IssueComment.objects.filter(issue_id=issue_id)
.filter( .filter(
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug, workspace__slug=slug,
) )
.filter(**filters) .filter(**filters)
@ -745,7 +861,10 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("issue") .select_related("issue")
@ -907,7 +1026,10 @@ class LabelViewSet(BaseViewSet):
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("parent") .select_related("parent")
@ -955,20 +1077,9 @@ class SubIssuesEndpoint(BaseAPIView):
Issue.issue_objects.filter( Issue.issue_objects.filter(
parent_id=issue_id, workspace__slug=slug parent_id=issue_id, workspace__slug=slug
) )
.select_related("project") .select_related("workspace", "project", "state", "parent")
.select_related("workspace") .prefetch_related("assignees", "labels", "issue_module__module")
.select_related("state") .annotate(cycle_id=F("issue_cycle__cycle_id"))
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate( .annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id")) link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by() .order_by()
@ -983,11 +1094,39 @@ class SubIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.prefetch_related( .annotate(
Prefetch( sub_issues_count=Issue.issue_objects.filter(
"issue_reactions", parent=OuterRef("id")
queryset=IssueReaction.objects.select_related("actor"),
) )
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
) )
.annotate(state_group=F("state__group")) .annotate(state_group=F("state__group"))
) )
@ -997,13 +1136,36 @@ class SubIssuesEndpoint(BaseAPIView):
for sub_issue in sub_issues: for sub_issue in sub_issues:
result[sub_issue.state_group].append(str(sub_issue.id)) result[sub_issue.state_group].append(str(sub_issue.id))
serializer = IssueSerializer( sub_issues = sub_issues.values(
sub_issues, "id",
many=True, "name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
) )
return Response( return Response(
{ {
"sub_issues": serializer.data, "sub_issues": sub_issues,
"state_distribution": result, "state_distribution": result,
}, },
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
@ -1080,7 +1242,10 @@ class IssueLinkViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.order_by("-created_at") .order_by("-created_at")
.distinct() .distinct()
) )
@ -1291,15 +1456,36 @@ class IssueArchiveViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
) )
@method_decorator(gzip_page) @method_decorator(gzip_page)
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true") show_sub_issues = request.GET.get("show_sub_issues", "true")
@ -1382,20 +1568,114 @@ class IssueArchiveViewSet(BaseViewSet):
if show_sub_issues == "true" if show_sub_issues == "true"
else issue_queryset.filter(parent__isnull=True) else issue_queryset.filter(parent__isnull=True)
) )
if self.expand or self.fields:
issues = IssueSerializer( issues = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None issue_queryset,
).data many=True,
fields=self.fields,
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk=None): def retrieve(self, request, slug, project_id, pk=None):
issue = self.get_queryset().filter(pk=pk).first() issue = (
return Response( self.get_queryset()
IssueDetailSerializer( .filter(pk=pk)
issue, fields=self.fields, expand=self.expand .prefetch_related(
).data, Prefetch(
status=status.HTTP_200_OK, "issue_reactions",
queryset=IssueReaction.objects.select_related(
"issue", "actor"
),
)
)
.prefetch_related(
Prefetch(
"issue_attachment",
queryset=IssueAttachment.objects.select_related("issue"),
)
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("created_by"),
)
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=OuterRef("pk"),
subscriber=request.user,
)
)
)
).first()
if not issue:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def archive(self, request, slug, project_id, pk=None):
issue = Issue.issue_objects.get(
workspace__slug=slug,
project_id=project_id,
pk=pk,
) )
if issue.state.group not in ["completed", "cancelled"]:
return Response(
{"error": "Can only archive completed or cancelled state group issue"},
status=status.HTTP_400_BAD_REQUEST,
)
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"archived_at": str(timezone.now().date()), "automation": False}),
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue.archived_at = timezone.now().date()
issue.save()
return Response({"archived_at": str(issue.archived_at)}, status=status.HTTP_200_OK)
def unarchive(self, request, slug, project_id, pk=None): def unarchive(self, request, slug, project_id, pk=None):
issue = Issue.objects.get( issue = Issue.objects.get(
@ -1420,7 +1700,7 @@ class IssueArchiveViewSet(BaseViewSet):
issue.archived_at = None issue.archived_at = None
issue.save() issue.save()
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) return Response(status=status.HTTP_204_NO_CONTENT)
class IssueSubscriberViewSet(BaseViewSet): class IssueSubscriberViewSet(BaseViewSet):
@ -1456,7 +1736,10 @@ class IssueSubscriberViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.order_by("-created_at") .order_by("-created_at")
.distinct() .distinct()
) )
@ -1540,7 +1823,10 @@ class IssueReactionViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.order_by("-created_at") .order_by("-created_at")
.distinct() .distinct()
) )
@ -1609,7 +1895,10 @@ class CommentReactionViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(comment_id=self.kwargs.get("comment_id")) .filter(comment_id=self.kwargs.get("comment_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.order_by("-created_at") .order_by("-created_at")
.distinct() .distinct()
) )
@ -1679,7 +1968,10 @@ class IssueRelationViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("issue") .select_related("issue")
@ -1856,12 +2148,6 @@ class IssueDraftViewSet(BaseViewSet):
.filter(is_draft=True) .filter(is_draft=True)
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate( .annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id")) link_count=IssueLink.objects.filter(issue=OuterRef("id"))
@ -1885,6 +2171,32 @@ class IssueDraftViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct() ).distinct()
@method_decorator(gzip_page) @method_decorator(gzip_page)
@ -1970,9 +2282,42 @@ class IssueDraftViewSet(BaseViewSet):
else: else:
issue_queryset = issue_queryset.order_by(order_by_param) issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueSerializer( # Only use serializer when expand else return by values
issue_queryset, many=True, fields=fields if fields else None if self.expand or self.fields:
).data issues = IssueSerializer(
issue_queryset,
many=True,
fields=self.fields,
expand=self.expand,
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
@ -2013,20 +2358,18 @@ class IssueDraftViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
issue = Issue.objects.get( issue = self.get_queryset().filter(pk=pk).first()
workspace__slug=slug, project_id=project_id, pk=pk
) if not issue:
serializer = IssueSerializer(issue, data=request.data, partial=True) return Response(
{"error": "Issue does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
serializer = IssueCreateSerializer(issue, data=request.data, partial=True)
if serializer.is_valid(): if serializer.is_valid():
if request.data.get( serializer.save()
"is_draft"
) is not None and not request.data.get("is_draft"):
serializer.save(
created_at=timezone.now(), updated_at=timezone.now()
)
else:
serializer.save()
issue_activity.delay( issue_activity.delay(
type="issue_draft.activity.updated", type="issue_draft.activity.updated",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
@ -2041,25 +2384,57 @@ class IssueDraftViewSet(BaseViewSet):
notification=True, notification=True,
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),
) )
return Response(serializer.data, status=status.HTTP_200_OK) return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk=None): def retrieve(self, request, slug, project_id, pk=None):
issue = self.get_queryset().filter(pk=pk).first() issue = (
return Response( self.get_queryset()
IssueSerializer( .filter(pk=pk)
issue, fields=self.fields, expand=self.expand .prefetch_related(
).data, Prefetch(
status=status.HTTP_200_OK, "issue_reactions",
) queryset=IssueReaction.objects.select_related(
"issue", "actor"
),
)
)
.prefetch_related(
Prefetch(
"issue_attachment",
queryset=IssueAttachment.objects.select_related("issue"),
)
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("created_by"),
)
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=OuterRef("pk"),
subscriber=request.user,
)
)
)
).first()
if not issue:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, pk=None): def destroy(self, request, slug, project_id, pk=None):
issue = Issue.objects.get( issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk workspace__slug=slug, project_id=project_id, pk=pk
) )
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
issue.delete() issue.delete()
issue_activity.delay( issue_activity.delay(
type="issue_draft.activity.deleted", type="issue_draft.activity.deleted",
@ -2067,7 +2442,7 @@ class IssueDraftViewSet(BaseViewSet):
actor_id=str(request.user.id), actor_id=str(request.user.id),
issue_id=str(pk), issue_id=str(pk),
project_id=str(project_id), project_id=str(project_id),
current_instance=current_instance, current_instance={},
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
notification=True, notification=True,
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),

View File

@ -449,8 +449,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
issue_module__module_id=self.kwargs.get("module_id"), issue_module__module_id=self.kwargs.get("module_id"),
) )
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
.prefetch_related("labels", "assignees") .prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related("issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate( .annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id")) link_count=IssueLink.objects.filter(issue=OuterRef("id"))
@ -474,6 +473,32 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct() ).distinct()
@method_decorator(gzip_page) @method_decorator(gzip_page)
@ -485,10 +510,39 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
] ]
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters) issue_queryset = self.get_queryset().filter(**filters)
serializer = IssueSerializer( if self.fields or self.expand:
issue_queryset, many=True, fields=fields if fields else None issues = IssueSerializer(
) issue_queryset, many=True, fields=fields if fields else None
return Response(serializer.data, status=status.HTTP_200_OK) ).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
return Response(issues, status=status.HTTP_200_OK)
# create multiple issues inside a module # create multiple issues inside a module
def create_module_issues(self, request, slug, project_id, module_id): def create_module_issues(self, request, slug, project_id, module_id):
@ -619,7 +673,10 @@ class ModuleLinkViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(module_id=self.kwargs.get("module_id")) .filter(module_id=self.kwargs.get("module_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.order_by("-created_at") .order_by("-created_at")
.distinct() .distinct()
) )

View File

@ -60,7 +60,10 @@ class PageViewSet(BaseViewSet):
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(parent__isnull=True) .filter(parent__isnull=True)
.filter(Q(owned_by=self.request.user) | Q(access=0)) .filter(Q(owned_by=self.request.user) | Q(access=0))
.select_related("project") .select_related("project")

View File

@ -77,6 +77,12 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
] ]
def get_queryset(self): def get_queryset(self):
sort_order = ProjectMember.objects.filter(
member=self.request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
is_active=True,
).values("sort_order")
return self.filter_queryset( return self.filter_queryset(
super() super()
.get_queryset() .get_queryset()
@ -147,6 +153,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
) )
) )
) )
.annotate(sort_order=Subquery(sort_order))
.prefetch_related( .prefetch_related(
Prefetch( Prefetch(
"project_projectmember", "project_projectmember",
@ -166,16 +173,8 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
for field in request.GET.get("fields", "").split(",") for field in request.GET.get("fields", "").split(",")
if field if field
] ]
sort_order_query = ProjectMember.objects.filter(
member=request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
is_active=True,
).values("sort_order")
projects = ( projects = (
self.get_queryset() self.get_queryset()
.annotate(sort_order=Subquery(sort_order_query))
.order_by("sort_order", "name") .order_by("sort_order", "name")
) )
if request.GET.get("per_page", False) and request.GET.get( if request.GET.get("per_page", False) and request.GET.get(
@ -204,7 +203,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
serializer.save() serializer.save()
# Add the user as Administrator to the project # Add the user as Administrator to the project
project_member = ProjectMember.objects.create( _ = ProjectMember.objects.create(
project_id=serializer.data["id"], project_id=serializer.data["id"],
member=request.user, member=request.user,
role=20, role=20,

View File

@ -48,8 +48,8 @@ class GlobalSearchEndpoint(BaseAPIView):
return ( return (
Project.objects.filter( Project.objects.filter(
q, q,
Q(project_projectmember__member=self.request.user) project_projectmember__member=self.request.user,
| Q(network=2), project_projectmember__is_active=True,
workspace__slug=slug, workspace__slug=slug,
) )
.distinct() .distinct()
@ -71,6 +71,7 @@ class GlobalSearchEndpoint(BaseAPIView):
issues = Issue.issue_objects.filter( issues = Issue.issue_objects.filter(
q, q,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug, workspace__slug=slug,
) )
@ -95,6 +96,7 @@ class GlobalSearchEndpoint(BaseAPIView):
cycles = Cycle.objects.filter( cycles = Cycle.objects.filter(
q, q,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug, workspace__slug=slug,
) )
@ -118,6 +120,7 @@ class GlobalSearchEndpoint(BaseAPIView):
modules = Module.objects.filter( modules = Module.objects.filter(
q, q,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug, workspace__slug=slug,
) )
@ -141,6 +144,7 @@ class GlobalSearchEndpoint(BaseAPIView):
pages = Page.objects.filter( pages = Page.objects.filter(
q, q,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug, workspace__slug=slug,
) )
@ -164,6 +168,7 @@ class GlobalSearchEndpoint(BaseAPIView):
issue_views = IssueView.objects.filter( issue_views = IssueView.objects.filter(
q, q,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug, workspace__slug=slug,
) )
@ -236,6 +241,7 @@ class IssueSearchEndpoint(BaseAPIView):
issues = Issue.issue_objects.filter( issues = Issue.issue_objects.filter(
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
) )
if workspace_search == "false": if workspace_search == "false":

View File

@ -31,7 +31,10 @@ class StateViewSet(BaseViewSet):
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(~Q(name="Triage")) .filter(~Q(name="Triage"))
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")

View File

@ -1,6 +1,6 @@
# Django imports # Django imports
from django.db.models import ( from django.db.models import (
Prefetch, Q,
OuterRef, OuterRef,
Func, Func,
F, F,
@ -13,16 +13,21 @@ from django.db.models import (
) )
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.db.models import Prefetch, OuterRef, Exists from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
# Module imports # Module imports
from . import BaseViewSet, BaseAPIView from . import BaseViewSet
from plane.app.serializers import ( from plane.app.serializers import (
GlobalViewSerializer,
IssueViewSerializer, IssueViewSerializer,
IssueSerializer, IssueSerializer,
IssueViewFavoriteSerializer, IssueViewFavoriteSerializer,
@ -30,22 +35,16 @@ from plane.app.serializers import (
from plane.app.permissions import ( from plane.app.permissions import (
WorkspaceEntityPermission, WorkspaceEntityPermission,
ProjectEntityPermission, ProjectEntityPermission,
WorkspaceViewerPermission,
ProjectLitePermission,
) )
from plane.db.models import ( from plane.db.models import (
Workspace, Workspace,
GlobalView,
IssueView, IssueView,
Issue, Issue,
IssueViewFavorite, IssueViewFavorite,
IssueReaction,
IssueLink, IssueLink,
IssueAttachment, IssueAttachment,
IssueSubscriber,
) )
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.grouper import group_results
class GlobalViewViewSet(BaseViewSet): class GlobalViewViewSet(BaseViewSet):
@ -87,13 +86,60 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.values("count") .values("count")
) )
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related( .annotate(cycle_id=F("issue_cycle__cycle_id"))
Prefetch( .annotate(
"issue_reactions", link_count=IssueLink.objects.filter(issue=OuterRef("id"))
queryset=IssueReaction.objects.select_related("actor"), .order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
) )
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
) )
) )
@ -121,30 +167,7 @@ class GlobalViewIssuesViewSet(BaseViewSet):
issue_queryset = ( issue_queryset = (
self.get_queryset() self.get_queryset()
.filter(**filters) .filter(**filters)
.filter(project__project_projectmember__member=self.request.user)
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
) )
# Priority Ordering # Priority Ordering
@ -207,10 +230,39 @@ class GlobalViewIssuesViewSet(BaseViewSet):
else: else:
issue_queryset = issue_queryset.order_by(order_by_param) issue_queryset = issue_queryset.order_by(order_by_param)
serializer = IssueSerializer( if self.fields:
issue_queryset, many=True, fields=fields if fields else None issues = IssueSerializer(
) issue_queryset, many=True, fields=self.fields
return Response(serializer.data, status=status.HTTP_200_OK) ).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
return Response(issues, status=status.HTTP_200_OK)
class IssueViewViewSet(BaseViewSet): class IssueViewViewSet(BaseViewSet):
@ -235,7 +287,10 @@ class IssueViewViewSet(BaseViewSet):
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.annotate(is_favorite=Exists(subquery)) .annotate(is_favorite=Exists(subquery))

View File

@ -22,9 +22,14 @@ from django.db.models import (
When, When,
Max, Max,
IntegerField, IntegerField,
Sum,
) )
from django.db.models.functions import ExtractWeek, Cast, ExtractDay from django.db.models.functions import ExtractWeek, Cast, ExtractDay
from django.db.models.fields import DateField from django.db.models.fields import DateField
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
# Third party modules # Third party modules
from rest_framework import status from rest_framework import status
@ -73,6 +78,9 @@ from plane.db.models import (
WorkspaceUserProperties, WorkspaceUserProperties,
Estimate, Estimate,
EstimatePoint, EstimatePoint,
Module,
ModuleLink,
Cycle,
) )
from plane.app.permissions import ( from plane.app.permissions import (
WorkSpaceBasePermission, WorkSpaceBasePermission,
@ -85,6 +93,12 @@ from plane.app.permissions import (
from plane.bgtasks.workspace_invitation_task import workspace_invitation from plane.bgtasks.workspace_invitation_task import workspace_invitation
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.bgtasks.event_tracking_task import workspace_invite_event from plane.bgtasks.event_tracking_task import workspace_invite_event
from plane.app.serializers.module import (
ModuleSerializer,
)
from plane.app.serializers.cycle import (
CycleSerializer,
)
class WorkSpaceViewSet(BaseViewSet): class WorkSpaceViewSet(BaseViewSet):
@ -546,7 +560,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
.get_queryset() .get_queryset()
.filter( .filter(
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
member__is_bot=False,
is_active=True, is_active=True,
) )
.select_related("workspace", "workspace__owner") .select_related("workspace", "workspace__owner")
@ -754,7 +767,6 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
project_ids = ( project_ids = (
ProjectMember.objects.filter( ProjectMember.objects.filter(
member=request.user, member=request.user,
member__is_bot=False,
is_active=True, is_active=True,
) )
.values_list("project_id", flat=True) .values_list("project_id", flat=True)
@ -764,7 +776,6 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
# Get all the project members in which the user is involved # Get all the project members in which the user is involved
project_members = ProjectMember.objects.filter( project_members = ProjectMember.objects.filter(
workspace__slug=slug, workspace__slug=slug,
member__is_bot=False,
project_id__in=project_ids, project_id__in=project_ids,
is_active=True, is_active=True,
).select_related("project", "member", "workspace") ).select_related("project", "member", "workspace")
@ -1075,6 +1086,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
workspace__slug=slug, workspace__slug=slug,
assignees__in=[user_id], assignees__in=[user_id],
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True
) )
.filter(**filters) .filter(**filters)
.annotate(state_group=F("state__group")) .annotate(state_group=F("state__group"))
@ -1090,6 +1102,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
workspace__slug=slug, workspace__slug=slug,
assignees__in=[user_id], assignees__in=[user_id],
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True
) )
.filter(**filters) .filter(**filters)
.values("priority") .values("priority")
@ -1112,6 +1125,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
Issue.issue_objects.filter( Issue.issue_objects.filter(
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
created_by_id=user_id, created_by_id=user_id,
) )
.filter(**filters) .filter(**filters)
@ -1123,6 +1137,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
workspace__slug=slug, workspace__slug=slug,
assignees__in=[user_id], assignees__in=[user_id],
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
) )
.filter(**filters) .filter(**filters)
.count() .count()
@ -1134,6 +1149,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
workspace__slug=slug, workspace__slug=slug,
assignees__in=[user_id], assignees__in=[user_id],
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
) )
.filter(**filters) .filter(**filters)
.count() .count()
@ -1145,6 +1161,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
assignees__in=[user_id], assignees__in=[user_id],
state__group="completed", state__group="completed",
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True
) )
.filter(**filters) .filter(**filters)
.count() .count()
@ -1155,6 +1172,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
workspace__slug=slug, workspace__slug=slug,
subscriber_id=user_id, subscriber_id=user_id,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True
) )
.filter(**filters) .filter(**filters)
.count() .count()
@ -1204,6 +1222,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
~Q(field__in=["comment", "vote", "reaction", "draft"]), ~Q(field__in=["comment", "vote", "reaction", "draft"]),
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
actor=user_id, actor=user_id,
).select_related("actor", "workspace", "issue", "project") ).select_related("actor", "workspace", "issue", "project")
@ -1234,6 +1253,7 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
Project.objects.filter( Project.objects.filter(
workspace__slug=slug, workspace__slug=slug,
project_projectmember__member=request.user, project_projectmember__member=request.user,
project_projectmember__is_active=True,
) )
.annotate( .annotate(
created_issues=Count( created_issues=Count(
@ -1343,6 +1363,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
| Q(issue_subscribers__subscriber_id=user_id), | Q(issue_subscribers__subscriber_id=user_id),
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True
) )
.filter(**filters) .filter(**filters)
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
@ -1370,6 +1391,32 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.order_by("created_at") .order_by("created_at")
).distinct() ).distinct()
@ -1448,6 +1495,7 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
labels = Label.objects.filter( labels = Label.objects.filter(
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True
) )
serializer = LabelSerializer(labels, many=True).data serializer = LabelSerializer(labels, many=True).data
return Response(serializer, status=status.HTTP_200_OK) return Response(serializer, status=status.HTTP_200_OK)
@ -1462,6 +1510,7 @@ class WorkspaceStatesEndpoint(BaseAPIView):
states = State.objects.filter( states = State.objects.filter(
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True
) )
serializer = StateSerializer(states, many=True).data serializer = StateSerializer(states, many=True).data
return Response(serializer, status=status.HTTP_200_OK) return Response(serializer, status=status.HTTP_200_OK)
@ -1490,6 +1539,192 @@ class WorkspaceEstimatesEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
class WorkspaceModulesEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
]
def get(self, request, slug):
modules = (
Module.objects.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("lead")
.prefetch_related("members")
.prefetch_related(
Prefetch(
"link_module",
queryset=ModuleLink.objects.select_related(
"module", "created_by"
),
)
)
.annotate(
total_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
),
)
.annotate(
completed_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="completed",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="cancelled",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="started",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="unstarted",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="backlog",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
)
serializer = ModuleSerializer(modules, many=True).data
return Response(serializer, status=status.HTTP_200_OK)
class WorkspaceCyclesEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
]
def get(self, request, slug):
cycles = (
Cycle.objects.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
total_estimates=Sum("issue_cycle__issue__estimate_point")
)
.annotate(
completed_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
.distinct()
)
serializer = CycleSerializer(cycles, many=True).data
return Response(serializer, status=status.HTTP_200_OK)
class WorkspaceUserPropertiesEndpoint(BaseAPIView): class WorkspaceUserPropertiesEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
WorkspaceViewerPermission, WorkspaceViewerPermission,

View File

@ -10,6 +10,7 @@ from django.utils import timezone
from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.html import strip_tags from django.utils.html import strip_tags
from django.conf import settings
# Module imports # Module imports
from plane.db.models import EmailNotificationLog, User, Issue from plane.db.models import EmailNotificationLog, User, Issue
@ -301,5 +302,7 @@ def send_email_notification(
print("Duplicate task recived. Skipping...") print("Duplicate task recived. Skipping...")
return return
except (Issue.DoesNotExist, User.DoesNotExist) as e: except (Issue.DoesNotExist, User.DoesNotExist) as e:
if settings.DEBUG:
print(e)
release_lock(lock_id=lock_id) release_lock(lock_id=lock_id)
return return

View File

@ -292,6 +292,7 @@ def issue_export_task(
workspace__id=workspace_id, workspace__id=workspace_id,
project_id__in=project_ids, project_id__in=project_ids,
project__project_projectmember__member=exporter_instance.initiated_by_id, project__project_projectmember__member=exporter_instance.initiated_by_id,
project__project_projectmember__is_active=True
) )
.select_related( .select_related(
"project", "workspace", "state", "parent", "created_by" "project", "workspace", "state", "parent", "created_by"

View File

@ -60,15 +60,6 @@ def service_importer(service, importer_id):
batch_size=100, batch_size=100,
) )
_ = [
send_welcome_slack.delay(
str(user.id),
True,
f"{user.email} was imported to Plane from {service}",
)
for user in new_users
]
workspace_users = User.objects.filter( workspace_users = User.objects.filter(
email__in=[ email__in=[
user.get("email").strip().lower() user.get("email").strip().lower()

View File

@ -483,17 +483,23 @@ def track_archive_at(
) )
) )
else: else:
if requested_data.get("automation"):
comment = "Plane has archived the issue"
new_value = "archive"
else:
comment = "Actor has archived the issue"
new_value = "manual_archive"
issue_activities.append( issue_activities.append(
IssueActivity( IssueActivity(
issue_id=issue_id, issue_id=issue_id,
project_id=project_id, project_id=project_id,
workspace_id=workspace_id, workspace_id=workspace_id,
comment="Plane has archived the issue", comment=comment,
verb="updated", verb="updated",
actor_id=actor_id, actor_id=actor_id,
field="archived_at", field="archived_at",
old_value=None, old_value=None,
new_value="archive", new_value=new_value,
epoch=epoch, epoch=epoch,
) )
) )

View File

@ -79,7 +79,7 @@ def archive_old_issues():
issue_activity.delay( issue_activity.delay(
type="issue.activity.updated", type="issue.activity.updated",
requested_data=json.dumps( requested_data=json.dumps(
{"archived_at": str(archive_at)} {"archived_at": str(archive_at), "automation": True}
), ),
actor_id=str(project.created_by_id), actor_id=str(project.created_by_id),
issue_id=issue.id, issue_id=issue.id,

View File

@ -12,15 +12,9 @@ from django.contrib.auth.models import (
PermissionsMixin, PermissionsMixin,
) )
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.conf import settings
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
# Third party imports
from sentry_sdk import capture_exception
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
def get_default_onboarding(): def get_default_onboarding():
return { return {
@ -144,25 +138,6 @@ class User(AbstractBaseUser, PermissionsMixin):
super(User, self).save(*args, **kwargs) super(User, self).save(*args, **kwargs)
@receiver(post_save, sender=User)
def send_welcome_slack(sender, instance, created, **kwargs):
try:
if created and not instance.is_bot:
# Send message on slack as well
if settings.SLACK_BOT_TOKEN:
client = WebClient(token=settings.SLACK_BOT_TOKEN)
try:
_ = client.chat_postMessage(
channel="#trackers",
text=f"New user {instance.email} has signed up and begun the onboarding journey.",
)
except SlackApiError as e:
print(f"Got an error: {e.response['error']}")
return
except Exception as e:
capture_exception(e)
return
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def create_user_notification(sender, instance, created, **kwargs): def create_user_notification(sender, instance, created, **kwargs):

View File

@ -1,4 +1,5 @@
"""Global Settings""" """Global Settings"""
# Python imports # Python imports
import os import os
import ssl import ssl
@ -307,7 +308,9 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get(
traces_sample_rate=1, traces_sample_rate=1,
send_default_pii=True, send_default_pii=True,
environment=os.environ.get("SENTRY_ENVIRONMENT", "development"), environment=os.environ.get("SENTRY_ENVIRONMENT", "development"),
profiles_sample_rate=1.0, profiles_sample_rate=float(
os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0.5)
),
) )

View File

@ -9,11 +9,11 @@ from plane.db.models import Issue
def search_issues(query, queryset): def search_issues(query, queryset):
fields = ["name", "sequence_id"] fields = ["name", "sequence_id", "project__identifier"]
q = Q() q = Q()
for field in fields: for field in fields:
if field == "sequence_id" and len(query) <= 20: if field == "sequence_id" and len(query) <= 20:
sequences = re.findall(r"[A-Za-z0-9]{1,12}-\d+", query) sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences: for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id}) q |= Q(**{"sequence_id": sequence_id})
else: else:

View File

@ -30,7 +30,7 @@ openpyxl==3.1.2
beautifulsoup4==4.12.2 beautifulsoup4==4.12.2
dj-database-url==2.1.0 dj-database-url==2.1.0
posthog==3.0.2 posthog==3.0.2
cryptography==42.0.0 cryptography==42.0.4
lxml==4.9.3 lxml==4.9.3
boto3==1.28.40 boto3==1.28.40

View File

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

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

@ -0,0 +1,78 @@
# 1-Click Self-Hosting
In this guide, we will walk you through the process of setting up a 1-click self-hosted environment. Self-hosting allows you to have full control over your applications and data. It's a great way to ensure privacy, control, and customization.
Let's get started!
## Installing Plane
Installing Plane is a very easy and minimal step process.
### Prerequisite
- Operating System (latest): Debian / Ubuntu / Centos
- Supported CPU Architechture: AMD64 / ARM64 / x86_64 / aarch64
### Downloading Latest Stable Release
```
curl -fsSL https://raw.githubusercontent.com/makeplane/plane/master/deploy/1-click/install.sh | sh -
```
<details>
<summary>Downloading Preview Release</summary>
```
export BRANCH=preview
curl -fsSL https://raw.githubusercontent.com/makeplane/plane/preview/deploy/1-click/install.sh | sh -
```
NOTE: `Preview` builds do not support ARM64/AARCH64 CPU architecture
</details>
--
Expect this after a successful install
![Install Output](images/install.png)
Access the application on a browser via http://server-ip-address
---
### Get Control of your Plane Server Setup
Plane App is available via the command `plane-app`. Running the command `plane-app --help` helps you to manage Plane
![Plane Help](images/help.png)
<ins>Basic Operations</ins>:
1. Start Server using `plane-app start`
1. Stop Server using `plane-app stop`
1. Restart Server using `plane-app restart`
<ins>Advanced Operations</ins>:
1. Configure Plane using `plane-app --configure`. This will give you options to modify
- NGINX Port (default 80)
- Domain Name (default is the local server public IP address)
- File Upload Size (default 5MB)
- External Postgres DB Url (optional - default empty)
- External Redis URL (optional - default empty)
- AWS S3 Bucket (optional - to be configured only in case the user wants to use an S3 Bucket)
1. Upgrade Plane using `plane-app --upgrade`. This will get the latest stable version of Plane files (docker-compose.yaml, .env, and docker images)
1. Updating Plane App installer using `plane-app --update-installer` will update the `plane-app` utility.
1. Uninstall Plane using `plane-app --uninstall`. This will uninstall the Plane application from the server and all docker containers but do not remove the data stored in Postgres, Redis, and Minio.
1. Plane App can be reinstalled using `plane-app --install`.
<ins>Application Data is stored in the mentioned folders</ins>:
1. DB Data: /opt/plane/data/postgres
1. Redis Data: /opt/plane/data/redis
1. Minio Data: /opt/plane/data/minio

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View File

@ -1,17 +1,20 @@
#!/bin/bash #!/bin/bash
export GIT_REPO=makeplane/plane
# Check if the user has sudo access # Check if the user has sudo access
if command -v curl &> /dev/null; then if command -v curl &> /dev/null; then
sudo curl -sSL \ sudo curl -sSL \
-o /usr/local/bin/plane-app \ -o /usr/local/bin/plane-app \
https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s) https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
else else
sudo wget -q \ sudo wget -q \
-O /usr/local/bin/plane-app \ -O /usr/local/bin/plane-app \
https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s) https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
fi fi
sudo chmod +x /usr/local/bin/plane-app sudo chmod +x /usr/local/bin/plane-app
sudo sed -i 's/export DEPLOY_BRANCH=${BRANCH:-master}/export DEPLOY_BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app sudo sed -i 's@export DEPLOY_BRANCH=${BRANCH:-master}@export DEPLOY_BRANCH='${BRANCH:-master}'@' /usr/local/bin/plane-app
sudo sed -i 's@CODE_REPO=${GIT_REPO:-makeplane/plane}@CODE_REPO='$GIT_REPO'@' /usr/local/bin/plane-app
plane-app --help plane-app -i #--help

View File

@ -90,9 +90,9 @@ function prepare_environment() {
show_message "- Updating OS with required tools ✋" >&2 show_message "- Updating OS with required tools ✋" >&2
sudo "$PACKAGE_MANAGER" update -y sudo "$PACKAGE_MANAGER" update -y
sudo "$PACKAGE_MANAGER" upgrade -y # sudo "$PACKAGE_MANAGER" upgrade -y
local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap") local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap" "jq")
for tool in "${required_tools[@]}"; do for tool in "${required_tools[@]}"; do
if ! command -v $tool &> /dev/null; then if ! command -v $tool &> /dev/null; then
@ -150,11 +150,11 @@ function download_plane() {
show_message "Downloading Plane Setup Files ✋" >&2 show_message "Downloading Plane Setup Files ✋" >&2
sudo curl -H 'Cache-Control: no-cache, no-store' \ sudo curl -H 'Cache-Control: no-cache, no-store' \
-s -o $PLANE_INSTALL_DIR/docker-compose.yaml \ -s -o $PLANE_INSTALL_DIR/docker-compose.yaml \
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s) https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s)
sudo curl -H 'Cache-Control: no-cache, no-store' \ sudo curl -H 'Cache-Control: no-cache, no-store' \
-s -o $PLANE_INSTALL_DIR/variables-upgrade.env \ -s -o $PLANE_INSTALL_DIR/variables-upgrade.env \
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s) https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s)
# if .env does not exists rename variables-upgrade.env to .env # if .env does not exists rename variables-upgrade.env to .env
if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then
@ -202,7 +202,7 @@ function printUsageInstructions() {
} }
function build_local_image() { function build_local_image() {
show_message "- Downloading Plane Source Code ✋" >&2 show_message "- Downloading Plane Source Code ✋" >&2
REPO=https://github.com/makeplane/plane.git REPO=https://github.com/$CODE_REPO.git
CURR_DIR=$PWD CURR_DIR=$PWD
PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp
sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null
@ -290,40 +290,40 @@ function configure_plane() {
fi fi
smtp_host=$(read_env "EMAIL_HOST") # smtp_host=$(read_env "EMAIL_HOST")
smtp_user=$(read_env "EMAIL_HOST_USER") # smtp_user=$(read_env "EMAIL_HOST_USER")
smtp_password=$(read_env "EMAIL_HOST_PASSWORD") # smtp_password=$(read_env "EMAIL_HOST_PASSWORD")
smtp_port=$(read_env "EMAIL_PORT") # smtp_port=$(read_env "EMAIL_PORT")
smtp_from=$(read_env "EMAIL_FROM") # smtp_from=$(read_env "EMAIL_FROM")
smtp_tls=$(read_env "EMAIL_USE_TLS") # smtp_tls=$(read_env "EMAIL_USE_TLS")
smtp_ssl=$(read_env "EMAIL_USE_SSL") # smtp_ssl=$(read_env "EMAIL_USE_SSL")
SMTP_SETTINGS=$(dialog \ # SMTP_SETTINGS=$(dialog \
--ok-label "Next" \ # --ok-label "Next" \
--cancel-label "Skip" \ # --cancel-label "Skip" \
--backtitle "Plane Configuration" \ # --backtitle "Plane Configuration" \
--title "SMTP Settings" \ # --title "SMTP Settings" \
--form "" \ # --form "" \
0 0 0 \ # 0 0 0 \
"Host:" 1 1 "$smtp_host" 1 10 80 0 \ # "Host:" 1 1 "$smtp_host" 1 10 80 0 \
"User:" 2 1 "$smtp_user" 2 10 80 0 \ # "User:" 2 1 "$smtp_user" 2 10 80 0 \
"Password:" 3 1 "$smtp_password" 3 10 80 0 \ # "Password:" 3 1 "$smtp_password" 3 10 80 0 \
"Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \ # "Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \
"From:" 5 1 "${smtp_from:-Mailer <mailer@example.com>}" 5 10 80 0 \ # "From:" 5 1 "${smtp_from:-Mailer <mailer@example.com>}" 5 10 80 0 \
"TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \ # "TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \
"SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \ # "SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \
2>&1 1>&3) # 2>&1 1>&3)
save_smtp_settings=0 # save_smtp_settings=0
if [ $? -eq 0 ]; then # if [ $? -eq 0 ]; then
save_smtp_settings=1 # save_smtp_settings=1
smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p) # smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p)
smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p) # smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p)
smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p) # smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p)
smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p) # smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p)
smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p) # smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p)
smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p) # smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p)
fi # fi
external_pgdb_url=$(dialog \ external_pgdb_url=$(dialog \
--backtitle "Plane Configuration" \ --backtitle "Plane Configuration" \
--title "Using External Postgres Database ?" \ --title "Using External Postgres Database ?" \
@ -383,15 +383,6 @@ function configure_plane() {
domain_name: $domain_name domain_name: $domain_name
upload_limit: $upload_limit upload_limit: $upload_limit
save_smtp_settings: $save_smtp_settings
smtp_host: $smtp_host
smtp_user: $smtp_user
smtp_password: $smtp_password
smtp_port: $smtp_port
smtp_from: $smtp_from
smtp_tls: $smtp_tls
smtp_ssl: $smtp_ssl
save_aws_settings: $save_aws_settings save_aws_settings: $save_aws_settings
aws_region: $aws_region aws_region: $aws_region
aws_access_key: $aws_access_key aws_access_key: $aws_access_key
@ -413,15 +404,15 @@ function configure_plane() {
fi fi
# check enable smpt settings value # check enable smpt settings value
if [ $save_smtp_settings == 1 ]; then # if [ $save_smtp_settings == 1 ]; then
update_env "EMAIL_HOST" "$smtp_host" # update_env "EMAIL_HOST" "$smtp_host"
update_env "EMAIL_HOST_USER" "$smtp_user" # update_env "EMAIL_HOST_USER" "$smtp_user"
update_env "EMAIL_HOST_PASSWORD" "$smtp_password" # update_env "EMAIL_HOST_PASSWORD" "$smtp_password"
update_env "EMAIL_PORT" "$smtp_port" # update_env "EMAIL_PORT" "$smtp_port"
update_env "EMAIL_FROM" "$smtp_from" # update_env "EMAIL_FROM" "$smtp_from"
update_env "EMAIL_USE_TLS" "$smtp_tls" # update_env "EMAIL_USE_TLS" "$smtp_tls"
update_env "EMAIL_USE_SSL" "$smtp_ssl" # update_env "EMAIL_USE_SSL" "$smtp_ssl"
fi # fi
# check enable aws settings value # check enable aws settings value
if [[ $save_aws_settings == 1 && $aws_access_key != "" && $aws_secret_key != "" ]] ; then if [[ $save_aws_settings == 1 && $aws_access_key != "" && $aws_secret_key != "" ]] ; then
@ -493,13 +484,24 @@ function install() {
check_for_docker_images check_for_docker_images
last_installed_on=$(read_config "INSTALLATION_DATE") last_installed_on=$(read_config "INSTALLATION_DATE")
if [ "$last_installed_on" == "" ]; then # if [ "$last_installed_on" == "" ]; then
configure_plane # configure_plane
fi # fi
printUsageInstructions
update_config "INSTALLATION_DATE" "$(date)"
update_env "NGINX_PORT" "80"
update_env "DOMAIN_NAME" "$MY_IP"
update_env "WEB_URL" "http://$MY_IP"
update_env "CORS_ALLOWED_ORIGINS" "http://$MY_IP"
update_config "INSTALLATION_DATE" "$(date '+%Y-%m-%d')"
if command -v crontab &> /dev/null; then
sudo touch /etc/cron.daily/makeplane
sudo chmod +x /etc/cron.daily/makeplane
sudo echo "0 2 * * * root /usr/local/bin/plane-app --upgrade" > /etc/cron.daily/makeplane
sudo crontab /etc/cron.daily/makeplane
fi
show_message "Plane Installed Successfully ✅" show_message "Plane Installed Successfully ✅"
show_message "" show_message ""
else else
@ -539,12 +541,15 @@ function upgrade() {
prepare_environment prepare_environment
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
stop_server
download_plane download_plane
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
check_for_docker_images check_for_docker_images
upgrade_configuration upgrade_configuration
update_config "UPGRADE_DATE" "$(date)" update_config "UPGRADE_DATE" "$(date)"
start_server
show_message "" show_message ""
show_message "Plane Upgraded Successfully ✅" show_message "Plane Upgraded Successfully ✅"
show_message "" show_message ""
@ -601,6 +606,11 @@ function uninstall() {
sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null
sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
if command -v crontab &> /dev/null; then
sudo crontab -r &> /dev/null
sudo rm /etc/cron.daily/makeplane &> /dev/null
fi
# rm -rf $PLANE_INSTALL_DIR &> /dev/null # rm -rf $PLANE_INSTALL_DIR &> /dev/null
show_message "- Configuration Cleaned ✅" show_message "- Configuration Cleaned ✅"
@ -642,7 +652,39 @@ function start_server() {
while ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do while ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do
sleep 1 sleep 1
done done
# wait for migrator container to exit with status 0 before starting the application
migrator_container_id=$(sudo docker container ls -aq -f "name=plane-migrator")
# if migrator container is running, wait for it to exit
if [ -n "$migrator_container_id" ]; then
while sudo docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do
show_message "Waiting for Plane Server ($APP_RELEASE) to start...✋ (Migrator in progress)" "replace_last_line" >&2
sleep 1
done
fi
# if migrator exit status is not 0, show error message and exit
if [ -n "$migrator_container_id" ]; then
migrator_exit_code=$(sudo docker inspect --format='{{.State.ExitCode}}' $migrator_container_id)
if [ $migrator_exit_code -ne 0 ]; then
# show_message "Migrator failed with exit code $migrator_exit_code ❌" "replace_last_line" >&2
show_message "Plane Server failed to start ❌" "replace_last_line" >&2
stop_server
exit 1
fi
fi
api_container_id=$(sudo docker container ls -q -f "name=plane-api")
while ! sudo docker logs $api_container_id 2>&1 | grep -i "Application startup complete";
do
show_message "Waiting for Plane Server ($APP_RELEASE) to start...✋ (API starting)" "replace_last_line" >&2
sleep 1
done
show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2 show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2
show_message "---------------------------------------------------------------" >&2
show_message "Access the Plane application at http://$MY_IP" >&2
show_message "---------------------------------------------------------------" >&2
else else
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
fi fi
@ -694,7 +736,7 @@ function update_installer() {
show_message "Updating Plane Installer ✋" >&2 show_message "Updating Plane Installer ✋" >&2
sudo curl -H 'Cache-Control: no-cache, no-store' \ sudo curl -H 'Cache-Control: no-cache, no-store' \
-s -o /usr/local/bin/plane-app \ -s -o /usr/local/bin/plane-app \
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s) https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s)
sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null
show_message "Plane Installer Updated ✅" "replace_last_line" >&2 show_message "Plane Installer Updated ✅" "replace_last_line" >&2
@ -711,12 +753,14 @@ fi
PLANE_INSTALL_DIR=/opt/plane PLANE_INSTALL_DIR=/opt/plane
DATA_DIR=$PLANE_INSTALL_DIR/data DATA_DIR=$PLANE_INSTALL_DIR/data
LOG_DIR=$PLANE_INSTALL_DIR/log LOG_DIR=$PLANE_INSTALL_DIR/logs
CODE_REPO=${GIT_REPO:-makeplane/plane}
OS_SUPPORTED=false OS_SUPPORTED=false
CPU_ARCH=$(uname -m) CPU_ARCH=$(uname -m)
PROGRESS_MSG="" PROGRESS_MSG=""
USE_GLOBAL_IMAGES=0 USE_GLOBAL_IMAGES=0
PACKAGE_MANAGER="" PACKAGE_MANAGER=""
MY_IP=$(curl -s ifconfig.me)
if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then
USE_GLOBAL_IMAGES=1 USE_GLOBAL_IMAGES=1
@ -740,6 +784,9 @@ elif [ "$1" == "restart" ]; then
restart_server restart_server
elif [ "$1" == "--install" ] || [ "$1" == "-i" ]; then elif [ "$1" == "--install" ] || [ "$1" == "-i" ]; then
install install
start_server
show_message "" >&2
show_message "To view help, use plane-app --help " >&2
elif [ "$1" == "--configure" ] || [ "$1" == "-c" ]; then elif [ "$1" == "--configure" ] || [ "$1" == "-c" ]; then
configure_plane configure_plane
printUsageInstructions printUsageInstructions

View File

@ -56,8 +56,6 @@ x-app-env : &app-env
- BUCKET_NAME=${BUCKET_NAME:-uploads} - BUCKET_NAME=${BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
services: services:
web: web:
<<: *app-env <<: *app-env
@ -138,7 +136,6 @@ services:
command: postgres -c 'max_connections=1000' command: postgres -c 'max_connections=1000'
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
plane-redis: plane-redis:
<<: *app-env <<: *app-env
image: redis:6.2.7-alpine image: redis:6.2.7-alpine

View File

@ -13,6 +13,23 @@ YELLOW='\033[1;33m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
function print_header() {
clear
cat <<"EOF"
---------------------------------------
____ _
| _ \| | __ _ _ __ ___
| |_) | |/ _` | '_ \ / _ \
| __/| | (_| | | | | __/
|_| |_|\__,_|_| |_|\___|
---------------------------------------
Project management tool from the future
---------------------------------------
EOF
}
function buildLocalImage() { function buildLocalImage() {
if [ "$1" == "--force-build" ]; then if [ "$1" == "--force-build" ]; then
DO_BUILD="1" DO_BUILD="1"
@ -110,7 +127,7 @@ function download() {
exit 0 exit 0
fi fi
else else
docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml pull docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH pull
fi fi
echo "" echo ""
@ -121,19 +138,48 @@ function download() {
} }
function startServices() { function startServices() {
cd $PLANE_INSTALL_DIR docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH up -d --quiet-pull
docker compose up -d --quiet-pull
cd $SCRIPT_DIR local migrator_container_id=$(docker container ls -aq -f "name=plane-app-migrator")
if [ -n "$migrator_container_id" ]; then
local idx=0
while docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do
local message=">>> Waiting for Data Migration to finish"
local dots=$(printf '%*s' $idx | tr ' ' '.')
echo -ne "\r$message$dots"
((idx++))
sleep 1
done
fi
printf "\r\033[K"
# if migrator exit status is not 0, show error message and exit
if [ -n "$migrator_container_id" ]; then
local migrator_exit_code=$(docker inspect --format='{{.State.ExitCode}}' $migrator_container_id)
if [ $migrator_exit_code -ne 0 ]; then
echo "Plane Server failed to start ❌"
stopServices
exit 1
fi
fi
local api_container_id=$(docker container ls -q -f "name=plane-app-api")
local idx2=0
while ! docker logs $api_container_id 2>&1 | grep -m 1 -i "Application startup complete" | grep -q ".";
do
local message=">>> Waiting for API Service to Start"
local dots=$(printf '%*s' $idx2 | tr ' ' '.')
echo -ne "\r$message$dots"
((idx2++))
sleep 1
done
printf "\r\033[K"
} }
function stopServices() { function stopServices() {
cd $PLANE_INSTALL_DIR docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH down
docker compose down
cd $SCRIPT_DIR
} }
function restartServices() { function restartServices() {
cd $PLANE_INSTALL_DIR docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH restart
docker compose restart
cd $SCRIPT_DIR
} }
function upgrade() { function upgrade() {
echo "***** STOPPING SERVICES ****" echo "***** STOPPING SERVICES ****"
@ -144,47 +190,137 @@ function upgrade() {
download download
echo "***** PLEASE VALIDATE AND START SERVICES ****" echo "***** PLEASE VALIDATE AND START SERVICES ****"
}
function viewSpecificLogs(){
local SERVICE_NAME=$1
if docker-compose -f $DOCKER_FILE_PATH ps | grep -q "$SERVICE_NAME"; then
echo "Service '$SERVICE_NAME' is running."
else
echo "Service '$SERVICE_NAME' is not running."
fi
docker compose -f $DOCKER_FILE_PATH logs -f $SERVICE_NAME
}
function viewLogs(){
ARG_SERVICE_NAME=$2
if [ -z "$ARG_SERVICE_NAME" ];
then
echo
echo "Select a Service you want to view the logs for:"
echo " 1) Web"
echo " 2) Space"
echo " 3) API"
echo " 4) Worker"
echo " 5) Beat-Worker"
echo " 6) Migrator"
echo " 7) Proxy"
echo " 8) Redis"
echo " 9) Postgres"
echo " 10) Minio"
echo " 0) Back to Main Menu"
echo
read -p "Service: " DOCKER_SERVICE_NAME
until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 10 )); do
echo "Invalid selection. Please enter a number between 1 and 11."
read -p "Service: " DOCKER_SERVICE_NAME
done
if [ -z "$DOCKER_SERVICE_NAME" ];
then
echo "INVALID SERVICE NAME SUPPLIED"
else
case $DOCKER_SERVICE_NAME in
1) viewSpecificLogs "web";;
2) viewSpecificLogs "space";;
3) viewSpecificLogs "api";;
4) viewSpecificLogs "worker";;
5) viewSpecificLogs "beat-worker";;
6) viewSpecificLogs "migrator";;
7) viewSpecificLogs "proxy";;
8) viewSpecificLogs "plane-redis";;
9) viewSpecificLogs "plane-db";;
10) viewSpecificLogs "plane-minio";;
0) askForAction;;
*) echo "INVALID SERVICE NAME SUPPLIED";;
esac
fi
elif [ -n "$ARG_SERVICE_NAME" ];
then
ARG_SERVICE_NAME=$(echo "$ARG_SERVICE_NAME" | tr '[:upper:]' '[:lower:]')
case $ARG_SERVICE_NAME in
web) viewSpecificLogs "web";;
space) viewSpecificLogs "space";;
api) viewSpecificLogs "api";;
worker) viewSpecificLogs "worker";;
beat-worker) viewSpecificLogs "beat-worker";;
migrator) viewSpecificLogs "migrator";;
proxy) viewSpecificLogs "proxy";;
redis) viewSpecificLogs "plane-redis";;
postgres) viewSpecificLogs "plane-db";;
minio) viewSpecificLogs "plane-minio";;
*) echo "INVALID SERVICE NAME SUPPLIED";;
esac
else
echo "INVALID SERVICE NAME SUPPLIED"
fi
} }
function askForAction() { function askForAction() {
echo local DEFAULT_ACTION=$1
echo "Select a Action you want to perform:"
echo " 1) Install (${CPU_ARCH})" if [ -z "$DEFAULT_ACTION" ];
echo " 2) Start" then
echo " 3) Stop" echo
echo " 4) Restart" echo "Select a Action you want to perform:"
echo " 5) Upgrade" echo " 1) Install (${CPU_ARCH})"
echo " 6) Exit" echo " 2) Start"
echo echo " 3) Stop"
read -p "Action [2]: " ACTION echo " 4) Restart"
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do echo " 5) Upgrade"
echo "$ACTION: invalid selection." echo " 6) View Logs"
echo " 7) Exit"
echo
read -p "Action [2]: " ACTION read -p "Action [2]: " ACTION
done until [[ -z "$ACTION" || "$ACTION" =~ ^[1-7]$ ]]; do
echo echo "$ACTION: invalid selection."
read -p "Action [2]: " ACTION
done
if [ -z "$ACTION" ];
then
ACTION=2
fi
echo
fi
if [ "$ACTION" == "1" ] if [ "$ACTION" == "1" ] || [ "$DEFAULT_ACTION" == "install" ]
then then
install install
askForAction askForAction
elif [ "$ACTION" == "2" ] || [ "$ACTION" == "" ] elif [ "$ACTION" == "2" ] || [ "$DEFAULT_ACTION" == "start" ]
then then
startServices startServices
askForAction askForAction
elif [ "$ACTION" == "3" ] elif [ "$ACTION" == "3" ] || [ "$DEFAULT_ACTION" == "stop" ]
then then
stopServices stopServices
askForAction askForAction
elif [ "$ACTION" == "4" ] elif [ "$ACTION" == "4" ] || [ "$DEFAULT_ACTION" == "restart" ]
then then
restartServices restartServices
askForAction askForAction
elif [ "$ACTION" == "5" ] elif [ "$ACTION" == "5" ] || [ "$DEFAULT_ACTION" == "upgrade" ]
then then
upgrade upgrade
askForAction askForAction
elif [ "$ACTION" == "6" ] elif [ "$ACTION" == "6" ] || [ "$DEFAULT_ACTION" == "logs" ]
then
viewLogs $@
askForAction
elif [ "$ACTION" == "7" ]
then then
exit 0 exit 0
else else
@ -217,4 +353,8 @@ then
fi fi
mkdir -p $PLANE_INSTALL_DIR/archive mkdir -p $PLANE_INSTALL_DIR/archive
askForAction DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/.env
print_header
askForAction $@

View File

@ -1,6 +1,6 @@
{ {
"repository": "https://github.com/makeplane/plane.git", "repository": "https://github.com/makeplane/plane.git",
"version": "0.15.1", "version": "0.16.0",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"private": true, "private": true,
"workspaces": [ "workspaces": [

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/editor-core", "name": "@plane/editor-core",
"version": "0.15.1", "version": "0.16.0",
"description": "Core Editor that powers Plane", "description": "Core Editor that powers Plane",
"private": true, "private": true,
"main": "./dist/index.mjs", "main": "./dist/index.mjs",

View File

@ -97,8 +97,8 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
} }
} }
} }
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3 }).run();
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); else editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run();
}; };
export const unsetLinkEditor = (editor: Editor) => { export const unsetLinkEditor = (editor: Editor) => {

View File

@ -170,68 +170,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
} }
} }
#editor-container {
table {
border-collapse: collapse;
table-layout: fixed;
margin: 0.5em 0 0.5em 0;
border: 1px solid rgb(var(--color-border-200));
width: 100%;
td,
th {
min-width: 1em;
border: 1px solid rgb(var(--color-border-200));
padding: 10px 15px;
vertical-align: top;
box-sizing: border-box;
position: relative;
transition: background-color 0.3s ease;
> * {
margin-bottom: 0;
}
}
th {
font-weight: bold;
text-align: left;
background-color: rgb(var(--color-primary-100));
}
td:hover {
background-color: rgba(var(--color-primary-300), 0.1);
}
.selectedCell:after {
z-index: 2;
position: absolute;
content: "";
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(var(--color-primary-300), 0.1);
pointer-events: none;
}
.column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 2px;
background-color: rgb(var(--color-primary-400));
pointer-events: none;
}
}
}
.tableWrapper {
overflow-x: auto;
}
.resize-cursor { .resize-cursor {
cursor: ew-resize; cursor: ew-resize;
cursor: col-resize; cursor: col-resize;

View File

@ -9,15 +9,15 @@
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; table-layout: fixed;
margin: 0; margin: 0;
margin-bottom: 3rem; margin-bottom: 1rem;
border: 1px solid rgba(var(--color-border-200)); border: 2px solid rgba(var(--color-border-300));
width: 100%; width: 100%;
} }
.tableWrapper table td, .tableWrapper table td,
.tableWrapper table th { .tableWrapper table th {
min-width: 1em; min-width: 1em;
border: 1px solid rgba(var(--color-border-200)); border: 1px solid rgba(var(--color-border-300));
padding: 10px 15px; padding: 10px 15px;
vertical-align: top; vertical-align: top;
box-sizing: border-box; box-sizing: border-box;
@ -43,7 +43,8 @@
.tableWrapper table th { .tableWrapper table th {
font-weight: bold; font-weight: bold;
text-align: left; text-align: left;
background-color: rgba(var(--color-primary-100)); background-color: #d9e4ff;
color: #171717;
} }
.tableWrapper table th * { .tableWrapper table th * {
@ -62,6 +63,35 @@
pointer-events: none; pointer-events: none;
} }
.colorPicker {
display: grid;
padding: 8px 8px;
grid-template-columns: repeat(6, 1fr);
gap: 5px;
}
.colorPickerLabel {
font-size: 0.85rem;
color: #6b7280;
padding: 8px 8px;
padding-bottom: 0px;
}
.colorPickerItem {
margin: 2px 0px;
width: 24px;
height: 24px;
border-radius: 4px;
border: none;
cursor: pointer;
}
.divider {
background-color: #e5e7eb;
height: 1px;
margin: 3px 0;
}
.tableWrapper table .column-resize-handle { .tableWrapper table .column-resize-handle {
position: absolute; position: absolute;
right: -2px; right: -2px;
@ -69,7 +99,7 @@
bottom: -2px; bottom: -2px;
width: 4px; width: 4px;
z-index: 99; z-index: 99;
background-color: rgba(var(--color-primary-400)); background-color: #d9e4ff;
pointer-events: none; pointer-events: none;
} }
@ -112,7 +142,7 @@
} }
.tableWrapper .tableControls .rowsControlDiv { .tableWrapper .tableControls .rowsControlDiv {
background-color: rgba(var(--color-primary-100)); background-color: #d9e4ff;
border: 1px solid rgba(var(--color-border-200)); border: 1px solid rgba(var(--color-border-200));
border-radius: 2px; border-radius: 2px;
background-size: 1.25rem; background-size: 1.25rem;
@ -127,7 +157,7 @@
} }
.tableWrapper .tableControls .columnsControlDiv { .tableWrapper .tableControls .columnsControlDiv {
background-color: rgba(var(--color-primary-100)); background-color: #d9e4ff;
border: 1px solid rgba(var(--color-border-200)); border: 1px solid rgba(var(--color-border-200));
border-radius: 2px; border-radius: 2px;
background-size: 1.25rem; background-size: 1.25rem;
@ -144,10 +174,12 @@
.tableWrapper .tableControls .tableColorPickerToolbox { .tableWrapper .tableControls .tableColorPickerToolbox {
border: 1px solid rgba(var(--color-border-300)); border: 1px solid rgba(var(--color-border-300));
background-color: rgba(var(--color-background-100)); background-color: rgba(var(--color-background-100));
border-radius: 5px;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
padding: 0.25rem; padding: 0.25rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 200px; width: max-content;
gap: 0.25rem; gap: 0.25rem;
} }
@ -158,7 +190,7 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
border: none; border: none;
padding: 0.1rem; padding: 0.3rem 0.5rem 0.1rem 0.1rem;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
@ -173,9 +205,7 @@
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer, .tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer,
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer, .tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer { .tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer {
border: 1px solid rgba(var(--color-border-300)); padding: 4px 0px;
border-radius: 3px;
padding: 4px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -187,8 +217,8 @@
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg, .tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg,
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg, .tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg { .tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg {
width: 2rem; width: 1rem;
height: 2rem; height: 1rem;
} }
.tableToolbox { .tableToolbox {

View File

@ -13,7 +13,7 @@ export const TableCell = Node.create<TableCellOptions>({
}; };
}, },
content: "paragraph+", content: "block+",
addAttributes() { addAttributes() {
return { return {
@ -33,7 +33,10 @@ export const TableCell = Node.create<TableCellOptions>({
}, },
}, },
background: { background: {
default: "none", default: null,
},
textColor: {
default: null,
}, },
}; };
}, },
@ -50,7 +53,7 @@ export const TableCell = Node.create<TableCellOptions>({
return [ return [
"td", "td",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: `background-color: ${node.attrs.background}`, style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor}`,
}), }),
0, 0,
]; ];

View File

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

View File

@ -13,6 +13,17 @@ export const TableRow = Node.create<TableRowOptions>({
}; };
}, },
addAttributes() {
return {
background: {
default: null,
},
textColor: {
default: null,
},
};
},
content: "(tableCell | tableHeader)*", content: "(tableCell | tableHeader)*",
tableRole: "row", tableRole: "row",
@ -22,6 +33,12 @@ export const TableRow = Node.create<TableRowOptions>({
}, },
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
return ["tr", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; const style = HTMLAttributes.background
? `background-color: ${HTMLAttributes.background}; color: ${HTMLAttributes.textColor}`
: "";
const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { style });
return ["tr", attributes, 0];
}, },
}); });

View File

@ -1,7 +1,7 @@
export const icons = { export const icons = {
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" length="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path fill="rgb(var(--color-text-300))" d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`, colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" length="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path fill="rgb(var(--color-text-300))" d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" length="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M12 3c.552 0 1 .448 1 1v8c.835-.628 1.874-1 3-1 2.761 0 5 2.239 5 5s-2.239 5-5 5c-1.032 0-1.99-.313-2.787-.848L13 20c0 .552-.448 1-1 1H6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2H7v14h4V5zm8 10h-6v2h6v-2z"/></svg>`, deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" length="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M20 5c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1 .628.835 1 1.874 1 3 0 2.761-2.239 5-5 5s-5-2.239-5-5c0-1.126.372-2.165 1-3H4c-.552 0-1-.448-1-1V6c0-.552.448-1 1-1h16zm-7 10v2h6v-2h-6zm6-8H5v4h14V7z"/></svg>`, deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
insertLeftTableIcon: `<svg insertLeftTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
length={24} length={24}
@ -35,6 +35,8 @@ export const icons = {
/> />
</svg> </svg>
`, `,
toggleColumnHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
toggleRowHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
insertBottomTableIcon: `<svg insertBottomTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
length={24} length={24}

View File

@ -81,53 +81,75 @@ const defaultTippyOptions: Partial<Props> = {
placement: "right", placement: "right",
}; };
function setCellsBackgroundColor(editor: Editor, backgroundColor: string) { function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) {
return editor return editor
.chain() .chain()
.focus() .focus()
.updateAttributes("tableCell", { .updateAttributes("tableCell", {
background: backgroundColor, background: color.backgroundColor,
}) textColor: color.textColor,
.updateAttributes("tableHeader", {
background: backgroundColor,
}) })
.run(); .run();
} }
function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) {
const { state, dispatch } = editor.view;
const { selection } = state;
if (!(selection instanceof CellSelection)) {
return false;
}
// Get the position of the hovered cell in the selection to determine the row.
const hoveredCell = selection.$headCell || selection.$anchorCell;
// Find the depth of the table row node
let rowDepth = hoveredCell.depth;
while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== "tableRow") {
rowDepth--;
}
// If we couldn't find a tableRow node, we can't set the background color
if (hoveredCell.node(rowDepth).type.name !== "tableRow") {
return false;
}
// Get the position where the table row starts
const rowStartPos = hoveredCell.start(rowDepth);
// Create a transaction that sets the background color on the tableRow node.
const tr = state.tr.setNodeMarkup(rowStartPos - 1, null, {
...hoveredCell.node(rowDepth).attrs,
background: color.backgroundColor,
textColor: color.textColor,
});
dispatch(tr);
return true;
}
const columnsToolboxItems: ToolboxItem[] = [ const columnsToolboxItems: ToolboxItem[] = [
{ {
label: "Add Column Before", label: "Toggle column header",
icon: icons.toggleColumnHeader,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderColumn().run(),
},
{
label: "Add column before",
icon: icons.insertLeftTableIcon, icon: icons.insertLeftTableIcon,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run(), action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run(),
}, },
{ {
label: "Add Column After", label: "Add column after",
icon: icons.insertRightTableIcon, icon: icons.insertRightTableIcon,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run(), action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run(),
}, },
{ {
label: "Pick Column Color", label: "Pick color",
icon: icons.colorPicker, icon: "", // No icon needed for color picker
action: ({ action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
editor,
triggerButton,
controlsContainer,
}: {
editor: Editor;
triggerButton: HTMLElement;
controlsContainer: Element;
}) => {
createColorPickerToolbox({
triggerButton,
tippyOptions: {
appendTo: controlsContainer,
},
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
});
},
}, },
{ {
label: "Delete Column", label: "Delete column",
icon: icons.deleteColumn, icon: icons.deleteColumn,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run(), action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run(),
}, },
@ -135,35 +157,24 @@ const columnsToolboxItems: ToolboxItem[] = [
const rowsToolboxItems: ToolboxItem[] = [ const rowsToolboxItems: ToolboxItem[] = [
{ {
label: "Add Row Above", label: "Toggle row header",
icon: icons.toggleRowHeader,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderRow().run(),
},
{
label: "Add row above",
icon: icons.insertTopTableIcon, icon: icons.insertTopTableIcon,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run(), action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run(),
}, },
{ {
label: "Add Row Below", label: "Add row below",
icon: icons.insertBottomTableIcon, icon: icons.insertBottomTableIcon,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run(), action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run(),
}, },
{ {
label: "Pick Row Color", label: "Pick color",
icon: icons.colorPicker, icon: "",
action: ({ action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
editor,
triggerButton,
controlsContainer,
}: {
editor: Editor;
triggerButton: HTMLButtonElement;
controlsContainer: Element | "parent" | ((ref: Element) => Element) | undefined;
}) => {
createColorPickerToolbox({
triggerButton,
tippyOptions: {
appendTo: controlsContainer,
},
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
});
},
}, },
{ {
label: "Delete Row", label: "Delete Row",
@ -176,37 +187,57 @@ function createToolbox({
triggerButton, triggerButton,
items, items,
tippyOptions, tippyOptions,
onSelectColor,
onClickItem, onClickItem,
colors,
}: { }: {
triggerButton: Element | null; triggerButton: Element | null;
items: ToolboxItem[]; items: ToolboxItem[];
tippyOptions: any; tippyOptions: any;
onClickItem: (item: ToolboxItem) => void; onClickItem: (item: ToolboxItem) => void;
onSelectColor: (color: { backgroundColor: string; textColor: string }) => void;
colors: { [key: string]: { backgroundColor: string; textColor: string; icon?: string } };
}): Instance<Props> { }): Instance<Props> {
// @ts-expect-error // @ts-expect-error
const toolbox = tippy(triggerButton, { const toolbox = tippy(triggerButton, {
content: h( content: h(
"div", "div",
{ className: "tableToolbox" }, { className: "tableToolbox" },
items.map((item) => items.map((item, index) => {
h( if (item.label === "Pick color") {
"div", return h("div", { className: "flex flex-col" }, [
{ h("div", { className: "divider" }),
className: "toolboxItem", h("div", { className: "colorPickerLabel" }, item.label),
itemType: "button", h(
onClick() { "div",
onClickItem(item); { className: "colorPicker grid" },
Object.entries(colors).map(([colorName, colorValue]) =>
h("div", {
className: "colorPickerItem",
style: `background-color: ${colorValue.backgroundColor};
color: ${colorValue.textColor || "inherit"};`,
innerHTML: colorValue?.icon || "",
onClick: () => onSelectColor(colorValue),
})
)
),
h("div", { className: "divider" }),
]);
} else {
return h(
"div",
{
className: "toolboxItem",
itemType: "div",
onClick: () => onClickItem(item),
}, },
}, [
[ h("div", { className: "iconContainer", innerHTML: item.icon }),
h("div", { h("div", { className: "label" }, item.label),
className: "iconContainer", ]
innerHTML: item.icon, );
}), }
h("div", { className: "label" }, item.label), })
]
)
)
), ),
...tippyOptions, ...tippyOptions,
}); });
@ -214,71 +245,6 @@ function createToolbox({
return Array.isArray(toolbox) ? toolbox[0] : toolbox; return Array.isArray(toolbox) ? toolbox[0] : toolbox;
} }
function createColorPickerToolbox({
triggerButton,
tippyOptions,
onSelectColor = () => {},
}: {
triggerButton: HTMLElement;
tippyOptions: Partial<Props>;
onSelectColor?: (color: string) => void;
}) {
const items = {
Default: "rgb(var(--color-primary-100))",
Orange: "#FFE5D1",
Grey: "#F1F1F1",
Yellow: "#FEF3C7",
Green: "#DCFCE7",
Red: "#FFDDDD",
Blue: "#D9E4FF",
Pink: "#FFE8FA",
Purple: "#E8DAFB",
};
const colorPicker = tippy(triggerButton, {
...defaultTippyOptions,
content: h(
"div",
{ className: "tableColorPickerToolbox" },
Object.entries(items).map(([key, value]) =>
h(
"div",
{
className: "toolboxItem",
itemType: "button",
onClick: () => {
onSelectColor(value);
colorPicker.hide();
},
},
[
h("div", {
className: "colorContainer",
style: {
backgroundColor: value,
},
}),
h(
"div",
{
className: "label",
},
key
),
]
)
)
),
onHidden: (instance) => {
instance.destroy();
},
showOnCreate: true,
...tippyOptions,
});
return colorPicker;
}
export class TableView implements NodeView { export class TableView implements NodeView {
node: ProseMirrorNode; node: ProseMirrorNode;
cellMinWidth: number; cellMinWidth: number;
@ -347,10 +313,27 @@ export class TableView implements NodeView {
this.rowsControl, this.rowsControl,
this.columnsControl this.columnsControl
); );
const columnColors = {
Blue: { backgroundColor: "#D9E4FF", textColor: "#171717" },
Orange: { backgroundColor: "#FFEDD5", textColor: "#171717" },
Grey: { backgroundColor: "#F1F1F1", textColor: "#171717" },
Yellow: { backgroundColor: "#FEF3C7", textColor: "#171717" },
Green: { backgroundColor: "#DCFCE7", textColor: "#171717" },
Red: { backgroundColor: "#FFDDDD", textColor: "#171717" },
Pink: { backgroundColor: "#FFE8FA", textColor: "#171717" },
Purple: { backgroundColor: "#E8DAFB", textColor: "#171717" },
None: {
backgroundColor: "none",
textColor: "none",
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="gray" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban"><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></svg>`,
},
};
this.columnsToolbox = createToolbox({ this.columnsToolbox = createToolbox({
triggerButton: this.columnsControl.querySelector(".columnsControlDiv"), triggerButton: this.columnsControl.querySelector(".columnsControlDiv"),
items: columnsToolboxItems, items: columnsToolboxItems,
colors: columnColors,
onSelectColor: (color) => setCellsBackgroundColor(this.editor, color),
tippyOptions: { tippyOptions: {
...defaultTippyOptions, ...defaultTippyOptions,
appendTo: this.controls, appendTo: this.controls,
@ -368,10 +351,12 @@ export class TableView implements NodeView {
this.rowsToolbox = createToolbox({ this.rowsToolbox = createToolbox({
triggerButton: this.rowsControl.firstElementChild, triggerButton: this.rowsControl.firstElementChild,
items: rowsToolboxItems, items: rowsToolboxItems,
colors: columnColors,
tippyOptions: { tippyOptions: {
...defaultTippyOptions, ...defaultTippyOptions,
appendTo: this.controls, appendTo: this.controls,
}, },
onSelectColor: (color) => setTableRowBackgroundColor(editor, color),
onClickItem: (item) => { onClickItem: (item) => {
item.action({ item.action({
editor: this.editor, editor: this.editor,
@ -383,8 +368,6 @@ export class TableView implements NodeView {
}); });
} }
// Table
this.colgroup = h( this.colgroup = h(
"colgroup", "colgroup",
null, null,
@ -437,16 +420,19 @@ export class TableView implements NodeView {
} }
updateControls() { updateControls() {
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce((acc, curr) => { const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce(
if (curr.spec.hoveredCell !== undefined) { (acc, curr) => {
acc["hoveredCell"] = curr.spec.hoveredCell; if (curr.spec.hoveredCell !== undefined) {
} acc["hoveredCell"] = curr.spec.hoveredCell;
}
if (curr.spec.hoveredTable !== undefined) { if (curr.spec.hoveredTable !== undefined) {
acc["hoveredTable"] = curr.spec.hoveredTable; acc["hoveredTable"] = curr.spec.hoveredTable;
} }
return acc; return acc;
}, {} as Record<string, HTMLElement>) as any; },
{} as Record<string, HTMLElement>
) as any;
if (table === undefined || cell === undefined) { if (table === undefined || cell === undefined) {
return this.root.classList.add("controls--disabled"); return this.root.classList.add("controls--disabled");
@ -457,12 +443,12 @@ export class TableView implements NodeView {
const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement; const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement;
if (!this.table) { if (!this.table || !cellDom) {
return; return;
} }
const tableRect = this.table.getBoundingClientRect(); const tableRect = this.table?.getBoundingClientRect();
const cellRect = cellDom.getBoundingClientRect(); const cellRect = cellDom?.getBoundingClientRect();
if (this.columnsControl) { if (this.columnsControl) {
this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`; this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`;

View File

@ -107,10 +107,9 @@ export const Table = Node.create({
addCommands() { addCommands() {
return { return {
insertTable: insertTable:
({ rows = 3, cols = 3, withHeaderRow = true } = {}) => ({ rows = 3, cols = 3, withHeaderRow = false } = {}) =>
({ tr, dispatch, editor }) => { ({ tr, dispatch, editor }) => {
const node = createTable(editor.schema, rows, cols, withHeaderRow); const node = createTable(editor.schema, rows, cols, withHeaderRow);
if (dispatch) { if (dispatch) {
const offset = tr.selection.anchor + 1; const offset = tr.selection.anchor + 1;

View File

@ -42,15 +42,6 @@ export function CoreEditorProps(
return false; return false;
}, },
handleDrop: (view, event, _slice, moved) => { handleDrop: (view, event, _slice, moved) => {
if (typeof window !== "undefined") {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return;
}
}
}
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
event.preventDefault(); event.preventDefault();
const file = event.dataTransfer.files[0]; const file = event.dataTransfer.files[0];

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/document-editor", "name": "@plane/document-editor",
"version": "0.15.1", "version": "0.16.0",
"description": "Package that powers Plane's Pages Editor", "description": "Package that powers Plane's Pages Editor",
"main": "./dist/index.mjs", "main": "./dist/index.mjs",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",

View File

@ -48,34 +48,12 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
function getComplexItems(): BubbleMenuItem[] { function getComplexItems(): BubbleMenuItem[] {
const items: BubbleMenuItem[] = [TableItem(editor)]; const items: BubbleMenuItem[] = [TableItem(editor)];
if (shouldShowImageItem()) { items.push(ImageItem(editor, uploadFile, setIsSubmitting));
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
}
return items; return items;
} }
const complexItems: BubbleMenuItem[] = getComplexItems(); const complexItems: BubbleMenuItem[] = getComplexItems();
function shouldShowImageItem(): boolean {
if (typeof window !== "undefined") {
const selectionRange: any = window?.getSelection();
const { selection } = props.editor.state;
if (selectionRange.rangeCount !== 0) {
const range = selectionRange.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return false;
}
if (isCellSelection(selection)) {
return false;
}
}
return true;
}
return false;
}
return ( return (
<div className="flex flex-wrap items-center divide-x divide-custom-border-200"> <div className="flex flex-wrap items-center divide-x divide-custom-border-200">
<div className="flex items-center gap-0.5 pr-2"> <div className="flex items-center gap-0.5 pr-2">

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/editor-extensions", "name": "@plane/editor-extensions",
"version": "0.15.1", "version": "0.16.0",
"description": "Package that powers Plane's Editor with extensions", "description": "Package that powers Plane's Editor with extensions",
"private": true, "private": true,
"main": "./dist/index.mjs", "main": "./dist/index.mjs",

View File

@ -35,7 +35,7 @@ export interface DragHandleOptions {
} }
function absoluteRect(node: Element) { function absoluteRect(node: Element) {
const data = node.getBoundingClientRect(); const data = node?.getBoundingClientRect();
return { return {
top: data.top, top: data.top,
@ -65,7 +65,7 @@ function nodeDOMAtCoords(coords: { x: number; y: number }) {
} }
function nodePosAtDOM(node: Element, view: EditorView) { function nodePosAtDOM(node: Element, view: EditorView) {
const boundingRect = node.getBoundingClientRect(); const boundingRect = node?.getBoundingClientRect();
if (node.nodeName === "IMG") { if (node.nodeName === "IMG") {
return view.posAtCoords({ return view.posAtCoords({

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/lite-text-editor", "name": "@plane/lite-text-editor",
"version": "0.15.1", "version": "0.16.0",
"description": "Package that powers Plane's Comment Editor", "description": "Package that powers Plane's Comment Editor",
"private": true, "private": true,
"main": "./dist/index.mjs", "main": "./dist/index.mjs",

View File

@ -60,34 +60,13 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
function getComplexItems(): BubbleMenuItem[] { function getComplexItems(): BubbleMenuItem[] {
const items: BubbleMenuItem[] = [TableItem(props.editor)]; const items: BubbleMenuItem[] = [TableItem(props.editor)];
if (shouldShowImageItem()) { items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting));
items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting));
}
return items; return items;
} }
const complexItems: BubbleMenuItem[] = getComplexItems(); const complexItems: BubbleMenuItem[] = getComplexItems();
function shouldShowImageItem(): boolean {
if (typeof window !== "undefined") {
const selectionRange: any = window?.getSelection();
const { selection } = props.editor.state;
if (selectionRange.rangeCount !== 0) {
const range = selectionRange.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return false;
}
if (isCellSelection(selection)) {
return false;
}
}
return true;
}
return false;
}
const handleAccessChange = (accessKey: string) => { const handleAccessChange = (accessKey: string) => {
props.commentAccessSpecifier?.onAccessChange(accessKey); props.commentAccessSpecifier?.onAccessChange(accessKey);
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/rich-text-editor", "name": "@plane/rich-text-editor",
"version": "0.15.1", "version": "0.16.0",
"description": "Rich Text Editor that powers Plane", "description": "Rich Text Editor that powers Plane",
"private": true, "private": true,
"main": "./dist/index.mjs", "main": "./dist/index.mjs",

View File

@ -1,7 +1,7 @@
{ {
"name": "eslint-config-custom", "name": "eslint-config-custom",
"private": true, "private": true,
"version": "0.15.1", "version": "0.16.0",
"main": "index.js", "main": "index.js",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -1,6 +1,6 @@
{ {
"name": "tailwind-config-custom", "name": "tailwind-config-custom",
"version": "0.15.1", "version": "0.16.0",
"description": "common tailwind configuration across monorepo", "description": "common tailwind configuration across monorepo",
"main": "index.js", "main": "index.js",
"private": true, "private": true,

View File

@ -1,6 +1,6 @@
{ {
"name": "tsconfig", "name": "tsconfig",
"version": "0.15.1", "version": "0.16.0",
"private": true, "private": true,
"files": [ "files": [
"base.json", "base.json",

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/types", "name": "@plane/types",
"version": "0.15.1", "version": "0.16.0",
"private": true, "private": true,
"main": "./src/index.d.ts" "main": "./src/index.d.ts"
} }

View File

@ -30,7 +30,7 @@ export interface ICycle {
is_favorite: boolean; is_favorite: boolean;
issue: string; issue: string;
name: string; name: string;
owned_by: string; owned_by_id: string;
progress_snapshot: TProgressSnapshot; progress_snapshot: TProgressSnapshot;
project_id: string; project_id: string;
status: TCycleGroups; status: TCycleGroups;

View File

@ -203,6 +203,8 @@ export interface ViewFlags {
export type GroupByColumnTypes = export type GroupByColumnTypes =
| "project" | "project"
| "cycle"
| "module"
| "state" | "state"
| "state_detail.group" | "state_detail.group"
| "priority" | "priority"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -177,17 +177,18 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
}; };
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => { const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
const { children, onClick, className = "" } = props; const { children, disabled = false, onClick, className } = props;
return ( return (
<Menu.Item as="div"> <Menu.Item as="div" disabled={disabled}>
{({ active, close }) => ( {({ active, close }) => (
<button <button
type="button" type="button"
className={cn( className={cn(
"w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200", "w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200",
{ {
"bg-custom-background-80": active, "bg-custom-background-80": active && !disabled,
"text-custom-text-400": disabled,
}, },
className className
)} )}
@ -195,6 +196,7 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
close(); close();
onClick && onClick(e); onClick && onClick(e);
}} }}
disabled={disabled}
> >
{children} {children}
</button> </button>

View File

@ -64,6 +64,7 @@ export type ICustomSearchSelectProps = IDropdownProps &
export interface ICustomMenuItemProps { export interface ICustomMenuItemProps {
children: React.ReactNode; children: React.ReactNode;
disabled?: boolean;
onClick?: (args?: any) => void; onClick?: (args?: any) => void;
className?: string; className?: string;
} }

View File

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

View File

@ -20,7 +20,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
const projectDetails = projectId ? getProjectById(projectId.toString()) : undefined; const projectDetails = projectId ? getProjectById(projectId.toString()) : undefined;
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined; const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined;
const moduleLeadDetails = moduleDetails && moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined; const moduleLeadDetails = moduleDetails && moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined;
return ( return (

View File

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

View File

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

View File

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

View File

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

View File

@ -154,237 +154,239 @@ export const CommandModal: React.FC = observer(() => {
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" /> <div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-20"> <div className="fixed inset-0 z-30 overflow-y-auto">
<Transition.Child <div className="flex items-center justify-center p-4 sm:p-6 md:p-20">
as={React.Fragment} <Transition.Child
enter="ease-out duration-300" as={React.Fragment}
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" enter="ease-out duration-300"
enterTo="opacity-100 translate-y-0 sm:scale-100" enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
leave="ease-in duration-200" enterTo="opacity-100 translate-y-0 sm:scale-100"
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leave="ease-in duration-200"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
> leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
<Dialog.Panel className="relative flex w-full items-center justify-center "> >
<div className="w-full max-w-2xl transform divide-y divide-custom-border-200 divide-opacity-10 rounded-lg bg-custom-background-100 shadow-custom-shadow-md transition-all"> <Dialog.Panel className="relative flex w-full max-w-2xl items-center justify-center transform divide-y divide-custom-border-200 divide-opacity-10 rounded-lg bg-custom-background-100 shadow-custom-shadow-md transition-all">
<Command <div className="w-full max-w-2xl">
filter={(value, search) => { <Command
if (value.toLowerCase().includes(search.toLowerCase())) return 1; filter={(value, search) => {
return 0; if (value.toLowerCase().includes(search.toLowerCase())) return 1;
}} return 0;
onKeyDown={(e) => { }}
// when search is empty and page is undefined onKeyDown={(e) => {
// when user tries to close the modal with esc // when search is empty and page is undefined
if (e.key === "Escape" && !page && !searchTerm) closePalette(); // when user tries to close the modal with esc
if (e.key === "Escape" && !page && !searchTerm) closePalette();
// Escape goes to previous page // Escape goes to previous page
// Backspace goes to previous page when search is empty // Backspace goes to previous page when search is empty
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) { if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
e.preventDefault(); e.preventDefault();
setPages((pages) => pages.slice(0, -1)); setPages((pages) => pages.slice(0, -1));
setPlaceholder("Type a command or search..."); setPlaceholder("Type a command or search...");
} }
}} }}
>
<div
className={`flex gap-4 p-3 pb-0 sm:items-center ${
issueDetails ? "flex-col justify-between sm:flex-row" : "justify-end"
}`}
> >
{issueDetails && ( <div
<div className="overflow-hidden truncate rounded-md bg-custom-background-80 p-2 text-xs font-medium text-custom-text-200"> className={`flex gap-4 p-3 pb-0 sm:items-center ${
{projectDetails?.identifier}-{issueDetails.sequence_id} {issueDetails.name} issueDetails ? "flex-col justify-between sm:flex-row" : "justify-end"
</div> }`}
)} >
{projectId && ( {issueDetails && (
<Tooltip tooltipContent="Toggle workspace level search"> <div className="overflow-hidden truncate rounded-md bg-custom-background-80 p-2 text-xs font-medium text-custom-text-200">
<div className="flex flex-shrink-0 cursor-pointer items-center gap-1 self-end text-xs sm:self-center"> {projectDetails?.identifier}-{issueDetails.sequence_id} {issueDetails.name}
<button
type="button"
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
className="flex-shrink-0"
>
Workspace Level
</button>
<ToggleSwitch
value={isWorkspaceLevel}
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
/>
</div> </div>
</Tooltip> )}
)} {projectId && (
</div> <Tooltip tooltipContent="Toggle workspace level search">
<div className="relative"> <div className="flex flex-shrink-0 cursor-pointer items-center gap-1 self-end text-xs sm:self-center">
<Search <button
className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-custom-text-200" type="button"
aria-hidden="true" onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
strokeWidth={2} className="flex-shrink-0"
/> >
<Command.Input Workspace Level
className="w-full border-0 border-b border-custom-border-200 bg-transparent p-4 pl-11 text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0" </button>
placeholder={placeholder} <ToggleSwitch
value={searchTerm} value={isWorkspaceLevel}
onValueChange={(e) => setSearchTerm(e)} onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
autoFocus />
tabIndex={1} </div>
/> </Tooltip>
</div> )}
</div>
<div className="relative">
<Search
className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-custom-text-200"
aria-hidden="true"
strokeWidth={2}
/>
<Command.Input
className="w-full border-0 border-b border-custom-border-200 bg-transparent p-4 pl-11 text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0"
placeholder={placeholder}
value={searchTerm}
onValueChange={(e) => setSearchTerm(e)}
autoFocus
tabIndex={1}
/>
</div>
<Command.List className="max-h-96 overflow-scroll p-2 vertical-scrollbar scrollbar-sm"> <Command.List className="max-h-96 overflow-scroll p-2 vertical-scrollbar scrollbar-sm">
{searchTerm !== "" && ( {searchTerm !== "" && (
<h5 className="mx-[3px] my-4 text-xs text-custom-text-100"> <h5 className="mx-[3px] my-4 text-xs text-custom-text-100">
Search results for{" "} Search results for{" "}
<span className="font-medium"> <span className="font-medium">
{'"'} {'"'}
{searchTerm} {searchTerm}
{'"'} {'"'}
</span>{" "} </span>{" "}
in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: in {!projectId || isWorkspaceLevel ? "workspace" : "project"}:
</h5> </h5>
)} )}
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( {!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
<div className="my-4 text-center text-sm text-custom-text-200">No results found.</div> <div className="my-4 text-center text-sm text-custom-text-200">No results found.</div>
)} )}
{(isLoading || isSearching) && ( {(isLoading || isSearching) && (
<Command.Loading> <Command.Loading>
<Loader className="space-y-3"> <Loader className="space-y-3">
<Loader.Item height="40px" /> <Loader.Item height="40px" />
<Loader.Item height="40px" /> <Loader.Item height="40px" />
<Loader.Item height="40px" /> <Loader.Item height="40px" />
<Loader.Item height="40px" /> <Loader.Item height="40px" />
</Loader> </Loader>
</Command.Loading> </Command.Loading>
)} )}
{debouncedSearchTerm !== "" && ( {debouncedSearchTerm !== "" && (
<CommandPaletteSearchResults closePalette={closePalette} results={results} /> <CommandPaletteSearchResults closePalette={closePalette} results={results} />
)} )}
{!page && ( {!page && (
<> <>
{/* issue actions */} {/* issue actions */}
{issueId && ( {issueId && (
<CommandPaletteIssueActions <CommandPaletteIssueActions
closePalette={closePalette} closePalette={closePalette}
issueDetails={issueDetails} issueDetails={issueDetails}
pages={pages} pages={pages}
setPages={(newPages) => setPages(newPages)} setPages={(newPages) => setPages(newPages)}
setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)} setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)}
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)} setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
/> />
)} )}
<Command.Group heading="Issue"> <Command.Group heading="Issue">
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("Command Palette");
toggleCreateIssueModal(true);
}}
className="focus:bg-custom-background-80"
>
<div className="flex items-center gap-2 text-custom-text-200">
<LayersIcon className="h-3.5 w-3.5" />
Create new issue
</div>
<kbd>C</kbd>
</Command.Item>
</Command.Group>
{workspaceSlug && (
<Command.Group heading="Project">
<Command.Item <Command.Item
onSelect={() => { onSelect={() => {
closePalette(); closePalette();
setTrackElement("Command palette"); setTrackElement("Command Palette");
toggleCreateProjectModal(true); toggleCreateIssueModal(true);
}}
className="focus:bg-custom-background-80"
>
<div className="flex items-center gap-2 text-custom-text-200">
<LayersIcon className="h-3.5 w-3.5" />
Create new issue
</div>
<kbd>C</kbd>
</Command.Item>
</Command.Group>
{workspaceSlug && (
<Command.Group heading="Project">
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("Command palette");
toggleCreateProjectModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FolderPlus className="h-3.5 w-3.5" />
Create new project
</div>
<kbd>P</kbd>
</Command.Item>
</Command.Group>
)}
{/* project actions */}
{projectId && <CommandPaletteProjectActions closePalette={closePalette} />}
<Command.Group heading="Workspace Settings">
<Command.Item
onSelect={() => {
setPlaceholder("Search workspace settings...");
setSearchTerm("");
setPages([...pages, "settings"]);
}} }}
className="focus:outline-none" className="focus:outline-none"
> >
<div className="flex items-center gap-2 text-custom-text-200"> <div className="flex items-center gap-2 text-custom-text-200">
<FolderPlus className="h-3.5 w-3.5" /> <Settings className="h-3.5 w-3.5" />
Create new project Search settings...
</div>
</Command.Item>
</Command.Group>
<Command.Group heading="Account">
<Command.Item onSelect={createNewWorkspace} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<FolderPlus className="h-3.5 w-3.5" />
Create new workspace
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change interface theme...");
setSearchTerm("");
setPages([...pages, "change-interface-theme"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Settings className="h-3.5 w-3.5" />
Change interface theme...
</div> </div>
<kbd>P</kbd>
</Command.Item> </Command.Item>
</Command.Group> </Command.Group>
)}
{/* project actions */} {/* help options */}
{projectId && <CommandPaletteProjectActions closePalette={closePalette} />} <CommandPaletteHelpActions closePalette={closePalette} />
</>
)}
<Command.Group heading="Workspace Settings"> {/* workspace settings actions */}
<Command.Item {page === "settings" && workspaceSlug && (
onSelect={() => { <CommandPaletteWorkspaceSettingsActions closePalette={closePalette} />
setPlaceholder("Search workspace settings..."); )}
setSearchTerm("");
setPages([...pages, "settings"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Settings className="h-3.5 w-3.5" />
Search settings...
</div>
</Command.Item>
</Command.Group>
<Command.Group heading="Account">
<Command.Item onSelect={createNewWorkspace} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<FolderPlus className="h-3.5 w-3.5" />
Create new workspace
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change interface theme...");
setSearchTerm("");
setPages([...pages, "change-interface-theme"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Settings className="h-3.5 w-3.5" />
Change interface theme...
</div>
</Command.Item>
</Command.Group>
{/* help options */} {/* issue details page actions */}
<CommandPaletteHelpActions closePalette={closePalette} /> {page === "change-issue-state" && issueDetails && (
</> <ChangeIssueState closePalette={closePalette} issue={issueDetails} />
)} )}
{page === "change-issue-priority" && issueDetails && (
<ChangeIssuePriority closePalette={closePalette} issue={issueDetails} />
)}
{page === "change-issue-assignee" && issueDetails && (
<ChangeIssueAssignee closePalette={closePalette} issue={issueDetails} />
)}
{/* workspace settings actions */} {/* theme actions */}
{page === "settings" && workspaceSlug && ( {page === "change-interface-theme" && (
<CommandPaletteWorkspaceSettingsActions closePalette={closePalette} /> <CommandPaletteThemeActions
)} closePalette={() => {
closePalette();
{/* issue details page actions */} setPages((pages) => pages.slice(0, -1));
{page === "change-issue-state" && issueDetails && ( }}
<ChangeIssueState closePalette={closePalette} issue={issueDetails} /> />
)} )}
{page === "change-issue-priority" && issueDetails && ( </Command.List>
<ChangeIssuePriority closePalette={closePalette} issue={issueDetails} /> </Command>
)} </div>
{page === "change-issue-assignee" && issueDetails && ( </Dialog.Panel>
<ChangeIssueAssignee closePalette={closePalette} issue={issueDetails} /> </Transition.Child>
)} </div>
{/* theme actions */}
{page === "change-interface-theme" && (
<CommandPaletteThemeActions
closePalette={() => {
closePalette();
setPages((pages) => pages.slice(0, -1));
}}
/>
)}
</Command.List>
</Command>
</div>
</Dialog.Panel>
</Transition.Child>
</div> </div>
</Dialog> </Dialog>
</Transition.Root> </Transition.Root>

View File

@ -1,13 +1,12 @@
import { Fragment } from "react"; import { Fragment } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import DatePicker from "react-datepicker"; import { DayPicker } from "react-day-picker";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { X } from "lucide-react";
// components // components
import { DateFilterSelect } from "./date-filter-select"; import { DateFilterSelect } from "./date-filter-select";
// ui // ui
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// icons
import { X } from "lucide-react";
// helpers // helpers
import { renderFormattedPayloadDate, renderFormattedDate } from "helpers/date-time.helper"; import { renderFormattedPayloadDate, renderFormattedDate } from "helpers/date-time.helper";
@ -46,9 +45,6 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
const isInvalid = watch("filterType") === "range" ? new Date(watch("date1")) > new Date(watch("date2")) : false; const isInvalid = watch("filterType") === "range" ? new Date(watch("date1")) > new Date(watch("date2")) : false;
const nextDay = new Date(watch("date1"));
nextDay.setDate(nextDay.getDate() + 1);
return ( return (
<Transition.Root show={isOpen} as={Fragment}> <Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}> <Dialog as="div" className="relative z-20" onClose={handleClose}>
@ -91,12 +87,15 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
control={control} control={control}
name="date1" name="date1"
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<DatePicker <DayPicker
selected={value} selected={value ? new Date(value) : undefined}
onChange={(val) => onChange(val)} defaultMonth={value ? new Date(value) : undefined}
dateFormat="dd-MM-yyyy" onSelect={(date) => onChange(date)}
calendarClassName="h-full" mode="single"
inline disabled={[
{ after: new Date(watch("date2")) }
]}
className="border border-custom-border-200 p-3 rounded-md"
/> />
)} )}
/> />
@ -105,13 +104,15 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
control={control} control={control}
name="date2" name="date2"
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<DatePicker <DayPicker
selected={value} selected={value ? new Date(value) : undefined}
onChange={onChange} defaultMonth={value ? new Date(value) : undefined}
dateFormat="dd-MM-yyyy" onSelect={(date) => onChange(date)}
calendarClassName="h-full" mode="single"
minDate={nextDay} disabled={[
inline { before: new Date(watch("date1")) }
]}
className="border border-custom-border-200 p-3 rounded-md"
/> />
)} )}
/> />

View File

@ -49,8 +49,10 @@ export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
// fetching project issues. // fetching project issues.
const { data: issues } = useSWR( const { data: issues } = useSWR(
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null, workspaceSlug && projectId && isOpen ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null workspaceSlug && projectId && isOpen
? () => issueService.getIssues(workspaceSlug as string, projectId as string)
: null
); );
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();

View File

@ -5,17 +5,17 @@ import { observer } from "mobx-react";
type Props = { type Props = {
onClick?: () => void; onClick?: () => void;
} };
export const SidebarHamburgerToggle: FC<Props> = observer((props) => { export const SidebarHamburgerToggle: FC<Props> = observer((props) => {
const { onClick } = props const { onClick } = props;
const { theme: themeStore } = useApplication(); const { theme: themeStore } = useApplication();
return ( return (
<div <div
className="w-7 h-7 flex-shrink-0 rounded flex justify-center items-center bg-custom-background-80 transition-all hover:bg-custom-background-90 cursor-pointer group md:hidden" className="w-7 h-7 flex-shrink-0 rounded flex justify-center items-center bg-custom-background-80 transition-all hover:bg-custom-background-90 cursor-pointer group md:hidden"
onClick={() => { onClick={() => {
if (onClick) onClick() if (onClick) onClick();
else themeStore.toggleMobileSidebar() else themeStore.toggleSidebar();
}} }}
> >
<Menu size={14} className="text-custom-text-200 group-hover:text-custom-text-100 transition-all" /> <Menu size={14} className="text-custom-text-200 group-hover:text-custom-text-100 transition-all" />

View File

@ -69,7 +69,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
); );
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null; const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by) : undefined; const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
const { data: activeCycleIssues } = useSWR( const { data: activeCycleIssues } = useSWR(
workspaceSlug && projectId && currentProjectActiveCycleId workspaceSlug && projectId && currentProjectActiveCycleId

View File

@ -152,6 +152,7 @@ export const CycleMobileHeader = () => {
handleDisplayFiltersUpdate={handleDisplayFilters} handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}} displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties} handleDisplayPropertiesUpdate={handleDisplayProperties}
ignoreGroupedFilters={["cycle"]}
/> />
</FiltersDropdown> </FiltersDropdown>
</div> </div>

View File

@ -1,7 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// components // components
import { DateDropdown, ProjectDropdown } from "components/dropdowns"; import { DateRangeDropdown, ProjectDropdown } from "components/dropdowns";
// ui // ui
import { Button, Input, TextArea } from "@plane/ui"; import { Button, Input, TextArea } from "@plane/ui";
// helpers // helpers
@ -32,7 +32,6 @@ export const CycleForm: React.FC<Props> = (props) => {
formState: { errors, isSubmitting, dirtyFields }, formState: { errors, isSubmitting, dirtyFields },
handleSubmit, handleSubmit,
control, control,
watch,
reset, reset,
} = useForm<ICycle>({ } = useForm<ICycle>({
defaultValues: { defaultValues: {
@ -51,15 +50,6 @@ export const CycleForm: React.FC<Props> = (props) => {
}); });
}, [data, reset]); }, [data, reset]);
const startDate = watch("start_date");
const endDate = watch("end_date");
const minDate = startDate ? new Date(startDate) : new Date();
minDate.setDate(minDate.getDate() + 1);
const maxDate = endDate ? new Date(endDate) : null;
maxDate?.setDate(maxDate.getDate() - 1);
return ( return (
<form onSubmit={handleSubmit((formData) => handleFormSubmit(formData, dirtyFields))}> <form onSubmit={handleSubmit((formData) => handleFormSubmit(formData, dirtyFields))}>
<div className="space-y-5"> <div className="space-y-5">
@ -132,39 +122,37 @@ export const CycleForm: React.FC<Props> = (props) => {
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div>
<Controller
control={control}
name="start_date"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<DateDropdown
value={value}
onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)}
buttonVariant="border-with-text"
placeholder="Start date"
minDate={new Date()}
maxDate={maxDate ?? undefined}
tabIndex={3}
/>
</div>
)}
/>
</div>
<Controller <Controller
control={control} control={control}
name="end_date" name="start_date"
render={({ field: { value, onChange } }) => ( render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<div className="h-7"> <Controller
<DateDropdown control={control}
value={value} name="end_date"
onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)} render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
buttonVariant="border-with-text" <DateRangeDropdown
placeholder="End date" buttonVariant="border-with-text"
minDate={minDate} className="h-7"
tabIndex={4} minDate={new Date()}
/> value={{
</div> from: startDateValue ? new Date(startDateValue) : undefined,
to: endDateValue ? new Date(endDateValue) : undefined,
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
hideIcon={{
to: true,
}}
tabIndex={3}
/>
)}
/>
)} )}
/> />
</div> </div>
@ -172,10 +160,10 @@ export const CycleForm: React.FC<Props> = (props) => {
</div> </div>
</div> </div>
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-100 pt-5 "> <div className="flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-100 pt-5 ">
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={5}> <Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={4}>
Cancel Cancel
</Button> </Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={6}> <Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={5}>
{data ? (isSubmitting ? "Updating" : "Update cycle") : isSubmitting ? "Creating" : "Create cycle"} {data ? (isSubmitting ? "Updating" : "Update cycle") : isSubmitting ? "Creating" : "Create cycle"}
</Button> </Button>
</div> </div>

View File

@ -1,8 +1,8 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { Disclosure, Popover, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
import isEmpty from "lodash/isEmpty"; import isEmpty from "lodash/isEmpty";
// services // services
import { CycleService } from "services/cycle.service"; import { CycleService } from "services/cycle.service";
@ -14,27 +14,12 @@ import { SidebarProgressStats } from "components/core";
import ProgressChart from "components/core/sidebar/progress-chart"; import ProgressChart from "components/core/sidebar/progress-chart";
import { CycleDeleteModal } from "components/cycles/delete-modal"; import { CycleDeleteModal } from "components/cycles/delete-modal";
// ui // ui
import { CustomRangeDatePicker } from "components/ui";
import { Avatar, CustomMenu, Loader, LayersIcon } from "@plane/ui"; import { Avatar, CustomMenu, Loader, LayersIcon } from "@plane/ui";
// icons // icons
import { import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, CalendarClock } from "lucide-react";
ChevronDown,
LinkIcon,
Trash2,
UserCircle2,
AlertCircle,
ChevronRight,
CalendarCheck2,
CalendarClock,
} from "lucide-react";
// helpers // helpers
import { copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
import { import { findHowManyDaysLeft, renderFormattedPayloadDate } from "helpers/date-time.helper";
findHowManyDaysLeft,
isDateGreaterThanToday,
renderFormattedPayloadDate,
renderFormattedDate,
} from "helpers/date-time.helper";
// types // types
import { ICycle } from "@plane/types"; import { ICycle } from "@plane/types";
// constants // constants
@ -42,6 +27,7 @@ import { EUserWorkspaceRoles } from "constants/workspace";
import { CYCLE_UPDATED } from "constants/event-tracker"; import { CYCLE_UPDATED } from "constants/event-tracker";
// fetch-keys // fetch-keys
import { CYCLE_STATUS } from "constants/cycle"; import { CYCLE_STATUS } from "constants/cycle";
import { DateRangeDropdown } from "components/dropdowns";
type Props = { type Props = {
cycleId: string; cycleId: string;
@ -61,9 +47,6 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { cycleId, handleClose } = props; const { cycleId, handleClose } = props;
// states // states
const [cycleDeleteModal, setCycleDeleteModal] = useState(false); const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
// refs
const startDateButtonRef = useRef<HTMLButtonElement | null>(null);
const endDateButtonRef = useRef<HTMLButtonElement | null>(null);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, peekCycle } = router.query; const { workspaceSlug, projectId, peekCycle } = router.query;
@ -74,13 +57,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
} = useUser(); } = useUser();
const { getCycleById, updateCycleDetails } = useCycle(); const { getCycleById, updateCycleDetails } = useCycle();
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
// derived values
const cycleDetails = getCycleById(cycleId); const cycleDetails = getCycleById(cycleId);
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined; const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined;
// toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// form info
const { setValue, reset, watch } = useForm({ const { control, reset } = useForm({
defaultValues, defaultValues,
}); });
@ -145,160 +128,38 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
} }
}; };
const handleStartDateChange = async (date: string) => { const handleDateChange = async (startDate: Date | undefined, endDate: Date | undefined) => {
setValue("start_date", date); if (!startDate || !endDate) return;
if (!watch("end_date") || watch("end_date") === "") endDateButtonRef.current?.click(); let isDateValid = false;
if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") { const payload = {
if (!isDateGreaterThanToday(`${watch("end_date")}`)) { start_date: renderFormattedPayloadDate(startDate),
setToastAlert({ end_date: renderFormattedPayloadDate(endDate),
type: "error", };
title: "Error!",
message: "Unable to create cycle in past date. Please enter a valid date.",
});
reset({ ...cycleDetails });
return;
}
if (cycleDetails?.start_date && cycleDetails?.end_date) { if (cycleDetails && cycleDetails.start_date && cycleDetails.end_date)
const isDateValidForExistingCycle = await dateChecker({ isDateValid = await dateChecker({
start_date: `${watch("start_date")}`, ...payload,
end_date: `${watch("end_date")}`, cycle_id: cycleDetails.id,
cycle_id: cycleDetails.id,
});
if (isDateValidForExistingCycle) {
submitChanges(
{
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"start_date"
);
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
}
reset({ ...cycleDetails });
return;
}
const isDateValid = await dateChecker({
start_date: `${watch("start_date")}`,
end_date: `${watch("end_date")}`,
}); });
else isDateValid = await dateChecker(payload);
if (isDateValid) { if (isDateValid) {
submitChanges( submitChanges(payload, "date_range");
{ setToastAlert({
start_date: renderFormattedPayloadDate(`${watch("start_date")}`), type: "success",
end_date: renderFormattedPayloadDate(`${watch("end_date")}`), title: "Success!",
}, message: "Cycle updated successfully.",
"start_date"
);
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
reset({ ...cycleDetails });
}
}
};
const handleEndDateChange = async (date: string) => {
setValue("end_date", date);
if (!watch("start_date") || watch("start_date") === "") startDateButtonRef.current?.click();
if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") {
if (!isDateGreaterThanToday(`${watch("end_date")}`)) {
setToastAlert({
type: "error",
title: "Error!",
message: "Unable to create cycle in past date. Please enter a valid date.",
});
reset({ ...cycleDetails });
return;
}
if (cycleDetails?.start_date && cycleDetails?.end_date) {
const isDateValidForExistingCycle = await dateChecker({
start_date: `${watch("start_date")}`,
end_date: `${watch("end_date")}`,
cycle_id: cycleDetails.id,
});
if (isDateValidForExistingCycle) {
submitChanges(
{
start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"end_date"
);
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
}
reset({ ...cycleDetails });
return;
}
const isDateValid = await dateChecker({
start_date: `${watch("start_date")}`,
end_date: `${watch("end_date")}`,
}); });
} else {
if (isDateValid) { setToastAlert({
submitChanges( type: "error",
{ title: "Error!",
start_date: renderFormattedPayloadDate(`${watch("start_date")}`), message:
end_date: renderFormattedPayloadDate(`${watch("end_date")}`), "You already have a cycle on the given dates, if you want to create a draft cycle, you can do that by removing both the dates.",
}, });
"end_date" reset({ ...cycleDetails });
);
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
reset({ ...cycleDetails });
}
} }
}; };
@ -351,9 +212,6 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</Loader> </Loader>
); );
const endDate = new Date(watch("end_date") ?? cycleDetails.end_date ?? "");
const startDate = new Date(watch("start_date") ?? cycleDetails.start_date ?? "");
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const issueCount = const issueCount =
@ -440,125 +298,52 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="flex flex-col gap-5 pb-6 pt-2.5"> <div className="flex flex-col gap-5 pb-6 pt-2.5">
<div className="flex items-center justify-start gap-1"> <div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300"> <div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<CalendarClock className="h-4 w-4" /> <CalendarClock className="h-4 w-4" />
<span className="text-base">Start date</span> <span className="text-base">Date range</span>
</div> </div>
<div className="relative flex w-1/2 items-center rounded-sm"> <div className="w-3/5 h-7">
<Popover className="flex h-full w-full items-center justify-center rounded-lg"> <Controller
{({ close }) => ( control={control}
<> name="start_date"
<Popover.Button render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
ref={startDateButtonRef} <Controller
className={`w-full cursor-pointer rounded-sm text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 ${ control={control}
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed" name="end_date"
}`} render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
disabled={isCompleted || !isEditingAllowed} <DateRangeDropdown
> className="h-7"
<span buttonContainerClassName="w-full"
className={`group flex w-full items-center justify-between gap-2 px-1.5 py-1 text-sm ${ buttonVariant="background-with-text"
watch("start_date") ? "" : "text-custom-text-400" minDate={new Date()}
}`} value={{
> from: startDateValue ? new Date(startDateValue) : undefined,
{renderFormattedDate(startDate) ?? "No date selected"} to: endDateValue ? new Date(endDateValue) : undefined,
</span> }}
</Popover.Button> onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
<Transition onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
as={React.Fragment} handleDateChange(val?.from, val?.to);
enter="transition ease-out duration-200" }}
enterFrom="opacity-0 translate-y-1" placeholder={{
enterTo="opacity-100 translate-y-0" from: "Start date",
leave="transition ease-in duration-150" to: "End date",
leaveFrom="opacity-100 translate-y-0" }}
leaveTo="opacity-0 translate-y-1" required={cycleDetails.status !== "draft"}
> />
<Popover.Panel className="absolute right-0 top-10 z-20 transform overflow-hidden"> )}
<CustomRangeDatePicker />
value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
if (val) {
setTrackElement("CYCLE_PAGE_SIDEBAR_START_DATE_BUTTON");
handleStartDateChange(val);
close();
}
}}
startDate={watch("start_date") ?? watch("end_date") ?? null}
endDate={watch("end_date") ?? watch("start_date") ?? null}
maxDate={new Date(`${watch("end_date")}`)}
selectsStart={watch("end_date") ? true : false}
/>
</Popover.Panel>
</Transition>
</>
)} )}
</Popover> />
</div> </div>
</div> </div>
<div className="flex items-center justify-start gap-1"> <div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300"> <div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<CalendarCheck2 className="h-4 w-4" />
<span className="text-base">Target date</span>
</div>
<div className="relative flex w-1/2 items-center rounded-sm">
<Popover className="flex h-full w-full items-center justify-center rounded-lg">
{({ close }) => (
<>
<Popover.Button
ref={endDateButtonRef}
className={`w-full cursor-pointer rounded-sm text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 ${
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
}`}
disabled={isCompleted || !isEditingAllowed}
>
<span
className={`group flex w-full items-center justify-between gap-2 px-1.5 py-1 text-sm ${
watch("end_date") ? "" : "text-custom-text-400"
}`}
>
{renderFormattedDate(endDate) ?? "No date selected"}
</span>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 top-10 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date}
onChange={(val) => {
if (val) {
setTrackElement("CYCLE_PAGE_SIDEBAR_END_DATE_BUTTON");
handleEndDateChange(val);
close();
}
}}
startDate={watch("start_date") ?? watch("end_date") ?? null}
endDate={watch("end_date") ?? watch("start_date") ?? null}
minDate={new Date(`${watch("start_date")}`)}
selectsEnd={watch("start_date") ? true : false}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
<UserCircle2 className="h-4 w-4" /> <UserCircle2 className="h-4 w-4" />
<span className="text-base">Lead</span> <span className="text-base">Lead</span>
</div> </div>
<div className="flex w-1/2 items-center rounded-sm"> <div className="flex w-3/5 items-center rounded-sm">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Avatar name={cycleOwnerDetails?.display_name} src={cycleOwnerDetails?.avatar} /> <Avatar name={cycleOwnerDetails?.display_name} src={cycleOwnerDetails?.avatar} />
<span className="text-sm text-custom-text-200">{cycleOwnerDetails?.display_name}</span> <span className="text-sm text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
@ -567,11 +352,11 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</div> </div>
<div className="flex items-center justify-start gap-1"> <div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300"> <div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<LayersIcon className="h-4 w-4" /> <LayersIcon className="h-4 w-4" />
<span className="text-base">Issues</span> <span className="text-base">Issues</span>
</div> </div>
<div className="flex w-1/2 items-center"> <div className="flex w-3/5 items-center">
<span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span> <span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span>
</div> </div>
</div> </div>

View File

@ -16,12 +16,13 @@ import { CYCLE_DETAILS } from "constants/fetch-keys";
type Props = { type Props = {
handleClick: () => void; handleClick: () => void;
disabled?: boolean;
}; };
const cycleService = new CycleService(); const cycleService = new CycleService();
export const TransferIssues: React.FC<Props> = (props) => { export const TransferIssues: React.FC<Props> = (props) => {
const { handleClick } = props; const { handleClick, disabled = false } = props;
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query; const { workspaceSlug, projectId, cycleId } = router.query;
@ -46,7 +47,12 @@ export const TransferIssues: React.FC<Props> = (props) => {
{isEmpty(cycleDetails?.progress_snapshot) && transferableIssuesCount > 0 && ( {isEmpty(cycleDetails?.progress_snapshot) && transferableIssuesCount > 0 && (
<div> <div>
<Button variant="primary" prependIcon={<TransferIcon color="white" />} onClick={handleClick}> <Button
variant="primary"
prependIcon={<TransferIcon color="white" />}
onClick={handleClick}
disabled={disabled}
>
Transfer Issues Transfer Issues
</Button> </Button>
</div> </div>

View File

@ -67,6 +67,7 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
const filterParams = getRedirectionFilters(selectedTab); const filterParams = getRedirectionFilters(selectedTab);
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />; if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
@ -84,30 +85,25 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
onChange={(val) => { onChange={(val) => {
if (val === selectedDurationFilter) return; if (val === selectedDurationFilter) return;
let newTab = selectedTab;
// switch to pending tab if target date is changed to none // switch to pending tab if target date is changed to none
if (val === "none" && selectedTab !== "completed") { if (val === "none" && selectedTab !== "completed") newTab = "pending";
handleUpdateFilters({ duration: val, tab: "pending" });
return;
}
// switch to upcoming tab if target date is changed to other than none // switch to upcoming tab if target date is changed to other than none
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") { if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming";
handleUpdateFilters({
duration: val,
tab: "upcoming",
});
return;
}
handleUpdateFilters({ duration: val }); handleUpdateFilters({
duration: val,
tab: newTab,
});
}} }}
/> />
</div> </div>
<Tab.Group <Tab.Group
as="div" as="div"
selectedIndex={tabsList.findIndex((tab) => tab.key === selectedTab)} selectedIndex={selectedTabIndex}
onChange={(i) => { onChange={(i) => {
const selectedTab = tabsList[i]; const newSelectedTab = tabsList[i];
handleUpdateFilters({ tab: selectedTab?.key ?? "pending" }); handleUpdateFilters({ tab: newSelectedTab?.key ?? "completed" });
}} }}
className="h-full flex flex-col" className="h-full flex flex-col"
> >
@ -115,18 +111,21 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} /> <TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
</div> </div>
<Tab.Panels as="div" className="h-full"> <Tab.Panels as="div" className="h-full">
{tabsList.map((tab) => ( {tabsList.map((tab) => {
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col"> if (tab.key !== selectedTab) return null;
<WidgetIssuesList
issues={widgetStats.issues} return (
tab={tab.key} <Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
totalIssues={widgetStats.count} <WidgetIssuesList
type="assigned" tab={tab.key}
workspaceSlug={workspaceSlug} type="assigned"
isLoading={fetching} workspaceSlug={workspaceSlug}
/> widgetStats={widgetStats}
</Tab.Panel> isLoading={fetching}
))} />
</Tab.Panel>
);
})}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
</div> </div>

View File

@ -64,6 +64,7 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
const filterParams = getRedirectionFilters(selectedTab); const filterParams = getRedirectionFilters(selectedTab);
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />; if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
@ -81,30 +82,25 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
onChange={(val) => { onChange={(val) => {
if (val === selectedDurationFilter) return; if (val === selectedDurationFilter) return;
let newTab = selectedTab;
// switch to pending tab if target date is changed to none // switch to pending tab if target date is changed to none
if (val === "none" && selectedTab !== "completed") { if (val === "none" && selectedTab !== "completed") newTab = "pending";
handleUpdateFilters({ duration: val, tab: "pending" });
return;
}
// switch to upcoming tab if target date is changed to other than none // switch to upcoming tab if target date is changed to other than none
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") { if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming";
handleUpdateFilters({
duration: val,
tab: "upcoming",
});
return;
}
handleUpdateFilters({ duration: val }); handleUpdateFilters({
duration: val,
tab: newTab,
});
}} }}
/> />
</div> </div>
<Tab.Group <Tab.Group
as="div" as="div"
selectedIndex={tabsList.findIndex((tab) => tab.key === selectedTab)} selectedIndex={selectedTabIndex}
onChange={(i) => { onChange={(i) => {
const selectedTab = tabsList[i]; const newSelectedTab = tabsList[i];
handleUpdateFilters({ tab: selectedTab.key ?? "pending" }); handleUpdateFilters({ tab: newSelectedTab.key ?? "completed" });
}} }}
className="h-full flex flex-col" className="h-full flex flex-col"
> >
@ -112,18 +108,21 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} /> <TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
</div> </div>
<Tab.Panels as="div" className="h-full"> <Tab.Panels as="div" className="h-full">
{tabsList.map((tab) => ( {tabsList.map((tab) => {
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col"> if (tab.key !== selectedTab) return null;
<WidgetIssuesList
issues={widgetStats.issues} return (
tab={tab.key} <Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
totalIssues={widgetStats.count} <WidgetIssuesList
type="created" tab={tab.key}
workspaceSlug={workspaceSlug} type="created"
isLoading={fetching} workspaceSlug={workspaceSlug}
/> widgetStats={widgetStats}
</Tab.Panel> isLoading={fetching}
))} />
</Tab.Panel>
);
})}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
</div> </div>

View File

@ -179,7 +179,7 @@ export const CreatedUpcomingIssueListItem: React.FC<IssueListItemProps> = observ
: "-"} : "-"}
</div> </div>
<div className="text-xs flex justify-center"> <div className="text-xs flex justify-center">
{issue.assignee_ids.length > 0 ? ( {issue.assignee_ids && issue.assignee_ids?.length > 0 ? (
<AvatarGroup> <AvatarGroup>
{issue.assignee_ids?.map((assigneeId) => { {issue.assignee_ids?.map((assigneeId) => {
const userDetails = getUserDetails(assigneeId); const userDetails = getUserDetails(assigneeId);

View File

@ -14,24 +14,23 @@ import {
IssueListItemProps, IssueListItemProps,
} from "components/dashboard/widgets"; } from "components/dashboard/widgets";
// ui // ui
import { getButtonStyling } from "@plane/ui"; import { Loader, getButtonStyling } from "@plane/ui";
// helpers // helpers
import { cn } from "helpers/common.helper"; import { cn } from "helpers/common.helper";
import { getRedirectionFilters } from "helpers/dashboard.helper"; import { getRedirectionFilters } from "helpers/dashboard.helper";
// types // types
import { TIssue, TIssuesListTypes } from "@plane/types"; import { TAssignedIssuesWidgetResponse, TCreatedIssuesWidgetResponse, TIssue, TIssuesListTypes } from "@plane/types";
export type WidgetIssuesListProps = { export type WidgetIssuesListProps = {
isLoading: boolean; isLoading: boolean;
issues: TIssue[];
tab: TIssuesListTypes; tab: TIssuesListTypes;
totalIssues: number;
type: "assigned" | "created"; type: "assigned" | "created";
widgetStats: TAssignedIssuesWidgetResponse | TCreatedIssuesWidgetResponse;
workspaceSlug: string; workspaceSlug: string;
}; };
export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => { export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
const { isLoading, issues, tab, totalIssues, type, workspaceSlug } = props; const { isLoading, tab, type, widgetStats, workspaceSlug } = props;
// store hooks // store hooks
const { setPeekIssue } = useIssueDetail(); const { setPeekIssue } = useIssueDetail();
@ -59,12 +58,19 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
}, },
}; };
const issuesList = widgetStats.issues;
return ( return (
<> <>
<div className="h-full"> <div className="h-full">
{isLoading ? ( {isLoading ? (
<></> <Loader className="space-y-4 mx-6 mt-7">
) : issues.length > 0 ? ( <Loader.Item height="25px" />
<Loader.Item height="25px" />
<Loader.Item height="25px" />
<Loader.Item height="25px" />
</Loader>
) : issuesList.length > 0 ? (
<> <>
<div className="mt-7 mx-6 border-b-[0.5px] border-custom-border-200 grid grid-cols-6 gap-1 text-xs text-custom-text-300 pb-1"> <div className="mt-7 mx-6 border-b-[0.5px] border-custom-border-200 grid grid-cols-6 gap-1 text-xs text-custom-text-300 pb-1">
<h6 <h6
@ -75,7 +81,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
> >
Issues Issues
<span className="flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-medium rounded-xl px-3 flex items-center text-center justify-center"> <span className="flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-medium rounded-xl px-3 flex items-center text-center justify-center">
{totalIssues} {widgetStats.count}
</span> </span>
</h6> </h6>
{["upcoming", "pending"].includes(tab) && <h6 className="text-center">Due date</h6>} {["upcoming", "pending"].includes(tab) && <h6 className="text-center">Due date</h6>}
@ -84,7 +90,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
{type === "created" && <h6 className="text-center">Assigned to</h6>} {type === "created" && <h6 className="text-center">Assigned to</h6>}
</div> </div>
<div className="px-4 pb-3 mt-2"> <div className="px-4 pb-3 mt-2">
{issues.map((issue) => { {issuesList.map((issue) => {
const IssueListItem = ISSUE_LIST_ITEM[type][tab]; const IssueListItem = ISSUE_LIST_ITEM[type][tab];
if (!IssueListItem) return null; if (!IssueListItem) return null;
@ -107,7 +113,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
</div> </div>
)} )}
</div> </div>
{issues.length > 0 && ( {!isLoading && issuesList.length > 0 && (
<Link <Link
href={`/${workspaceSlug}/workspace-views/${type}/${filterParams}`} href={`/${workspaceSlug}/workspace-views/${type}/${filterParams}`}
className={cn( className={cn(

View File

@ -37,7 +37,7 @@ export const OverviewStatsWidget: React.FC<WidgetProps> = observer((props) => {
key: "overdue", key: "overdue",
title: "Issues overdue", title: "Issues overdue",
count: widgetStats?.pending_issues_count, count: widgetStats?.pending_issues_count,
link: `/${workspaceSlug}/workspace-views/assigned/?target_date=${today};before`, link: `/${workspaceSlug}/workspace-views/assigned/?state_group=backlog,unstarted,started&target_date=${today};before`,
}, },
{ {
key: "created", key: "created",

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