mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge pull request #643 from makeplane/develop
promote: develop to stage release
This commit is contained in:
commit
ed60707bae
56
.github/ISSUE_TEMPLATE/--bug-report.yaml
vendored
Normal file
56
.github/ISSUE_TEMPLATE/--bug-report.yaml
vendored
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Create a bug report to help us improve Plane
|
||||||
|
title: "[bug]: "
|
||||||
|
labels: [bug, need testing]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thank you for taking the time to fill out this bug report.
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Is there an existing issue for this?
|
||||||
|
description: Please search to see if an issue already exists for the bug you encountered
|
||||||
|
options:
|
||||||
|
- label: I have searched the existing issues
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Current behavior
|
||||||
|
description: A concise description of what you're experiencing and what you expect
|
||||||
|
placeholder: |
|
||||||
|
When I do <X>, <Y> happens and I see the error message attached below:
|
||||||
|
```...```
|
||||||
|
What I expect is <Z>
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: Add steps to reproduce this behaviour, include console or network logs and screenshots
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: env
|
||||||
|
attributes:
|
||||||
|
label: Environment
|
||||||
|
options:
|
||||||
|
- Production
|
||||||
|
- Deploy preview
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
options:
|
||||||
|
- Cloud
|
||||||
|
- Self-hosted
|
||||||
|
- Local
|
||||||
|
validations:
|
||||||
|
required: true
|
28
.github/ISSUE_TEMPLATE/--feature-request.yaml
vendored
Normal file
28
.github/ISSUE_TEMPLATE/--feature-request.yaml
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest a feature to improve Plane
|
||||||
|
title: "[feature]: "
|
||||||
|
labels: [feature]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thank you for taking the time to request a feature for Plane
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Is there an existing issue for this?
|
||||||
|
description: Please search to see if an issue related to this feature request already exists
|
||||||
|
options:
|
||||||
|
- label: I have searched the existing issues
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Summary
|
||||||
|
description: One paragraph description of the feature
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Why should this be worked on?
|
||||||
|
description: A concise description of the problems or use cases for this feature request
|
||||||
|
validations:
|
||||||
|
required: true
|
6
.github/ISSUE_TEMPLATE/config.yaml
vendored
Normal file
6
.github/ISSUE_TEMPLATE/config.yaml
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
contact_links:
|
||||||
|
- name: Help and support
|
||||||
|
about: Reach out to us on our Discord server or GitHub discussions.
|
||||||
|
- name: Dedicated support
|
||||||
|
url: mailto:support@plane.so
|
||||||
|
about: Write to us if you'd like dedicated support using Plane
|
54
.github/workflows/push-image-backend.yml
vendored
Normal file
54
.github/workflows/push-image-backend.yml
vendored
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
name: Build Api Server Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'develop'
|
||||||
|
- 'master'
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_push_backend:
|
||||||
|
name: Build Api Server Docker Image
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v3.3.0
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
|
with:
|
||||||
|
platforms: linux/arm64,linux/amd64
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2.5.0
|
||||||
|
|
||||||
|
- name: Login to Github Container Registry
|
||||||
|
uses: docker/login-action@v2.1.0
|
||||||
|
with:
|
||||||
|
registry: "ghcr.io"
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4.3.0
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}-backend
|
||||||
|
|
||||||
|
- name: Build Api Server
|
||||||
|
uses: docker/build-push-action@v4.0.0
|
||||||
|
with:
|
||||||
|
context: ./apiserver
|
||||||
|
file: ./apiserver/Dockerfile.api
|
||||||
|
platforms: linux/arm64,linux/amd64
|
||||||
|
push: true
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
52
.github/workflows/push-image-frontend.yml
vendored
Normal file
52
.github/workflows/push-image-frontend.yml
vendored
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
name: Build Frontend Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'develop'
|
||||||
|
- 'master'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_push_frontend:
|
||||||
|
name: Build Frontend Docker Image
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v3.3.0
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
|
with:
|
||||||
|
platforms: linux/arm64,linux/amd64
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2.5.0
|
||||||
|
|
||||||
|
- name: Login to Github Container Registry
|
||||||
|
uses: docker/login-action@v2.1.0
|
||||||
|
with:
|
||||||
|
registry: "ghcr.io"
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4.3.0
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}-frontend
|
||||||
|
|
||||||
|
- name: Build Frontend Server
|
||||||
|
uses: docker/build-push-action@v4.0.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./apps/app/Dockerfile.web
|
||||||
|
platforms: linux/arm64,linux/amd64
|
||||||
|
push: true
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
@ -1,22 +1,21 @@
|
|||||||
SECRET_KEY="<-- django secret -->"
|
|
||||||
DJANGO_SETTINGS_MODULE="plane.settings.production"
|
DJANGO_SETTINGS_MODULE="plane.settings.production"
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL=postgres://plane:plane@db:5432/plane
|
DATABASE_URL=postgres://plane:xyzzyspoon@db:5432/plane
|
||||||
# Cache
|
# Cache
|
||||||
REDIS_URL=redis://redis:6379/
|
REDIS_URL=redis://redis:6379/
|
||||||
# SMPT
|
# SMPT
|
||||||
EMAIL_HOST="<-- email smtp -->"
|
EMAIL_HOST=""
|
||||||
EMAIL_HOST_USER="<-- email host user -->"
|
EMAIL_HOST_USER=""
|
||||||
EMAIL_HOST_PASSWORD="<-- email host password -->"
|
EMAIL_HOST_PASSWORD=""
|
||||||
# AWS
|
# AWS
|
||||||
AWS_REGION="<-- aws region -->"
|
AWS_REGION=""
|
||||||
AWS_ACCESS_KEY_ID="<-- aws access key -->"
|
AWS_ACCESS_KEY_ID=""
|
||||||
AWS_SECRET_ACCESS_KEY="<-- aws secret acess key -->"
|
AWS_SECRET_ACCESS_KEY=""
|
||||||
AWS_S3_BUCKET_NAME="<-- aws s3 bucket name -->"
|
AWS_S3_BUCKET_NAME=""
|
||||||
# FE
|
# FE
|
||||||
WEB_URL="localhost/"
|
WEB_URL="localhost/"
|
||||||
# OAUTH
|
# OAUTH
|
||||||
GITHUB_CLIENT_SECRET="<-- github secret -->"
|
GITHUB_CLIENT_SECRET=""
|
||||||
# Flags
|
# Flags
|
||||||
DISABLE_COLLECTSTATIC=1
|
DISABLE_COLLECTSTATIC=1
|
||||||
DOCKERIZED=1
|
DOCKERIZED=1
|
||||||
|
@ -10,6 +10,7 @@ from .workspace import (
|
|||||||
WorkSpaceMemberSerializer,
|
WorkSpaceMemberSerializer,
|
||||||
TeamSerializer,
|
TeamSerializer,
|
||||||
WorkSpaceMemberInviteSerializer,
|
WorkSpaceMemberInviteSerializer,
|
||||||
|
WorkspaceLiteSerializer,
|
||||||
)
|
)
|
||||||
from .project import (
|
from .project import (
|
||||||
ProjectSerializer,
|
ProjectSerializer,
|
||||||
@ -18,10 +19,11 @@ from .project import (
|
|||||||
ProjectMemberInviteSerializer,
|
ProjectMemberInviteSerializer,
|
||||||
ProjectIdentifierSerializer,
|
ProjectIdentifierSerializer,
|
||||||
ProjectFavoriteSerializer,
|
ProjectFavoriteSerializer,
|
||||||
|
ProjectLiteSerializer,
|
||||||
)
|
)
|
||||||
from .state import StateSerializer
|
from .state import StateSerializer, StateLiteSerializer
|
||||||
from .shortcut import ShortCutSerializer
|
from .shortcut import ShortCutSerializer
|
||||||
from .view import ViewSerializer
|
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
|
||||||
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer
|
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer
|
||||||
from .asset import FileAssetSerializer
|
from .asset import FileAssetSerializer
|
||||||
from .issue import (
|
from .issue import (
|
||||||
@ -38,6 +40,7 @@ from .issue import (
|
|||||||
IssueFlatSerializer,
|
IssueFlatSerializer,
|
||||||
IssueStateSerializer,
|
IssueStateSerializer,
|
||||||
IssueLinkSerializer,
|
IssueLinkSerializer,
|
||||||
|
IssueLiteSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .module import (
|
from .module import (
|
||||||
@ -58,3 +61,7 @@ from .integration import (
|
|||||||
GithubRepositorySyncSerializer,
|
GithubRepositorySyncSerializer,
|
||||||
GithubCommentSyncSerializer,
|
GithubCommentSyncSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .importer import ImporterSerializer
|
||||||
|
|
||||||
|
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
|
||||||
|
@ -5,12 +5,23 @@ from rest_framework import serializers
|
|||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
from .user import UserLiteSerializer
|
from .user import UserLiteSerializer
|
||||||
from .issue import IssueStateSerializer
|
from .issue import IssueStateSerializer
|
||||||
|
from .workspace import WorkspaceLiteSerializer
|
||||||
|
from .project import ProjectLiteSerializer
|
||||||
from plane.db.models import Cycle, CycleIssue, CycleFavorite
|
from plane.db.models import Cycle, CycleIssue, CycleFavorite
|
||||||
|
|
||||||
|
|
||||||
class CycleSerializer(BaseSerializer):
|
class CycleSerializer(BaseSerializer):
|
||||||
owned_by = UserLiteSerializer(read_only=True)
|
owned_by = UserLiteSerializer(read_only=True)
|
||||||
is_favorite = serializers.BooleanField(read_only=True)
|
is_favorite = serializers.BooleanField(read_only=True)
|
||||||
|
total_issues = serializers.IntegerField(read_only=True)
|
||||||
|
cancelled_issues = serializers.IntegerField(read_only=True)
|
||||||
|
completed_issues = serializers.IntegerField(read_only=True)
|
||||||
|
started_issues = serializers.IntegerField(read_only=True)
|
||||||
|
unstarted_issues = serializers.IntegerField(read_only=True)
|
||||||
|
backlog_issues = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
|
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||||
|
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Cycle
|
model = Cycle
|
||||||
|
12
apiserver/plane/api/serializers/importer.py
Normal file
12
apiserver/plane/api/serializers/importer.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Module imports
|
||||||
|
from .base import BaseSerializer
|
||||||
|
from .user import UserLiteSerializer
|
||||||
|
from plane.db.models import Importer
|
||||||
|
|
||||||
|
|
||||||
|
class ImporterSerializer(BaseSerializer):
|
||||||
|
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Importer
|
||||||
|
fields = "__all__"
|
@ -4,10 +4,10 @@ from rest_framework import serializers
|
|||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
from .user import UserLiteSerializer
|
from .user import UserLiteSerializer
|
||||||
from .state import StateSerializer
|
from .state import StateSerializer, StateLiteSerializer
|
||||||
from .user import UserLiteSerializer
|
from .user import UserLiteSerializer
|
||||||
from .project import ProjectSerializer
|
from .project import ProjectSerializer, ProjectLiteSerializer
|
||||||
from .workspace import WorkSpaceSerializer
|
from .workspace import WorkspaceLiteSerializer
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
User,
|
User,
|
||||||
Issue,
|
Issue,
|
||||||
@ -50,8 +50,8 @@ class IssueFlatSerializer(BaseSerializer):
|
|||||||
class IssueCreateSerializer(BaseSerializer):
|
class IssueCreateSerializer(BaseSerializer):
|
||||||
state_detail = StateSerializer(read_only=True, source="state")
|
state_detail = StateSerializer(read_only=True, source="state")
|
||||||
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
|
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
|
||||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||||
workspace_detail = WorkSpaceSerializer(read_only=True, source="workspace")
|
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||||
|
|
||||||
assignees_list = serializers.ListField(
|
assignees_list = serializers.ListField(
|
||||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
|
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
|
||||||
@ -244,6 +244,7 @@ class IssueCreateSerializer(BaseSerializer):
|
|||||||
|
|
||||||
class IssueActivitySerializer(BaseSerializer):
|
class IssueActivitySerializer(BaseSerializer):
|
||||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||||
|
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IssueActivity
|
model = IssueActivity
|
||||||
@ -305,6 +306,16 @@ class LabelSerializer(BaseSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class LabelLiteSerializer(BaseSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Label
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"color",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class IssueLabelSerializer(BaseSerializer):
|
class IssueLabelSerializer(BaseSerializer):
|
||||||
# label_details = LabelSerializer(read_only=True, source="label")
|
# label_details = LabelSerializer(read_only=True, source="label")
|
||||||
|
|
||||||
@ -434,6 +445,8 @@ class IssueStateSerializer(BaseSerializer):
|
|||||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||||
label_details = LabelSerializer(read_only=True, source="labels", many=True)
|
label_details = LabelSerializer(read_only=True, source="labels", many=True)
|
||||||
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
|
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
|
||||||
|
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||||
|
bridge_id = serializers.UUIDField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Issue
|
model = Issue
|
||||||
@ -466,3 +479,29 @@ class IssueSerializer(BaseSerializer):
|
|||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class IssueLiteSerializer(BaseSerializer):
|
||||||
|
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||||
|
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||||
|
state_detail = StateLiteSerializer(read_only=True, source="state")
|
||||||
|
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
|
||||||
|
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
|
||||||
|
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||||
|
cycle_id = serializers.UUIDField(read_only=True)
|
||||||
|
module_id = serializers.UUIDField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Issue
|
||||||
|
fields = "__all__"
|
||||||
|
read_only_fields = [
|
||||||
|
"start_date",
|
||||||
|
"target_date",
|
||||||
|
"completed_at",
|
||||||
|
"workspace",
|
||||||
|
"project",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
@ -4,10 +4,18 @@ from rest_framework import serializers
|
|||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
from .user import UserLiteSerializer
|
from .user import UserLiteSerializer
|
||||||
from .project import ProjectSerializer
|
from .project import ProjectSerializer, ProjectLiteSerializer
|
||||||
|
from .workspace import WorkspaceLiteSerializer
|
||||||
from .issue import IssueStateSerializer
|
from .issue import IssueStateSerializer
|
||||||
|
|
||||||
from plane.db.models import User, Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite
|
from plane.db.models import (
|
||||||
|
User,
|
||||||
|
Module,
|
||||||
|
ModuleMember,
|
||||||
|
ModuleIssue,
|
||||||
|
ModuleLink,
|
||||||
|
ModuleFavorite,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ModuleWriteSerializer(BaseSerializer):
|
class ModuleWriteSerializer(BaseSerializer):
|
||||||
@ -17,6 +25,9 @@ class ModuleWriteSerializer(BaseSerializer):
|
|||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||||
|
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Module
|
model = Module
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
@ -133,9 +144,14 @@ class ModuleSerializer(BaseSerializer):
|
|||||||
project_detail = ProjectSerializer(read_only=True, source="project")
|
project_detail = ProjectSerializer(read_only=True, source="project")
|
||||||
lead_detail = UserLiteSerializer(read_only=True, source="lead")
|
lead_detail = UserLiteSerializer(read_only=True, source="lead")
|
||||||
members_detail = UserLiteSerializer(read_only=True, many=True, source="members")
|
members_detail = UserLiteSerializer(read_only=True, many=True, source="members")
|
||||||
issue_module = ModuleIssueSerializer(read_only=True, many=True)
|
|
||||||
link_module = ModuleLinkSerializer(read_only=True, many=True)
|
link_module = ModuleLinkSerializer(read_only=True, many=True)
|
||||||
is_favorite = serializers.BooleanField(read_only=True)
|
is_favorite = serializers.BooleanField(read_only=True)
|
||||||
|
total_issues = serializers.IntegerField(read_only=True)
|
||||||
|
cancelled_issues = serializers.IntegerField(read_only=True)
|
||||||
|
completed_issues = serializers.IntegerField(read_only=True)
|
||||||
|
started_issues = serializers.IntegerField(read_only=True)
|
||||||
|
unstarted_issues = serializers.IntegerField(read_only=True)
|
||||||
|
backlog_issues = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Module
|
model = Module
|
||||||
@ -149,6 +165,7 @@ class ModuleSerializer(BaseSerializer):
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class ModuleFavoriteSerializer(BaseSerializer):
|
class ModuleFavoriteSerializer(BaseSerializer):
|
||||||
module_detail = ModuleFlatSerializer(source="module", read_only=True)
|
module_detail = ModuleFlatSerializer(source="module", read_only=True)
|
||||||
|
|
||||||
|
105
apiserver/plane/api/serializers/page.py
Normal file
105
apiserver/plane/api/serializers/page.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# Third party imports
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from .base import BaseSerializer
|
||||||
|
from .issue import IssueFlatSerializer, LabelSerializer
|
||||||
|
from .workspace import WorkspaceLiteSerializer
|
||||||
|
from .project import ProjectLiteSerializer
|
||||||
|
from plane.db.models import Page, PageBlock, PageFavorite, PageLabel, Label
|
||||||
|
|
||||||
|
|
||||||
|
class PageBlockSerializer(BaseSerializer):
|
||||||
|
issue_detail = IssueFlatSerializer(source="issue", read_only=True)
|
||||||
|
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||||
|
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PageBlock
|
||||||
|
fields = "__all__"
|
||||||
|
read_only_fields = [
|
||||||
|
"workspace",
|
||||||
|
"project",
|
||||||
|
"page",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PageSerializer(BaseSerializer):
|
||||||
|
is_favorite = serializers.BooleanField(read_only=True)
|
||||||
|
label_details = LabelSerializer(read_only=True, source="labels", many=True)
|
||||||
|
labels_list = serializers.ListField(
|
||||||
|
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
|
||||||
|
write_only=True,
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
blocks = PageBlockSerializer(read_only=True, many=True)
|
||||||
|
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||||
|
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Page
|
||||||
|
fields = "__all__"
|
||||||
|
read_only_fields = [
|
||||||
|
"workspace",
|
||||||
|
"project",
|
||||||
|
"owned_by",
|
||||||
|
]
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
labels = validated_data.pop("labels_list", None)
|
||||||
|
project_id = self.context["project_id"]
|
||||||
|
owned_by_id = self.context["owned_by_id"]
|
||||||
|
page = Page.objects.create(
|
||||||
|
**validated_data, project_id=project_id, owned_by_id=owned_by_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if labels is not None:
|
||||||
|
PageLabel.objects.bulk_create(
|
||||||
|
[
|
||||||
|
PageLabel(
|
||||||
|
label=label,
|
||||||
|
page=page,
|
||||||
|
project_id=project_id,
|
||||||
|
workspace_id=page.workspace_id,
|
||||||
|
created_by_id=page.created_by_id,
|
||||||
|
updated_by_id=page.updated_by_id,
|
||||||
|
)
|
||||||
|
for label in labels
|
||||||
|
],
|
||||||
|
batch_size=10,
|
||||||
|
)
|
||||||
|
return page
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
labels = validated_data.pop("labels_list", None)
|
||||||
|
if labels is not None:
|
||||||
|
PageLabel.objects.filter(page=instance).delete()
|
||||||
|
PageLabel.objects.bulk_create(
|
||||||
|
[
|
||||||
|
PageLabel(
|
||||||
|
label=label,
|
||||||
|
page=instance,
|
||||||
|
project_id=instance.project_id,
|
||||||
|
workspace_id=instance.workspace_id,
|
||||||
|
created_by_id=instance.created_by_id,
|
||||||
|
updated_by_id=instance.updated_by_id,
|
||||||
|
)
|
||||||
|
for label in labels
|
||||||
|
],
|
||||||
|
batch_size=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class PageFavoriteSerializer(BaseSerializer):
|
||||||
|
page_detail = PageSerializer(source="page", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PageFavorite
|
||||||
|
fields = "__all__"
|
||||||
|
read_only_fields = [
|
||||||
|
"workspace",
|
||||||
|
"project",
|
||||||
|
"user",
|
||||||
|
]
|
@ -6,7 +6,7 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
from plane.api.serializers.workspace import WorkSpaceSerializer
|
from plane.api.serializers.workspace import WorkSpaceSerializer, WorkspaceLiteSerializer
|
||||||
from plane.api.serializers.user import UserLiteSerializer
|
from plane.api.serializers.user import UserLiteSerializer
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
Project,
|
Project,
|
||||||
@ -18,6 +18,8 @@ from plane.db.models import (
|
|||||||
|
|
||||||
|
|
||||||
class ProjectSerializer(BaseSerializer):
|
class ProjectSerializer(BaseSerializer):
|
||||||
|
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Project
|
model = Project
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
@ -56,12 +58,15 @@ class ProjectSerializer(BaseSerializer):
|
|||||||
project_identifier = ProjectIdentifier.objects.filter(
|
project_identifier = ProjectIdentifier.objects.filter(
|
||||||
name=identifier, workspace_id=instance.workspace_id
|
name=identifier, workspace_id=instance.workspace_id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if project_identifier is None:
|
if project_identifier is None:
|
||||||
project = super().update(instance, validated_data)
|
project = super().update(instance, validated_data)
|
||||||
_ = ProjectIdentifier.objects.update(name=identifier, project=project)
|
project_identifier = ProjectIdentifier.objects.filter(
|
||||||
|
project=project
|
||||||
|
).first()
|
||||||
|
if project_identifier is not None:
|
||||||
|
project_identifier.name = identifier
|
||||||
|
project_identifier.save()
|
||||||
return project
|
return project
|
||||||
|
|
||||||
# If found check if the project_id to be updated and identifier project id is same
|
# If found check if the project_id to be updated and identifier project id is same
|
||||||
if project_identifier.project_id == instance.id:
|
if project_identifier.project_id == instance.id:
|
||||||
# If same pass update
|
# If same pass update
|
||||||
@ -118,3 +123,10 @@ class ProjectFavoriteSerializer(BaseSerializer):
|
|||||||
"workspace",
|
"workspace",
|
||||||
"user",
|
"user",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectLiteSerializer(BaseSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Project
|
||||||
|
fields = ["id", "identifier", "name"]
|
||||||
|
read_only_fields = fields
|
||||||
|
@ -1,10 +1,15 @@
|
|||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
|
from .workspace import WorkspaceLiteSerializer
|
||||||
|
from .project import ProjectLiteSerializer
|
||||||
|
|
||||||
from plane.db.models import State
|
from plane.db.models import State
|
||||||
|
|
||||||
|
|
||||||
class StateSerializer(BaseSerializer):
|
class StateSerializer(BaseSerializer):
|
||||||
|
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||||
|
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = State
|
model = State
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
@ -12,3 +17,15 @@ class StateSerializer(BaseSerializer):
|
|||||||
"workspace",
|
"workspace",
|
||||||
"project",
|
"project",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class StateLiteSerializer(BaseSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = State
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"color",
|
||||||
|
"group",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
@ -1,14 +1,58 @@
|
|||||||
|
# Third party imports
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
|
from .workspace import WorkspaceLiteSerializer
|
||||||
from plane.db.models import View
|
from .project import ProjectLiteSerializer
|
||||||
|
from plane.db.models import IssueView, IssueViewFavorite
|
||||||
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
|
||||||
|
|
||||||
class ViewSerializer(BaseSerializer):
|
class IssueViewSerializer(BaseSerializer):
|
||||||
|
is_favorite = serializers.BooleanField(read_only=True)
|
||||||
|
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||||
|
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = View
|
model = IssueView
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
"workspace",
|
"workspace",
|
||||||
"project",
|
"project",
|
||||||
|
"query",
|
||||||
|
]
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
query_params = validated_data.get("query_data", {})
|
||||||
|
|
||||||
|
if not bool(query_params):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"query_data": ["Query data field cannot be empty"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
validated_data["query"] = issue_filters(query_params, "POST")
|
||||||
|
return IssueView.objects.create(**validated_data)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
query_params = validated_data.get("query_data", {})
|
||||||
|
if not bool(query_params):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"query_data": ["Query data field cannot be empty"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
validated_data["query"] = issue_filters(query_params, "PATCH")
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class IssueViewFavoriteSerializer(BaseSerializer):
|
||||||
|
view_detail = IssueViewSerializer(source="issue_view", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = IssueViewFavorite
|
||||||
|
fields = "__all__"
|
||||||
|
read_only_fields = [
|
||||||
|
"workspace",
|
||||||
|
"project",
|
||||||
|
"user",
|
||||||
]
|
]
|
||||||
|
@ -10,7 +10,6 @@ from plane.db.models import Workspace, WorkspaceMember, Team, WorkspaceMemberInv
|
|||||||
|
|
||||||
|
|
||||||
class WorkSpaceSerializer(BaseSerializer):
|
class WorkSpaceSerializer(BaseSerializer):
|
||||||
|
|
||||||
owner = UserLiteSerializer(read_only=True)
|
owner = UserLiteSerializer(read_only=True)
|
||||||
total_members = serializers.IntegerField(read_only=True)
|
total_members = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
@ -28,7 +27,6 @@ class WorkSpaceSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class WorkSpaceMemberSerializer(BaseSerializer):
|
class WorkSpaceMemberSerializer(BaseSerializer):
|
||||||
|
|
||||||
member = UserLiteSerializer(read_only=True)
|
member = UserLiteSerializer(read_only=True)
|
||||||
workspace = WorkSpaceSerializer(read_only=True)
|
workspace = WorkSpaceSerializer(read_only=True)
|
||||||
|
|
||||||
@ -38,7 +36,6 @@ class WorkSpaceMemberSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
||||||
|
|
||||||
workspace = WorkSpaceSerializer(read_only=True)
|
workspace = WorkSpaceSerializer(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -47,7 +44,6 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class TeamSerializer(BaseSerializer):
|
class TeamSerializer(BaseSerializer):
|
||||||
|
|
||||||
members_detail = UserLiteSerializer(read_only=True, source="members", many=True)
|
members_detail = UserLiteSerializer(read_only=True, source="members", many=True)
|
||||||
members = serializers.ListField(
|
members = serializers.ListField(
|
||||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
|
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
|
||||||
@ -93,3 +89,14 @@ class TeamSerializer(BaseSerializer):
|
|||||||
return super().update(instance, validated_data)
|
return super().update(instance, validated_data)
|
||||||
else:
|
else:
|
||||||
return super().update(instance, validated_data)
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceLiteSerializer(BaseSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Workspace
|
||||||
|
fields = [
|
||||||
|
"name",
|
||||||
|
"slug",
|
||||||
|
"id",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
@ -21,6 +21,7 @@ from plane.api.views import (
|
|||||||
# User
|
# User
|
||||||
UserEndpoint,
|
UserEndpoint,
|
||||||
UpdateUserOnBoardedEndpoint,
|
UpdateUserOnBoardedEndpoint,
|
||||||
|
UserActivityEndpoint,
|
||||||
## End User
|
## End User
|
||||||
# Workspaces
|
# Workspaces
|
||||||
WorkSpaceViewSet,
|
WorkSpaceViewSet,
|
||||||
@ -38,9 +39,13 @@ from plane.api.views import (
|
|||||||
AddTeamToProjectEndpoint,
|
AddTeamToProjectEndpoint,
|
||||||
UserLastProjectWithWorkspaceEndpoint,
|
UserLastProjectWithWorkspaceEndpoint,
|
||||||
UserWorkspaceInvitationEndpoint,
|
UserWorkspaceInvitationEndpoint,
|
||||||
|
UserActivityGraphEndpoint,
|
||||||
|
UserIssueCompletedGraphEndpoint,
|
||||||
|
UserWorkspaceDashboardEndpoint,
|
||||||
## End Workspaces
|
## End Workspaces
|
||||||
# File Assets
|
# File Assets
|
||||||
FileAssetEndpoint,
|
FileAssetEndpoint,
|
||||||
|
UserAssetsEndpoint,
|
||||||
## End File Assets
|
## End File Assets
|
||||||
# Projects
|
# Projects
|
||||||
ProjectViewSet,
|
ProjectViewSet,
|
||||||
@ -61,13 +66,14 @@ from plane.api.views import (
|
|||||||
IssueCommentViewSet,
|
IssueCommentViewSet,
|
||||||
UserWorkSpaceIssues,
|
UserWorkSpaceIssues,
|
||||||
BulkDeleteIssuesEndpoint,
|
BulkDeleteIssuesEndpoint,
|
||||||
|
BulkImportIssuesEndpoint,
|
||||||
ProjectUserViewsEndpoint,
|
ProjectUserViewsEndpoint,
|
||||||
TimeLineIssueViewSet,
|
TimeLineIssueViewSet,
|
||||||
IssuePropertyViewSet,
|
IssuePropertyViewSet,
|
||||||
LabelViewSet,
|
LabelViewSet,
|
||||||
SubIssuesEndpoint,
|
SubIssuesEndpoint,
|
||||||
IssueLinkViewSet,
|
IssueLinkViewSet,
|
||||||
ModuleLinkViewSet,
|
BulkCreateIssueLabelsEndpoint,
|
||||||
## End Issues
|
## End Issues
|
||||||
# States
|
# States
|
||||||
StateViewSet,
|
StateViewSet,
|
||||||
@ -76,7 +82,9 @@ from plane.api.views import (
|
|||||||
ShortCutViewSet,
|
ShortCutViewSet,
|
||||||
## End Shortcuts
|
## End Shortcuts
|
||||||
# Views
|
# Views
|
||||||
ViewViewSet,
|
IssueViewViewSet,
|
||||||
|
ViewIssuesEndpoint,
|
||||||
|
IssueViewFavoriteViewSet,
|
||||||
## End Views
|
## End Views
|
||||||
# Cycles
|
# Cycles
|
||||||
CycleViewSet,
|
CycleViewSet,
|
||||||
@ -86,12 +94,26 @@ from plane.api.views import (
|
|||||||
CompletedCyclesEndpoint,
|
CompletedCyclesEndpoint,
|
||||||
CycleFavoriteViewSet,
|
CycleFavoriteViewSet,
|
||||||
DraftCyclesEndpoint,
|
DraftCyclesEndpoint,
|
||||||
|
TransferCycleIssueEndpoint,
|
||||||
|
InCompleteCyclesEndpoint,
|
||||||
## End Cycles
|
## End Cycles
|
||||||
# Modules
|
# Modules
|
||||||
ModuleViewSet,
|
ModuleViewSet,
|
||||||
ModuleIssueViewSet,
|
ModuleIssueViewSet,
|
||||||
ModuleFavoriteViewSet,
|
ModuleFavoriteViewSet,
|
||||||
|
ModuleLinkViewSet,
|
||||||
|
BulkImportModulesEndpoint,
|
||||||
## End Modules
|
## End Modules
|
||||||
|
# Pages
|
||||||
|
PageViewSet,
|
||||||
|
PageBlockViewSet,
|
||||||
|
PageFavoriteViewSet,
|
||||||
|
CreateIssueFromPageBlockEndpoint,
|
||||||
|
RecentPagesEndpoint,
|
||||||
|
FavoritePagesEndpoint,
|
||||||
|
MyPagesEndpoint,
|
||||||
|
CreatedbyOtherPagesEndpoint,
|
||||||
|
## End Pages
|
||||||
# Api Tokens
|
# Api Tokens
|
||||||
ApiTokenEndpoint,
|
ApiTokenEndpoint,
|
||||||
## End Api Tokens
|
## End Api Tokens
|
||||||
@ -102,7 +124,19 @@ from plane.api.views import (
|
|||||||
GithubRepositorySyncViewSet,
|
GithubRepositorySyncViewSet,
|
||||||
GithubIssueSyncViewSet,
|
GithubIssueSyncViewSet,
|
||||||
GithubCommentSyncViewSet,
|
GithubCommentSyncViewSet,
|
||||||
|
BulkCreateGithubIssueSyncEndpoint,
|
||||||
## End Integrations
|
## End Integrations
|
||||||
|
# Importer
|
||||||
|
ServiceIssueImportSummaryEndpoint,
|
||||||
|
ImportServiceEndpoint,
|
||||||
|
UpdateServiceImportStatusEndpoint,
|
||||||
|
## End importer
|
||||||
|
# Search
|
||||||
|
GlobalSearchEndpoint,
|
||||||
|
## End Search
|
||||||
|
# Gpt
|
||||||
|
GPTIntegrationEndpoint,
|
||||||
|
## End Gpt
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -153,6 +187,7 @@ urlpatterns = [
|
|||||||
UpdateUserOnBoardedEndpoint.as_view(),
|
UpdateUserOnBoardedEndpoint.as_view(),
|
||||||
name="change-password",
|
name="change-password",
|
||||||
),
|
),
|
||||||
|
path("users/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
|
||||||
# user workspaces
|
# user workspaces
|
||||||
path(
|
path(
|
||||||
"users/me/workspaces/",
|
"users/me/workspaces/",
|
||||||
@ -176,6 +211,23 @@ urlpatterns = [
|
|||||||
name="workspace",
|
name="workspace",
|
||||||
),
|
),
|
||||||
# user join workspace
|
# user join workspace
|
||||||
|
# User Graphs
|
||||||
|
path(
|
||||||
|
"users/me/workspaces/<str:slug>/activity-graph/",
|
||||||
|
UserActivityGraphEndpoint.as_view(),
|
||||||
|
name="user-activity-graph",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"users/me/workspaces/<str:slug>/issues-completed-graph/",
|
||||||
|
UserIssueCompletedGraphEndpoint.as_view(),
|
||||||
|
name="completed-graph",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"users/me/workspaces/<str:slug>/dashboard/",
|
||||||
|
UserWorkspaceDashboardEndpoint.as_view(),
|
||||||
|
name="user-workspace-dashboard",
|
||||||
|
),
|
||||||
|
## User Graph
|
||||||
path(
|
path(
|
||||||
"users/me/invitations/workspaces/<str:slug>/<uuid:pk>/join/",
|
"users/me/invitations/workspaces/<str:slug>/<uuid:pk>/join/",
|
||||||
JoinWorkspaceEndpoint.as_view(),
|
JoinWorkspaceEndpoint.as_view(),
|
||||||
@ -452,7 +504,7 @@ urlpatterns = [
|
|||||||
# Views
|
# Views
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/views/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/views/",
|
||||||
ViewViewSet.as_view(
|
IssueViewViewSet.as_view(
|
||||||
{
|
{
|
||||||
"get": "list",
|
"get": "list",
|
||||||
"post": "create",
|
"post": "create",
|
||||||
@ -462,7 +514,7 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/",
|
||||||
ViewViewSet.as_view(
|
IssueViewViewSet.as_view(
|
||||||
{
|
{
|
||||||
"get": "retrieve",
|
"get": "retrieve",
|
||||||
"put": "update",
|
"put": "update",
|
||||||
@ -472,6 +524,30 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
name="project-view",
|
name="project-view",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:view_id>/issues/",
|
||||||
|
ViewIssuesEndpoint.as_view(),
|
||||||
|
name="project-view-issues",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/",
|
||||||
|
IssueViewFavoriteViewSet.as_view(
|
||||||
|
{
|
||||||
|
"get": "list",
|
||||||
|
"post": "create",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
name="user-favorite-view",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/<uuid:view_id>/",
|
||||||
|
IssueViewFavoriteViewSet.as_view(
|
||||||
|
{
|
||||||
|
"delete": "destroy",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
name="user-favorite-view",
|
||||||
|
),
|
||||||
## End Views
|
## End Views
|
||||||
## Cycles
|
## Cycles
|
||||||
path(
|
path(
|
||||||
@ -557,6 +633,16 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
name="user-favorite-cycle",
|
name="user-favorite-cycle",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/transfer-issues/",
|
||||||
|
TransferCycleIssueEndpoint.as_view(),
|
||||||
|
name="transfer-issues",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/incomplete-cycles/",
|
||||||
|
InCompleteCyclesEndpoint.as_view(),
|
||||||
|
name="transfer-issues",
|
||||||
|
),
|
||||||
## End Cycles
|
## End Cycles
|
||||||
# Issue
|
# Issue
|
||||||
path(
|
path(
|
||||||
@ -608,9 +694,20 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
name="project-issue-labels",
|
name="project-issue-labels",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-create-labels/",
|
||||||
|
BulkCreateIssueLabelsEndpoint.as_view(),
|
||||||
|
name="project-bulk-labels",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-delete-issues/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-delete-issues/",
|
||||||
BulkDeleteIssuesEndpoint.as_view(),
|
BulkDeleteIssuesEndpoint.as_view(),
|
||||||
|
name="project-issues-bulk",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-import-issues/<str:service>/",
|
||||||
|
BulkImportIssuesEndpoint.as_view(),
|
||||||
|
name="project-issues-bulk",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/my-issues/",
|
"workspaces/<str:slug>/my-issues/",
|
||||||
@ -728,12 +825,22 @@ urlpatterns = [
|
|||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/file-assets/",
|
"workspaces/<str:slug>/file-assets/",
|
||||||
FileAssetEndpoint.as_view(),
|
FileAssetEndpoint.as_view(),
|
||||||
name="File Assets",
|
name="file-assets",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/file-assets/<uuid:pk>/",
|
"workspaces/file-assets/<uuid:workspace_id>/<str:asset_key>/",
|
||||||
FileAssetEndpoint.as_view(),
|
FileAssetEndpoint.as_view(),
|
||||||
name="File Assets",
|
name="file-assets",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"users/file-assets/",
|
||||||
|
UserAssetsEndpoint.as_view(),
|
||||||
|
name="user-file-assets",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"users/file-assets/<str:asset_key>/",
|
||||||
|
UserAssetsEndpoint.as_view(),
|
||||||
|
name="user-file-assets",
|
||||||
),
|
),
|
||||||
## End File Assets
|
## End File Assets
|
||||||
## Modules
|
## Modules
|
||||||
@ -822,7 +929,100 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
name="user-favorite-module",
|
name="user-favorite-module",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-import-modules/<str:service>/",
|
||||||
|
BulkImportModulesEndpoint.as_view(),
|
||||||
|
name="bulk-modules-create",
|
||||||
|
),
|
||||||
## End Modules
|
## End Modules
|
||||||
|
# Pages
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
|
||||||
|
PageViewSet.as_view(
|
||||||
|
{
|
||||||
|
"get": "list",
|
||||||
|
"post": "create",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
name="project-pages",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/",
|
||||||
|
PageViewSet.as_view(
|
||||||
|
{
|
||||||
|
"get": "retrieve",
|
||||||
|
"patch": "partial_update",
|
||||||
|
"delete": "destroy",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
name="project-pages",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/page-blocks/",
|
||||||
|
PageBlockViewSet.as_view(
|
||||||
|
{
|
||||||
|
"get": "list",
|
||||||
|
"post": "create",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
name="project-page-blocks",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/page-blocks/<uuid:pk>/",
|
||||||
|
PageBlockViewSet.as_view(
|
||||||
|
{
|
||||||
|
"get": "retrieve",
|
||||||
|
"patch": "partial_update",
|
||||||
|
"delete": "destroy",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
name="project-page-blocks",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-pages/",
|
||||||
|
PageFavoriteViewSet.as_view(
|
||||||
|
{
|
||||||
|
"get": "list",
|
||||||
|
"post": "create",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
name="user-favorite-pages",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-pages/<uuid:page_id>/",
|
||||||
|
PageFavoriteViewSet.as_view(
|
||||||
|
{
|
||||||
|
"delete": "destroy",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
name="user-favorite-pages",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/page-blocks/<uuid:page_block_id>/issues/",
|
||||||
|
CreateIssueFromPageBlockEndpoint.as_view(),
|
||||||
|
name="page-block-issues",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/recent-pages/",
|
||||||
|
RecentPagesEndpoint.as_view(),
|
||||||
|
name="recent-pages",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/favorite-pages/",
|
||||||
|
FavoritePagesEndpoint.as_view(),
|
||||||
|
name="recent-pages",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/my-pages/",
|
||||||
|
MyPagesEndpoint.as_view(),
|
||||||
|
name="user-pages",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/created-by-other-pages/",
|
||||||
|
CreatedbyOtherPagesEndpoint.as_view(),
|
||||||
|
name="created-by-other-pages",
|
||||||
|
),
|
||||||
|
## End Pages
|
||||||
# API Tokens
|
# API Tokens
|
||||||
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),
|
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),
|
||||||
path("api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-tokens"),
|
path("api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-tokens"),
|
||||||
@ -909,6 +1109,10 @@ urlpatterns = [
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/bulk-create-github-issue-sync/",
|
||||||
|
BulkCreateGithubIssueSyncEndpoint.as_view(),
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:pk>/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:pk>/",
|
||||||
GithubIssueSyncViewSet.as_view(
|
GithubIssueSyncViewSet.as_view(
|
||||||
@ -938,4 +1142,40 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
## End Github Integrations
|
## End Github Integrations
|
||||||
## End Integrations
|
## End Integrations
|
||||||
|
# Importer
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/importers/<str:service>/",
|
||||||
|
ServiceIssueImportSummaryEndpoint.as_view(),
|
||||||
|
name="importer",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/importers/<str:service>/",
|
||||||
|
ImportServiceEndpoint.as_view(),
|
||||||
|
name="importer",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/importers/",
|
||||||
|
ImportServiceEndpoint.as_view(),
|
||||||
|
name="importer",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/service/<str:service>/importers/<uuid:importer_id>/",
|
||||||
|
UpdateServiceImportStatusEndpoint.as_view(),
|
||||||
|
name="importer",
|
||||||
|
),
|
||||||
|
## End Importer
|
||||||
|
# Search
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/search/",
|
||||||
|
GlobalSearchEndpoint.as_view(),
|
||||||
|
name="global-search",
|
||||||
|
),
|
||||||
|
## End Search
|
||||||
|
# Gpt
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
|
||||||
|
GPTIntegrationEndpoint.as_view(),
|
||||||
|
name="importer",
|
||||||
|
),
|
||||||
|
## End Gpt
|
||||||
]
|
]
|
||||||
|
@ -16,6 +16,7 @@ from .project import (
|
|||||||
from .people import (
|
from .people import (
|
||||||
UserEndpoint,
|
UserEndpoint,
|
||||||
UpdateUserOnBoardedEndpoint,
|
UpdateUserOnBoardedEndpoint,
|
||||||
|
UserActivityEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .oauth import OauthEndpoint
|
from .oauth import OauthEndpoint
|
||||||
@ -36,10 +37,13 @@ from .workspace import (
|
|||||||
UserLastProjectWithWorkspaceEndpoint,
|
UserLastProjectWithWorkspaceEndpoint,
|
||||||
WorkspaceMemberUserEndpoint,
|
WorkspaceMemberUserEndpoint,
|
||||||
WorkspaceMemberUserViewsEndpoint,
|
WorkspaceMemberUserViewsEndpoint,
|
||||||
|
UserActivityGraphEndpoint,
|
||||||
|
UserIssueCompletedGraphEndpoint,
|
||||||
|
UserWorkspaceDashboardEndpoint,
|
||||||
)
|
)
|
||||||
from .state import StateViewSet
|
from .state import StateViewSet
|
||||||
from .shortcut import ShortCutViewSet
|
from .shortcut import ShortCutViewSet
|
||||||
from .view import ViewViewSet
|
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
|
||||||
from .cycle import (
|
from .cycle import (
|
||||||
CycleViewSet,
|
CycleViewSet,
|
||||||
CycleIssueViewSet,
|
CycleIssueViewSet,
|
||||||
@ -48,8 +52,10 @@ from .cycle import (
|
|||||||
CompletedCyclesEndpoint,
|
CompletedCyclesEndpoint,
|
||||||
CycleFavoriteViewSet,
|
CycleFavoriteViewSet,
|
||||||
DraftCyclesEndpoint,
|
DraftCyclesEndpoint,
|
||||||
|
TransferCycleIssueEndpoint,
|
||||||
|
InCompleteCyclesEndpoint,
|
||||||
)
|
)
|
||||||
from .asset import FileAssetEndpoint
|
from .asset import FileAssetEndpoint, UserAssetsEndpoint
|
||||||
from .issue import (
|
from .issue import (
|
||||||
IssueViewSet,
|
IssueViewSet,
|
||||||
WorkSpaceIssuesEndpoint,
|
WorkSpaceIssuesEndpoint,
|
||||||
@ -62,6 +68,7 @@ from .issue import (
|
|||||||
UserWorkSpaceIssues,
|
UserWorkSpaceIssues,
|
||||||
SubIssuesEndpoint,
|
SubIssuesEndpoint,
|
||||||
IssueLinkViewSet,
|
IssueLinkViewSet,
|
||||||
|
BulkCreateIssueLabelsEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .auth_extended import (
|
from .auth_extended import (
|
||||||
@ -96,4 +103,29 @@ from .integration import (
|
|||||||
GithubRepositorySyncViewSet,
|
GithubRepositorySyncViewSet,
|
||||||
GithubCommentSyncViewSet,
|
GithubCommentSyncViewSet,
|
||||||
GithubRepositoriesEndpoint,
|
GithubRepositoriesEndpoint,
|
||||||
|
BulkCreateGithubIssueSyncEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .importer import (
|
||||||
|
ServiceIssueImportSummaryEndpoint,
|
||||||
|
ImportServiceEndpoint,
|
||||||
|
UpdateServiceImportStatusEndpoint,
|
||||||
|
BulkImportIssuesEndpoint,
|
||||||
|
BulkImportModulesEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .page import (
|
||||||
|
PageViewSet,
|
||||||
|
PageBlockViewSet,
|
||||||
|
PageFavoriteViewSet,
|
||||||
|
CreateIssueFromPageBlockEndpoint,
|
||||||
|
RecentPagesEndpoint,
|
||||||
|
FavoritePagesEndpoint,
|
||||||
|
MyPagesEndpoint,
|
||||||
|
CreatedbyOtherPagesEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .search import GlobalSearchEndpoint
|
||||||
|
|
||||||
|
|
||||||
|
from .gpt import GPTIntegrationEndpoint
|
||||||
|
@ -17,8 +17,9 @@ class FileAssetEndpoint(BaseAPIView):
|
|||||||
A viewset for viewing and editing task instances.
|
A viewset for viewing and editing task instances.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get(self, request, slug):
|
def get(self, request, workspace_id, asset_key):
|
||||||
files = FileAsset.objects.filter(workspace__slug=slug)
|
asset_key = str(workspace_id) + "/" + asset_key
|
||||||
|
files = FileAsset.objects.filter(asset=asset_key)
|
||||||
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
|
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
@ -42,9 +43,55 @@ class FileAssetEndpoint(BaseAPIView):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete(self, request, slug, pk):
|
def delete(self, request, workspace_id, asset_key):
|
||||||
try:
|
try:
|
||||||
file_asset = FileAsset.objects.get(pk=pk, workspace__slug=slug)
|
asset_key = str(workspace_id) + "/" + asset_key
|
||||||
|
file_asset = FileAsset.objects.get(asset=asset_key)
|
||||||
|
# Delete the file from storage
|
||||||
|
file_asset.asset.delete(save=False)
|
||||||
|
# Delete the file object
|
||||||
|
file_asset.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
except FileAsset.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "File Asset doesn't exist"}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserAssetsEndpoint(BaseAPIView):
|
||||||
|
def get(self, request, asset_key):
|
||||||
|
try:
|
||||||
|
files = FileAsset.objects.filter(asset=asset_key, created_by=request.user)
|
||||||
|
serializer = FileAssetSerializer(files, context={"request": request})
|
||||||
|
return Response(serializer.data)
|
||||||
|
except FileAsset.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "File Asset does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
serializer = FileAssetSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, request, asset_key):
|
||||||
|
try:
|
||||||
|
file_asset = FileAsset.objects.get(asset=asset_key, created_by=request.user)
|
||||||
# Delete the file from storage
|
# Delete the file from storage
|
||||||
file_asset.asset.delete(save=False)
|
file_asset.asset.delete(save=False)
|
||||||
# Delete the file object
|
# Delete the file object
|
||||||
|
@ -3,6 +3,7 @@ import uuid
|
|||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
import json
|
import json
|
||||||
|
import requests
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -85,6 +86,28 @@ class SignInEndpoint(BaseAPIView):
|
|||||||
"user": serialized_user,
|
"user": serialized_user,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Send Analytics
|
||||||
|
if settings.ANALYTICS_BASE_API:
|
||||||
|
_ = requests.post(
|
||||||
|
settings.ANALYTICS_BASE_API,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"event_id": uuid.uuid4().hex,
|
||||||
|
"event_data": {
|
||||||
|
"medium": "email",
|
||||||
|
},
|
||||||
|
"user": {"email": email, "id": str(user.id)},
|
||||||
|
"device_ctx": {
|
||||||
|
"ip": request.META.get("REMOTE_ADDR"),
|
||||||
|
"user_agent": request.META.get("HTTP_USER_AGENT"),
|
||||||
|
},
|
||||||
|
"event_type": "SIGN_UP",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return Response(data, status=status.HTTP_200_OK)
|
return Response(data, status=status.HTTP_200_OK)
|
||||||
# Sign in Process
|
# Sign in Process
|
||||||
else:
|
else:
|
||||||
@ -114,7 +137,27 @@ class SignInEndpoint(BaseAPIView):
|
|||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
access_token, refresh_token = get_tokens_for_user(user)
|
access_token, refresh_token = get_tokens_for_user(user)
|
||||||
|
# Send Analytics
|
||||||
|
if settings.ANALYTICS_BASE_API:
|
||||||
|
_ = requests.post(
|
||||||
|
settings.ANALYTICS_BASE_API,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"event_id": uuid.uuid4().hex,
|
||||||
|
"event_data": {
|
||||||
|
"medium": "email",
|
||||||
|
},
|
||||||
|
"user": {"email": email, "id": str(user.id)},
|
||||||
|
"device_ctx": {
|
||||||
|
"ip": request.META.get("REMOTE_ADDR"),
|
||||||
|
"user_agent": request.META.get("HTTP_USER_AGENT"),
|
||||||
|
},
|
||||||
|
"event_type": "SIGN_IN",
|
||||||
|
},
|
||||||
|
)
|
||||||
data = {
|
data = {
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"refresh_token": refresh_token,
|
"refresh_token": refresh_token,
|
||||||
@ -268,6 +311,29 @@ class MagicSignInEndpoint(BaseAPIView):
|
|||||||
if str(token) == str(user_token):
|
if str(token) == str(user_token):
|
||||||
if User.objects.filter(email=email).exists():
|
if User.objects.filter(email=email).exists():
|
||||||
user = User.objects.get(email=email)
|
user = User.objects.get(email=email)
|
||||||
|
# Send event to Jitsu for tracking
|
||||||
|
if settings.ANALYTICS_BASE_API:
|
||||||
|
_ = requests.post(
|
||||||
|
settings.ANALYTICS_BASE_API,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"event_id": uuid.uuid4().hex,
|
||||||
|
"event_data": {
|
||||||
|
"medium": "code",
|
||||||
|
},
|
||||||
|
"user": {"email": email, "id": str(user.id)},
|
||||||
|
"device_ctx": {
|
||||||
|
"ip": request.META.get("REMOTE_ADDR"),
|
||||||
|
"user_agent": request.META.get(
|
||||||
|
"HTTP_USER_AGENT"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"event_type": "SIGN_IN",
|
||||||
|
},
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
user = User.objects.create(
|
user = User.objects.create(
|
||||||
email=email,
|
email=email,
|
||||||
@ -275,6 +341,29 @@ class MagicSignInEndpoint(BaseAPIView):
|
|||||||
password=make_password(uuid.uuid4().hex),
|
password=make_password(uuid.uuid4().hex),
|
||||||
is_password_autoset=True,
|
is_password_autoset=True,
|
||||||
)
|
)
|
||||||
|
# Send event to Jitsu for tracking
|
||||||
|
if settings.ANALYTICS_BASE_API:
|
||||||
|
_ = requests.post(
|
||||||
|
settings.ANALYTICS_BASE_API,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"event_id": uuid.uuid4().hex,
|
||||||
|
"event_data": {
|
||||||
|
"medium": "code",
|
||||||
|
},
|
||||||
|
"user": {"email": email, "id": str(user.id)},
|
||||||
|
"device_ctx": {
|
||||||
|
"ip": request.META.get("REMOTE_ADDR"),
|
||||||
|
"user_agent": request.META.get(
|
||||||
|
"HTTP_USER_AGENT"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"event_type": "SIGN_UP",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
user.last_active = timezone.now()
|
user.last_active = timezone.now()
|
||||||
user.last_login_time = timezone.now()
|
user.last_login_time = timezone.now()
|
||||||
|
@ -3,9 +3,11 @@ import json
|
|||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef
|
from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef, Count, Prefetch
|
||||||
from django.core import serializers
|
from django.core import serializers
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views.decorators.gzip import gzip_page
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -18,11 +20,18 @@ from plane.api.serializers import (
|
|||||||
CycleSerializer,
|
CycleSerializer,
|
||||||
CycleIssueSerializer,
|
CycleIssueSerializer,
|
||||||
CycleFavoriteSerializer,
|
CycleFavoriteSerializer,
|
||||||
|
IssueStateSerializer,
|
||||||
)
|
)
|
||||||
from plane.api.permissions import ProjectEntityPermission
|
from plane.api.permissions import ProjectEntityPermission
|
||||||
from plane.db.models import Cycle, CycleIssue, Issue, CycleFavorite
|
from plane.db.models import (
|
||||||
|
Cycle,
|
||||||
|
CycleIssue,
|
||||||
|
Issue,
|
||||||
|
CycleFavorite,
|
||||||
|
)
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
from plane.utils.grouper import group_results
|
from plane.utils.grouper import group_results
|
||||||
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
|
||||||
|
|
||||||
class CycleViewSet(BaseViewSet):
|
class CycleViewSet(BaseViewSet):
|
||||||
@ -38,6 +47,12 @@ class CycleViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
subquery = CycleFavorite.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
cycle_id=OuterRef("pk"),
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
)
|
||||||
return self.filter_queryset(
|
return self.filter_queryset(
|
||||||
super()
|
super()
|
||||||
.get_queryset()
|
.get_queryset()
|
||||||
@ -47,26 +62,42 @@ class CycleViewSet(BaseViewSet):
|
|||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("owned_by")
|
.select_related("owned_by")
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
.annotate(total_issues=Count("issue_cycle"))
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="started"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("-is_favorite", "name")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
def list(self, request, slug, project_id):
|
|
||||||
try:
|
|
||||||
subquery = CycleFavorite.objects.filter(
|
|
||||||
user=self.request.user,
|
|
||||||
cycle_id=OuterRef("pk"),
|
|
||||||
project_id=project_id,
|
|
||||||
workspace__slug=slug,
|
|
||||||
)
|
|
||||||
cycles = self.get_queryset().annotate(is_favorite=Exists(subquery))
|
|
||||||
return Response(CycleSerializer(cycles, many=True).data)
|
|
||||||
except Exception as e:
|
|
||||||
capture_exception(e)
|
|
||||||
return Response(
|
|
||||||
{"error": "Something went wrong please try again later"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
def create(self, request, slug, project_id):
|
def create(self, request, slug, project_id):
|
||||||
try:
|
try:
|
||||||
if (
|
if (
|
||||||
@ -98,6 +129,36 @@ class CycleViewSet(BaseViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def partial_update(self, request, slug, project_id, pk):
|
||||||
|
try:
|
||||||
|
cycle = Cycle.objects.get(
|
||||||
|
workspace__slug=slug, project_id=project_id, pk=pk
|
||||||
|
)
|
||||||
|
|
||||||
|
if cycle.end_date is not None and cycle.end_date < timezone.now().date():
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "The Cycle has already been completed so it cannot be edited"
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = CycleSerializer(cycle, data=request.data, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except Cycle.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Cycle does not exist"}, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CycleIssueViewSet(BaseViewSet):
|
class CycleIssueViewSet(BaseViewSet):
|
||||||
serializer_class = CycleIssueSerializer
|
serializer_class = CycleIssueSerializer
|
||||||
@ -140,22 +201,43 @@ class CycleIssueViewSet(BaseViewSet):
|
|||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@method_decorator(gzip_page)
|
||||||
def list(self, request, slug, project_id, cycle_id):
|
def list(self, request, slug, project_id, cycle_id):
|
||||||
try:
|
try:
|
||||||
order_by = request.GET.get("order_by", "created_at")
|
order_by = request.GET.get("order_by", "created_at")
|
||||||
queryset = self.get_queryset().order_by(f"issue__{order_by}")
|
|
||||||
group_by = request.GET.get("group_by", False)
|
group_by = request.GET.get("group_by", False)
|
||||||
|
filters = issue_filters(request.query_params, "GET")
|
||||||
|
issues = (
|
||||||
|
Issue.objects.filter(issue_cycle__cycle_id=cycle_id)
|
||||||
|
.annotate(
|
||||||
|
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.annotate(bridge_id=F("issue_cycle__id"))
|
||||||
|
.filter(project_id=project_id)
|
||||||
|
.filter(workspace__slug=slug)
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("state")
|
||||||
|
.select_related("parent")
|
||||||
|
.prefetch_related("assignees")
|
||||||
|
.prefetch_related("labels")
|
||||||
|
.order_by(order_by)
|
||||||
|
.filter(**filters)
|
||||||
|
)
|
||||||
|
|
||||||
cycle_issues = CycleIssueSerializer(queryset, many=True).data
|
issues_data = IssueStateSerializer(issues, many=True).data
|
||||||
|
|
||||||
if group_by:
|
if group_by:
|
||||||
return Response(
|
return Response(
|
||||||
group_results(cycle_issues, f"issue_detail.{group_by}"),
|
group_results(issues_data, group_by),
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
cycle_issues,
|
issues_data,
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -178,6 +260,14 @@ class CycleIssueViewSet(BaseViewSet):
|
|||||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if cycle.end_date is not None and cycle.end_date < timezone.now().date():
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "The Cycle has already been completed so no new issues can be added"
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
# Get all CycleIssues already created
|
# Get all CycleIssues already created
|
||||||
cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues))
|
cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues))
|
||||||
records_to_update = []
|
records_to_update = []
|
||||||
@ -263,10 +353,20 @@ class CycleIssueViewSet(BaseViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class CycleDateCheckEndpoint(BaseAPIView):
|
class CycleDateCheckEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
def post(self, request, slug, project_id):
|
def post(self, request, slug, project_id):
|
||||||
try:
|
try:
|
||||||
start_date = request.data.get("start_date")
|
start_date = request.data.get("start_date", False)
|
||||||
end_date = request.data.get("end_date")
|
end_date = request.data.get("end_date", False)
|
||||||
|
|
||||||
|
if not start_date or not end_date:
|
||||||
|
return Response(
|
||||||
|
{"error": "Start date and end date both are required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
cycles = Cycle.objects.filter(
|
cycles = Cycle.objects.filter(
|
||||||
Q(start_date__lte=start_date, end_date__gte=start_date)
|
Q(start_date__lte=start_date, end_date__gte=start_date)
|
||||||
@ -294,6 +394,10 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class CurrentUpcomingCyclesEndpoint(BaseAPIView):
|
class CurrentUpcomingCyclesEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
def get(self, request, slug, project_id):
|
def get(self, request, slug, project_id):
|
||||||
try:
|
try:
|
||||||
subquery = CycleFavorite.objects.filter(
|
subquery = CycleFavorite.objects.filter(
|
||||||
@ -302,18 +406,94 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
current_cycle = Cycle.objects.filter(
|
current_cycle = (
|
||||||
workspace__slug=slug,
|
Cycle.objects.filter(
|
||||||
project_id=project_id,
|
workspace__slug=slug,
|
||||||
start_date__lte=timezone.now(),
|
project_id=project_id,
|
||||||
end_date__gte=timezone.now(),
|
start_date__lte=timezone.now(),
|
||||||
).annotate(is_favorite=Exists(subquery))
|
end_date__gte=timezone.now(),
|
||||||
|
)
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("owned_by")
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
.annotate(total_issues=Count("issue_cycle"))
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="started"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("name", "-is_favorite")
|
||||||
|
)
|
||||||
|
|
||||||
upcoming_cycle = Cycle.objects.filter(
|
upcoming_cycle = (
|
||||||
workspace__slug=slug,
|
Cycle.objects.filter(
|
||||||
project_id=project_id,
|
workspace__slug=slug,
|
||||||
start_date__gt=timezone.now(),
|
project_id=project_id,
|
||||||
).annotate(is_favorite=Exists(subquery))
|
start_date__gt=timezone.now(),
|
||||||
|
)
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("owned_by")
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
.annotate(total_issues=Count("issue_cycle"))
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="started"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("name", "-is_favorite")
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@ -332,6 +512,10 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class CompletedCyclesEndpoint(BaseAPIView):
|
class CompletedCyclesEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
def get(self, request, slug, project_id):
|
def get(self, request, slug, project_id):
|
||||||
try:
|
try:
|
||||||
subquery = CycleFavorite.objects.filter(
|
subquery = CycleFavorite.objects.filter(
|
||||||
@ -340,11 +524,49 @@ class CompletedCyclesEndpoint(BaseAPIView):
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
)
|
||||||
completed_cycles = Cycle.objects.filter(
|
completed_cycles = (
|
||||||
workspace__slug=slug,
|
Cycle.objects.filter(
|
||||||
project_id=project_id,
|
workspace__slug=slug,
|
||||||
end_date__lt=timezone.now(),
|
project_id=project_id,
|
||||||
).annotate(is_favorite=Exists(subquery))
|
end_date__lt=timezone.now(),
|
||||||
|
)
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("owned_by")
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
.annotate(total_issues=Count("issue_cycle"))
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="started"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("name", "-is_favorite")
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@ -364,13 +586,61 @@ class CompletedCyclesEndpoint(BaseAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class DraftCyclesEndpoint(BaseAPIView):
|
class DraftCyclesEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
def get(self, request, slug, project_id):
|
def get(self, request, slug, project_id):
|
||||||
try:
|
try:
|
||||||
draft_cycles = Cycle.objects.filter(
|
subquery = CycleFavorite.objects.filter(
|
||||||
workspace__slug=slug,
|
user=self.request.user,
|
||||||
|
cycle_id=OuterRef("pk"),
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
end_date=None,
|
workspace__slug=slug,
|
||||||
start_date=None,
|
)
|
||||||
|
draft_cycles = (
|
||||||
|
Cycle.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
end_date=None,
|
||||||
|
start_date=None,
|
||||||
|
)
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("owned_by")
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
.annotate(total_issues=Count("issue_cycle"))
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="started"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_issues=Count(
|
||||||
|
"issue_cycle__issue__state__group",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("name", "-is_favorite")
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
@ -386,6 +656,10 @@ class DraftCyclesEndpoint(BaseAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class CycleFavoriteViewSet(BaseViewSet):
|
class CycleFavoriteViewSet(BaseViewSet):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
serializer_class = CycleFavoriteSerializer
|
serializer_class = CycleFavoriteSerializer
|
||||||
model = CycleFavorite
|
model = CycleFavorite
|
||||||
|
|
||||||
@ -445,3 +719,82 @@ class CycleFavoriteViewSet(BaseViewSet):
|
|||||||
{"error": "Something went wrong please try again later"},
|
{"error": "Something went wrong please try again later"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TransferCycleIssueEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def post(self, request, slug, project_id, cycle_id):
|
||||||
|
try:
|
||||||
|
new_cycle_id = request.data.get("new_cycle_id", False)
|
||||||
|
|
||||||
|
if not new_cycle_id:
|
||||||
|
return Response(
|
||||||
|
{"error": "New Cycle Id is required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
new_cycle = Cycle.objects.get(
|
||||||
|
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
new_cycle.end_date is not None
|
||||||
|
and new_cycle.end_date < timezone.now().date()
|
||||||
|
):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "The cycle where the issues are transferred is already completed"
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
cycle_issues = CycleIssue.objects.filter(
|
||||||
|
cycle_id=cycle_id,
|
||||||
|
project_id=project_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
issue__state__group__in=["backlog", "unstarted", "started"],
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_cycles = []
|
||||||
|
for cycle_issue in cycle_issues:
|
||||||
|
cycle_issue.cycle_id = new_cycle_id
|
||||||
|
updated_cycles.append(cycle_issue)
|
||||||
|
|
||||||
|
cycle_issues = CycleIssue.objects.bulk_update(
|
||||||
|
updated_cycles, ["cycle_id"], batch_size=100
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({"message": "Success"}, status=status.HTTP_200_OK)
|
||||||
|
except Cycle.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "New Cycle Does not exist"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InCompleteCyclesEndpoint(BaseAPIView):
|
||||||
|
def get(self, request, slug, project_id):
|
||||||
|
try:
|
||||||
|
cycles = Cycle.objects.filter(
|
||||||
|
Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True),
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
).select_related("owned_by")
|
||||||
|
|
||||||
|
serializer = CycleSerializer(cycles, many=True)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
101
apiserver/plane/api/views/gpt.py
Normal file
101
apiserver/plane/api/views/gpt.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# Python imports
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status
|
||||||
|
import openai
|
||||||
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
|
# Django imports
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from .base import BaseAPIView
|
||||||
|
from plane.api.permissions import ProjectEntityPermission
|
||||||
|
from plane.db.models import Workspace, Project
|
||||||
|
from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class GPTIntegrationEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def post(self, request, slug, project_id):
|
||||||
|
try:
|
||||||
|
if not settings.OPENAI_API_KEY or not settings.GPT_ENGINE:
|
||||||
|
return Response(
|
||||||
|
{"error": "OpenAI API key and engine is required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
# If logger is enabled check for request limit
|
||||||
|
if settings.LOGGER_BASE_URL:
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
settings.LOGGER_BASE_URL,
|
||||||
|
json={"user_id": str(request.user.id)},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
count = response.json().get("count", 0)
|
||||||
|
if not response.json().get("success", False):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "You have surpassed the monthly limit for AI assistance"
|
||||||
|
},
|
||||||
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
|
||||||
|
prompt = request.data.get("prompt", False)
|
||||||
|
task = request.data.get("task", False)
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
return Response(
|
||||||
|
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
final_text = task + "\n" + prompt
|
||||||
|
|
||||||
|
openai.api_key = settings.OPENAI_API_KEY
|
||||||
|
response = openai.Completion.create(
|
||||||
|
engine=settings.GPT_ENGINE,
|
||||||
|
prompt=final_text,
|
||||||
|
temperature=0.7,
|
||||||
|
max_tokens=1024,
|
||||||
|
)
|
||||||
|
|
||||||
|
workspace = Workspace.objects.get(slug=slug)
|
||||||
|
project = Project.objects.get(pk=project_id)
|
||||||
|
|
||||||
|
text = response.choices[0].text.strip()
|
||||||
|
text_html = text.replace("\n", "<br/>")
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"response": text,
|
||||||
|
"response_html": text_html,
|
||||||
|
"count": count,
|
||||||
|
"project_detail": ProjectLiteSerializer(project).data,
|
||||||
|
"workspace_detail": WorkspaceLiteSerializer(workspace).data,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
except (Workspace.DoesNotExist, Project.DoesNotExist) as e:
|
||||||
|
return Response(
|
||||||
|
{"error": "Workspace or Project Does not exist"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
519
apiserver/plane/api/views/importer.py
Normal file
519
apiserver/plane/api/views/importer.py
Normal file
@ -0,0 +1,519 @@
|
|||||||
|
# Python imports
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
|
# Django imports
|
||||||
|
from django.db.models import Max
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from plane.api.views import BaseAPIView
|
||||||
|
from plane.db.models import (
|
||||||
|
WorkspaceIntegration,
|
||||||
|
Importer,
|
||||||
|
APIToken,
|
||||||
|
Project,
|
||||||
|
State,
|
||||||
|
IssueSequence,
|
||||||
|
Issue,
|
||||||
|
IssueActivity,
|
||||||
|
IssueComment,
|
||||||
|
IssueLink,
|
||||||
|
IssueLabel,
|
||||||
|
Workspace,
|
||||||
|
IssueAssignee,
|
||||||
|
Module,
|
||||||
|
ModuleLink,
|
||||||
|
ModuleIssue,
|
||||||
|
)
|
||||||
|
from plane.api.serializers import (
|
||||||
|
ImporterSerializer,
|
||||||
|
IssueFlatSerializer,
|
||||||
|
ModuleSerializer,
|
||||||
|
)
|
||||||
|
from plane.utils.integrations.github import get_github_repo_details
|
||||||
|
from plane.utils.importers.jira import jira_project_issue_summary
|
||||||
|
from plane.bgtasks.importer_task import service_importer
|
||||||
|
from plane.utils.html_processor import strip_tags
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
|
||||||
|
def get(self, request, slug, service):
|
||||||
|
try:
|
||||||
|
if service == "github":
|
||||||
|
workspace_integration = WorkspaceIntegration.objects.get(
|
||||||
|
integration__provider="github", workspace__slug=slug
|
||||||
|
)
|
||||||
|
|
||||||
|
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
|
||||||
|
owner = request.GET.get("owner")
|
||||||
|
repo = request.GET.get("repo")
|
||||||
|
|
||||||
|
issue_count, labels, collaborators = get_github_repo_details(
|
||||||
|
access_tokens_url, owner, repo
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"issue_count": issue_count,
|
||||||
|
"labels": labels,
|
||||||
|
"collaborators": collaborators,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
if service == "jira":
|
||||||
|
project_name = request.data.get("project_name", "")
|
||||||
|
api_token = request.data.get("api_token", "")
|
||||||
|
email = request.data.get("email", "")
|
||||||
|
cloud_hostname = request.data.get("cloud_hostname", "")
|
||||||
|
if (
|
||||||
|
not bool(project_name)
|
||||||
|
or not bool(api_token)
|
||||||
|
or not bool(email)
|
||||||
|
or not bool(cloud_hostname)
|
||||||
|
):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "Project name, Project key, API token, Cloud hostname and email are requied"
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
jira_project_issue_summary(
|
||||||
|
email, api_token, project_name, cloud_hostname
|
||||||
|
),
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{"error": "Service not supported yet"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except WorkspaceIntegration.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Requested integration was not installed in the workspace"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ImportServiceEndpoint(BaseAPIView):
|
||||||
|
def post(self, request, slug, service):
|
||||||
|
try:
|
||||||
|
project_id = request.data.get("project_id", False)
|
||||||
|
|
||||||
|
if not project_id:
|
||||||
|
return Response(
|
||||||
|
{"error": "Project ID is required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
workspace = Workspace.objects.get(slug=slug)
|
||||||
|
|
||||||
|
if service == "github":
|
||||||
|
data = request.data.get("data", False)
|
||||||
|
metadata = request.data.get("metadata", False)
|
||||||
|
config = request.data.get("config", False)
|
||||||
|
if not data or not metadata or not config:
|
||||||
|
return Response(
|
||||||
|
{"error": "Data, config and metadata are required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
api_token = APIToken.objects.filter(
|
||||||
|
user=request.user, workspace=workspace
|
||||||
|
).first()
|
||||||
|
if api_token is None:
|
||||||
|
api_token = APIToken.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
label="Importer",
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
importer = Importer.objects.create(
|
||||||
|
service=service,
|
||||||
|
project_id=project_id,
|
||||||
|
status="queued",
|
||||||
|
initiated_by=request.user,
|
||||||
|
data=data,
|
||||||
|
metadata=metadata,
|
||||||
|
token=api_token,
|
||||||
|
config=config,
|
||||||
|
created_by=request.user,
|
||||||
|
updated_by=request.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
service_importer.delay(service, importer.id)
|
||||||
|
serializer = ImporterSerializer(importer)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
if service == "jira":
|
||||||
|
data = request.data.get("data", False)
|
||||||
|
metadata = request.data.get("metadata", False)
|
||||||
|
config = request.data.get("config", False)
|
||||||
|
if not data or not metadata:
|
||||||
|
return Response(
|
||||||
|
{"error": "Data, config and metadata are required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
api_token = APIToken.objects.filter(
|
||||||
|
user=request.user, workspace=workspace
|
||||||
|
).first()
|
||||||
|
if api_token is None:
|
||||||
|
api_token = APIToken.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
label="Importer",
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
importer = Importer.objects.create(
|
||||||
|
service=service,
|
||||||
|
project_id=project_id,
|
||||||
|
status="queued",
|
||||||
|
initiated_by=request.user,
|
||||||
|
data=data,
|
||||||
|
metadata=metadata,
|
||||||
|
token=api_token,
|
||||||
|
config=config,
|
||||||
|
created_by=request.user,
|
||||||
|
updated_by=request.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
service_importer.delay(service, importer.id)
|
||||||
|
serializer = ImporterSerializer(importer)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{"error": "Servivce not supported yet"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
Workspace.DoesNotExist,
|
||||||
|
WorkspaceIntegration.DoesNotExist,
|
||||||
|
Project.DoesNotExist,
|
||||||
|
) as e:
|
||||||
|
return Response(
|
||||||
|
{"error": "Workspace Integration or Project does not exist"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, request, slug):
|
||||||
|
try:
|
||||||
|
imports = Importer.objects.filter(workspace__slug=slug).order_by(
|
||||||
|
"-created_at"
|
||||||
|
)
|
||||||
|
serializer = ImporterSerializer(imports, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateServiceImportStatusEndpoint(BaseAPIView):
|
||||||
|
def post(self, request, slug, project_id, service, importer_id):
|
||||||
|
try:
|
||||||
|
importer = Importer.objects.get(
|
||||||
|
pk=importer_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
service=service,
|
||||||
|
)
|
||||||
|
importer.status = request.data.get("status", "processing")
|
||||||
|
importer.save()
|
||||||
|
return Response(status.HTTP_200_OK)
|
||||||
|
except Importer.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Importer does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkImportIssuesEndpoint(BaseAPIView):
|
||||||
|
def post(self, request, slug, project_id, service):
|
||||||
|
try:
|
||||||
|
# Get the project
|
||||||
|
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||||
|
|
||||||
|
# Get the default state
|
||||||
|
default_state = State.objects.filter(
|
||||||
|
project_id=project_id, default=True
|
||||||
|
).first()
|
||||||
|
# if there is no default state assign any random state
|
||||||
|
if default_state is None:
|
||||||
|
default_state = State.objects.filter(project_id=project_id).first()
|
||||||
|
|
||||||
|
# Get the maximum sequence_id
|
||||||
|
last_id = IssueSequence.objects.filter(project_id=project_id).aggregate(
|
||||||
|
largest=Max("sequence")
|
||||||
|
)["largest"]
|
||||||
|
|
||||||
|
last_id = 1 if last_id is None else last_id + 1
|
||||||
|
|
||||||
|
# Get the maximum sort order
|
||||||
|
largest_sort_order = Issue.objects.filter(
|
||||||
|
project_id=project_id, state=default_state
|
||||||
|
).aggregate(largest=Max("sort_order"))["largest"]
|
||||||
|
|
||||||
|
largest_sort_order = (
|
||||||
|
65535 if largest_sort_order is None else largest_sort_order + 10000
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the issues_data
|
||||||
|
issues_data = request.data.get("issues_data", [])
|
||||||
|
|
||||||
|
if not len(issues_data):
|
||||||
|
return Response(
|
||||||
|
{"error": "Issue data is required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Issues
|
||||||
|
bulk_issues = []
|
||||||
|
for issue_data in issues_data:
|
||||||
|
bulk_issues.append(
|
||||||
|
Issue(
|
||||||
|
project_id=project_id,
|
||||||
|
workspace_id=project.workspace_id,
|
||||||
|
state_id=issue_data.get("state")
|
||||||
|
if issue_data.get("state", False)
|
||||||
|
else default_state.id,
|
||||||
|
name=issue_data.get("name", "Issue Created through Bulk"),
|
||||||
|
description_html=issue_data.get("description_html", "<p></p>"),
|
||||||
|
description_stripped=(
|
||||||
|
None
|
||||||
|
if (
|
||||||
|
issue_data.get("description_html") == ""
|
||||||
|
or issue_data.get("description_html") is None
|
||||||
|
)
|
||||||
|
else strip_tags(issue_data.get("description_html"))
|
||||||
|
),
|
||||||
|
sequence_id=last_id,
|
||||||
|
sort_order=largest_sort_order,
|
||||||
|
start_date=issue_data.get("start_date", None),
|
||||||
|
target_date=issue_data.get("target_date", None),
|
||||||
|
priority=issue_data.get("priority", None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
largest_sort_order = largest_sort_order + 10000
|
||||||
|
last_id = last_id + 1
|
||||||
|
|
||||||
|
issues = Issue.objects.bulk_create(
|
||||||
|
bulk_issues,
|
||||||
|
batch_size=100,
|
||||||
|
ignore_conflicts=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sequences
|
||||||
|
_ = IssueSequence.objects.bulk_create(
|
||||||
|
[
|
||||||
|
IssueSequence(
|
||||||
|
issue=issue,
|
||||||
|
sequence=issue.sequence_id,
|
||||||
|
project_id=project_id,
|
||||||
|
workspace_id=project.workspace_id,
|
||||||
|
)
|
||||||
|
for issue in issues
|
||||||
|
],
|
||||||
|
batch_size=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach Labels
|
||||||
|
bulk_issue_labels = []
|
||||||
|
for issue, issue_data in zip(issues, issues_data):
|
||||||
|
labels_list = issue_data.get("labels_list", [])
|
||||||
|
bulk_issue_labels = bulk_issue_labels + [
|
||||||
|
IssueLabel(
|
||||||
|
issue=issue,
|
||||||
|
label_id=label_id,
|
||||||
|
project_id=project_id,
|
||||||
|
workspace_id=project.workspace_id,
|
||||||
|
created_by=request.user,
|
||||||
|
updated_by=request.user,
|
||||||
|
)
|
||||||
|
for label_id in labels_list
|
||||||
|
]
|
||||||
|
|
||||||
|
_ = IssueLabel.objects.bulk_create(
|
||||||
|
bulk_issue_labels, batch_size=100, ignore_conflicts=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach Assignees
|
||||||
|
bulk_issue_assignees = []
|
||||||
|
for issue, issue_data in zip(issues, issues_data):
|
||||||
|
assignees_list = issue_data.get("assignees_list", [])
|
||||||
|
bulk_issue_assignees = bulk_issue_assignees + [
|
||||||
|
IssueAssignee(
|
||||||
|
issue=issue,
|
||||||
|
assignee_id=assignee_id,
|
||||||
|
project_id=project_id,
|
||||||
|
workspace_id=project.workspace_id,
|
||||||
|
created_by=request.user,
|
||||||
|
updated_by=request.user,
|
||||||
|
)
|
||||||
|
for assignee_id in assignees_list
|
||||||
|
]
|
||||||
|
|
||||||
|
_ = IssueAssignee.objects.bulk_create(
|
||||||
|
bulk_issue_assignees, batch_size=100, ignore_conflicts=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track the issue activities
|
||||||
|
IssueActivity.objects.bulk_create(
|
||||||
|
[
|
||||||
|
IssueActivity(
|
||||||
|
issue=issue,
|
||||||
|
actor=request.user,
|
||||||
|
project_id=project_id,
|
||||||
|
workspace_id=project.workspace_id,
|
||||||
|
comment=f"{request.user.email} importer the issue from {service}",
|
||||||
|
verb="created",
|
||||||
|
)
|
||||||
|
for issue in issues
|
||||||
|
],
|
||||||
|
batch_size=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create Comments
|
||||||
|
bulk_issue_comments = []
|
||||||
|
for issue, issue_data in zip(issues, issues_data):
|
||||||
|
comments_list = issue_data.get("comments_list", [])
|
||||||
|
bulk_issue_comments = bulk_issue_comments + [
|
||||||
|
IssueComment(
|
||||||
|
issue=issue,
|
||||||
|
comment_html=comment.get("comment_html", "<p></p>"),
|
||||||
|
actor=request.user,
|
||||||
|
project_id=project_id,
|
||||||
|
workspace_id=project.workspace_id,
|
||||||
|
created_by=request.user,
|
||||||
|
updated_by=request.user,
|
||||||
|
)
|
||||||
|
for comment in comments_list
|
||||||
|
]
|
||||||
|
|
||||||
|
_ = IssueComment.objects.bulk_create(bulk_issue_comments, batch_size=100)
|
||||||
|
|
||||||
|
# Attach Links
|
||||||
|
_ = IssueLink.objects.bulk_create(
|
||||||
|
[
|
||||||
|
IssueLink(
|
||||||
|
issue=issue,
|
||||||
|
url=issue_data.get("link", {}).get("url", "https://github.com"),
|
||||||
|
title=issue_data.get("link", {}).get("title", "Original Issue"),
|
||||||
|
project_id=project_id,
|
||||||
|
workspace_id=project.workspace_id,
|
||||||
|
created_by=request.user,
|
||||||
|
updated_by=request.user,
|
||||||
|
)
|
||||||
|
for issue, issue_data in zip(issues, issues_data)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{"issues": IssueFlatSerializer(issues, many=True).data},
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
except Project.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Project Does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkImportModulesEndpoint(BaseAPIView):
|
||||||
|
def post(self, request, slug, project_id, service):
|
||||||
|
try:
|
||||||
|
modules_data = request.data.get("modules_data", [])
|
||||||
|
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||||
|
|
||||||
|
modules = Module.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Module(
|
||||||
|
name=module.get("name", uuid.uuid4().hex),
|
||||||
|
description=module.get("description", ""),
|
||||||
|
start_date=module.get("start_date", None),
|
||||||
|
target_date=module.get("target_date", None),
|
||||||
|
project_id=project_id,
|
||||||
|
workspace_id=project.workspace_id,
|
||||||
|
created_by=request.user,
|
||||||
|
updated_by=request.user,
|
||||||
|
)
|
||||||
|
for module in modules_data
|
||||||
|
],
|
||||||
|
batch_size=100,
|
||||||
|
ignore_conflicts=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = ModuleLink.objects.bulk_create(
|
||||||
|
[
|
||||||
|
ModuleLink(
|
||||||
|
module=module,
|
||||||
|
url=module_data.get("link", {}).get("url", "https://plane.so"),
|
||||||
|
title=module_data.get("link", {}).get(
|
||||||
|
"title", "Original Issue"
|
||||||
|
),
|
||||||
|
project_id=project_id,
|
||||||
|
workspace_id=project.workspace_id,
|
||||||
|
created_by=request.user,
|
||||||
|
updated_by=request.user,
|
||||||
|
)
|
||||||
|
for module, module_data in zip(modules, modules_data)
|
||||||
|
],
|
||||||
|
batch_size=100,
|
||||||
|
ignore_conflicts=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
bulk_module_issues = []
|
||||||
|
for module, module_data in zip(modules, modules_data):
|
||||||
|
module_issues_list = module_data.get("module_issues_list", [])
|
||||||
|
bulk_module_issues = bulk_module_issues + [
|
||||||
|
ModuleIssue(
|
||||||
|
issue_id=issue,
|
||||||
|
module=module,
|
||||||
|
project_id=project_id,
|
||||||
|
workspace_id=project.workspace_id,
|
||||||
|
created_by=request.user,
|
||||||
|
updated_by=request.user,
|
||||||
|
)
|
||||||
|
for issue in module_issues_list
|
||||||
|
]
|
||||||
|
|
||||||
|
_ = ModuleIssue.objects.bulk_create(
|
||||||
|
bulk_module_issues, batch_size=100, ignore_conflicts=True
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = ModuleSerializer(modules, many=True)
|
||||||
|
return Response(
|
||||||
|
{"modules": serializer.data}, status=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
except Project.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
@ -2,6 +2,7 @@ from .base import IntegrationViewSet, WorkspaceIntegrationViewSet
|
|||||||
from .github import (
|
from .github import (
|
||||||
GithubRepositorySyncViewSet,
|
GithubRepositorySyncViewSet,
|
||||||
GithubIssueSyncViewSet,
|
GithubIssueSyncViewSet,
|
||||||
|
BulkCreateGithubIssueSyncEndpoint,
|
||||||
GithubCommentSyncViewSet,
|
GithubCommentSyncViewSet,
|
||||||
GithubRepositoriesEndpoint,
|
GithubRepositoriesEndpoint,
|
||||||
)
|
)
|
||||||
|
@ -25,7 +25,7 @@ from plane.utils.integrations.github import (
|
|||||||
get_github_metadata,
|
get_github_metadata,
|
||||||
delete_github_installation,
|
delete_github_installation,
|
||||||
)
|
)
|
||||||
|
from plane.api.permissions import WorkSpaceAdminPermission
|
||||||
|
|
||||||
class IntegrationViewSet(BaseViewSet):
|
class IntegrationViewSet(BaseViewSet):
|
||||||
serializer_class = IntegrationSerializer
|
serializer_class = IntegrationSerializer
|
||||||
@ -75,11 +75,33 @@ class IntegrationViewSet(BaseViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def destroy(self, request, pk):
|
||||||
|
try:
|
||||||
|
integration = Integration.objects.get(pk=pk)
|
||||||
|
if integration.verified:
|
||||||
|
return Response(
|
||||||
|
{"error": "Verified integrations cannot be updated"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
integration.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
except Integration.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Integration Does not exist"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceIntegrationViewSet(BaseViewSet):
|
class WorkspaceIntegrationViewSet(BaseViewSet):
|
||||||
serializer_class = WorkspaceIntegrationSerializer
|
serializer_class = WorkspaceIntegrationSerializer
|
||||||
model = WorkspaceIntegration
|
model = WorkspaceIntegration
|
||||||
|
|
||||||
|
permission_classes = [
|
||||||
|
WorkSpaceAdminPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return (
|
return (
|
||||||
super()
|
super()
|
||||||
|
@ -13,6 +13,7 @@ from plane.db.models import (
|
|||||||
ProjectMember,
|
ProjectMember,
|
||||||
Label,
|
Label,
|
||||||
GithubCommentSync,
|
GithubCommentSync,
|
||||||
|
Project,
|
||||||
)
|
)
|
||||||
from plane.api.serializers import (
|
from plane.api.serializers import (
|
||||||
GithubIssueSyncSerializer,
|
GithubIssueSyncSerializer,
|
||||||
@ -20,15 +21,27 @@ from plane.api.serializers import (
|
|||||||
GithubCommentSyncSerializer,
|
GithubCommentSyncSerializer,
|
||||||
)
|
)
|
||||||
from plane.utils.integrations.github import get_github_repos
|
from plane.utils.integrations.github import get_github_repos
|
||||||
|
from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission
|
||||||
|
|
||||||
|
|
||||||
class GithubRepositoriesEndpoint(BaseAPIView):
|
class GithubRepositoriesEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectBasePermission,
|
||||||
|
]
|
||||||
|
|
||||||
def get(self, request, slug, workspace_integration_id):
|
def get(self, request, slug, workspace_integration_id):
|
||||||
try:
|
try:
|
||||||
page = request.GET.get("page", 1)
|
page = request.GET.get("page", 1)
|
||||||
workspace_integration = WorkspaceIntegration.objects.get(
|
workspace_integration = WorkspaceIntegration.objects.get(
|
||||||
workspace__slug=slug, pk=workspace_integration_id
|
workspace__slug=slug, pk=workspace_integration_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if workspace_integration.integration.provider != "github":
|
||||||
|
return Response(
|
||||||
|
{"error": "Not a github integration"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
|
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
|
||||||
repositories_url = (
|
repositories_url = (
|
||||||
workspace_integration.metadata["repositories_url"]
|
workspace_integration.metadata["repositories_url"]
|
||||||
@ -44,6 +57,10 @@ class GithubRepositoriesEndpoint(BaseAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class GithubRepositorySyncViewSet(BaseViewSet):
|
class GithubRepositorySyncViewSet(BaseViewSet):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectBasePermission,
|
||||||
|
]
|
||||||
|
|
||||||
serializer_class = GithubRepositorySyncSerializer
|
serializer_class = GithubRepositorySyncSerializer
|
||||||
model = GithubRepositorySync
|
model = GithubRepositorySync
|
||||||
|
|
||||||
@ -84,10 +101,6 @@ class GithubRepositorySyncViewSet(BaseViewSet):
|
|||||||
GithubRepository.objects.filter(
|
GithubRepository.objects.filter(
|
||||||
project_id=project_id, workspace__slug=slug
|
project_id=project_id, workspace__slug=slug
|
||||||
).delete()
|
).delete()
|
||||||
# Project member delete
|
|
||||||
ProjectMember.objects.filter(
|
|
||||||
member=workspace_integration.actor, role=20, project_id=project_id
|
|
||||||
).delete()
|
|
||||||
|
|
||||||
# Create repository
|
# Create repository
|
||||||
repo = GithubRepository.objects.create(
|
repo = GithubRepository.objects.create(
|
||||||
@ -124,7 +137,7 @@ class GithubRepositorySyncViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Add bot as a member in the project
|
# Add bot as a member in the project
|
||||||
_ = ProjectMember.objects.create(
|
_ = ProjectMember.objects.get_or_create(
|
||||||
member=workspace_integration.actor, role=20, project_id=project_id
|
member=workspace_integration.actor, role=20, project_id=project_id
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -148,6 +161,10 @@ class GithubRepositorySyncViewSet(BaseViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class GithubIssueSyncViewSet(BaseViewSet):
|
class GithubIssueSyncViewSet(BaseViewSet):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
serializer_class = GithubIssueSyncSerializer
|
serializer_class = GithubIssueSyncSerializer
|
||||||
model = GithubIssueSync
|
model = GithubIssueSync
|
||||||
|
|
||||||
@ -158,7 +175,52 @@ class GithubIssueSyncViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkCreateGithubIssueSyncEndpoint(BaseAPIView):
|
||||||
|
def post(self, request, slug, project_id, repo_sync_id):
|
||||||
|
try:
|
||||||
|
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||||
|
|
||||||
|
github_issue_syncs = request.data.get("github_issue_syncs", [])
|
||||||
|
github_issue_syncs = GithubIssueSync.objects.bulk_create(
|
||||||
|
[
|
||||||
|
GithubIssueSync(
|
||||||
|
issue_id=github_issue_sync.get("issue"),
|
||||||
|
repo_issue_id=github_issue_sync.get("repo_issue_id"),
|
||||||
|
issue_url=github_issue_sync.get("issue_url"),
|
||||||
|
github_issue_id=github_issue_sync.get("github_issue_id"),
|
||||||
|
repository_sync_id=repo_sync_id,
|
||||||
|
project_id=project_id,
|
||||||
|
workspace_id=project.workspace_id,
|
||||||
|
created_by=request.user,
|
||||||
|
updated_by=request.user,
|
||||||
|
)
|
||||||
|
for github_issue_sync in github_issue_syncs
|
||||||
|
],
|
||||||
|
batch_size=100,
|
||||||
|
ignore_conflicts=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = GithubIssueSyncSerializer(github_issue_syncs, many=True)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
except Project.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Project does not exist"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GithubCommentSyncViewSet(BaseViewSet):
|
class GithubCommentSyncViewSet(BaseViewSet):
|
||||||
|
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
serializer_class = GithubCommentSyncSerializer
|
serializer_class = GithubCommentSyncSerializer
|
||||||
model = GithubCommentSync
|
model = GithubCommentSync
|
||||||
|
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import json
|
import json
|
||||||
|
import random
|
||||||
from itertools import groupby, chain
|
from itertools import groupby, chain
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.db.models import Prefetch, OuterRef, Func, F, Q
|
from django.db.models import Prefetch, OuterRef, Func, F, Q
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views.decorators.gzip import gzip_page
|
||||||
|
|
||||||
# Third Party imports
|
# Third Party imports
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -24,6 +27,7 @@ from plane.api.serializers import (
|
|||||||
LabelSerializer,
|
LabelSerializer,
|
||||||
IssueFlatSerializer,
|
IssueFlatSerializer,
|
||||||
IssueLinkSerializer,
|
IssueLinkSerializer,
|
||||||
|
IssueLiteSerializer,
|
||||||
)
|
)
|
||||||
from plane.api.permissions import (
|
from plane.api.permissions import (
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
@ -38,13 +42,11 @@ from plane.db.models import (
|
|||||||
TimelineIssue,
|
TimelineIssue,
|
||||||
IssueProperty,
|
IssueProperty,
|
||||||
Label,
|
Label,
|
||||||
IssueBlocker,
|
|
||||||
CycleIssue,
|
|
||||||
ModuleIssue,
|
|
||||||
IssueLink,
|
IssueLink,
|
||||||
)
|
)
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
from plane.utils.grouper import group_results
|
from plane.utils.grouper import group_results
|
||||||
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
|
||||||
|
|
||||||
class IssueViewSet(BaseViewSet):
|
class IssueViewSet(BaseViewSet):
|
||||||
@ -133,59 +135,29 @@ class IssueViewSet(BaseViewSet):
|
|||||||
.select_related("parent")
|
.select_related("parent")
|
||||||
.prefetch_related("assignees")
|
.prefetch_related("assignees")
|
||||||
.prefetch_related("labels")
|
.prefetch_related("labels")
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"blocked_issues",
|
|
||||||
queryset=IssueBlocker.objects.select_related("blocked_by", "block"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"blocker_issues",
|
|
||||||
queryset=IssueBlocker.objects.select_related("block", "blocked_by"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"issue_cycle",
|
|
||||||
queryset=CycleIssue.objects.select_related("cycle", "issue"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"issue_module",
|
|
||||||
queryset=ModuleIssue.objects.select_related(
|
|
||||||
"module", "issue"
|
|
||||||
).prefetch_related("module__members"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"issue_link",
|
|
||||||
queryset=IssueLink.objects.select_related("issue").select_related(
|
|
||||||
"created_by"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@method_decorator(gzip_page)
|
||||||
def list(self, request, slug, project_id):
|
def list(self, request, slug, project_id):
|
||||||
try:
|
try:
|
||||||
# Issue State groups
|
filters = issue_filters(request.query_params, "GET")
|
||||||
type = request.GET.get("type", "all")
|
show_sub_issues = request.GET.get("show_sub_issues", "true")
|
||||||
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
|
||||||
if type == "backlog":
|
|
||||||
group = ["backlog"]
|
|
||||||
if type == "active":
|
|
||||||
group = ["unstarted", "started"]
|
|
||||||
|
|
||||||
issue_queryset = (
|
issue_queryset = (
|
||||||
self.get_queryset()
|
self.get_queryset()
|
||||||
.order_by(request.GET.get("order_by", "created_at"))
|
.order_by(request.GET.get("order_by", "created_at"))
|
||||||
.filter(state__group__in=group)
|
.filter(**filters)
|
||||||
|
.annotate(cycle_id=F("issue_cycle__id"))
|
||||||
|
.annotate(module_id=F("issue_module__id"))
|
||||||
)
|
)
|
||||||
|
|
||||||
issues = IssueSerializer(issue_queryset, many=True).data
|
issue_queryset = (
|
||||||
|
issue_queryset
|
||||||
|
if show_sub_issues == "true"
|
||||||
|
else issue_queryset.filter(parent__isnull=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
issues = IssueLiteSerializer(issue_queryset, many=True).data
|
||||||
|
|
||||||
## Grouping the results
|
## Grouping the results
|
||||||
group_by = request.GET.get("group_by", False)
|
group_by = request.GET.get("group_by", False)
|
||||||
@ -197,7 +169,6 @@ class IssueViewSet(BaseViewSet):
|
|||||||
return Response(issues, status=status.HTTP_200_OK)
|
return Response(issues, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Something went wrong please try again later"},
|
{"error": "Something went wrong please try again later"},
|
||||||
@ -235,8 +206,20 @@ class IssueViewSet(BaseViewSet):
|
|||||||
{"error": "Project was not found"}, status=status.HTTP_404_NOT_FOUND
|
{"error": "Project was not found"}, status=status.HTTP_404_NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def retrieve(self, request, slug, project_id, pk=None):
|
||||||
|
try:
|
||||||
|
issue = Issue.objects.get(
|
||||||
|
workspace__slug=slug, project_id=project_id, pk=pk
|
||||||
|
)
|
||||||
|
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||||
|
except Issue.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserWorkSpaceIssues(BaseAPIView):
|
class UserWorkSpaceIssues(BaseAPIView):
|
||||||
|
@method_decorator(gzip_page)
|
||||||
def get(self, request, slug):
|
def get(self, request, slug):
|
||||||
try:
|
try:
|
||||||
issues = (
|
issues = (
|
||||||
@ -253,44 +236,9 @@ class UserWorkSpaceIssues(BaseAPIView):
|
|||||||
.select_related("parent")
|
.select_related("parent")
|
||||||
.prefetch_related("assignees")
|
.prefetch_related("assignees")
|
||||||
.prefetch_related("labels")
|
.prefetch_related("labels")
|
||||||
.prefetch_related(
|
.order_by("-created_at")
|
||||||
Prefetch(
|
|
||||||
"blocked_issues",
|
|
||||||
queryset=IssueBlocker.objects.select_related(
|
|
||||||
"blocked_by", "block"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"blocker_issues",
|
|
||||||
queryset=IssueBlocker.objects.select_related(
|
|
||||||
"block", "blocked_by"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"issue_cycle",
|
|
||||||
queryset=CycleIssue.objects.select_related("cycle", "issue"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"issue_module",
|
|
||||||
queryset=ModuleIssue.objects.select_related("module", "issue"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"issue_link",
|
|
||||||
queryset=IssueLink.objects.select_related(
|
|
||||||
"issue"
|
|
||||||
).select_related("created_by"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
serializer = IssueSerializer(issues, many=True)
|
serializer = IssueLiteSerializer(issues, many=True)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
@ -305,10 +253,13 @@ class WorkSpaceIssuesEndpoint(BaseAPIView):
|
|||||||
WorkSpaceAdminPermission,
|
WorkSpaceAdminPermission,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@method_decorator(gzip_page)
|
||||||
def get(self, request, slug):
|
def get(self, request, slug):
|
||||||
try:
|
try:
|
||||||
issues = Issue.objects.filter(workspace__slug=slug).filter(
|
issues = (
|
||||||
project__project_projectmember__member=self.request.user
|
Issue.objects.filter(workspace__slug=slug)
|
||||||
|
.filter(project__project_projectmember__member=self.request.user)
|
||||||
|
.order_by("-created_at")
|
||||||
)
|
)
|
||||||
serializer = IssueSerializer(issues, many=True)
|
serializer = IssueSerializer(issues, many=True)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
@ -325,6 +276,7 @@ class IssueActivityEndpoint(BaseAPIView):
|
|||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@method_decorator(gzip_page)
|
||||||
def get(self, request, slug, project_id, issue_id):
|
def get(self, request, slug, project_id, issue_id):
|
||||||
try:
|
try:
|
||||||
issue_activities = (
|
issue_activities = (
|
||||||
@ -333,8 +285,8 @@ class IssueActivityEndpoint(BaseAPIView):
|
|||||||
~Q(field="comment"),
|
~Q(field="comment"),
|
||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
)
|
)
|
||||||
.select_related("actor")
|
.select_related("actor", "workspace")
|
||||||
).order_by("created_by")
|
).order_by("created_at")
|
||||||
issue_comments = (
|
issue_comments = (
|
||||||
IssueComment.objects.filter(issue_id=issue_id)
|
IssueComment.objects.filter(issue_id=issue_id)
|
||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(project__project_projectmember__member=self.request.user)
|
||||||
@ -561,6 +513,7 @@ class LabelViewSet(BaseViewSet):
|
|||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("parent")
|
.select_related("parent")
|
||||||
|
.order_by("name")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -605,6 +558,7 @@ class SubIssuesEndpoint(BaseAPIView):
|
|||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@method_decorator(gzip_page)
|
||||||
def get(self, request, slug, project_id, issue_id):
|
def get(self, request, slug, project_id, issue_id):
|
||||||
try:
|
try:
|
||||||
sub_issues = (
|
sub_issues = (
|
||||||
@ -617,37 +571,9 @@ class SubIssuesEndpoint(BaseAPIView):
|
|||||||
.select_related("parent")
|
.select_related("parent")
|
||||||
.prefetch_related("assignees")
|
.prefetch_related("assignees")
|
||||||
.prefetch_related("labels")
|
.prefetch_related("labels")
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"blocked_issues",
|
|
||||||
queryset=IssueBlocker.objects.select_related(
|
|
||||||
"blocked_by", "block"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"blocker_issues",
|
|
||||||
queryset=IssueBlocker.objects.select_related(
|
|
||||||
"block", "blocked_by"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"issue_cycle",
|
|
||||||
queryset=CycleIssue.objects.select_related("cycle", "issue"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"issue_module",
|
|
||||||
queryset=ModuleIssue.objects.select_related("module", "issue"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = IssueSerializer(sub_issues, many=True)
|
serializer = IssueLiteSerializer(sub_issues, many=True)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
@ -715,5 +641,45 @@ class IssueLinkViewSet(BaseViewSet):
|
|||||||
.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)
|
||||||
|
.order_by("-created_at")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkCreateIssueLabelsEndpoint(BaseAPIView):
|
||||||
|
def post(self, request, slug, project_id):
|
||||||
|
try:
|
||||||
|
label_data = request.data.get("label_data", [])
|
||||||
|
project = Project.objects.get(pk=project_id)
|
||||||
|
|
||||||
|
labels = Label.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Label(
|
||||||
|
name=label.get("name", "Migrated"),
|
||||||
|
description=label.get("description", "Migrated Issue"),
|
||||||
|
color="#" + "%06x" % random.randint(0, 0xFFFFFF),
|
||||||
|
project_id=project_id,
|
||||||
|
workspace_id=project.workspace_id,
|
||||||
|
created_by=request.user,
|
||||||
|
updated_by=request.user,
|
||||||
|
)
|
||||||
|
for label in label_data
|
||||||
|
],
|
||||||
|
batch_size=50,
|
||||||
|
ignore_conflicts=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{"labels": LabelSerializer(labels, many=True).data},
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
except Project.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Project Does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
@ -3,8 +3,10 @@ import json
|
|||||||
|
|
||||||
# Django Imports
|
# Django Imports
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.db.models import Prefetch, F, OuterRef, Func, Exists
|
from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q
|
||||||
from django.core import serializers
|
from django.core import serializers
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views.decorators.gzip import gzip_page
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -19,6 +21,7 @@ from plane.api.serializers import (
|
|||||||
ModuleIssueSerializer,
|
ModuleIssueSerializer,
|
||||||
ModuleLinkSerializer,
|
ModuleLinkSerializer,
|
||||||
ModuleFavoriteSerializer,
|
ModuleFavoriteSerializer,
|
||||||
|
IssueStateSerializer,
|
||||||
)
|
)
|
||||||
from plane.api.permissions import ProjectEntityPermission
|
from plane.api.permissions import ProjectEntityPermission
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
@ -31,6 +34,7 @@ from plane.db.models import (
|
|||||||
)
|
)
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
from plane.utils.grouper import group_results
|
from plane.utils.grouper import group_results
|
||||||
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
|
||||||
|
|
||||||
class ModuleViewSet(BaseViewSet):
|
class ModuleViewSet(BaseViewSet):
|
||||||
@ -47,29 +51,60 @@ class ModuleViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
subquery = ModuleFavorite.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
module_id=OuterRef("pk"),
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
super()
|
super()
|
||||||
.get_queryset()
|
.get_queryset()
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.select_related("lead")
|
.select_related("lead")
|
||||||
.prefetch_related("members")
|
.prefetch_related("members")
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"issue_module",
|
|
||||||
queryset=ModuleIssue.objects.select_related(
|
|
||||||
"module", "issue", "issue__state", "issue__project"
|
|
||||||
).prefetch_related("issue__assignees", "issue__labels"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
Prefetch(
|
Prefetch(
|
||||||
"link_module",
|
"link_module",
|
||||||
queryset=ModuleLink.objects.select_related("module", "created_by"),
|
queryset=ModuleLink.objects.select_related("module", "created_by"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.annotate(total_issues=Count("issue_module"))
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"issue_module__issue__state__group",
|
||||||
|
filter=Q(issue_module__issue__state__group="completed"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_issues=Count(
|
||||||
|
"issue_module__issue__state__group",
|
||||||
|
filter=Q(issue_module__issue__state__group="cancelled"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_issues=Count(
|
||||||
|
"issue_module__issue__state__group",
|
||||||
|
filter=Q(issue_module__issue__state__group="started"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_issues=Count(
|
||||||
|
"issue_module__issue__state__group",
|
||||||
|
filter=Q(issue_module__issue__state__group="unstarted"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_issues=Count(
|
||||||
|
"issue_module__issue__state__group",
|
||||||
|
filter=Q(issue_module__issue__state__group="backlog"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("-is_favorite", "name")
|
||||||
)
|
)
|
||||||
|
|
||||||
def create(self, request, slug, project_id):
|
def create(self, request, slug, project_id):
|
||||||
@ -101,23 +136,6 @@ class ModuleViewSet(BaseViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
def list(self, request, slug, project_id):
|
|
||||||
try:
|
|
||||||
subquery = ModuleFavorite.objects.filter(
|
|
||||||
user=self.request.user,
|
|
||||||
module_id=OuterRef("pk"),
|
|
||||||
project_id=project_id,
|
|
||||||
workspace__slug=slug,
|
|
||||||
)
|
|
||||||
modules = self.get_queryset().annotate(is_favorite=Exists(subquery))
|
|
||||||
return Response(ModuleSerializer(modules, many=True).data)
|
|
||||||
except Exception as e:
|
|
||||||
capture_exception(e)
|
|
||||||
return Response(
|
|
||||||
{"error": "Something went wrong please try again later"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ModuleIssueViewSet(BaseViewSet):
|
class ModuleIssueViewSet(BaseViewSet):
|
||||||
serializer_class = ModuleIssueSerializer
|
serializer_class = ModuleIssueSerializer
|
||||||
@ -161,22 +179,43 @@ class ModuleIssueViewSet(BaseViewSet):
|
|||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@method_decorator(gzip_page)
|
||||||
def list(self, request, slug, project_id, module_id):
|
def list(self, request, slug, project_id, module_id):
|
||||||
try:
|
try:
|
||||||
order_by = request.GET.get("order_by", "issue__created_at")
|
order_by = request.GET.get("order_by", "created_at")
|
||||||
queryset = self.get_queryset().order_by(f"issue__{order_by}")
|
|
||||||
group_by = request.GET.get("group_by", False)
|
group_by = request.GET.get("group_by", False)
|
||||||
|
filters = issue_filters(request.query_params, "GET")
|
||||||
|
issues = (
|
||||||
|
Issue.objects.filter(issue_module__module_id=module_id)
|
||||||
|
.annotate(
|
||||||
|
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.annotate(bridge_id=F("issue_module__id"))
|
||||||
|
.filter(project_id=project_id)
|
||||||
|
.filter(workspace__slug=slug)
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("state")
|
||||||
|
.select_related("parent")
|
||||||
|
.prefetch_related("assignees")
|
||||||
|
.prefetch_related("labels")
|
||||||
|
.order_by(order_by)
|
||||||
|
.filter(**filters)
|
||||||
|
)
|
||||||
|
|
||||||
module_issues = ModuleIssueSerializer(queryset, many=True).data
|
issues_data = IssueStateSerializer(issues, many=True).data
|
||||||
|
|
||||||
if group_by:
|
if group_by:
|
||||||
return Response(
|
return Response(
|
||||||
group_results(module_issues, f"issue_detail.{group_by}"),
|
group_results(issues_data, group_by),
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
module_issues,
|
issues_data,
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -302,11 +341,16 @@ class ModuleLinkViewSet(BaseViewSet):
|
|||||||
.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)
|
||||||
|
.order_by("-created_at")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ModuleFavoriteViewSet(BaseViewSet):
|
class ModuleFavoriteViewSet(BaseViewSet):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
serializer_class = ModuleFavoriteSerializer
|
serializer_class = ModuleFavoriteSerializer
|
||||||
model = ModuleFavorite
|
model = ModuleFavorite
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ import os
|
|||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
# Third Party modules
|
# Third Party modules
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -204,7 +205,26 @@ class OauthEndpoint(BaseAPIView):
|
|||||||
"last_login_at": timezone.now(),
|
"last_login_at": timezone.now(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if settings.ANALYTICS_BASE_API:
|
||||||
|
_ = requests.post(
|
||||||
|
settings.ANALYTICS_BASE_API,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"event_id": uuid.uuid4().hex,
|
||||||
|
"event_data": {
|
||||||
|
"medium": f"oauth-{medium}",
|
||||||
|
},
|
||||||
|
"user": {"email": email, "id": str(user.id)},
|
||||||
|
"device_ctx": {
|
||||||
|
"ip": request.META.get("REMOTE_ADDR"),
|
||||||
|
"user_agent": request.META.get("HTTP_USER_AGENT"),
|
||||||
|
},
|
||||||
|
"event_type": "SIGN_IN",
|
||||||
|
},
|
||||||
|
)
|
||||||
return Response(data, status=status.HTTP_200_OK)
|
return Response(data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
@ -253,6 +273,26 @@ class OauthEndpoint(BaseAPIView):
|
|||||||
"user": serialized_user,
|
"user": serialized_user,
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
}
|
}
|
||||||
|
if settings.ANALYTICS_BASE_API:
|
||||||
|
_ = requests.post(
|
||||||
|
settings.ANALYTICS_BASE_API,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"event_id": uuid.uuid4().hex,
|
||||||
|
"event_data": {
|
||||||
|
"medium": f"oauth-{medium}",
|
||||||
|
},
|
||||||
|
"user": {"email": email, "id": str(user.id)},
|
||||||
|
"device_ctx": {
|
||||||
|
"ip": request.META.get("REMOTE_ADDR"),
|
||||||
|
"user_agent": request.META.get("HTTP_USER_AGENT"),
|
||||||
|
},
|
||||||
|
"event_type": "SIGN_UP",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
SocialLoginConnection.objects.update_or_create(
|
SocialLoginConnection.objects.update_or_create(
|
||||||
medium=medium,
|
medium=medium,
|
||||||
|
487
apiserver/plane/api/views/page.py
Normal file
487
apiserver/plane/api/views/page.py
Normal file
@ -0,0 +1,487 @@
|
|||||||
|
# Python imports
|
||||||
|
from datetime import timedelta, datetime, date
|
||||||
|
|
||||||
|
# Django imports
|
||||||
|
from django.db import IntegrityError
|
||||||
|
from django.db.models import Exists, OuterRef, Q, Prefetch
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from .base import BaseViewSet, BaseAPIView
|
||||||
|
from plane.api.permissions import ProjectEntityPermission
|
||||||
|
from plane.db.models import (
|
||||||
|
Page,
|
||||||
|
PageBlock,
|
||||||
|
PageFavorite,
|
||||||
|
Issue,
|
||||||
|
IssueAssignee,
|
||||||
|
IssueActivity,
|
||||||
|
)
|
||||||
|
from plane.api.serializers import (
|
||||||
|
PageSerializer,
|
||||||
|
PageBlockSerializer,
|
||||||
|
PageFavoriteSerializer,
|
||||||
|
IssueLiteSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PageViewSet(BaseViewSet):
|
||||||
|
serializer_class = PageSerializer
|
||||||
|
model = Page
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
search_fields = [
|
||||||
|
"name",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
subquery = PageFavorite.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
page_id=OuterRef("pk"),
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
)
|
||||||
|
return self.filter_queryset(
|
||||||
|
super()
|
||||||
|
.get_queryset()
|
||||||
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
|
.filter(project__project_projectmember__member=self.request.user)
|
||||||
|
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("owned_by")
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
.order_by(self.request.GET.get("order_by", "-created_at"))
|
||||||
|
.prefetch_related("labels")
|
||||||
|
.order_by("name", "-is_favorite")
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"blocks",
|
||||||
|
queryset=PageBlock.objects.select_related(
|
||||||
|
"page", "issue", "workspace", "project"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(
|
||||||
|
project_id=self.kwargs.get("project_id"), owned_by=self.request.user
|
||||||
|
)
|
||||||
|
|
||||||
|
def create(self, request, slug, project_id):
|
||||||
|
try:
|
||||||
|
serializer = PageSerializer(
|
||||||
|
data=request.data,
|
||||||
|
context={"project_id": project_id, "owned_by_id": request.user.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PageBlockViewSet(BaseViewSet):
|
||||||
|
serializer_class = PageBlockSerializer
|
||||||
|
model = PageBlock
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.filter_queryset(
|
||||||
|
super()
|
||||||
|
.get_queryset()
|
||||||
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
|
.filter(page_id=self.kwargs.get("page_id"))
|
||||||
|
.filter(project__project_projectmember__member=self.request.user)
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("page")
|
||||||
|
.select_related("issue")
|
||||||
|
.order_by("sort_order")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
page_id=self.kwargs.get("page_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PageFavoriteViewSet(BaseViewSet):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
serializer_class = PageFavoriteSerializer
|
||||||
|
model = PageFavorite
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.filter_queryset(
|
||||||
|
super()
|
||||||
|
.get_queryset()
|
||||||
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
|
.filter(user=self.request.user)
|
||||||
|
.select_related("page", "page__owned_by")
|
||||||
|
)
|
||||||
|
|
||||||
|
def create(self, request, slug, project_id):
|
||||||
|
try:
|
||||||
|
serializer = PageFavoriteSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save(user=request.user, project_id=project_id)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except IntegrityError as e:
|
||||||
|
if "already exists" in str(e):
|
||||||
|
return Response(
|
||||||
|
{"error": "The page is already added to favorites"},
|
||||||
|
status=status.HTTP_410_GONE,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
def destroy(self, request, slug, project_id, page_id):
|
||||||
|
try:
|
||||||
|
page_favorite = PageFavorite.objects.get(
|
||||||
|
project=project_id,
|
||||||
|
user=request.user,
|
||||||
|
workspace__slug=slug,
|
||||||
|
page_id=page_id,
|
||||||
|
)
|
||||||
|
page_favorite.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
except PageFavorite.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Page is not in favorites"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateIssueFromPageBlockEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def post(self, request, slug, project_id, page_id, page_block_id):
|
||||||
|
try:
|
||||||
|
page_block = PageBlock.objects.get(
|
||||||
|
pk=page_block_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
page_id=page_id,
|
||||||
|
)
|
||||||
|
issue = Issue.objects.create(
|
||||||
|
name=page_block.name,
|
||||||
|
project_id=project_id,
|
||||||
|
description=page_block.description,
|
||||||
|
description_html=page_block.description_html,
|
||||||
|
description_stripped=page_block.description_stripped,
|
||||||
|
)
|
||||||
|
_ = IssueAssignee.objects.create(
|
||||||
|
issue=issue, assignee=request.user, project_id=project_id
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = IssueActivity.objects.create(
|
||||||
|
issue=issue,
|
||||||
|
actor=request.user,
|
||||||
|
project_id=project_id,
|
||||||
|
comment=f"{request.user.email} created the issue from {page_block.name} block",
|
||||||
|
verb="created",
|
||||||
|
)
|
||||||
|
|
||||||
|
page_block.issue = issue
|
||||||
|
page_block.save()
|
||||||
|
|
||||||
|
return Response(IssueLiteSerializer(issue).data, status=status.HTTP_200_OK)
|
||||||
|
except PageBlock.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Page Block does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecentPagesEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get(self, request, slug, project_id):
|
||||||
|
try:
|
||||||
|
subquery = PageFavorite.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
page_id=OuterRef("pk"),
|
||||||
|
project_id=project_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
)
|
||||||
|
current_time = date.today()
|
||||||
|
day_before = current_time - timedelta(days=1)
|
||||||
|
|
||||||
|
todays_pages = (
|
||||||
|
Page.objects.filter(
|
||||||
|
updated_at__date=date.today(),
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.filter(project__project_projectmember__member=request.user)
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("owned_by")
|
||||||
|
.prefetch_related("labels")
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"blocks",
|
||||||
|
queryset=PageBlock.objects.select_related(
|
||||||
|
"page", "issue", "workspace", "project"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("-is_favorite", "-updated_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
yesterdays_pages = (
|
||||||
|
Page.objects.filter(
|
||||||
|
updated_at__date=day_before,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.filter(project__project_projectmember__member=request.user)
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("owned_by")
|
||||||
|
.prefetch_related("labels")
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"blocks",
|
||||||
|
queryset=PageBlock.objects.select_related(
|
||||||
|
"page", "issue", "workspace", "project"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("-is_favorite", "-updated_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
earlier_this_week = (
|
||||||
|
Page.objects.filter(
|
||||||
|
updated_at__date__range=(
|
||||||
|
(timezone.now() - timedelta(days=7)),
|
||||||
|
(timezone.now() - timedelta(days=2)),
|
||||||
|
),
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||||
|
.filter(project__project_projectmember__member=request.user)
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("owned_by")
|
||||||
|
.prefetch_related("labels")
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"blocks",
|
||||||
|
queryset=PageBlock.objects.select_related(
|
||||||
|
"page", "issue", "workspace", "project"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("-is_favorite", "-updated_at")
|
||||||
|
)
|
||||||
|
todays_pages_serializer = PageSerializer(todays_pages, many=True)
|
||||||
|
yesterday_pages_serializer = PageSerializer(yesterdays_pages, many=True)
|
||||||
|
earlier_this_week_serializer = PageSerializer(earlier_this_week, many=True)
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"today": todays_pages_serializer.data,
|
||||||
|
"yesterday": yesterday_pages_serializer.data,
|
||||||
|
"earlier_this_week": earlier_this_week_serializer.data,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FavoritePagesEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get(self, request, slug, project_id):
|
||||||
|
try:
|
||||||
|
subquery = PageFavorite.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
page_id=OuterRef("pk"),
|
||||||
|
project_id=project_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
)
|
||||||
|
pages = (
|
||||||
|
Page.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||||
|
.filter(project__project_projectmember__member=request.user)
|
||||||
|
.filter(is_favorite=True)
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("owned_by")
|
||||||
|
.prefetch_related("labels")
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"blocks",
|
||||||
|
queryset=PageBlock.objects.select_related(
|
||||||
|
"page", "issue", "workspace", "project"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("name", "-is_favorite")
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = PageSerializer(pages, many=True)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MyPagesEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get(self, request, slug, project_id):
|
||||||
|
try:
|
||||||
|
subquery = PageFavorite.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
page_id=OuterRef("pk"),
|
||||||
|
project_id=project_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
)
|
||||||
|
pages = (
|
||||||
|
Page.objects.filter(
|
||||||
|
workspace__slug=slug, project_id=project_id, owned_by=request.user
|
||||||
|
)
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("owned_by")
|
||||||
|
.prefetch_related("labels")
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||||
|
.filter(project__project_projectmember__member=request.user)
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"blocks",
|
||||||
|
queryset=PageBlock.objects.select_related(
|
||||||
|
"page", "issue", "workspace", "project"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("-is_favorite", "name")
|
||||||
|
)
|
||||||
|
serializer = PageSerializer(pages, many=True)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreatedbyOtherPagesEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get(self, request, slug, project_id):
|
||||||
|
try:
|
||||||
|
subquery = PageFavorite.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
page_id=OuterRef("pk"),
|
||||||
|
project_id=project_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
)
|
||||||
|
pages = (
|
||||||
|
Page.objects.filter(
|
||||||
|
~Q(owned_by=request.user),
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
access=0,
|
||||||
|
)
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("owned_by")
|
||||||
|
.prefetch_related("labels")
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"blocks",
|
||||||
|
queryset=PageBlock.objects.select_related(
|
||||||
|
"page", "issue", "workspace", "project"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("-is_favorite", "name")
|
||||||
|
)
|
||||||
|
serializer = PageSerializer(pages, many=True)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
@ -7,10 +7,19 @@ from sentry_sdk import capture_exception
|
|||||||
# Module imports
|
# Module imports
|
||||||
from plane.api.serializers import (
|
from plane.api.serializers import (
|
||||||
UserSerializer,
|
UserSerializer,
|
||||||
|
IssueActivitySerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
from plane.api.views.base import BaseViewSet, BaseAPIView
|
from plane.api.views.base import BaseViewSet, BaseAPIView
|
||||||
from plane.db.models import User, Workspace
|
from plane.db.models import (
|
||||||
|
User,
|
||||||
|
Workspace,
|
||||||
|
WorkspaceMemberInvite,
|
||||||
|
Issue,
|
||||||
|
IssueActivity,
|
||||||
|
)
|
||||||
|
from plane.utils.paginator import BasePaginator
|
||||||
|
|
||||||
|
|
||||||
class UserEndpoint(BaseViewSet):
|
class UserEndpoint(BaseViewSet):
|
||||||
serializer_class = UserSerializer
|
serializer_class = UserSerializer
|
||||||
@ -22,11 +31,34 @@ class UserEndpoint(BaseViewSet):
|
|||||||
def retrieve(self, request):
|
def retrieve(self, request):
|
||||||
try:
|
try:
|
||||||
workspace = Workspace.objects.get(pk=request.user.last_workspace_id)
|
workspace = Workspace.objects.get(pk=request.user.last_workspace_id)
|
||||||
|
workspace_invites = WorkspaceMemberInvite.objects.filter(
|
||||||
|
email=request.user.email
|
||||||
|
).count()
|
||||||
|
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{"user": UserSerializer(request.user).data, "slug": workspace.slug}
|
{
|
||||||
|
"user": UserSerializer(request.user).data,
|
||||||
|
"slug": workspace.slug,
|
||||||
|
"workspace_invites": workspace_invites,
|
||||||
|
"assigned_issues": assigned_issues,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
except Workspace.DoesNotExist:
|
except Workspace.DoesNotExist:
|
||||||
return Response({"user": UserSerializer(request.user).data, "slug": None})
|
workspace_invites = WorkspaceMemberInvite.objects.filter(
|
||||||
|
email=request.user.email
|
||||||
|
).count()
|
||||||
|
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"user": UserSerializer(request.user).data,
|
||||||
|
"slug": None,
|
||||||
|
"workspace_invites": workspace_invites,
|
||||||
|
"assigned_issues": assigned_issues,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Something went wrong please try again later"},
|
{"error": "Something went wrong please try again later"},
|
||||||
@ -49,3 +81,25 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView):
|
|||||||
{"error": "Something went wrong please try again later"},
|
{"error": "Something went wrong please try again later"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserActivityEndpoint(BaseAPIView, BasePaginator):
|
||||||
|
def get(self, request):
|
||||||
|
try:
|
||||||
|
queryset = IssueActivity.objects.filter(actor=request.user).select_related(
|
||||||
|
"actor", "workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.paginate(
|
||||||
|
request=request,
|
||||||
|
queryset=queryset,
|
||||||
|
on_results=lambda issue_activities: IssueActivitySerializer(
|
||||||
|
issue_activities, many=True
|
||||||
|
).data,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
@ -64,6 +64,11 @@ class ProjectViewSet(BaseViewSet):
|
|||||||
return ProjectDetailSerializer
|
return ProjectDetailSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
subquery = ProjectFavorite.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
project_id=OuterRef("pk"),
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
)
|
||||||
return self.filter_queryset(
|
return self.filter_queryset(
|
||||||
super()
|
super()
|
||||||
.get_queryset()
|
.get_queryset()
|
||||||
@ -72,6 +77,7 @@ class ProjectViewSet(BaseViewSet):
|
|||||||
.select_related(
|
.select_related(
|
||||||
"workspace", "workspace__owner", "default_assignee", "project_lead"
|
"workspace", "workspace__owner", "default_assignee", "project_lead"
|
||||||
)
|
)
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -82,7 +88,11 @@ class ProjectViewSet(BaseViewSet):
|
|||||||
project_id=OuterRef("pk"),
|
project_id=OuterRef("pk"),
|
||||||
workspace__slug=self.kwargs.get("slug"),
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
)
|
)
|
||||||
projects = self.get_queryset().annotate(is_favorite=Exists(subquery))
|
projects = (
|
||||||
|
self.get_queryset()
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
.order_by("-is_favorite", "name")
|
||||||
|
)
|
||||||
return Response(ProjectDetailSerializer(projects, many=True).data)
|
return Response(ProjectDetailSerializer(projects, many=True).data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
@ -167,6 +177,12 @@ class ProjectViewSet(BaseViewSet):
|
|||||||
{"name": "The project name is already taken"},
|
{"name": "The project name is already taken"},
|
||||||
status=status.HTTP_410_GONE,
|
status=status.HTTP_410_GONE,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_410_GONE,
|
||||||
|
)
|
||||||
except Workspace.DoesNotExist as e:
|
except Workspace.DoesNotExist as e:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND
|
{"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
175
apiserver/plane/api/views/search.py
Normal file
175
apiserver/plane/api/views/search.py
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
# Python imports
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Django imports
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from .base import BaseAPIView
|
||||||
|
from plane.db.models import Workspace, Project, Issue, Cycle, Module, Page, IssueView
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSearchEndpoint(BaseAPIView):
|
||||||
|
"""Endpoint to search across multiple fields in the workspace and
|
||||||
|
also show related workspace if found
|
||||||
|
"""
|
||||||
|
|
||||||
|
def filter_workspaces(self, query, slug, project_id):
|
||||||
|
fields = ["name"]
|
||||||
|
q = Q()
|
||||||
|
for field in fields:
|
||||||
|
q |= Q(**{f"{field}__icontains": query})
|
||||||
|
return Workspace.objects.filter(
|
||||||
|
q, workspace_member__member=self.request.user
|
||||||
|
).distinct().values("name", "id", "slug")
|
||||||
|
|
||||||
|
def filter_projects(self, query, slug, project_id):
|
||||||
|
fields = ["name"]
|
||||||
|
q = Q()
|
||||||
|
for field in fields:
|
||||||
|
q |= Q(**{f"{field}__icontains": query})
|
||||||
|
return Project.objects.filter(
|
||||||
|
q,
|
||||||
|
Q(project_projectmember__member=self.request.user) | Q(network=2),
|
||||||
|
workspace__slug=slug,
|
||||||
|
).distinct().values("name", "id", "identifier", "workspace__slug")
|
||||||
|
|
||||||
|
def filter_issues(self, query, slug, project_id):
|
||||||
|
fields = ["name", "sequence_id"]
|
||||||
|
q = Q()
|
||||||
|
for field in fields:
|
||||||
|
if field == "sequence_id":
|
||||||
|
sequences = re.findall(r"\d+\.\d+|\d+", query)
|
||||||
|
for sequence_id in sequences:
|
||||||
|
q |= Q(**{"sequence_id": sequence_id})
|
||||||
|
else:
|
||||||
|
q |= Q(**{f"{field}__icontains": query})
|
||||||
|
return Issue.objects.filter(
|
||||||
|
q,
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
).distinct().values(
|
||||||
|
"name",
|
||||||
|
"id",
|
||||||
|
"sequence_id",
|
||||||
|
"project__identifier",
|
||||||
|
"project_id",
|
||||||
|
"workspace__slug",
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_cycles(self, query, slug, project_id):
|
||||||
|
fields = ["name"]
|
||||||
|
q = Q()
|
||||||
|
for field in fields:
|
||||||
|
q |= Q(**{f"{field}__icontains": query})
|
||||||
|
return Cycle.objects.filter(
|
||||||
|
q,
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
).distinct().values(
|
||||||
|
"name",
|
||||||
|
"id",
|
||||||
|
"project_id",
|
||||||
|
"workspace__slug",
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_modules(self, query, slug, project_id):
|
||||||
|
fields = ["name"]
|
||||||
|
q = Q()
|
||||||
|
for field in fields:
|
||||||
|
q |= Q(**{f"{field}__icontains": query})
|
||||||
|
return Module.objects.filter(
|
||||||
|
q,
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
).distinct().values(
|
||||||
|
"name",
|
||||||
|
"id",
|
||||||
|
"project_id",
|
||||||
|
"workspace__slug",
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_pages(self, query, slug, project_id):
|
||||||
|
fields = ["name"]
|
||||||
|
q = Q()
|
||||||
|
for field in fields:
|
||||||
|
q |= Q(**{f"{field}__icontains": query})
|
||||||
|
return Page.objects.filter(
|
||||||
|
q,
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
).distinct().values(
|
||||||
|
"name",
|
||||||
|
"id",
|
||||||
|
"project_id",
|
||||||
|
"workspace__slug",
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_views(self, query, slug, project_id):
|
||||||
|
fields = ["name"]
|
||||||
|
q = Q()
|
||||||
|
for field in fields:
|
||||||
|
q |= Q(**{f"{field}__icontains": query})
|
||||||
|
return IssueView.objects.filter(
|
||||||
|
q,
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
).distinct().values(
|
||||||
|
"name",
|
||||||
|
"id",
|
||||||
|
"project_id",
|
||||||
|
"workspace__slug",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, request, slug, project_id):
|
||||||
|
try:
|
||||||
|
query = request.query_params.get("search", False)
|
||||||
|
if not query:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"results": {
|
||||||
|
"workspace": [],
|
||||||
|
"project": [],
|
||||||
|
"issue": [],
|
||||||
|
"cycle": [],
|
||||||
|
"module": [],
|
||||||
|
"issue_view": [],
|
||||||
|
"page": [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
MODELS_MAPPER = {
|
||||||
|
"workspace": self.filter_workspaces,
|
||||||
|
"project": self.filter_projects,
|
||||||
|
"issue": self.filter_issues,
|
||||||
|
"cycle": self.filter_cycles,
|
||||||
|
"module": self.filter_modules,
|
||||||
|
"issue_view": self.filter_views,
|
||||||
|
"page": self.filter_pages,
|
||||||
|
}
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
for model in MODELS_MAPPER.keys():
|
||||||
|
func = MODELS_MAPPER.get(model, None)
|
||||||
|
results[model] = func(query, slug, project_id)
|
||||||
|
return Response({"results": results}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
@ -1,14 +1,35 @@
|
|||||||
|
# Django imports
|
||||||
|
from django.db import IntegrityError
|
||||||
|
from django.db.models import Prefetch, OuterRef, Exists
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status
|
||||||
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from . import BaseViewSet
|
from . import BaseViewSet, BaseAPIView
|
||||||
from plane.api.serializers import ViewSerializer
|
from plane.api.serializers import (
|
||||||
|
IssueViewSerializer,
|
||||||
|
IssueLiteSerializer,
|
||||||
|
IssueViewFavoriteSerializer,
|
||||||
|
)
|
||||||
from plane.api.permissions import ProjectEntityPermission
|
from plane.api.permissions import ProjectEntityPermission
|
||||||
from plane.db.models import View
|
from plane.db.models import (
|
||||||
|
IssueView,
|
||||||
|
Issue,
|
||||||
|
IssueBlocker,
|
||||||
|
IssueLink,
|
||||||
|
CycleIssue,
|
||||||
|
ModuleIssue,
|
||||||
|
IssueViewFavorite,
|
||||||
|
)
|
||||||
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
|
||||||
|
|
||||||
class ViewViewSet(BaseViewSet):
|
class IssueViewViewSet(BaseViewSet):
|
||||||
|
serializer_class = IssueViewSerializer
|
||||||
serializer_class = ViewSerializer
|
model = IssueView
|
||||||
model = View
|
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
]
|
]
|
||||||
@ -17,6 +38,12 @@ class ViewViewSet(BaseViewSet):
|
|||||||
serializer.save(project_id=self.kwargs.get("project_id"))
|
serializer.save(project_id=self.kwargs.get("project_id"))
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
subquery = IssueViewFavorite.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
view_id=OuterRef("pk"),
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
)
|
||||||
return self.filter_queryset(
|
return self.filter_queryset(
|
||||||
super()
|
super()
|
||||||
.get_queryset()
|
.get_queryset()
|
||||||
@ -25,5 +52,108 @@ class ViewViewSet(BaseViewSet):
|
|||||||
.filter(project__project_projectmember__member=self.request.user)
|
.filter(project__project_projectmember__member=self.request.user)
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
|
.annotate(is_favorite=Exists(subquery))
|
||||||
|
.order_by("-is_favorite", "name")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ViewIssuesEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get(self, request, slug, project_id, view_id):
|
||||||
|
try:
|
||||||
|
view = IssueView.objects.get(pk=view_id)
|
||||||
|
queries = view.query
|
||||||
|
|
||||||
|
filters = issue_filters(request.query_params, "GET")
|
||||||
|
|
||||||
|
issues = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
**queries, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
.filter(**filters)
|
||||||
|
.select_related("project")
|
||||||
|
.select_related("workspace")
|
||||||
|
.select_related("state")
|
||||||
|
.select_related("parent")
|
||||||
|
.prefetch_related("assignees")
|
||||||
|
.prefetch_related("labels")
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = IssueLiteSerializer(issues, many=True)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
except IssueView.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Issue View does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IssueViewFavoriteViewSet(BaseViewSet):
|
||||||
|
serializer_class = IssueViewFavoriteSerializer
|
||||||
|
model = IssueViewFavorite
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.filter_queryset(
|
||||||
|
super()
|
||||||
|
.get_queryset()
|
||||||
|
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
|
.filter(user=self.request.user)
|
||||||
|
.select_related("view")
|
||||||
|
)
|
||||||
|
|
||||||
|
def create(self, request, slug, project_id):
|
||||||
|
try:
|
||||||
|
serializer = IssueViewFavoriteSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save(user=request.user, project_id=project_id)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except IntegrityError as e:
|
||||||
|
if "already exists" in str(e):
|
||||||
|
return Response(
|
||||||
|
{"error": "The view is already added to favorites"},
|
||||||
|
status=status.HTTP_410_GONE,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
def destroy(self, request, slug, project_id, view_id):
|
||||||
|
try:
|
||||||
|
view_favourite = IssueViewFavorite.objects.get(
|
||||||
|
project=project_id,
|
||||||
|
user=request.user,
|
||||||
|
workspace__slug=slug,
|
||||||
|
view_id=view_id,
|
||||||
|
)
|
||||||
|
view_favourite.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
except IssueViewFavorite.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "View is not in favorites"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import jwt
|
import jwt
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
@ -10,8 +11,16 @@ from django.utils import timezone
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
from django.db.models import CharField, Count, OuterRef, Func, F
|
from django.db.models import (
|
||||||
from django.db.models.functions import Cast
|
CharField,
|
||||||
|
Count,
|
||||||
|
OuterRef,
|
||||||
|
Func,
|
||||||
|
F,
|
||||||
|
Q,
|
||||||
|
)
|
||||||
|
from django.db.models.functions import ExtractWeek, Cast, ExtractDay
|
||||||
|
from django.db.models.fields import DateField
|
||||||
|
|
||||||
# Third party modules
|
# Third party modules
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@ -37,6 +46,8 @@ from plane.db.models import (
|
|||||||
WorkspaceMemberInvite,
|
WorkspaceMemberInvite,
|
||||||
Team,
|
Team,
|
||||||
ProjectMember,
|
ProjectMember,
|
||||||
|
IssueActivity,
|
||||||
|
Issue,
|
||||||
)
|
)
|
||||||
from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission
|
from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission
|
||||||
from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
||||||
@ -59,7 +70,9 @@ class WorkSpaceViewSet(BaseViewSet):
|
|||||||
lookup_field = "slug"
|
lookup_field = "slug"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.filter_queryset(super().get_queryset().select_related("owner"))
|
return self.filter_queryset(
|
||||||
|
super().get_queryset().select_related("owner")
|
||||||
|
).order_by("name")
|
||||||
|
|
||||||
def create(self, request):
|
def create(self, request):
|
||||||
try:
|
try:
|
||||||
@ -578,3 +591,164 @@ class WorkspaceMemberUserViewsEndpoint(BaseAPIView):
|
|||||||
{"error": "Something went wrong please try again later"},
|
{"error": "Something went wrong please try again later"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserActivityGraphEndpoint(BaseAPIView):
|
||||||
|
def get(self, request, slug):
|
||||||
|
try:
|
||||||
|
issue_activities = (
|
||||||
|
IssueActivity.objects.filter(
|
||||||
|
actor=request.user,
|
||||||
|
workspace__slug=slug,
|
||||||
|
created_at__date__gte=date.today() + relativedelta(months=-6),
|
||||||
|
)
|
||||||
|
.annotate(created_date=Cast("created_at", DateField()))
|
||||||
|
.values("created_date")
|
||||||
|
.annotate(activity_count=Count("created_date"))
|
||||||
|
.order_by("created_date")
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(issue_activities, status=status.HTTP_200_OK)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserIssueCompletedGraphEndpoint(BaseAPIView):
|
||||||
|
def get(self, request, slug):
|
||||||
|
try:
|
||||||
|
month = request.GET.get("month", 1)
|
||||||
|
|
||||||
|
issues = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
assignees__in=[request.user],
|
||||||
|
workspace__slug=slug,
|
||||||
|
completed_at__month=month,
|
||||||
|
completed_at__isnull=False,
|
||||||
|
)
|
||||||
|
.annotate(completed_week=ExtractWeek("completed_at"))
|
||||||
|
.annotate(week=F("completed_week") % 4)
|
||||||
|
.values("week")
|
||||||
|
.annotate(completed_count=Count("completed_week"))
|
||||||
|
.order_by("week")
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(issues, status=status.HTTP_200_OK)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WeekInMonth(Func):
|
||||||
|
function = "FLOOR"
|
||||||
|
template = "(((%(expressions)s - 1) / 7) + 1)::INTEGER"
|
||||||
|
|
||||||
|
|
||||||
|
class UserWorkspaceDashboardEndpoint(BaseAPIView):
|
||||||
|
def get(self, request, slug):
|
||||||
|
try:
|
||||||
|
issue_activities = (
|
||||||
|
IssueActivity.objects.filter(
|
||||||
|
actor=request.user,
|
||||||
|
workspace__slug=slug,
|
||||||
|
created_at__date__gte=date.today() + relativedelta(months=-3),
|
||||||
|
)
|
||||||
|
.annotate(created_date=Cast("created_at", DateField()))
|
||||||
|
.values("created_date")
|
||||||
|
.annotate(activity_count=Count("created_date"))
|
||||||
|
.order_by("created_date")
|
||||||
|
)
|
||||||
|
|
||||||
|
month = request.GET.get("month", 1)
|
||||||
|
|
||||||
|
completed_issues = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
assignees__in=[request.user],
|
||||||
|
workspace__slug=slug,
|
||||||
|
completed_at__month=month,
|
||||||
|
completed_at__isnull=False,
|
||||||
|
)
|
||||||
|
.annotate(day_of_month=ExtractDay("completed_at"))
|
||||||
|
.annotate(week_in_month=WeekInMonth(F("day_of_month")))
|
||||||
|
.values("week_in_month")
|
||||||
|
.annotate(completed_count=Count("id"))
|
||||||
|
.order_by("week_in_month")
|
||||||
|
)
|
||||||
|
|
||||||
|
assigned_issues = Issue.objects.filter(
|
||||||
|
workspace__slug=slug, assignees__in=[request.user]
|
||||||
|
).count()
|
||||||
|
|
||||||
|
pending_issues_count = Issue.objects.filter(
|
||||||
|
~Q(state__group__in=["completed", "cancelled"]),
|
||||||
|
workspace__slug=slug,
|
||||||
|
assignees__in=[request.user],
|
||||||
|
).count()
|
||||||
|
|
||||||
|
completed_issues_count = Issue.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
assignees__in=[request.user],
|
||||||
|
state__group="completed",
|
||||||
|
).count()
|
||||||
|
|
||||||
|
issues_due_week = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
assignees__in=[request.user],
|
||||||
|
)
|
||||||
|
.annotate(target_week=ExtractWeek("target_date"))
|
||||||
|
.filter(target_week=timezone.now().date().isocalendar()[1])
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
state_distribution = (
|
||||||
|
Issue.objects.filter(workspace__slug=slug, assignees__in=[request.user])
|
||||||
|
.annotate(state_group=F("state__group"))
|
||||||
|
.values("state_group")
|
||||||
|
.annotate(state_count=Count("state_group"))
|
||||||
|
.order_by("state_group")
|
||||||
|
)
|
||||||
|
|
||||||
|
overdue_issues = Issue.objects.filter(
|
||||||
|
~Q(state__group__in=["completed", "cancelled"]),
|
||||||
|
workspace__slug=slug,
|
||||||
|
assignees__in=[request.user],
|
||||||
|
target_date__lt=timezone.now(),
|
||||||
|
completed_at__isnull=True,
|
||||||
|
).values("id", "name", "workspace__slug", "project_id", "target_date")
|
||||||
|
|
||||||
|
upcoming_issues = Issue.objects.filter(
|
||||||
|
~Q(state__group__in=["completed", "cancelled"]),
|
||||||
|
target_date__gte=timezone.now(),
|
||||||
|
workspace__slug=slug,
|
||||||
|
assignees__in=[request.user],
|
||||||
|
completed_at__isnull=True,
|
||||||
|
).values("id", "name", "workspace__slug", "project_id", "target_date")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"issue_activities": issue_activities,
|
||||||
|
"completed_issues": completed_issues,
|
||||||
|
"assigned_issues_count": assigned_issues,
|
||||||
|
"pending_issues_count": pending_issues_count,
|
||||||
|
"completed_issues_count": completed_issues_count,
|
||||||
|
"issues_due_week_count": issues_due_week,
|
||||||
|
"state_distribution": state_distribution,
|
||||||
|
"overdue_issues": overdue_issues,
|
||||||
|
"upcoming_issues": upcoming_issues,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
163
apiserver/plane/bgtasks/importer_task.py
Normal file
163
apiserver/plane/bgtasks/importer_task.py
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
# Python imports
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
import uuid
|
||||||
|
import jwt
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Django imports
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
from django.contrib.auth.hashers import make_password
|
||||||
|
|
||||||
|
# Third Party imports
|
||||||
|
from django_rq import job
|
||||||
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from plane.api.serializers import ImporterSerializer
|
||||||
|
from plane.db.models import (
|
||||||
|
Importer,
|
||||||
|
WorkspaceMember,
|
||||||
|
GithubRepositorySync,
|
||||||
|
GithubRepository,
|
||||||
|
ProjectMember,
|
||||||
|
WorkspaceIntegration,
|
||||||
|
Label,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from .workspace_invitation_task import workspace_invitation
|
||||||
|
|
||||||
|
|
||||||
|
@job("default")
|
||||||
|
def service_importer(service, importer_id):
|
||||||
|
try:
|
||||||
|
importer = Importer.objects.get(pk=importer_id)
|
||||||
|
importer.status = "processing"
|
||||||
|
importer.save()
|
||||||
|
|
||||||
|
users = importer.data.get("users", [])
|
||||||
|
|
||||||
|
# For all invited users create the uers
|
||||||
|
new_users = User.objects.bulk_create(
|
||||||
|
[
|
||||||
|
User(
|
||||||
|
email=user.get("email").strip().lower(),
|
||||||
|
username=uuid.uuid4().hex,
|
||||||
|
password=make_password(uuid.uuid4().hex),
|
||||||
|
is_password_autoset=True,
|
||||||
|
)
|
||||||
|
for user in users
|
||||||
|
if user.get("import", False) == "invite"
|
||||||
|
],
|
||||||
|
batch_size=10,
|
||||||
|
ignore_conflicts=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
workspace_users = User.objects.filter(
|
||||||
|
email__in=[
|
||||||
|
user.get("email").strip().lower()
|
||||||
|
for user in users
|
||||||
|
if user.get("import", False) == "invite"
|
||||||
|
or user.get("import", False) == "map"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Add new users to Workspace and project automatically
|
||||||
|
WorkspaceMember.objects.bulk_create(
|
||||||
|
[
|
||||||
|
WorkspaceMember(member=user, workspace_id=importer.workspace_id)
|
||||||
|
for user in workspace_users
|
||||||
|
],
|
||||||
|
batch_size=100,
|
||||||
|
ignore_conflicts=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
ProjectMember.objects.bulk_create(
|
||||||
|
[
|
||||||
|
ProjectMember(
|
||||||
|
project_id=importer.project_id,
|
||||||
|
workspace_id=importer.workspace_id,
|
||||||
|
member=user,
|
||||||
|
)
|
||||||
|
for user in workspace_users
|
||||||
|
],
|
||||||
|
batch_size=100,
|
||||||
|
ignore_conflicts=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if sync config is on for github importers
|
||||||
|
if service == "github" and importer.config.get("sync", False):
|
||||||
|
name = importer.metadata.get("name", False)
|
||||||
|
url = importer.metadata.get("url", False)
|
||||||
|
config = importer.metadata.get("config", {})
|
||||||
|
owner = importer.metadata.get("owner", False)
|
||||||
|
repository_id = importer.metadata.get("repository_id", False)
|
||||||
|
|
||||||
|
workspace_integration = WorkspaceIntegration.objects.get(
|
||||||
|
workspace_id=importer.workspace_id, integration__provider="github"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete the old repository object
|
||||||
|
GithubRepositorySync.objects.filter(project_id=importer.project_id).delete()
|
||||||
|
GithubRepository.objects.filter(project_id=importer.project_id).delete()
|
||||||
|
|
||||||
|
# Create a Label for github
|
||||||
|
label = Label.objects.filter(
|
||||||
|
name="GitHub", project_id=importer.project_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if label is None:
|
||||||
|
label = Label.objects.create(
|
||||||
|
name="GitHub",
|
||||||
|
project_id=importer.project_id,
|
||||||
|
description="Label to sync Plane issues with GitHub issues",
|
||||||
|
color="#003773",
|
||||||
|
)
|
||||||
|
# Create repository
|
||||||
|
repo = GithubRepository.objects.create(
|
||||||
|
name=name,
|
||||||
|
url=url,
|
||||||
|
config=config,
|
||||||
|
repository_id=repository_id,
|
||||||
|
owner=owner,
|
||||||
|
project_id=importer.project_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create repo sync
|
||||||
|
repo_sync = GithubRepositorySync.objects.create(
|
||||||
|
repository=repo,
|
||||||
|
workspace_integration=workspace_integration,
|
||||||
|
actor=workspace_integration.actor,
|
||||||
|
credentials=importer.data.get("credentials", {}),
|
||||||
|
project_id=importer.project_id,
|
||||||
|
label=label,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add bot as a member in the project
|
||||||
|
_ = ProjectMember.objects.get_or_create(
|
||||||
|
member=workspace_integration.actor,
|
||||||
|
role=20,
|
||||||
|
project_id=importer.project_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.PROXY_BASE_URL:
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
import_data_json = json.dumps(
|
||||||
|
ImporterSerializer(importer).data,
|
||||||
|
cls=DjangoJSONEncoder,
|
||||||
|
)
|
||||||
|
res = requests.post(
|
||||||
|
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(importer.workspace_id)}/projects/{str(importer.project_id)}/importers/{str(service)}/",
|
||||||
|
json=import_data_json,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
importer = Importer.objects.get(pk=importer_id)
|
||||||
|
importer.status = "failed"
|
||||||
|
importer.save()
|
||||||
|
capture_exception(e)
|
||||||
|
return
|
@ -676,8 +676,8 @@ def create_comment_activity(
|
|||||||
verb="created",
|
verb="created",
|
||||||
actor=actor,
|
actor=actor,
|
||||||
field="comment",
|
field="comment",
|
||||||
new_value=requested_data.get("comment_html"),
|
new_value=requested_data.get("comment_html", ""),
|
||||||
new_identifier=requested_data.get("id"),
|
new_identifier=requested_data.get("id", None),
|
||||||
issue_comment_id=requested_data.get("id", None),
|
issue_comment_id=requested_data.get("id", None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -696,11 +696,11 @@ def update_comment_activity(
|
|||||||
verb="updated",
|
verb="updated",
|
||||||
actor=actor,
|
actor=actor,
|
||||||
field="comment",
|
field="comment",
|
||||||
old_value=current_instance.get("comment_html"),
|
old_value=current_instance.get("comment_html", ""),
|
||||||
old_identifier=current_instance.get("id"),
|
old_identifier=current_instance.get("id"),
|
||||||
new_value=requested_data.get("comment_html"),
|
new_value=requested_data.get("comment_html", ""),
|
||||||
new_identifier=current_instance.get("id"),
|
new_identifier=current_instance.get("id", None),
|
||||||
issue_comment_id=current_instance.get("id"),
|
issue_comment_id=current_instance.get("id", None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -742,7 +742,11 @@ def issue_activity(event):
|
|||||||
try:
|
try:
|
||||||
issue_activities = []
|
issue_activities = []
|
||||||
type = event.get("type")
|
type = event.get("type")
|
||||||
requested_data = json.loads(event.get("requested_data"))
|
requested_data = (
|
||||||
|
json.loads(event.get("requested_data"))
|
||||||
|
if event.get("current_instance") is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
current_instance = (
|
current_instance = (
|
||||||
json.loads(event.get("current_instance"))
|
json.loads(event.get("current_instance"))
|
||||||
if event.get("current_instance") is not None
|
if event.get("current_instance") is not None
|
||||||
|
@ -2,10 +2,13 @@
|
|||||||
from django.core.mail import EmailMultiAlternatives
|
from django.core.mail import EmailMultiAlternatives
|
||||||
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
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from django_rq import job
|
from django_rq import job
|
||||||
from sentry_sdk import capture_exception
|
from sentry_sdk import capture_exception
|
||||||
|
from slack_sdk import WebClient
|
||||||
|
from slack_sdk.errors import SlackApiError
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.db.models import Workspace, User, WorkspaceMemberInvite
|
from plane.db.models import Workspace, User, WorkspaceMemberInvite
|
||||||
@ -13,9 +16,7 @@ from plane.db.models import Workspace, User, WorkspaceMemberInvite
|
|||||||
|
|
||||||
@job("default")
|
@job("default")
|
||||||
def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
workspace = Workspace.objects.get(pk=workspace_id)
|
workspace = Workspace.objects.get(pk=workspace_id)
|
||||||
workspace_member_invite = WorkspaceMemberInvite.objects.get(
|
workspace_member_invite = WorkspaceMemberInvite.objects.get(
|
||||||
token=token, email=email
|
token=token, email=email
|
||||||
@ -49,6 +50,18 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
|||||||
msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email])
|
msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email])
|
||||||
msg.attach_alternative(html_content, "text/html")
|
msg.attach_alternative(html_content, "text/html")
|
||||||
msg.send()
|
msg.send()
|
||||||
|
|
||||||
|
# Send message on slack as well
|
||||||
|
if settings.SLACK_BOT_TOKEN:
|
||||||
|
client = WebClient(token=settings.SLACK_BOT_TOKEN)
|
||||||
|
try:
|
||||||
|
_ = client.chat_postMessage(
|
||||||
|
channel="#trackers",
|
||||||
|
text=f"{workspace_member_invite.email} has been invited to {workspace.name} as a {workspace_member_invite.role}",
|
||||||
|
)
|
||||||
|
except SlackApiError as e:
|
||||||
|
print(f"Got an error: {e.response['error']}")
|
||||||
|
|
||||||
return
|
return
|
||||||
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e:
|
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e:
|
||||||
return
|
return
|
||||||
|
92
apiserver/plane/db/migrations/0023_auto_20230316_0040.py
Normal file
92
apiserver/plane/db/migrations/0023_auto_20230316_0040.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
# Generated by Django 3.2.16 on 2023-03-15 19:10
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('db', '0022_auto_20230307_0304'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Importer',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||||
|
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||||
|
('service', models.CharField(choices=[('github', 'GitHub')], max_length=50)),
|
||||||
|
('status', models.CharField(choices=[('queued', 'Queued'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='queued', max_length=50)),
|
||||||
|
('metadata', models.JSONField(default=dict)),
|
||||||
|
('config', models.JSONField(default=dict)),
|
||||||
|
('data', models.JSONField(default=dict)),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='importer_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||||
|
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='imports', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_importer', to='db.project')),
|
||||||
|
('token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='importer', to='db.apitoken')),
|
||||||
|
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='importer_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||||
|
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_importer', to='db.workspace')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Importer',
|
||||||
|
'verbose_name_plural': 'Importers',
|
||||||
|
'db_table': 'importers',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='IssueView',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||||
|
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||||
|
('name', models.CharField(max_length=255, verbose_name='View Name')),
|
||||||
|
('description', models.TextField(blank=True, verbose_name='View Description')),
|
||||||
|
('query', models.JSONField(verbose_name='View Query')),
|
||||||
|
('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)),
|
||||||
|
('query_data', models.JSONField(default=dict)),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueview_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||||
|
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueview', to='db.project')),
|
||||||
|
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueview_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||||
|
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueview', to='db.workspace')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Issue View',
|
||||||
|
'verbose_name_plural': 'Issue Views',
|
||||||
|
'db_table': 'issue_views',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='IssueViewFavorite',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||||
|
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueviewfavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||||
|
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueviewfavorite', to='db.project')),
|
||||||
|
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueviewfavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_view_favorites', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('view', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='view_favorites', to='db.issueview')),
|
||||||
|
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueviewfavorite', to='db.workspace')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'View Favorite',
|
||||||
|
'verbose_name_plural': 'View Favorites',
|
||||||
|
'db_table': 'view_favorites',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
'unique_together': {('view', 'user')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='label',
|
||||||
|
unique_together={('name', 'project')},
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='View',
|
||||||
|
),
|
||||||
|
]
|
113
apiserver/plane/db/migrations/0024_auto_20230322_0138.py
Normal file
113
apiserver/plane/db/migrations/0024_auto_20230322_0138.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
# Generated by Django 3.2.16 on 2023-03-21 20:08
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('db', '0023_auto_20230316_0040'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Page',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||||
|
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('description', models.JSONField(blank=True, default=dict)),
|
||||||
|
('description_html', models.TextField(blank=True, default='<p></p>')),
|
||||||
|
('description_stripped', models.TextField(blank=True, null=True)),
|
||||||
|
('access', models.PositiveSmallIntegerField(choices=[(0, 'Public'), (1, 'Private')], default=0)),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='page_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||||
|
('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pages', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Page',
|
||||||
|
'verbose_name_plural': 'Pages',
|
||||||
|
'db_table': 'pages',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='project',
|
||||||
|
name='issue_views_view',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='importer',
|
||||||
|
name='service',
|
||||||
|
field=models.CharField(choices=[('github', 'GitHub'), ('jira', 'Jira')], max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='project',
|
||||||
|
name='cover_image',
|
||||||
|
field=models.URLField(blank=True, max_length=800, null=True),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PageBlock',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||||
|
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('description', models.JSONField(blank=True, default=dict)),
|
||||||
|
('description_html', models.TextField(blank=True, default='<p></p>')),
|
||||||
|
('description_stripped', models.TextField(blank=True, null=True)),
|
||||||
|
('completed_at', models.DateTimeField(null=True)),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pageblock_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||||
|
('issue', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blocks', to='db.issue')),
|
||||||
|
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocks', to='db.page')),
|
||||||
|
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_pageblock', to='db.project')),
|
||||||
|
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pageblock_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||||
|
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_pageblock', to='db.workspace')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Page Block',
|
||||||
|
'verbose_name_plural': 'Page Blocks',
|
||||||
|
'db_table': 'page_blocks',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='page',
|
||||||
|
name='project',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_page', to='db.project'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='page',
|
||||||
|
name='updated_by',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='page_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='page',
|
||||||
|
name='workspace',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_page', to='db.workspace'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PageFavorite',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||||
|
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagefavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||||
|
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_favorites', to='db.page')),
|
||||||
|
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_pagefavorite', to='db.project')),
|
||||||
|
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagefavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_favorites', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_pagefavorite', to='db.workspace')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Page Favorite',
|
||||||
|
'verbose_name_plural': 'Page Favorites',
|
||||||
|
'db_table': 'page_favorites',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
'unique_together': {('page', 'user')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
61
apiserver/plane/db/migrations/0025_auto_20230331_0203.py
Normal file
61
apiserver/plane/db/migrations/0025_auto_20230331_0203.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Generated by Django 3.2.18 on 2023-03-30 20:33
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('db', '0024_auto_20230322_0138'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='page',
|
||||||
|
name='color',
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='pageblock',
|
||||||
|
name='sort_order',
|
||||||
|
field=models.FloatField(default=65535),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='pageblock',
|
||||||
|
name='sync',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='project',
|
||||||
|
name='page_view',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PageLabel',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||||
|
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagelabel_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||||
|
('label', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_labels', to='db.label')),
|
||||||
|
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_labels', to='db.page')),
|
||||||
|
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_pagelabel', to='db.project')),
|
||||||
|
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagelabel_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||||
|
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_pagelabel', to='db.workspace')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Page Label',
|
||||||
|
'verbose_name_plural': 'Page Labels',
|
||||||
|
'db_table': 'page_labels',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='page',
|
||||||
|
name='labels',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='pages', through='db.PageLabel', to='db.Label'),
|
||||||
|
),
|
||||||
|
]
|
@ -31,6 +31,7 @@ from .issue import (
|
|||||||
Label,
|
Label,
|
||||||
IssueBlocker,
|
IssueBlocker,
|
||||||
IssueLink,
|
IssueLink,
|
||||||
|
IssueSequence,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .asset import FileAsset
|
from .asset import FileAsset
|
||||||
@ -43,7 +44,7 @@ from .cycle import Cycle, CycleIssue, CycleFavorite
|
|||||||
|
|
||||||
from .shortcut import Shortcut
|
from .shortcut import Shortcut
|
||||||
|
|
||||||
from .view import View
|
from .view import IssueView, IssueViewFavorite
|
||||||
|
|
||||||
from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite
|
from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite
|
||||||
|
|
||||||
@ -57,3 +58,7 @@ from .integration import (
|
|||||||
GithubIssueSync,
|
GithubIssueSync,
|
||||||
GithubCommentSync,
|
GithubCommentSync,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .importer import Importer
|
||||||
|
|
||||||
|
from .page import Page, PageBlock, PageFavorite, PageLabel
|
@ -1,3 +1,6 @@
|
|||||||
|
# Python imports
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
# Django import
|
# Django import
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@ -7,7 +10,9 @@ from . import BaseModel
|
|||||||
|
|
||||||
|
|
||||||
def get_upload_path(instance, filename):
|
def get_upload_path(instance, filename):
|
||||||
return f"{instance.workspace.id}/{filename}"
|
if instance.workspace_id is not None:
|
||||||
|
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
|
||||||
|
return f"user-{uuid4().hex}-{filename}"
|
||||||
|
|
||||||
|
|
||||||
def file_size(value):
|
def file_size(value):
|
||||||
@ -15,6 +20,7 @@ def file_size(value):
|
|||||||
if value.size > limit:
|
if value.size > limit:
|
||||||
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
||||||
|
|
||||||
|
|
||||||
class FileAsset(BaseModel):
|
class FileAsset(BaseModel):
|
||||||
"""
|
"""
|
||||||
A file asset.
|
A file asset.
|
||||||
|
45
apiserver/plane/db/models/importer.py
Normal file
45
apiserver/plane/db/models/importer.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Django imports
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from . import ProjectBaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Importer(ProjectBaseModel):
|
||||||
|
service = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=(
|
||||||
|
("github", "GitHub"),
|
||||||
|
("jira", "Jira"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=(
|
||||||
|
("queued", "Queued"),
|
||||||
|
("processing", "Processing"),
|
||||||
|
("completed", "Completed"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
),
|
||||||
|
default="queued",
|
||||||
|
)
|
||||||
|
initiated_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="imports"
|
||||||
|
)
|
||||||
|
metadata = models.JSONField(default=dict)
|
||||||
|
config = models.JSONField(default=dict)
|
||||||
|
data = models.JSONField(default=dict)
|
||||||
|
token = models.ForeignKey(
|
||||||
|
"db.APIToken", on_delete=models.CASCADE, related_name="importer"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Importer"
|
||||||
|
verbose_name_plural = "Importers"
|
||||||
|
db_table = "importers"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return name of the service"""
|
||||||
|
return f"{self.service} <{self.project.name}>"
|
@ -85,7 +85,7 @@ class Issue(ProjectBaseModel):
|
|||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
from plane.db.models import State
|
from plane.db.models import State, PageBlock
|
||||||
|
|
||||||
# Get the completed states of the project
|
# Get the completed states of the project
|
||||||
completed_states = State.objects.filter(
|
completed_states = State.objects.filter(
|
||||||
@ -94,7 +94,15 @@ class Issue(ProjectBaseModel):
|
|||||||
# Check if the current issue state and completed state id are same
|
# Check if the current issue state and completed state id are same
|
||||||
if self.state.id in completed_states:
|
if self.state.id in completed_states:
|
||||||
self.completed_at = timezone.now()
|
self.completed_at = timezone.now()
|
||||||
|
# check if there are any page blocks
|
||||||
|
PageBlock.objects.filter(issue_id=self.id).filter().update(
|
||||||
|
completed_at=timezone.now()
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
PageBlock.objects.filter(issue_id=self.id).filter().update(
|
||||||
|
completed_at=None
|
||||||
|
)
|
||||||
self.completed_at = None
|
self.completed_at = None
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@ -307,6 +315,7 @@ class Label(ProjectBaseModel):
|
|||||||
color = models.CharField(max_length=255, blank=True)
|
color = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
unique_together = ["name", "project"]
|
||||||
verbose_name = "Label"
|
verbose_name = "Label"
|
||||||
verbose_name_plural = "Labels"
|
verbose_name_plural = "Labels"
|
||||||
db_table = "labels"
|
db_table = "labels"
|
||||||
|
126
apiserver/plane/db/models/page.py
Normal file
126
apiserver/plane/db/models/page.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
# Django imports
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from . import ProjectBaseModel
|
||||||
|
from plane.utils.html_processor import strip_tags
|
||||||
|
|
||||||
|
|
||||||
|
class Page(ProjectBaseModel):
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.JSONField(default=dict, blank=True)
|
||||||
|
description_html = models.TextField(blank=True, default="<p></p>")
|
||||||
|
description_stripped = models.TextField(blank=True, null=True)
|
||||||
|
owned_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pages"
|
||||||
|
)
|
||||||
|
access = models.PositiveSmallIntegerField(
|
||||||
|
choices=((0, "Public"), (1, "Private")), default=0
|
||||||
|
)
|
||||||
|
color = models.CharField(max_length=255, blank=True)
|
||||||
|
labels = models.ManyToManyField(
|
||||||
|
"db.Label", blank=True, related_name="pages", through="db.PageLabel"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Page"
|
||||||
|
verbose_name_plural = "Pages"
|
||||||
|
db_table = "pages"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return owner email and page name"""
|
||||||
|
return f"{self.owned_by.email} <{self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class PageBlock(ProjectBaseModel):
|
||||||
|
page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="blocks")
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.JSONField(default=dict, blank=True)
|
||||||
|
description_html = models.TextField(blank=True, default="<p></p>")
|
||||||
|
description_stripped = models.TextField(blank=True, null=True)
|
||||||
|
issue = models.ForeignKey(
|
||||||
|
"db.Issue", on_delete=models.SET_NULL, related_name="blocks", null=True
|
||||||
|
)
|
||||||
|
completed_at = models.DateTimeField(null=True)
|
||||||
|
sort_order = models.FloatField(default=65535)
|
||||||
|
sync = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self._state.adding:
|
||||||
|
largest_sort_order = PageBlock.objects.filter(
|
||||||
|
project=self.project, page=self.page
|
||||||
|
).aggregate(largest=models.Max("sort_order"))["largest"]
|
||||||
|
if largest_sort_order is not None:
|
||||||
|
self.sort_order = largest_sort_order + 10000
|
||||||
|
|
||||||
|
# Strip the html tags using html parser
|
||||||
|
self.description_stripped = (
|
||||||
|
None
|
||||||
|
if (self.description_html == "" or self.description_html is None)
|
||||||
|
else strip_tags(self.description_html)
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.completed_at and self.issue:
|
||||||
|
try:
|
||||||
|
from plane.db.models import State, Issue
|
||||||
|
|
||||||
|
completed_state = State.objects.filter(
|
||||||
|
group="completed", project=self.project
|
||||||
|
).first()
|
||||||
|
if completed_state is not None:
|
||||||
|
Issue.objects.update(pk=self.issue_id, state=completed_state)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
super(PageBlock, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Page Block"
|
||||||
|
verbose_name_plural = "Page Blocks"
|
||||||
|
db_table = "page_blocks"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return page and page block"""
|
||||||
|
return f"{self.page.name} <{self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class PageFavorite(ProjectBaseModel):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="page_favorites",
|
||||||
|
)
|
||||||
|
page = models.ForeignKey(
|
||||||
|
"db.Page", on_delete=models.CASCADE, related_name="page_favorites"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ["page", "user"]
|
||||||
|
verbose_name = "Page Favorite"
|
||||||
|
verbose_name_plural = "Page Favorites"
|
||||||
|
db_table = "page_favorites"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return user and the page"""
|
||||||
|
return f"{self.user.email} <{self.page.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class PageLabel(ProjectBaseModel):
|
||||||
|
label = models.ForeignKey(
|
||||||
|
"db.Label", on_delete=models.CASCADE, related_name="page_labels"
|
||||||
|
)
|
||||||
|
page = models.ForeignKey(
|
||||||
|
"db.Page", on_delete=models.CASCADE, related_name="page_labels"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Page Label"
|
||||||
|
verbose_name_plural = "Page Labels"
|
||||||
|
db_table = "page_labels"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.page.name} {self.label.name}"
|
@ -63,7 +63,9 @@ class Project(BaseModel):
|
|||||||
icon = models.CharField(max_length=255, null=True, blank=True)
|
icon = models.CharField(max_length=255, null=True, blank=True)
|
||||||
module_view = models.BooleanField(default=True)
|
module_view = models.BooleanField(default=True)
|
||||||
cycle_view = models.BooleanField(default=True)
|
cycle_view = models.BooleanField(default=True)
|
||||||
cover_image = models.URLField(blank=True, null=True)
|
issue_views_view = models.BooleanField(default=True)
|
||||||
|
page_view = models.BooleanField(default=True)
|
||||||
|
cover_image = models.URLField(blank=True, null=True, max_length=800)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Return name of the project"""
|
"""Return name of the project"""
|
||||||
|
@ -11,9 +11,12 @@ from django.utils import timezone
|
|||||||
from django.core.mail import EmailMultiAlternatives
|
from django.core.mail import EmailMultiAlternatives
|
||||||
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
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from sentry_sdk import capture_exception
|
from sentry_sdk import capture_exception
|
||||||
|
from slack_sdk import WebClient
|
||||||
|
from slack_sdk.errors import SlackApiError
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractBaseUser, PermissionsMixin):
|
class User(AbstractBaseUser, PermissionsMixin):
|
||||||
@ -123,6 +126,16 @@ def send_welcome_email(sender, instance, created, **kwargs):
|
|||||||
msg.attach_alternative(html_content, "text/html")
|
msg.attach_alternative(html_content, "text/html")
|
||||||
msg.send()
|
msg.send()
|
||||||
|
|
||||||
|
# Send message on slack as well
|
||||||
|
if settings.SLACK_BOT_TOKEN:
|
||||||
|
client = WebClient(token=settings.SLACK_BOT_TOKEN)
|
||||||
|
try:
|
||||||
|
_ = client.chat_postMessage(
|
||||||
|
channel="#trackers",
|
||||||
|
text=f"New user {instance.email} has signed up and begun the onboarding journey.",
|
||||||
|
)
|
||||||
|
except SlackApiError as e:
|
||||||
|
print(f"Got an error: {e.response['error']}")
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
|
@ -1,22 +1,48 @@
|
|||||||
# Django imports
|
# Django imports
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
# Module import
|
# Module import
|
||||||
from . import ProjectBaseModel
|
from . import ProjectBaseModel
|
||||||
|
|
||||||
|
|
||||||
class View(ProjectBaseModel):
|
class IssueView(ProjectBaseModel):
|
||||||
name = models.CharField(max_length=255, verbose_name="View Name")
|
name = models.CharField(max_length=255, verbose_name="View Name")
|
||||||
description = models.TextField(verbose_name="View Description", blank=True)
|
description = models.TextField(verbose_name="View Description", blank=True)
|
||||||
query = models.JSONField(verbose_name="View Query")
|
query = models.JSONField(verbose_name="View Query")
|
||||||
|
access = models.PositiveSmallIntegerField(
|
||||||
|
default=1, choices=((0, "Private"), (1, "Public"))
|
||||||
|
)
|
||||||
|
query_data = models.JSONField(default=dict)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "View"
|
verbose_name = "Issue View"
|
||||||
verbose_name_plural = "Views"
|
verbose_name_plural = "Issue Views"
|
||||||
db_table = "views"
|
db_table = "issue_views"
|
||||||
ordering = ("-created_at",)
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Return name of the View"""
|
"""Return name of the View"""
|
||||||
return f"{self.name} <{self.project.name}>"
|
return f"{self.name} <{self.project.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class IssueViewFavorite(ProjectBaseModel):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="user_view_favorites",
|
||||||
|
)
|
||||||
|
view = models.ForeignKey(
|
||||||
|
"db.IssueView", on_delete=models.CASCADE, related_name="view_favorites"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ["view", "user"]
|
||||||
|
verbose_name = "View Favorite"
|
||||||
|
verbose_name_plural = "View Favorites"
|
||||||
|
db_table = "view_favorites"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return user and the view"""
|
||||||
|
return f"{self.user.email} <{self.view.name}>"
|
||||||
|
@ -48,6 +48,7 @@ MIDDLEWARE = [
|
|||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
"crum.CurrentRequestUserMiddleware",
|
"crum.CurrentRequestUserMiddleware",
|
||||||
|
"django.middleware.gzip.GZipMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
|
@ -78,3 +78,13 @@ if DOCKERIZED:
|
|||||||
|
|
||||||
WEB_URL = os.environ.get("WEB_URL", "localhost:3000")
|
WEB_URL = os.environ.get("WEB_URL", "localhost:3000")
|
||||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||||
|
|
||||||
|
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
|
||||||
|
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
|
||||||
|
|
||||||
|
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
||||||
|
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
|
||||||
|
|
||||||
|
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
|
||||||
|
|
||||||
|
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
|
||||||
|
@ -22,13 +22,7 @@ DATABASES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# CORS WHITELIST ON PROD
|
|
||||||
CORS_ORIGIN_WHITELIST = [
|
|
||||||
# "https://example.com",
|
|
||||||
# "https://sub.example.com",
|
|
||||||
# "http://localhost:8080",
|
|
||||||
# "http://127.0.0.1:9000"
|
|
||||||
]
|
|
||||||
# Parse database configuration from $DATABASE_URL
|
# Parse database configuration from $DATABASE_URL
|
||||||
DATABASES["default"] = dj_database_url.config()
|
DATABASES["default"] = dj_database_url.config()
|
||||||
SITE_ID = 1
|
SITE_ID = 1
|
||||||
@ -43,12 +37,33 @@ DOCKERIZED = os.environ.get(
|
|||||||
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
|
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
|
||||||
# Allow all host headers
|
|
||||||
ALLOWED_HOSTS = ["*"]
|
|
||||||
|
|
||||||
# TODO: Make it FALSE and LIST DOMAINS IN FULL PROD.
|
# TODO: Make it FALSE and LIST DOMAINS IN FULL PROD.
|
||||||
CORS_ALLOW_ALL_ORIGINS = True
|
CORS_ALLOW_ALL_ORIGINS = True
|
||||||
|
|
||||||
|
|
||||||
|
CORS_ALLOW_METHODS = [
|
||||||
|
"DELETE",
|
||||||
|
"GET",
|
||||||
|
"OPTIONS",
|
||||||
|
"PATCH",
|
||||||
|
"POST",
|
||||||
|
"PUT",
|
||||||
|
]
|
||||||
|
|
||||||
|
CORS_ALLOW_HEADERS = [
|
||||||
|
"accept",
|
||||||
|
"accept-encoding",
|
||||||
|
"authorization",
|
||||||
|
"content-type",
|
||||||
|
"dnt",
|
||||||
|
"origin",
|
||||||
|
"user-agent",
|
||||||
|
"x-csrftoken",
|
||||||
|
"x-requested-with",
|
||||||
|
]
|
||||||
|
|
||||||
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
# Simplified static file serving.
|
# Simplified static file serving.
|
||||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||||
|
|
||||||
@ -211,3 +226,13 @@ RQ_QUEUES = {
|
|||||||
WEB_URL = os.environ.get("WEB_URL")
|
WEB_URL = os.environ.get("WEB_URL")
|
||||||
|
|
||||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||||
|
|
||||||
|
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
|
||||||
|
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
|
||||||
|
|
||||||
|
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
||||||
|
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
|
||||||
|
|
||||||
|
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
|
||||||
|
|
||||||
|
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
|
||||||
|
@ -187,3 +187,13 @@ RQ_QUEUES = {
|
|||||||
WEB_URL = os.environ.get("WEB_URL")
|
WEB_URL = os.environ.get("WEB_URL")
|
||||||
|
|
||||||
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||||
|
|
||||||
|
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
|
||||||
|
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
|
||||||
|
|
||||||
|
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
||||||
|
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
|
||||||
|
|
||||||
|
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
|
||||||
|
|
||||||
|
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
|
||||||
|
@ -27,6 +27,15 @@ def group_results(results_data, group_by):
|
|||||||
"""
|
"""
|
||||||
response_dict = dict()
|
response_dict = dict()
|
||||||
|
|
||||||
|
if group_by == "priority":
|
||||||
|
response_dict = {
|
||||||
|
"urgent": [],
|
||||||
|
"high": [],
|
||||||
|
"medium": [],
|
||||||
|
"low": [],
|
||||||
|
"None": [],
|
||||||
|
}
|
||||||
|
|
||||||
for value in results_data:
|
for value in results_data:
|
||||||
group_attribute = resolve_keys(group_by, value)
|
group_attribute = resolve_keys(group_by, value)
|
||||||
if isinstance(group_attribute, list):
|
if isinstance(group_attribute, list):
|
||||||
|
0
apiserver/plane/utils/importers/__init__.py
Normal file
0
apiserver/plane/utils/importers/__init__.py
Normal file
53
apiserver/plane/utils/importers/jira.py
Normal file
53
apiserver/plane/utils/importers/jira.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import requests
|
||||||
|
from requests.auth import HTTPBasicAuth
|
||||||
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
|
|
||||||
|
def jira_project_issue_summary(email, api_token, project_name, hostname):
|
||||||
|
try:
|
||||||
|
auth = HTTPBasicAuth(email, api_token)
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
|
||||||
|
issue_url = f"https://{hostname}/rest/api/3/search?jql=project={project_name} AND issuetype=Story"
|
||||||
|
issue_response = requests.request(
|
||||||
|
"GET", issue_url, headers=headers, auth=auth
|
||||||
|
).json()["total"]
|
||||||
|
|
||||||
|
module_url = f"https://{hostname}/rest/api/3/search?jql=project={project_name} AND issuetype=Epic"
|
||||||
|
module_response = requests.request(
|
||||||
|
"GET", module_url, headers=headers, auth=auth
|
||||||
|
).json()["total"]
|
||||||
|
|
||||||
|
status_url = f"https://{hostname}/rest/api/3/status/?jql=project={project_name}"
|
||||||
|
status_response = requests.request(
|
||||||
|
"GET", status_url, headers=headers, auth=auth
|
||||||
|
).json()
|
||||||
|
|
||||||
|
labels_url = f"https://{hostname}/rest/api/3/label/?jql=project={project_name}"
|
||||||
|
labels_response = requests.request(
|
||||||
|
"GET", labels_url, headers=headers, auth=auth
|
||||||
|
).json()["total"]
|
||||||
|
|
||||||
|
users_url = (
|
||||||
|
f"https://{hostname}/rest/api/3/users/search?jql=project={project_name}"
|
||||||
|
)
|
||||||
|
users_response = requests.request(
|
||||||
|
"GET", users_url, headers=headers, auth=auth
|
||||||
|
).json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"issues": issue_response,
|
||||||
|
"modules": module_response,
|
||||||
|
"labels": labels_response,
|
||||||
|
"states": len(status_response),
|
||||||
|
"users": (
|
||||||
|
[
|
||||||
|
user
|
||||||
|
for user in users_response
|
||||||
|
if user.get("accountType") == "atlassian"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return {"error": "Something went wrong could not fetch information from jira"}
|
@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import jwt
|
import jwt
|
||||||
import requests
|
import requests
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
@ -30,7 +31,7 @@ def get_github_metadata(installation_id):
|
|||||||
|
|
||||||
url = f"https://api.github.com/app/installations/{installation_id}"
|
url = f"https://api.github.com/app/installations/{installation_id}"
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": "Bearer " + token,
|
"Authorization": "Bearer " + str(token),
|
||||||
"Accept": "application/vnd.github+json",
|
"Accept": "application/vnd.github+json",
|
||||||
}
|
}
|
||||||
response = requests.get(url, headers=headers).json()
|
response = requests.get(url, headers=headers).json()
|
||||||
@ -41,7 +42,7 @@ def get_github_repos(access_tokens_url, repositories_url):
|
|||||||
token = get_jwt_token()
|
token = get_jwt_token()
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": "Bearer " + token,
|
"Authorization": "Bearer " + str(token),
|
||||||
"Accept": "application/vnd.github+json",
|
"Accept": "application/vnd.github+json",
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,9 +51,9 @@ def get_github_repos(access_tokens_url, repositories_url):
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
oauth_token = oauth_response.get("token")
|
oauth_token = oauth_response.get("token", "")
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": "Bearer " + oauth_token,
|
"Authorization": "Bearer " + str(oauth_token),
|
||||||
"Accept": "application/vnd.github+json",
|
"Accept": "application/vnd.github+json",
|
||||||
}
|
}
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
@ -67,8 +68,63 @@ def delete_github_installation(installation_id):
|
|||||||
|
|
||||||
url = f"https://api.github.com/app/installations/{installation_id}"
|
url = f"https://api.github.com/app/installations/{installation_id}"
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": "Bearer " + token,
|
"Authorization": "Bearer " + str(token),
|
||||||
"Accept": "application/vnd.github+json",
|
"Accept": "application/vnd.github+json",
|
||||||
}
|
}
|
||||||
response = requests.delete(url, headers=headers)
|
response = requests.delete(url, headers=headers)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def get_github_repo_details(access_tokens_url, owner, repo):
|
||||||
|
token = get_jwt_token()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Bearer " + str(token),
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
}
|
||||||
|
|
||||||
|
oauth_response = requests.post(
|
||||||
|
access_tokens_url,
|
||||||
|
headers=headers,
|
||||||
|
).json()
|
||||||
|
|
||||||
|
oauth_token = oauth_response.get("token")
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Bearer " + oauth_token,
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
}
|
||||||
|
open_issues = requests.get(
|
||||||
|
f"https://api.github.com/repos/{owner}/{repo}",
|
||||||
|
headers=headers,
|
||||||
|
).json()["open_issues_count"]
|
||||||
|
|
||||||
|
total_labels = 0
|
||||||
|
|
||||||
|
labels_response = requests.get(
|
||||||
|
f"https://api.github.com/repos/{owner}/{repo}/labels?per_page=100&page=1",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if there are more pages
|
||||||
|
if len(labels_response.links.keys()):
|
||||||
|
# get the query parameter of last
|
||||||
|
last_url = labels_response.links.get("last").get("url")
|
||||||
|
parsed_url = urlparse(last_url)
|
||||||
|
last_page_value = parse_qs(parsed_url.query)["page"][0]
|
||||||
|
total_labels = total_labels + 100 * (last_page_value - 1)
|
||||||
|
|
||||||
|
# Get labels in last page
|
||||||
|
last_page_labels = requests.get(last_url, headers=headers).json()
|
||||||
|
total_labels = total_labels + len(last_page_labels)
|
||||||
|
else:
|
||||||
|
total_labels = len(labels_response.json())
|
||||||
|
|
||||||
|
# Currently only supporting upto 100 collaborators
|
||||||
|
# TODO: Update this function to fetch all collaborators
|
||||||
|
collaborators = requests.get(
|
||||||
|
f"https://api.github.com/repos/{owner}/{repo}/collaborators?per_page=100&page=1",
|
||||||
|
headers=headers,
|
||||||
|
).json()
|
||||||
|
|
||||||
|
return open_issues, total_labels, collaborators
|
||||||
|
214
apiserver/plane/utils/issue_filters.py
Normal file
214
apiserver/plane/utils/issue_filters.py
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
from django.utils.timezone import make_aware
|
||||||
|
from django.utils.dateparse import parse_datetime
|
||||||
|
|
||||||
|
|
||||||
|
def filter_state(params, filter, method):
|
||||||
|
if method == "GET":
|
||||||
|
states = params.get("state").split(",")
|
||||||
|
if len(states) and "" not in states:
|
||||||
|
filter["state__in"] = states
|
||||||
|
else:
|
||||||
|
if params.get("state", None) and len(params.get("state")):
|
||||||
|
filter["state__in"] = params.get("state")
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
|
def filter_priority(params, filter, method):
|
||||||
|
if method == "GET":
|
||||||
|
priorties = params.get("priority").split(",")
|
||||||
|
if len(priorties) and "" not in priorties:
|
||||||
|
filter["priority__in"] = priorties
|
||||||
|
else:
|
||||||
|
if params.get("priority", None) and len(params.get("priority")):
|
||||||
|
filter["priority__in"] = params.get("priority")
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
|
def filter_parent(params, filter, method):
|
||||||
|
if method == "GET":
|
||||||
|
parents = params.get("parent").split(",")
|
||||||
|
if len(parents) and "" not in parents:
|
||||||
|
filter["parent__in"] = parents
|
||||||
|
else:
|
||||||
|
if params.get("parent", None) and len(params.get("parent")):
|
||||||
|
filter["parent__in"] = params.get("parent")
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
|
def filter_labels(params, filter, method):
|
||||||
|
if method == "GET":
|
||||||
|
labels = params.get("labels").split(",")
|
||||||
|
if len(labels) and "" not in labels:
|
||||||
|
filter["labels__in"] = labels
|
||||||
|
else:
|
||||||
|
if params.get("labels", None) and len(params.get("labels")):
|
||||||
|
filter["labels__in"] = params.get("labels")
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
|
def filter_assignees(params, filter, method):
|
||||||
|
if method == "GET":
|
||||||
|
assignees = params.get("assignees").split(",")
|
||||||
|
if len(assignees) and "" not in assignees:
|
||||||
|
filter["assignees__in"] = assignees
|
||||||
|
else:
|
||||||
|
if params.get("assignees", None) and len(params.get("assignees")):
|
||||||
|
filter["assignees__in"] = params.get("assignees")
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
|
def filter_created_by(params, filter, method):
|
||||||
|
if method == "GET":
|
||||||
|
created_bys = params.get("created_by").split(",")
|
||||||
|
if len(created_bys) and "" not in created_bys:
|
||||||
|
filter["created_by__in"] = created_bys
|
||||||
|
else:
|
||||||
|
if params.get("created_by", None) and len(params.get("created_by")):
|
||||||
|
filter["created_by__in"] = params.get("created_by")
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
|
def filter_name(params, filter, method):
|
||||||
|
if params.get("name", "") != "":
|
||||||
|
filter["name__icontains"] = params.get("name")
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
|
def filter_created_at(params, filter, method):
|
||||||
|
if method == "GET":
|
||||||
|
created_ats = params.get("created_at").split(",")
|
||||||
|
if len(created_ats) and "" not in created_ats:
|
||||||
|
for query in created_ats:
|
||||||
|
created_at_query = query.split(";")
|
||||||
|
if len(created_at_query) == 2 and "after" in created_at_query:
|
||||||
|
filter["created_at__date__gte"] = created_at_query[0]
|
||||||
|
else:
|
||||||
|
filter["created_at__date__lte"] = created_at_query[0]
|
||||||
|
else:
|
||||||
|
if params.get("created_at", None) and len(params.get("created_at")):
|
||||||
|
for query in params.get("created_at"):
|
||||||
|
if query.get("timeline", "after") == "after":
|
||||||
|
filter["created_at__date__gte"] = query.get("datetime")
|
||||||
|
else:
|
||||||
|
filter["created_at__date__lte"] = query.get("datetime")
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
|
def filter_updated_at(params, filter, method):
|
||||||
|
if method == "GET":
|
||||||
|
updated_ats = params.get("updated_at").split(",")
|
||||||
|
if len(updated_ats) and "" not in updated_ats:
|
||||||
|
for query in updated_ats:
|
||||||
|
updated_at_query = query.split(";")
|
||||||
|
if len(updated_at_query) == 2 and "after" in updated_at_query:
|
||||||
|
filter["updated_at__date__gte"] = updated_at_query[0]
|
||||||
|
else:
|
||||||
|
filter["updated_at__date__lte"] = updated_at_query[0]
|
||||||
|
else:
|
||||||
|
if params.get("updated_at", None) and len(params.get("updated_at")):
|
||||||
|
for query in params.get("updated_at"):
|
||||||
|
if query.get("timeline", "after") == "after":
|
||||||
|
filter["updated_at__date__gte"] = query.get("datetime")
|
||||||
|
else:
|
||||||
|
filter["updated_at__date__lte"] = query.get("datetime")
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
|
def filter_start_date(params, filter, method):
|
||||||
|
if method == "GET":
|
||||||
|
start_dates = params.get("start_date").split(",")
|
||||||
|
if len(start_dates) and "" not in start_dates:
|
||||||
|
for query in start_dates:
|
||||||
|
start_date_query = query.split(";")
|
||||||
|
if len(start_date_query) == 2 and "after" in start_date_query:
|
||||||
|
filter["start_date__gte"] = start_date_query[0]
|
||||||
|
else:
|
||||||
|
filter["start_date__lte"] = start_date_query[0]
|
||||||
|
else:
|
||||||
|
if params.get("start_date", None) and len(params.get("start_date")):
|
||||||
|
for query in params.get("start_date"):
|
||||||
|
if query.get("timeline", "after") == "after":
|
||||||
|
filter["start_date__gte"] = query.get("datetime")
|
||||||
|
else:
|
||||||
|
filter["start_date__lte"] = query.get("datetime")
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
|
def filter_target_date(params, filter, method):
|
||||||
|
if method == "GET":
|
||||||
|
target_dates = params.get("target_date").split(",")
|
||||||
|
if len(target_dates) and "" not in target_dates:
|
||||||
|
for query in target_dates:
|
||||||
|
target_date_query = query.split(";")
|
||||||
|
if len(target_date_query) == 2 and "after" in target_date_query:
|
||||||
|
filter["target_date__gte"] = target_date_query[0]
|
||||||
|
else:
|
||||||
|
filter["target_date__lte"] = target_date_query[0]
|
||||||
|
else:
|
||||||
|
if params.get("target_date", None) and len(params.get("target_date")):
|
||||||
|
for query in params.get("target_date"):
|
||||||
|
if query.get("timeline", "after") == "after":
|
||||||
|
filter["target_date__gte"] = query.get("datetime")
|
||||||
|
else:
|
||||||
|
filter["target_date__lte"] = query.get("datetime")
|
||||||
|
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
|
def filter_completed_at(params, filter, method):
|
||||||
|
if method == "GET":
|
||||||
|
completed_ats = params.get("completed_at").split(",")
|
||||||
|
if len(completed_ats) and "" not in completed_ats:
|
||||||
|
for query in completed_ats:
|
||||||
|
completed_at_query = query.split(";")
|
||||||
|
if len(completed_at_query) == 2 and "after" in completed_at_query:
|
||||||
|
filter["completed_at__date__gte"] = completed_at_query[0]
|
||||||
|
else:
|
||||||
|
filter["completed_at__lte"] = completed_at_query[0]
|
||||||
|
else:
|
||||||
|
if params.get("completed_at", None) and len(params.get("completed_at")):
|
||||||
|
for query in params.get("completed_at"):
|
||||||
|
if query.get("timeline", "after") == "after":
|
||||||
|
filter["completed_at__date__gte"] = query.get("datetime")
|
||||||
|
else:
|
||||||
|
filter["completed_at__lte"] = query.get("datetime")
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
|
def filter_issue_state_type(params, filter, method):
|
||||||
|
type = params.get("type", "all")
|
||||||
|
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||||
|
if type == "backlog":
|
||||||
|
group = ["backlog"]
|
||||||
|
if type == "active":
|
||||||
|
group = ["unstarted", "started"]
|
||||||
|
|
||||||
|
filter["state__group__in"] = group
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
|
def issue_filters(query_params, method):
|
||||||
|
filter = dict()
|
||||||
|
|
||||||
|
ISSUE_FILTER = {
|
||||||
|
"state": filter_state,
|
||||||
|
"priority": filter_priority,
|
||||||
|
"parent": filter_parent,
|
||||||
|
"labels": filter_labels,
|
||||||
|
"assignees": filter_assignees,
|
||||||
|
"created_by": filter_created_by,
|
||||||
|
"name": filter_name,
|
||||||
|
"created_at": filter_created_at,
|
||||||
|
"updated_at": filter_updated_at,
|
||||||
|
"start_date": filter_start_date,
|
||||||
|
"target_date": filter_target_date,
|
||||||
|
"completed_at": filter_completed_at,
|
||||||
|
"type": filter_issue_state_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value in ISSUE_FILTER.items():
|
||||||
|
if key in query_params:
|
||||||
|
func = value
|
||||||
|
func(query_params, filter, method)
|
||||||
|
|
||||||
|
return filter
|
@ -7,7 +7,7 @@ psycopg2==2.9.5
|
|||||||
django-oauth-toolkit==2.2.0
|
django-oauth-toolkit==2.2.0
|
||||||
mistune==2.0.4
|
mistune==2.0.4
|
||||||
djangorestframework==3.14.0
|
djangorestframework==3.14.0
|
||||||
redis==4.4.2
|
redis==4.5.4
|
||||||
django-nested-admin==4.0.2
|
django-nested-admin==4.0.2
|
||||||
django-cors-headers==3.13.0
|
django-cors-headers==3.13.0
|
||||||
whitenoise==6.3.0
|
whitenoise==6.3.0
|
||||||
@ -26,4 +26,6 @@ google-api-python-client==2.75.0
|
|||||||
django-rq==2.6.0
|
django-rq==2.6.0
|
||||||
django-redis==5.2.0
|
django-redis==5.2.0
|
||||||
uvicorn==0.20.0
|
uvicorn==0.20.0
|
||||||
channels==4.0.0
|
channels==4.0.0
|
||||||
|
openai==0.27.2
|
||||||
|
slack-sdk==3.20.2
|
@ -1,7 +1,8 @@
|
|||||||
NEXT_PUBLIC_API_BASE_URL = "http://localhost"
|
# Replace with your instance Public IP
|
||||||
NEXT_PUBLIC_GOOGLE_CLIENTID="<-- google client id -->"
|
# NEXT_PUBLIC_API_BASE_URL = "http://localhost"
|
||||||
NEXT_PUBLIC_GITHUB_APP_NAME="<-- github app name -->"
|
NEXT_PUBLIC_GOOGLE_CLIENTID=""
|
||||||
NEXT_PUBLIC_GITHUB_ID="<-- github client id -->"
|
NEXT_PUBLIC_GITHUB_APP_NAME=""
|
||||||
NEXT_PUBLIC_SENTRY_DSN="<-- sentry dns -->"
|
NEXT_PUBLIC_GITHUB_ID=""
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN=""
|
||||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||||
NEXT_PUBLIC_ENABLE_SENTRY=0
|
NEXT_PUBLIC_ENABLE_SENTRY=0
|
@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
// ui
|
// ui
|
||||||
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||||
import { Button, Input } from "components/ui";
|
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||||
// services
|
// services
|
||||||
import authenticationService from "services/authentication.service";
|
import authenticationService from "services/authentication.service";
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
@ -90,7 +90,7 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<form className="mt-5 space-y-5">
|
<form className="space-y-5 py-5 px-5">
|
||||||
{(codeSent || codeResent) && (
|
{(codeSent || codeResent) && (
|
||||||
<div className="rounded-md bg-green-50 p-4">
|
<div className="rounded-md bg-green-50 p-4">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
@ -121,7 +121,7 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
|||||||
) || "Email ID is not valid",
|
) || "Email ID is not valid",
|
||||||
}}
|
}}
|
||||||
error={errors.email}
|
error={errors.email}
|
||||||
placeholder="Enter your Email ID"
|
placeholder="Enter you email Id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -140,8 +140,8 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`text-xs mt-5 w-full flex justify-end outline-none ${
|
className={`mt-5 flex w-full justify-end text-xs outline-none ${
|
||||||
isResendDisabled ? "text-gray-400 cursor-default" : "cursor-pointer text-theme"
|
isResendDisabled ? "cursor-default text-gray-400" : "cursor-pointer text-theme"
|
||||||
} `}
|
} `}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsCodeResending(true);
|
setIsCodeResending(true);
|
||||||
@ -169,27 +169,29 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
{codeSent ? (
|
{codeSent ? (
|
||||||
<Button
|
<PrimaryButton
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full text-center"
|
className="w-full text-center"
|
||||||
|
size="md"
|
||||||
onClick={handleSubmit(handleSignin)}
|
onClick={handleSubmit(handleSignin)}
|
||||||
disabled={isSubmitting || (!isValid && isDirty)}
|
loading={isSubmitting || (!isValid && isDirty)}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||||
</Button>
|
</PrimaryButton>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<PrimaryButton
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full text-center"
|
className="w-full text-center"
|
||||||
|
size="md"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleSubmit(onSubmit)().then(() => {
|
handleSubmit(onSubmit)().then(() => {
|
||||||
setResendCodeTimer(30);
|
setResendCodeTimer(30);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
disabled={isSubmitting || (!isValid && isDirty)}
|
loading={isSubmitting || (!isValid && isDirty)}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Sending code..." : "Send code"}
|
{isSubmitting ? "Sending code..." : "Send code"}
|
||||||
</Button>
|
</PrimaryButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
// next
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
// react hook form
|
// react hook form
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
// ui
|
// services
|
||||||
import { Button, Input } from "components/ui";
|
|
||||||
import authenticationService from "services/authentication.service";
|
import authenticationService from "services/authentication.service";
|
||||||
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
|
// ui
|
||||||
|
import { Input, SecondaryButton } from "components/ui";
|
||||||
// types
|
// types
|
||||||
type EmailPasswordFormValues = {
|
type EmailPasswordFormValues = {
|
||||||
email: string;
|
email: string;
|
||||||
@ -58,7 +60,7 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<form className="mt-5" onSubmit={handleSubmit(onSubmit)}>
|
<form className="mt-5 py-5 px-5" onSubmit={handleSubmit(onSubmit)}>
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
@ -97,13 +99,13 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<Button
|
<SecondaryButton
|
||||||
disabled={isSubmitting || (!isValid && isDirty)}
|
|
||||||
className="w-full text-center"
|
|
||||||
type="submit"
|
type="submit"
|
||||||
|
className="w-full text-center"
|
||||||
|
loading={isSubmitting || (!isValid && isDirty)}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Signing in..." : "Sign In"}
|
{isSubmitting ? "Signing in..." : "Sign In"}
|
||||||
</Button>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
|
@ -19,28 +19,6 @@ export const EmailSignInForm: FC<EmailSignInFormProps> = (props) => {
|
|||||||
) : (
|
) : (
|
||||||
<EmailPasswordForm onSuccess={handleSuccess} />
|
<EmailPasswordForm onSuccess={handleSuccess} />
|
||||||
)}
|
)}
|
||||||
<div className="mt-6">
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<div className="w-full border-t border-gray-300" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-sm">
|
|
||||||
<span className="bg-white px-2 text-gray-500">or</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* <div className="mt-6 flex w-full flex-col items-stretch gap-y-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center rounded border border-gray-300 px-3 py-2 text-sm duration-300 hover:bg-gray-100"
|
|
||||||
onClick={() => setUseCode((prev) => !prev)}
|
|
||||||
>
|
|
||||||
<KeyIcon className="h-[25px] w-[25px]" />
|
|
||||||
<span className="w-full text-center font-medium">
|
|
||||||
{useCode ? "Continue with Password" : "Continue with Code"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div> */}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -3,7 +3,7 @@ import Link from "next/link";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
// images
|
// images
|
||||||
import githubImage from "/public/logos/github.png";
|
import githubImage from "/public/logos/github-black.png";
|
||||||
|
|
||||||
const { NEXT_PUBLIC_GITHUB_ID } = process.env;
|
const { NEXT_PUBLIC_GITHUB_ID } = process.env;
|
||||||
|
|
||||||
@ -33,19 +33,15 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<div className="px-1 w-full">
|
||||||
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
<Link
|
||||||
>
|
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||||
<button className="flex w-full items-center rounded bg-black px-3 py-2 text-sm text-white opacity-90 duration-300 hover:opacity-100">
|
>
|
||||||
<Image
|
<button className="flex w-full items-center justify-center gap-3 rounded-md border border-gray-200 p-2 text-sm font-medium text-gray-600 duration-300 hover:bg-gray-50">
|
||||||
src={githubImage}
|
<Image src={githubImage} height={22} width={22} color="#000" alt="GitHub Logo" />
|
||||||
height={25}
|
<span>Sign In with Github</span>
|
||||||
width={25}
|
</button>
|
||||||
className="flex-shrink-0"
|
</Link>
|
||||||
alt="GitHub Logo"
|
</div>
|
||||||
/>
|
|
||||||
<span className="w-full text-center font-medium">Continue with GitHub</span>
|
|
||||||
</button>
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -27,7 +27,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
|||||||
theme: "outline",
|
theme: "outline",
|
||||||
size: "large",
|
size: "large",
|
||||||
logo_alignment: "center",
|
logo_alignment: "center",
|
||||||
width: document.getElementById("googleSignInButton")?.offsetWidth,
|
width: "410",
|
||||||
text: "continue_with",
|
text: "continue_with",
|
||||||
} as GsiButtonConfiguration // customization attributes
|
} as GsiButtonConfiguration // customization attributes
|
||||||
);
|
);
|
||||||
@ -47,7 +47,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
|
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
|
||||||
<div className="w-full" id="googleSignInButton" ref={googleSignInButton} />
|
<div className="h-12" id="googleSignInButton" ref={googleSignInButton} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -14,12 +14,13 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div
|
<button
|
||||||
className="grid h-8 w-8 cursor-pointer place-items-center flex-shrink-0 rounded border border-gray-300 text-center text-sm hover:bg-gray-100"
|
type="button"
|
||||||
|
className="grid h-8 w-8 flex-shrink-0 cursor-pointer place-items-center rounded border border-gray-300 text-center text-sm hover:bg-gray-100"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="h-3 w-3" />
|
<ArrowLeftIcon className="h-3 w-3" />
|
||||||
</div>
|
</button>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -44,7 +45,7 @@ const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) =>
|
|||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-3 text-sm max-w-64">
|
<div className="max-w-64 px-3 text-sm">
|
||||||
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
|
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
|
||||||
{icon}
|
{icon}
|
||||||
<span className="break-all">{title}</span>
|
<span className="break-all">{title}</span>
|
||||||
|
109
apps/app/components/command-palette/change-issue-assignee.tsx
Normal file
109
apps/app/components/command-palette/change-issue-assignee.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
import React, { Dispatch, SetStateAction, useCallback } from "react";
|
||||||
|
import useSWR, { mutate } from "swr";
|
||||||
|
|
||||||
|
// cmdk
|
||||||
|
import { Command } from "cmdk";
|
||||||
|
// services
|
||||||
|
import issuesService from "services/issues.service";
|
||||||
|
// types
|
||||||
|
import { IIssue } from "types";
|
||||||
|
// constants
|
||||||
|
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||||
|
// icons
|
||||||
|
import { CheckIcon } from "components/icons";
|
||||||
|
import projectService from "services/project.service";
|
||||||
|
import { Avatar } from "components/ui";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
issue: IIssue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId, issueId } = router.query;
|
||||||
|
|
||||||
|
const { data: members } = useSWR(
|
||||||
|
projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||||
|
workspaceSlug && projectId
|
||||||
|
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const options =
|
||||||
|
members?.map(({ member }) => ({
|
||||||
|
value: member.id,
|
||||||
|
query:
|
||||||
|
(member.first_name && member.first_name !== "" ? member.first_name : member.email) +
|
||||||
|
" " +
|
||||||
|
member.last_name ?? "",
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar user={member} />
|
||||||
|
{member.first_name && member.first_name !== "" ? member.first_name : member.email}
|
||||||
|
</div>
|
||||||
|
{issue.assignees.includes(member.id) && (
|
||||||
|
<div>
|
||||||
|
<CheckIcon className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
const updateIssue = useCallback(
|
||||||
|
async (formData: Partial<IIssue>) => {
|
||||||
|
if (!workspaceSlug || !projectId || !issueId) return;
|
||||||
|
|
||||||
|
mutate(
|
||||||
|
ISSUE_DETAILS(issueId as string),
|
||||||
|
(prevData: IIssue) => ({
|
||||||
|
...prevData,
|
||||||
|
...formData,
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = { ...formData };
|
||||||
|
await issuesService
|
||||||
|
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
|
||||||
|
.then(() => {
|
||||||
|
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[workspaceSlug, issueId, projectId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleIssueAssignees = (assignee: string) => {
|
||||||
|
const updatedAssignees = issue.assignees ?? [];
|
||||||
|
|
||||||
|
if (updatedAssignees.includes(assignee)) {
|
||||||
|
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
|
||||||
|
} else {
|
||||||
|
updatedAssignees.push(assignee);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateIssue({ assignees_list: updatedAssignees });
|
||||||
|
setIsPaletteOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{options.map((option) => (
|
||||||
|
<Command.Item
|
||||||
|
key={option.value}
|
||||||
|
onSelect={() => handleIssueAssignees(option.value)}
|
||||||
|
className="focus:bg-slate-200 focus:outline-none"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{option.content}
|
||||||
|
</Command.Item>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,75 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
import React, { Dispatch, SetStateAction, useCallback } from "react";
|
||||||
|
import { mutate } from "swr";
|
||||||
|
|
||||||
|
// cmdk
|
||||||
|
import { Command } from "cmdk";
|
||||||
|
// services
|
||||||
|
import issuesService from "services/issues.service";
|
||||||
|
// types
|
||||||
|
import { IIssue } from "types";
|
||||||
|
// constants
|
||||||
|
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||||
|
import { PRIORITIES } from "constants/project";
|
||||||
|
// icons
|
||||||
|
import { CheckIcon, getPriorityIcon } from "components/icons";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
issue: IIssue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId, issueId } = router.query;
|
||||||
|
|
||||||
|
const submitChanges = useCallback(
|
||||||
|
async (formData: Partial<IIssue>) => {
|
||||||
|
if (!workspaceSlug || !projectId || !issueId) return;
|
||||||
|
|
||||||
|
mutate(
|
||||||
|
ISSUE_DETAILS(issueId as string),
|
||||||
|
(prevData: IIssue) => ({
|
||||||
|
...prevData,
|
||||||
|
...formData,
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = { ...formData };
|
||||||
|
await issuesService
|
||||||
|
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
|
||||||
|
.then(() => {
|
||||||
|
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[workspaceSlug, issueId, projectId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleIssueState = (priority: string | null) => {
|
||||||
|
submitChanges({ priority });
|
||||||
|
setIsPaletteOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{PRIORITIES.map((priority) => (
|
||||||
|
<Command.Item
|
||||||
|
key={priority}
|
||||||
|
onSelect={() => handleIssueState(priority)}
|
||||||
|
className="focus:bg-slate-200 focus:outline-none"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{getPriorityIcon(priority)}
|
||||||
|
<span className="capitalize">{priority ?? "None"}</span>
|
||||||
|
</div>
|
||||||
|
<div>{priority === issue.priority && <CheckIcon className="h-3 w-3" />}</div>
|
||||||
|
</Command.Item>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
96
apps/app/components/command-palette/change-issue-state.tsx
Normal file
96
apps/app/components/command-palette/change-issue-state.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
import React, { Dispatch, SetStateAction, useCallback } from "react";
|
||||||
|
import useSWR, { mutate } from "swr";
|
||||||
|
|
||||||
|
// cmdk
|
||||||
|
import { Command } from "cmdk";
|
||||||
|
// ui
|
||||||
|
import { Spinner } from "components/ui";
|
||||||
|
// helpers
|
||||||
|
import { getStatesList } from "helpers/state.helper";
|
||||||
|
// services
|
||||||
|
import issuesService from "services/issues.service";
|
||||||
|
import stateService from "services/state.service";
|
||||||
|
// types
|
||||||
|
import { IIssue } from "types";
|
||||||
|
// fetch keys
|
||||||
|
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, STATE_LIST } from "constants/fetch-keys";
|
||||||
|
// icons
|
||||||
|
import { CheckIcon, getStateGroupIcon } from "components/icons";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
issue: IIssue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId, issueId } = router.query;
|
||||||
|
|
||||||
|
const { data: stateGroups, mutate: mutateIssueDetails } = useSWR(
|
||||||
|
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
|
||||||
|
workspaceSlug && projectId
|
||||||
|
? () => stateService.getStates(workspaceSlug as string, projectId as string)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const states = getStatesList(stateGroups ?? {});
|
||||||
|
|
||||||
|
const submitChanges = useCallback(
|
||||||
|
async (formData: Partial<IIssue>) => {
|
||||||
|
if (!workspaceSlug || !projectId || !issueId) return;
|
||||||
|
|
||||||
|
mutate(
|
||||||
|
ISSUE_DETAILS(issueId as string),
|
||||||
|
(prevData: IIssue) => ({
|
||||||
|
...prevData,
|
||||||
|
...formData,
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = { ...formData };
|
||||||
|
await issuesService
|
||||||
|
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
|
||||||
|
.then(() => {
|
||||||
|
mutateIssueDetails();
|
||||||
|
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[workspaceSlug, issueId, projectId, mutateIssueDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleIssueState = (stateId: string) => {
|
||||||
|
submitChanges({ state: stateId });
|
||||||
|
setIsPaletteOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{states ? (
|
||||||
|
states.length > 0 ? (
|
||||||
|
states.map((state) => (
|
||||||
|
<Command.Item
|
||||||
|
key={state.id}
|
||||||
|
onSelect={() => handleIssueState(state.id)}
|
||||||
|
className="focus:bg-slate-200 focus:outline-none"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{getStateGroupIcon(state.group, "16", "16", state.color)}
|
||||||
|
<p>{state.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>{state.id === issue.state && <CheckIcon className="h-3 w-3" />}</div>
|
||||||
|
</Command.Item>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center">No states found</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Spinner />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
File diff suppressed because it is too large
Load Diff
@ -1,2 +1,5 @@
|
|||||||
export * from "./command-pallette";
|
export * from "./command-pallette";
|
||||||
export * from "./shortcuts-modal";
|
export * from "./shortcuts-modal";
|
||||||
|
export * from "./change-issue-state";
|
||||||
|
export * from "./change-issue-priority";
|
||||||
|
export * from "./change-issue-assignee";
|
||||||
|
@ -3,6 +3,8 @@ import React, { useEffect, useState } from "react";
|
|||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
// icons
|
// icons
|
||||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||||
|
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { MacCommandIcon } from "components/icons";
|
||||||
// ui
|
// ui
|
||||||
import { Input } from "components/ui";
|
import { Input } from "components/ui";
|
||||||
|
|
||||||
@ -15,7 +17,7 @@ const shortcuts = [
|
|||||||
{
|
{
|
||||||
title: "Navigation",
|
title: "Navigation",
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ keys: "Ctrl,/,Cmd,K", description: "To open navigator" },
|
{ keys: "Ctrl,K", description: "To open navigator" },
|
||||||
{ keys: "↑", description: "Move up" },
|
{ keys: "↑", description: "Move up" },
|
||||||
{ keys: "↓", description: "Move down" },
|
{ keys: "↓", description: "Move down" },
|
||||||
{ keys: "←", description: "Move left" },
|
{ keys: "←", description: "Move left" },
|
||||||
@ -34,8 +36,8 @@ const shortcuts = [
|
|||||||
{ keys: "Delete", description: "To bulk delete issues" },
|
{ keys: "Delete", description: "To bulk delete issues" },
|
||||||
{ keys: "H", description: "To open shortcuts guide" },
|
{ keys: "H", description: "To open shortcuts guide" },
|
||||||
{
|
{
|
||||||
keys: "Ctrl,/,Cmd,Alt,C",
|
keys: "Ctrl,Alt,C",
|
||||||
description: "To copy issue url when on issue detail page.",
|
description: "To copy issue url when on issue detail page",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -100,13 +102,17 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
|||||||
</span>
|
</span>
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<div className="flex w-full items-center justify-start gap-1 rounded border-[0.6px] border-gray-200 bg-gray-100 px-3 py-2">
|
||||||
id="search"
|
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-gray-500" />
|
||||||
name="search"
|
<Input
|
||||||
type="text"
|
className="w-full border-none bg-transparent py-1 px-2 text-xs text-gray-500 focus:outline-none"
|
||||||
placeholder="Search for shortcuts"
|
id="search"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
name="search"
|
||||||
/>
|
type="text"
|
||||||
|
placeholder="Search for shortcuts"
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col gap-y-3">
|
<div className="flex w-full flex-col gap-y-3">
|
||||||
{query.trim().length > 0 ? (
|
{query.trim().length > 0 ? (
|
||||||
@ -114,14 +120,20 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
|||||||
filteredShortcuts.map((shortcut) => (
|
filteredShortcuts.map((shortcut) => (
|
||||||
<div key={shortcut.keys} className="flex w-full flex-col">
|
<div key={shortcut.keys} className="flex w-full flex-col">
|
||||||
<div className="flex flex-col gap-y-3">
|
<div className="flex flex-col gap-y-3">
|
||||||
<div className="flex justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-gray-500">{shortcut.description}</p>
|
<p className="text-sm text-gray-500">{shortcut.description}</p>
|
||||||
<div className="flex items-center gap-x-1">
|
<div className="flex items-center gap-x-2.5">
|
||||||
{shortcut.keys.split(",").map((key, index) => (
|
{shortcut.keys.split(",").map((key, index) => (
|
||||||
<span key={index} className="flex items-center gap-1">
|
<span key={index} className="flex items-center gap-1">
|
||||||
<kbd className="rounded bg-gray-200 px-1 text-sm">
|
{key === "Ctrl" ? (
|
||||||
{key}
|
<span className="flex h-full items-center rounded-sm border border-gray-200 bg-gray-100 p-2">
|
||||||
</kbd>
|
<MacCommandIcon />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<kbd className="rounded-sm border border-gray-200 bg-gray-100 px-2 py-1 text-sm font-medium text-gray-800">
|
||||||
|
{key === "Ctrl" ? <MacCommandIcon /> : key}
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -147,14 +159,20 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
|||||||
<p className="mb-4 font-medium">{title}</p>
|
<p className="mb-4 font-medium">{title}</p>
|
||||||
<div className="flex flex-col gap-y-3">
|
<div className="flex flex-col gap-y-3">
|
||||||
{shortcuts.map(({ keys, description }, index) => (
|
{shortcuts.map(({ keys, description }, index) => (
|
||||||
<div key={index} className="flex justify-between">
|
<div key={index} className="flex items-center justify-between">
|
||||||
<p className="text-sm text-gray-500">{description}</p>
|
<p className="text-sm text-gray-500">{description}</p>
|
||||||
<div className="flex items-center gap-x-1">
|
<div className="flex items-center gap-x-2.5">
|
||||||
{keys.split(",").map((key, index) => (
|
{keys.split(",").map((key, index) => (
|
||||||
<span key={index} className="flex items-center gap-1">
|
<span key={index} className="flex items-center gap-1">
|
||||||
<kbd className="rounded bg-gray-200 px-1 text-sm">
|
{key === "Ctrl" ? (
|
||||||
{key}
|
<span className="flex h-full items-center rounded-sm border border-gray-200 bg-gray-100 p-2">
|
||||||
</kbd>
|
<MacCommandIcon />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<kbd className="rounded-sm border border-gray-200 bg-gray-100 px-2 py-1 text-sm font-medium text-gray-800">
|
||||||
|
{key === "Ctrl" ? <MacCommandIcon /> : key}
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,30 +1,30 @@
|
|||||||
// hooks
|
// hooks
|
||||||
import useIssueView from "hooks/use-issue-view";
|
import useProjectIssuesView from "hooks/use-issues-view";
|
||||||
// components
|
// components
|
||||||
import { SingleBoard } from "components/core/board-view/single-board";
|
import { SingleBoard } from "components/core/board-view/single-board";
|
||||||
|
// helpers
|
||||||
|
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||||
// types
|
// types
|
||||||
import { IIssue, IProjectMember, IState, UserAuth } from "types";
|
import { IIssue, IState, UserAuth } from "types";
|
||||||
|
import { getStateGroupIcon } from "components/icons";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
type: "issue" | "cycle" | "module";
|
type: "issue" | "cycle" | "module";
|
||||||
issues: IIssue[];
|
|
||||||
states: IState[] | undefined;
|
states: IState[] | undefined;
|
||||||
members: IProjectMember[] | undefined;
|
addIssueToState: (groupTitle: string) => void;
|
||||||
addIssueToState: (groupTitle: string, stateId: string | null) => void;
|
|
||||||
makeIssueCopy: (issue: IIssue) => void;
|
makeIssueCopy: (issue: IIssue) => void;
|
||||||
handleEditIssue: (issue: IIssue) => void;
|
handleEditIssue: (issue: IIssue) => void;
|
||||||
openIssuesListModal?: (() => void) | null;
|
openIssuesListModal?: (() => void) | null;
|
||||||
handleDeleteIssue: (issue: IIssue) => void;
|
handleDeleteIssue: (issue: IIssue) => void;
|
||||||
handleTrashBox: (isDragging: boolean) => void;
|
handleTrashBox: (isDragging: boolean) => void;
|
||||||
removeIssue: ((bridgeId: string) => void) | null;
|
removeIssue: ((bridgeId: string) => void) | null;
|
||||||
|
isCompleted?: boolean;
|
||||||
userAuth: UserAuth;
|
userAuth: UserAuth;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AllBoards: React.FC<Props> = ({
|
export const AllBoards: React.FC<Props> = ({
|
||||||
type,
|
type,
|
||||||
issues,
|
|
||||||
states,
|
states,
|
||||||
members,
|
|
||||||
addIssueToState,
|
addIssueToState,
|
||||||
makeIssueCopy,
|
makeIssueCopy,
|
||||||
handleEditIssue,
|
handleEditIssue,
|
||||||
@ -32,58 +32,75 @@ export const AllBoards: React.FC<Props> = ({
|
|||||||
handleDeleteIssue,
|
handleDeleteIssue,
|
||||||
handleTrashBox,
|
handleTrashBox,
|
||||||
removeIssue,
|
removeIssue,
|
||||||
|
isCompleted = false,
|
||||||
userAuth,
|
userAuth,
|
||||||
}) => {
|
}) => {
|
||||||
const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssueView(issues);
|
const {
|
||||||
|
groupedByIssues,
|
||||||
|
groupByProperty: selectedGroup,
|
||||||
|
showEmptyGroups,
|
||||||
|
} = useProjectIssuesView();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{groupedByIssues ? (
|
{groupedByIssues ? (
|
||||||
<div className="h-[calc(100vh-157px)] w-full lg:h-[calc(100vh-115px)]">
|
<div className="horizontal-scroll-enable flex h-[calc(100vh-140px)] gap-x-4">
|
||||||
<div className="horizontal-scroll-enable flex h-full gap-x-4 overflow-x-auto overflow-y-hidden">
|
{Object.keys(groupedByIssues).map((singleGroup, index) => {
|
||||||
{Object.keys(groupedByIssues).map((singleGroup, index) => {
|
const currentState =
|
||||||
const currentState =
|
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
|
||||||
selectedGroup === "state_detail.name"
|
|
||||||
? states?.find((s) => s.name === singleGroup)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const stateId =
|
if (!showEmptyGroups && groupedByIssues[singleGroup].length === 0) return null;
|
||||||
selectedGroup === "state_detail.name"
|
|
||||||
? states?.find((s) => s.name === singleGroup)?.id ?? null
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const bgColor =
|
return (
|
||||||
selectedGroup === "state_detail.name"
|
<SingleBoard
|
||||||
? states?.find((s) => s.name === singleGroup)?.color
|
key={index}
|
||||||
: "#000000";
|
type={type}
|
||||||
|
currentState={currentState}
|
||||||
|
groupTitle={singleGroup}
|
||||||
|
handleEditIssue={handleEditIssue}
|
||||||
|
makeIssueCopy={makeIssueCopy}
|
||||||
|
addIssueToState={() => addIssueToState(singleGroup)}
|
||||||
|
handleDeleteIssue={handleDeleteIssue}
|
||||||
|
openIssuesListModal={openIssuesListModal ?? null}
|
||||||
|
handleTrashBox={handleTrashBox}
|
||||||
|
removeIssue={removeIssue}
|
||||||
|
isCompleted={isCompleted}
|
||||||
|
userAuth={userAuth}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!showEmptyGroups && (
|
||||||
|
<div className="h-full w-96 flex-shrink-0 space-y-3 p-1">
|
||||||
|
<h2 className="text-lg font-semibold">Hidden groups</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.keys(groupedByIssues).map((singleGroup, index) => {
|
||||||
|
const currentState =
|
||||||
|
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
|
||||||
|
|
||||||
return (
|
if (groupedByIssues[singleGroup].length === 0)
|
||||||
<SingleBoard
|
return (
|
||||||
key={index}
|
<div
|
||||||
type={type}
|
key={index}
|
||||||
currentState={currentState}
|
className="flex items-center justify-between gap-2 rounded bg-white p-2 shadow"
|
||||||
bgColor={bgColor}
|
>
|
||||||
groupTitle={singleGroup}
|
<div className="flex items-center gap-2">
|
||||||
groupedByIssues={groupedByIssues}
|
{currentState &&
|
||||||
selectedGroup={selectedGroup}
|
getStateGroupIcon(currentState.group, "16", "16", currentState.color)}
|
||||||
members={members}
|
<h4 className="text-sm capitalize">
|
||||||
handleEditIssue={handleEditIssue}
|
{selectedGroup === "state"
|
||||||
makeIssueCopy={makeIssueCopy}
|
? addSpaceIfCamelCase(currentState?.name ?? "")
|
||||||
addIssueToState={() => addIssueToState(singleGroup, stateId)}
|
: addSpaceIfCamelCase(singleGroup)}
|
||||||
handleDeleteIssue={handleDeleteIssue}
|
</h4>
|
||||||
openIssuesListModal={openIssuesListModal ?? null}
|
</div>
|
||||||
orderBy={orderBy}
|
<span className="text-xs text-gray-500">0</span>
|
||||||
handleTrashBox={handleTrashBox}
|
</div>
|
||||||
removeIssue={removeIssue}
|
);
|
||||||
userAuth={userAuth}
|
})}
|
||||||
/>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<div className="flex h-full w-full items-center justify-center">Loading...</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,52 +1,93 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
// services
|
||||||
|
import issuesService from "services/issues.service";
|
||||||
|
import projectService from "services/project.service";
|
||||||
|
// hooks
|
||||||
|
import useIssuesView from "hooks/use-issues-view";
|
||||||
// icons
|
// icons
|
||||||
import { ArrowsPointingInIcon, ArrowsPointingOutIcon, PlusIcon } from "@heroicons/react/24/outline";
|
import { ArrowsPointingInIcon, ArrowsPointingOutIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { getStateGroupIcon } from "components/icons";
|
||||||
// helpers
|
// helpers
|
||||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||||
// types
|
// types
|
||||||
import { IIssue, IProjectMember, IState, NestedKeyOf } from "types";
|
import { IIssueLabels, IState } from "types";
|
||||||
import { getStateGroupIcon } from "components/icons";
|
// fetch-keys
|
||||||
|
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
groupedByIssues: {
|
|
||||||
[key: string]: IIssue[];
|
|
||||||
};
|
|
||||||
currentState?: IState | null;
|
currentState?: IState | null;
|
||||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
|
||||||
groupTitle: string;
|
groupTitle: string;
|
||||||
bgColor?: string;
|
|
||||||
addIssueToState: () => void;
|
addIssueToState: () => void;
|
||||||
members: IProjectMember[] | undefined;
|
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
isCompleted?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BoardHeader: React.FC<Props> = ({
|
export const BoardHeader: React.FC<Props> = ({
|
||||||
groupedByIssues,
|
|
||||||
currentState,
|
currentState,
|
||||||
selectedGroup,
|
|
||||||
groupTitle,
|
groupTitle,
|
||||||
bgColor,
|
|
||||||
addIssueToState,
|
addIssueToState,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
setIsCollapsed,
|
setIsCollapsed,
|
||||||
members,
|
isCompleted = false,
|
||||||
}) => {
|
}) => {
|
||||||
const createdBy =
|
const router = useRouter();
|
||||||
selectedGroup === "created_by"
|
const { workspaceSlug, projectId } = router.query;
|
||||||
? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "loading..."
|
|
||||||
: null;
|
|
||||||
|
|
||||||
let assignees: any;
|
const { groupedByIssues, groupByProperty: selectedGroup } = useIssuesView();
|
||||||
if (selectedGroup === "assignees") {
|
|
||||||
assignees = groupTitle && groupTitle !== "" ? groupTitle.split(",") : [];
|
const { data: issueLabels } = useSWR<IIssueLabels[]>(
|
||||||
assignees =
|
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
|
||||||
assignees.length > 0
|
workspaceSlug && projectId
|
||||||
? assignees
|
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
|
||||||
.map((a: string) => members?.find((m) => m.member.id === a)?.member.first_name)
|
: null
|
||||||
.join(", ")
|
);
|
||||||
: "No assignee";
|
|
||||||
}
|
const { data: members } = useSWR(
|
||||||
|
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||||
|
workspaceSlug && projectId
|
||||||
|
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
let bgColor = "#000000";
|
||||||
|
if (selectedGroup === "state") bgColor = currentState?.color ?? "#000000";
|
||||||
|
|
||||||
|
if (selectedGroup === "priority")
|
||||||
|
groupTitle === "high"
|
||||||
|
? (bgColor = "#dc2626")
|
||||||
|
: groupTitle === "medium"
|
||||||
|
? (bgColor = "#f97316")
|
||||||
|
: groupTitle === "low"
|
||||||
|
? (bgColor = "#22c55e")
|
||||||
|
: (bgColor = "#ff0000");
|
||||||
|
|
||||||
|
const getGroupTitle = () => {
|
||||||
|
let title = addSpaceIfCamelCase(groupTitle);
|
||||||
|
|
||||||
|
switch (selectedGroup) {
|
||||||
|
case "state":
|
||||||
|
title = addSpaceIfCamelCase(currentState?.name ?? "");
|
||||||
|
break;
|
||||||
|
case "labels":
|
||||||
|
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None";
|
||||||
|
break;
|
||||||
|
case "created_by":
|
||||||
|
const member = members?.find((member) => member.member.id === groupTitle)?.member;
|
||||||
|
title =
|
||||||
|
member?.first_name && member.first_name !== ""
|
||||||
|
? `${member.first_name} ${member.last_name}`
|
||||||
|
: member?.email ?? "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return title;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -56,25 +97,21 @@ export const BoardHeader: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
|
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
|
||||||
<div
|
<div
|
||||||
className={`flex cursor-pointer items-center gap-x-3.5 ${
|
className={`flex cursor-pointer items-center gap-x-3 ${
|
||||||
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
|
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{currentState && getStateGroupIcon(currentState.group, "20", "20", bgColor)}
|
{currentState && getStateGroupIcon(currentState.group, "18", "18", bgColor)}
|
||||||
<h2
|
<h2
|
||||||
className={`text-xl font-semibold capitalize`}
|
className="text-lg font-semibold capitalize"
|
||||||
style={{
|
style={{
|
||||||
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
|
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedGroup === "created_by"
|
{getGroupTitle()}
|
||||||
? createdBy
|
|
||||||
: selectedGroup === "assignees"
|
|
||||||
? assignees
|
|
||||||
: addSpaceIfCamelCase(groupTitle)}
|
|
||||||
</h2>
|
</h2>
|
||||||
<span className="ml-0.5 text-sm bg-gray-100 py-1 px-3 rounded-full">
|
<span className="ml-0.5 rounded-full bg-gray-100 py-1 px-3 text-sm">
|
||||||
{groupedByIssues[groupTitle].length}
|
{groupedByIssues?.[groupTitle].length ?? 0}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -93,13 +130,15 @@ export const BoardHeader: React.FC<Props> = ({
|
|||||||
<ArrowsPointingOutIcon className="h-4 w-4" />
|
<ArrowsPointingOutIcon className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{!isCompleted && (
|
||||||
type="button"
|
<button
|
||||||
className="grid h-7 w-7 place-items-center rounded p-1 text-gray-700 outline-none duration-300 hover:bg-gray-100"
|
type="button"
|
||||||
onClick={addIssueToState}
|
className="grid h-7 w-7 place-items-center rounded p-1 text-gray-700 outline-none duration-300 hover:bg-gray-100"
|
||||||
>
|
onClick={addIssueToState}
|
||||||
<PlusIcon className="h-4 w-4" />
|
>
|
||||||
</button>
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
@ -6,6 +6,7 @@ import { useRouter } from "next/router";
|
|||||||
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
||||||
import { Draggable } from "react-beautiful-dnd";
|
import { Draggable } from "react-beautiful-dnd";
|
||||||
// hooks
|
// hooks
|
||||||
|
import useIssuesView from "hooks/use-issues-view";
|
||||||
import useIssuesProperties from "hooks/use-issue-properties";
|
import useIssuesProperties from "hooks/use-issue-properties";
|
||||||
// components
|
// components
|
||||||
import { BoardHeader, SingleBoardIssue } from "components/core";
|
import { BoardHeader, SingleBoardIssue } from "components/core";
|
||||||
@ -16,177 +17,169 @@ import { PlusIcon } from "@heroicons/react/24/outline";
|
|||||||
// helpers
|
// helpers
|
||||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||||
// types
|
// types
|
||||||
import { IIssue, IProjectMember, IState, NestedKeyOf, UserAuth } from "types";
|
import { IIssue, IState, UserAuth } from "types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
type?: "issue" | "cycle" | "module";
|
type?: "issue" | "cycle" | "module";
|
||||||
currentState?: IState | null;
|
currentState?: IState | null;
|
||||||
bgColor?: string;
|
|
||||||
groupTitle: string;
|
groupTitle: string;
|
||||||
groupedByIssues: {
|
|
||||||
[key: string]: IIssue[];
|
|
||||||
};
|
|
||||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
|
||||||
members: IProjectMember[] | undefined;
|
|
||||||
handleEditIssue: (issue: IIssue) => void;
|
handleEditIssue: (issue: IIssue) => void;
|
||||||
makeIssueCopy: (issue: IIssue) => void;
|
makeIssueCopy: (issue: IIssue) => void;
|
||||||
addIssueToState: () => void;
|
addIssueToState: () => void;
|
||||||
handleDeleteIssue: (issue: IIssue) => void;
|
handleDeleteIssue: (issue: IIssue) => void;
|
||||||
openIssuesListModal?: (() => void) | null;
|
openIssuesListModal?: (() => void) | null;
|
||||||
orderBy: NestedKeyOf<IIssue> | null;
|
|
||||||
handleTrashBox: (isDragging: boolean) => void;
|
handleTrashBox: (isDragging: boolean) => void;
|
||||||
removeIssue: ((bridgeId: string) => void) | null;
|
removeIssue: ((bridgeId: string) => void) | null;
|
||||||
|
isCompleted?: boolean;
|
||||||
userAuth: UserAuth;
|
userAuth: UserAuth;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SingleBoard: React.FC<Props> = ({
|
export const SingleBoard: React.FC<Props> = ({
|
||||||
type,
|
type,
|
||||||
currentState,
|
currentState,
|
||||||
bgColor,
|
|
||||||
groupTitle,
|
groupTitle,
|
||||||
groupedByIssues,
|
|
||||||
selectedGroup,
|
|
||||||
members,
|
|
||||||
handleEditIssue,
|
handleEditIssue,
|
||||||
makeIssueCopy,
|
makeIssueCopy,
|
||||||
addIssueToState,
|
addIssueToState,
|
||||||
handleDeleteIssue,
|
handleDeleteIssue,
|
||||||
openIssuesListModal,
|
openIssuesListModal,
|
||||||
orderBy,
|
|
||||||
handleTrashBox,
|
handleTrashBox,
|
||||||
removeIssue,
|
removeIssue,
|
||||||
|
isCompleted = false,
|
||||||
userAuth,
|
userAuth,
|
||||||
}) => {
|
}) => {
|
||||||
// collapse/expand
|
// collapse/expand
|
||||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||||
|
|
||||||
|
const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssuesView();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
||||||
|
|
||||||
if (selectedGroup === "priority")
|
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
|
||||||
groupTitle === "high"
|
|
||||||
? (bgColor = "#dc2626")
|
|
||||||
: groupTitle === "medium"
|
|
||||||
? (bgColor = "#f97316")
|
|
||||||
: groupTitle === "low"
|
|
||||||
? (bgColor = "#22c55e")
|
|
||||||
: (bgColor = "#ff0000");
|
|
||||||
|
|
||||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
useEffect(() => {
|
||||||
|
if (currentState?.group === "completed" || currentState?.group === "cancelled")
|
||||||
|
setIsCollapsed(false);
|
||||||
|
}, [currentState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`h-full flex-shrink-0 rounded ${!isCollapsed ? "" : "w-96 bg-gray-50"}`}>
|
<div className={`h-full flex-shrink-0 ${!isCollapsed ? "" : "w-96 bg-gray-50"}`}>
|
||||||
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3"}`}>
|
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3"}`}>
|
||||||
<BoardHeader
|
<BoardHeader
|
||||||
addIssueToState={addIssueToState}
|
addIssueToState={addIssueToState}
|
||||||
currentState={currentState}
|
currentState={currentState}
|
||||||
bgColor={bgColor}
|
|
||||||
selectedGroup={selectedGroup}
|
|
||||||
groupTitle={groupTitle}
|
groupTitle={groupTitle}
|
||||||
groupedByIssues={groupedByIssues}
|
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
setIsCollapsed={setIsCollapsed}
|
setIsCollapsed={setIsCollapsed}
|
||||||
members={members}
|
isCompleted={isCompleted}
|
||||||
/>
|
/>
|
||||||
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
|
{isCollapsed && (
|
||||||
{(provided, snapshot) => (
|
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
|
||||||
<div
|
{(provided, snapshot) => (
|
||||||
className={`relative h-full overflow-y-auto p-1 ${
|
<div
|
||||||
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
|
className={`relative h-full overflow-y-auto p-1 ${
|
||||||
} ${!isCollapsed ? "hidden" : "block"}`}
|
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
|
||||||
ref={provided.innerRef}
|
} ${!isCollapsed ? "hidden" : "block"}`}
|
||||||
{...provided.droppableProps}
|
ref={provided.innerRef}
|
||||||
>
|
{...provided.droppableProps}
|
||||||
{orderBy !== "sort_order" && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={`absolute ${
|
|
||||||
snapshot.isDraggingOver ? "block" : "hidden"
|
|
||||||
} pointer-events-none top-0 left-0 z-[99] h-full w-full bg-gray-100 opacity-50`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`absolute ${
|
|
||||||
snapshot.isDraggingOver ? "block" : "hidden"
|
|
||||||
} pointer-events-none top-1/2 left-1/2 z-[99] -translate-y-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-white p-2 text-xs`}
|
|
||||||
>
|
|
||||||
This board is ordered by {replaceUnderscoreIfSnakeCase(orderBy ?? "created_at")}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{groupedByIssues[groupTitle].map((issue, index: number) => (
|
|
||||||
<Draggable
|
|
||||||
key={issue.id}
|
|
||||||
draggableId={issue.id}
|
|
||||||
index={index}
|
|
||||||
isDragDisabled={
|
|
||||||
isNotAllowed || selectedGroup === "created_by" || selectedGroup === "assignees"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(provided, snapshot) => (
|
|
||||||
<SingleBoardIssue
|
|
||||||
key={index}
|
|
||||||
provided={provided}
|
|
||||||
snapshot={snapshot}
|
|
||||||
type={type}
|
|
||||||
issue={issue}
|
|
||||||
selectedGroup={selectedGroup}
|
|
||||||
properties={properties}
|
|
||||||
editIssue={() => handleEditIssue(issue)}
|
|
||||||
makeIssueCopy={() => makeIssueCopy(issue)}
|
|
||||||
handleDeleteIssue={handleDeleteIssue}
|
|
||||||
orderBy={orderBy}
|
|
||||||
handleTrashBox={handleTrashBox}
|
|
||||||
removeIssue={() => {
|
|
||||||
removeIssue && removeIssue(issue.bridge);
|
|
||||||
}}
|
|
||||||
userAuth={userAuth}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
))}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: orderBy === "sort_order" ? "inline" : "none",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{provided.placeholder}
|
{orderBy !== "sort_order" && (
|
||||||
</span>
|
<>
|
||||||
{type === "issue" ? (
|
<div
|
||||||
<button
|
className={`absolute ${
|
||||||
type="button"
|
snapshot.isDraggingOver ? "block" : "hidden"
|
||||||
className="flex items-center gap-2 font-medium text-theme outline-none"
|
} pointer-events-none top-0 left-0 z-[99] h-full w-full bg-gray-100 opacity-50`}
|
||||||
onClick={addIssueToState}
|
/>
|
||||||
>
|
<div
|
||||||
<PlusIcon className="h-4 w-4" />
|
className={`absolute ${
|
||||||
Add Issue
|
snapshot.isDraggingOver ? "block" : "hidden"
|
||||||
</button>
|
} pointer-events-none top-1/2 left-1/2 z-[99] -translate-y-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-white p-2 text-xs`}
|
||||||
) : (
|
|
||||||
<CustomMenu
|
|
||||||
customButton={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-2 font-medium text-theme outline-none"
|
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4" />
|
This board is ordered by{" "}
|
||||||
Add Issue
|
{replaceUnderscoreIfSnakeCase(orderBy ?? "created_at")}
|
||||||
</button>
|
</div>
|
||||||
}
|
</>
|
||||||
optionsPosition="left"
|
)}
|
||||||
noBorder
|
{groupedByIssues?.[groupTitle].map((issue, index) => (
|
||||||
|
<Draggable
|
||||||
|
key={issue.id}
|
||||||
|
draggableId={issue.id}
|
||||||
|
index={index}
|
||||||
|
isDragDisabled={isNotAllowed || selectedGroup === "created_by"}
|
||||||
|
>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<SingleBoardIssue
|
||||||
|
key={index}
|
||||||
|
provided={provided}
|
||||||
|
snapshot={snapshot}
|
||||||
|
type={type}
|
||||||
|
index={index}
|
||||||
|
selectedGroup={selectedGroup}
|
||||||
|
issue={issue}
|
||||||
|
groupTitle={groupTitle}
|
||||||
|
properties={properties}
|
||||||
|
editIssue={() => handleEditIssue(issue)}
|
||||||
|
makeIssueCopy={() => makeIssueCopy(issue)}
|
||||||
|
handleDeleteIssue={handleDeleteIssue}
|
||||||
|
handleTrashBox={handleTrashBox}
|
||||||
|
removeIssue={() => {
|
||||||
|
if (removeIssue && issue.bridge_id) removeIssue(issue.bridge_id);
|
||||||
|
}}
|
||||||
|
isCompleted={isCompleted}
|
||||||
|
userAuth={userAuth}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: orderBy === "sort_order" ? "inline" : "none",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
|
{provided.placeholder}
|
||||||
{openIssuesListModal && (
|
</span>
|
||||||
<CustomMenu.MenuItem onClick={openIssuesListModal}>
|
{type === "issue" ? (
|
||||||
Add an existing issue
|
<button
|
||||||
</CustomMenu.MenuItem>
|
type="button"
|
||||||
)}
|
className="flex items-center gap-2 font-medium text-theme outline-none"
|
||||||
</CustomMenu>
|
onClick={addIssueToState}
|
||||||
)}
|
>
|
||||||
</div>
|
<PlusIcon className="h-4 w-4" />
|
||||||
)}
|
Add Issue
|
||||||
</StrictModeDroppable>
|
</button>
|
||||||
|
) : (
|
||||||
|
!isCompleted && (
|
||||||
|
<CustomMenu
|
||||||
|
customButton={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 font-medium text-theme outline-none"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
Add Issue
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
optionsPosition="left"
|
||||||
|
noBorder
|
||||||
|
>
|
||||||
|
<CustomMenu.MenuItem onClick={addIssueToState}>
|
||||||
|
Create new
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
{openIssuesListModal && (
|
||||||
|
<CustomMenu.MenuItem onClick={openIssuesListModal}>
|
||||||
|
Add an existing issue
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
)}
|
||||||
|
</CustomMenu>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</StrictModeDroppable>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -15,6 +15,7 @@ import {
|
|||||||
// services
|
// services
|
||||||
import issuesService from "services/issues.service";
|
import issuesService from "services/issues.service";
|
||||||
// hooks
|
// hooks
|
||||||
|
import useIssuesView from "hooks/use-issues-view";
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// components
|
// components
|
||||||
import {
|
import {
|
||||||
@ -24,41 +25,44 @@ import {
|
|||||||
ViewStateSelect,
|
ViewStateSelect,
|
||||||
} from "components/issues/view-select";
|
} from "components/issues/view-select";
|
||||||
// ui
|
// ui
|
||||||
import { ContextMenu, CustomMenu, Tooltip } from "components/ui";
|
import { ContextMenu, CustomMenu } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
import {
|
import {
|
||||||
ClipboardDocumentCheckIcon,
|
ClipboardDocumentCheckIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
|
XMarkIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
// helpers
|
// helpers
|
||||||
|
import { handleIssuesMutation } from "constants/issue";
|
||||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||||
// types
|
// types
|
||||||
import {
|
import { IIssue, Properties, TIssueGroupByOptions, UserAuth } from "types";
|
||||||
CycleIssueResponse,
|
|
||||||
IIssue,
|
|
||||||
ModuleIssueResponse,
|
|
||||||
NestedKeyOf,
|
|
||||||
Properties,
|
|
||||||
UserAuth,
|
|
||||||
} from "types";
|
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
import {
|
||||||
|
CYCLE_DETAILS,
|
||||||
|
CYCLE_ISSUES_WITH_PARAMS,
|
||||||
|
MODULE_DETAILS,
|
||||||
|
MODULE_ISSUES_WITH_PARAMS,
|
||||||
|
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||||
|
} from "constants/fetch-keys";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
type?: string;
|
type?: string;
|
||||||
provided: DraggableProvided;
|
provided: DraggableProvided;
|
||||||
snapshot: DraggableStateSnapshot;
|
snapshot: DraggableStateSnapshot;
|
||||||
issue: IIssue;
|
issue: IIssue;
|
||||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
|
||||||
properties: Properties;
|
properties: Properties;
|
||||||
|
groupTitle?: string;
|
||||||
|
index: number;
|
||||||
|
selectedGroup: TIssueGroupByOptions;
|
||||||
editIssue: () => void;
|
editIssue: () => void;
|
||||||
makeIssueCopy: () => void;
|
makeIssueCopy: () => void;
|
||||||
removeIssue?: (() => void) | null;
|
removeIssue?: (() => void) | null;
|
||||||
handleDeleteIssue: (issue: IIssue) => void;
|
handleDeleteIssue: (issue: IIssue) => void;
|
||||||
orderBy: NestedKeyOf<IIssue> | null;
|
|
||||||
handleTrashBox: (isDragging: boolean) => void;
|
handleTrashBox: (isDragging: boolean) => void;
|
||||||
|
isCompleted?: boolean;
|
||||||
userAuth: UserAuth;
|
userAuth: UserAuth;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -67,20 +71,24 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
provided,
|
provided,
|
||||||
snapshot,
|
snapshot,
|
||||||
issue,
|
issue,
|
||||||
selectedGroup,
|
|
||||||
properties,
|
properties,
|
||||||
|
index,
|
||||||
|
selectedGroup,
|
||||||
editIssue,
|
editIssue,
|
||||||
makeIssueCopy,
|
makeIssueCopy,
|
||||||
removeIssue,
|
removeIssue,
|
||||||
|
groupTitle,
|
||||||
handleDeleteIssue,
|
handleDeleteIssue,
|
||||||
orderBy,
|
|
||||||
handleTrashBox,
|
handleTrashBox,
|
||||||
|
isCompleted = false,
|
||||||
userAuth,
|
userAuth,
|
||||||
}) => {
|
}) => {
|
||||||
// context menu
|
// context menu
|
||||||
const [contextMenu, setContextMenu] = useState(false);
|
const [contextMenu, setContextMenu] = useState(false);
|
||||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const { orderBy, params } = useIssuesView();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||||
|
|
||||||
@ -91,75 +99,59 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
if (cycleId)
|
if (cycleId)
|
||||||
mutate<CycleIssueResponse[]>(
|
mutate<
|
||||||
CYCLE_ISSUES(cycleId as string),
|
| {
|
||||||
(prevData) => {
|
[key: string]: IIssue[];
|
||||||
const updatedIssues = (prevData ?? []).map((p) => {
|
}
|
||||||
if (p.issue_detail.id === issue.id) {
|
| IIssue[]
|
||||||
return {
|
>(
|
||||||
...p,
|
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params),
|
||||||
issue_detail: {
|
(prevData) =>
|
||||||
...p.issue_detail,
|
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
|
||||||
...formData,
|
|
||||||
assignees: formData.assignees_list ?? p.issue_detail.assignees_list,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
});
|
|
||||||
return [...updatedIssues];
|
|
||||||
},
|
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
if (moduleId)
|
if (moduleId)
|
||||||
mutate<ModuleIssueResponse[]>(
|
mutate<
|
||||||
MODULE_ISSUES(moduleId as string),
|
| {
|
||||||
(prevData) => {
|
[key: string]: IIssue[];
|
||||||
const updatedIssues = (prevData ?? []).map((p) => {
|
}
|
||||||
if (p.issue_detail.id === issue.id) {
|
| IIssue[]
|
||||||
return {
|
>(
|
||||||
...p,
|
MODULE_ISSUES_WITH_PARAMS(moduleId as string),
|
||||||
issue_detail: {
|
(prevData) =>
|
||||||
...p.issue_detail,
|
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
|
||||||
...formData,
|
|
||||||
assignees: formData.assignees_list ?? p.issue_detail.assignees_list,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
});
|
|
||||||
return [...updatedIssues];
|
|
||||||
},
|
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
mutate<IIssue[]>(
|
mutate<
|
||||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
| {
|
||||||
|
[key: string]: IIssue[];
|
||||||
|
}
|
||||||
|
| IIssue[]
|
||||||
|
>(
|
||||||
|
PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
|
||||||
(prevData) =>
|
(prevData) =>
|
||||||
(prevData ?? []).map((p) => {
|
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
|
||||||
if (p.id === issue.id)
|
|
||||||
return { ...p, ...formData, assignees: formData.assignees_list ?? p.assignees_list };
|
|
||||||
|
|
||||||
return p;
|
|
||||||
}),
|
|
||||||
|
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
issuesService
|
issuesService
|
||||||
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
|
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
|
||||||
.then((res) => {
|
.then(() => {
|
||||||
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
|
if (cycleId) {
|
||||||
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
|
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
|
||||||
|
mutate(CYCLE_DETAILS(cycleId as string));
|
||||||
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
|
} else if (moduleId) {
|
||||||
|
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
|
||||||
|
mutate(MODULE_DETAILS(moduleId as string));
|
||||||
|
} else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[workspaceSlug, projectId, cycleId, moduleId, issue]
|
[workspaceSlug, projectId, cycleId, moduleId, issue, groupTitle, index, selectedGroup, params]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getStyle = (
|
const getStyle = (
|
||||||
@ -168,9 +160,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
) => {
|
) => {
|
||||||
if (orderBy === "sort_order") return style;
|
if (orderBy === "sort_order") return style;
|
||||||
if (!snapshot.isDragging) return {};
|
if (!snapshot.isDragging) return {};
|
||||||
if (!snapshot.isDropAnimating) {
|
if (!snapshot.isDropAnimating) return style;
|
||||||
return style;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...style,
|
...style,
|
||||||
@ -196,7 +186,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
|
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
|
||||||
}, [snapshot, handleTrashBox]);
|
}, [snapshot, handleTrashBox]);
|
||||||
|
|
||||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -206,15 +196,19 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
isOpen={contextMenu}
|
isOpen={contextMenu}
|
||||||
setIsOpen={setContextMenu}
|
setIsOpen={setContextMenu}
|
||||||
>
|
>
|
||||||
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
|
{!isNotAllowed && (
|
||||||
Edit issue
|
<>
|
||||||
</ContextMenu.Item>
|
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
|
||||||
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
|
Edit issue
|
||||||
Make a copy...
|
</ContextMenu.Item>
|
||||||
</ContextMenu.Item>
|
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
|
||||||
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
|
Make a copy...
|
||||||
Delete issue
|
</ContextMenu.Item>
|
||||||
</ContextMenu.Item>
|
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
|
||||||
|
Delete issue
|
||||||
|
</ContextMenu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
|
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
|
||||||
Copy issue link
|
Copy issue link
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
@ -233,22 +227,36 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
setContextMenuPosition({ x: e.pageX, y: e.pageY });
|
setContextMenuPosition({ x: e.pageX, y: e.pageY });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="group/card relative select-none p-4">
|
<div className="group/card relative select-none p-3.5">
|
||||||
{!isNotAllowed && (
|
{!isNotAllowed && (
|
||||||
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
|
<div className="z-1 absolute top-1.5 right-1.5 opacity-0 group-hover/card:opacity-100">
|
||||||
{type && !isNotAllowed && (
|
{type && !isNotAllowed && (
|
||||||
<CustomMenu width="auto" ellipsis>
|
<CustomMenu width="auto" ellipsis>
|
||||||
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
|
<CustomMenu.MenuItem onClick={editIssue}>
|
||||||
|
<div className="flex items-center justify-start gap-2">
|
||||||
|
<PencilIcon className="h-4 w-4" />
|
||||||
|
<span>Edit issue</span>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
{type !== "issue" && removeIssue && (
|
{type !== "issue" && removeIssue && (
|
||||||
<CustomMenu.MenuItem onClick={removeIssue}>
|
<CustomMenu.MenuItem onClick={removeIssue}>
|
||||||
<>Remove from {type}</>
|
<div className="flex items-center justify-start gap-2">
|
||||||
|
<XMarkIcon className="h-4 w-4" />
|
||||||
|
<span>Remove from {type}</span>
|
||||||
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
)}
|
)}
|
||||||
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
|
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
|
||||||
Delete issue
|
<div className="flex items-center justify-start gap-2">
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
<span>Delete issue</span>
|
||||||
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||||
Copy issue link
|
<div className="flex items-center justify-start gap-2">
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
<span>Copy issue Link</span>
|
||||||
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
)}
|
)}
|
||||||
@ -301,7 +309,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
{properties.labels && issue.label_details.length > 0 && (
|
{properties.labels && issue.label_details.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{issue.label_details.map((label) => (
|
{issue.label_details.map((label) => (
|
||||||
<span
|
<div
|
||||||
key={label.id}
|
key={label.id}
|
||||||
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
||||||
>
|
>
|
||||||
@ -312,7 +320,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{label.name}
|
{label.name}
|
||||||
</span>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -13,7 +13,7 @@ import issuesServices from "services/issues.service";
|
|||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// ui
|
// ui
|
||||||
import { Button } from "components/ui";
|
import { DangerButton, SecondaryButton } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||||
import { LayerDiagonalIcon } from "components/icons";
|
import { LayerDiagonalIcon } from "components/icons";
|
||||||
@ -100,12 +100,6 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
|||||||
type: "success",
|
type: "success",
|
||||||
message: res.message,
|
message: res.message,
|
||||||
});
|
});
|
||||||
|
|
||||||
mutate<IIssue[]>(
|
|
||||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
|
||||||
(prevData) => (prevData ?? []).filter((p) => !data.delete_issue_ids.includes(p.id)),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
handleClose();
|
handleClose();
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@ -211,17 +205,10 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
|||||||
|
|
||||||
{filteredIssues.length > 0 && (
|
{filteredIssues.length > 0 && (
|
||||||
<div className="flex items-center justify-end gap-2 p-3">
|
<div className="flex items-center justify-end gap-2 p-3">
|
||||||
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
|
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||||
Close
|
<DangerButton onClick={handleSubmit(handleDelete)} loading={isSubmitting}>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit(handleDelete)}
|
|
||||||
theme="danger"
|
|
||||||
size="sm"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Deleting..." : "Delete selected issues"}
|
{isSubmitting ? "Deleting..." : "Delete selected issues"}
|
||||||
</Button>
|
</DangerButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
470
apps/app/components/core/calendar-view/calendar.tsx
Normal file
470
apps/app/components/core/calendar-view/calendar.tsx
Normal file
@ -0,0 +1,470 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import useSWR, { mutate } from "swr";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
// helper
|
||||||
|
import { renderDateFormat } from "helpers/date-time.helper";
|
||||||
|
import {
|
||||||
|
startOfWeek,
|
||||||
|
lastDayOfWeek,
|
||||||
|
eachDayOfInterval,
|
||||||
|
weekDayInterval,
|
||||||
|
formatDate,
|
||||||
|
getCurrentWeekStartDate,
|
||||||
|
getCurrentWeekEndDate,
|
||||||
|
subtractMonths,
|
||||||
|
addMonths,
|
||||||
|
updateDateWithYear,
|
||||||
|
updateDateWithMonth,
|
||||||
|
isSameMonth,
|
||||||
|
isSameYear,
|
||||||
|
subtract7DaysToDate,
|
||||||
|
addSevenDaysToDate,
|
||||||
|
} from "helpers/calendar.helper";
|
||||||
|
// ui
|
||||||
|
import { Popover, Transition } from "@headlessui/react";
|
||||||
|
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
|
||||||
|
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
||||||
|
import { CustomMenu } from "components/ui";
|
||||||
|
// icon
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
// services
|
||||||
|
import issuesService from "services/issues.service";
|
||||||
|
import cyclesService from "services/cycles.service";
|
||||||
|
// fetch key
|
||||||
|
import {
|
||||||
|
CYCLE_CALENDAR_ISSUES,
|
||||||
|
MODULE_CALENDAR_ISSUES,
|
||||||
|
PROJECT_CALENDAR_ISSUES,
|
||||||
|
} from "constants/fetch-keys";
|
||||||
|
// type
|
||||||
|
import { IIssue } from "types";
|
||||||
|
// constant
|
||||||
|
import { monthOptions, yearOptions } from "constants/calendar";
|
||||||
|
import modulesService from "services/modules.service";
|
||||||
|
|
||||||
|
interface ICalendarRange {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CalendarView = () => {
|
||||||
|
const [showWeekEnds, setShowWeekEnds] = useState<boolean>(false);
|
||||||
|
const [currentDate, setCurrentDate] = useState<Date>(new Date());
|
||||||
|
const [isMonthlyView, setIsMonthlyView] = useState<boolean>(true);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||||
|
|
||||||
|
const [calendarDateRange, setCalendarDateRange] = useState<ICalendarRange>({
|
||||||
|
startDate: startOfWeek(currentDate),
|
||||||
|
endDate: lastDayOfWeek(currentDate),
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetDateFilter = {
|
||||||
|
target_date: `${renderDateFormat(calendarDateRange.startDate)};after,${renderDateFormat(
|
||||||
|
calendarDateRange.endDate
|
||||||
|
)};before`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: projectCalendarIssues } = useSWR(
|
||||||
|
workspaceSlug && projectId ? PROJECT_CALENDAR_ISSUES(projectId as string) : null,
|
||||||
|
workspaceSlug && projectId
|
||||||
|
? () =>
|
||||||
|
issuesService.getIssuesWithParams(
|
||||||
|
workspaceSlug as string,
|
||||||
|
projectId as string,
|
||||||
|
targetDateFilter
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: cycleCalendarIssues } = useSWR(
|
||||||
|
workspaceSlug && projectId && cycleId
|
||||||
|
? CYCLE_CALENDAR_ISSUES(projectId as string, cycleId as string)
|
||||||
|
: null,
|
||||||
|
workspaceSlug && projectId && cycleId
|
||||||
|
? () =>
|
||||||
|
cyclesService.getCycleIssuesWithParams(
|
||||||
|
workspaceSlug as string,
|
||||||
|
projectId as string,
|
||||||
|
cycleId as string,
|
||||||
|
targetDateFilter
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: moduleCalendarIssues } = useSWR(
|
||||||
|
workspaceSlug && projectId && moduleId
|
||||||
|
? MODULE_CALENDAR_ISSUES(projectId as string, moduleId as string)
|
||||||
|
: null,
|
||||||
|
workspaceSlug && projectId && moduleId
|
||||||
|
? () =>
|
||||||
|
modulesService.getModuleIssuesWithParams(
|
||||||
|
workspaceSlug as string,
|
||||||
|
projectId as string,
|
||||||
|
moduleId as string,
|
||||||
|
targetDateFilter
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalDate = eachDayOfInterval({
|
||||||
|
start: calendarDateRange.startDate,
|
||||||
|
end: calendarDateRange.endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onlyWeekDays = weekDayInterval({
|
||||||
|
start: calendarDateRange.startDate,
|
||||||
|
end: calendarDateRange.endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentViewDays = showWeekEnds ? totalDate : onlyWeekDays;
|
||||||
|
|
||||||
|
const calendarIssues = cycleCalendarIssues ?? moduleCalendarIssues ?? projectCalendarIssues;
|
||||||
|
|
||||||
|
const currentViewDaysData = currentViewDays.map((date: Date) => {
|
||||||
|
const filterIssue =
|
||||||
|
calendarIssues && calendarIssues.length > 0
|
||||||
|
? (calendarIssues as IIssue[]).filter(
|
||||||
|
(issue) =>
|
||||||
|
issue.target_date && renderDateFormat(issue.target_date) === renderDateFormat(date)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
date: renderDateFormat(date),
|
||||||
|
issues: filterIssue,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const weeks = ((date: Date[]) => {
|
||||||
|
const weeks = [];
|
||||||
|
if (showWeekEnds) {
|
||||||
|
for (let day = 0; day <= 6; day++) {
|
||||||
|
weeks.push(date[day]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let day = 0; day <= 4; day++) {
|
||||||
|
weeks.push(date[day]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return weeks;
|
||||||
|
})(currentViewDays);
|
||||||
|
|
||||||
|
const onDragEnd = (result: DropResult) => {
|
||||||
|
const { source, destination, draggableId } = result;
|
||||||
|
|
||||||
|
if (!destination || !workspaceSlug || !projectId) return;
|
||||||
|
if (source.droppableId === destination.droppableId) return;
|
||||||
|
|
||||||
|
const fetchKey = cycleId
|
||||||
|
? CYCLE_CALENDAR_ISSUES(projectId as string, cycleId as string)
|
||||||
|
: moduleId
|
||||||
|
? MODULE_CALENDAR_ISSUES(projectId as string, moduleId as string)
|
||||||
|
: PROJECT_CALENDAR_ISSUES(projectId as string);
|
||||||
|
|
||||||
|
mutate<IIssue[]>(
|
||||||
|
fetchKey,
|
||||||
|
(prevData) =>
|
||||||
|
(prevData ?? []).map((p) => {
|
||||||
|
if (p.id === draggableId)
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
target_date: destination.droppableId,
|
||||||
|
};
|
||||||
|
return p;
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
issuesService.patchIssue(workspaceSlug as string, projectId as string, draggableId, {
|
||||||
|
target_date: destination?.droppableId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDate = (date: Date) => {
|
||||||
|
setCurrentDate(date);
|
||||||
|
|
||||||
|
setCalendarDateRange({
|
||||||
|
startDate: startOfWeek(date),
|
||||||
|
endDate: lastDayOfWeek(date),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
|
<div className="h-full overflow-y-auto rounded-lg text-gray-600 -m-2">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="relative flex h-full w-full gap-2 items-center justify-start text-sm ">
|
||||||
|
<Popover className="flex h-full items-center justify-start rounded-lg">
|
||||||
|
{({ open }) => (
|
||||||
|
<>
|
||||||
|
<Popover.Button className={`group flex h-full items-start gap-1 text-gray-800`}>
|
||||||
|
<div className="flex items-center justify-center gap-2 text-2xl font-semibold">
|
||||||
|
<span className="text-black">{formatDate(currentDate, "Month")}</span>{" "}
|
||||||
|
<span>{formatDate(currentDate, "yyyy")}</span>
|
||||||
|
</div>
|
||||||
|
</Popover.Button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
as={React.Fragment}
|
||||||
|
enter="transition ease-out duration-200"
|
||||||
|
enterFrom="opacity-0 translate-y-1"
|
||||||
|
enterTo="opacity-100 translate-y-0"
|
||||||
|
leave="transition ease-in duration-150"
|
||||||
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
|
leaveTo="opacity-0 translate-y-1"
|
||||||
|
>
|
||||||
|
<Popover.Panel className="absolute top-10 left-0 z-20 w-full max-w-xs flex flex-col transform overflow-hidden bg-white shadow-lg rounded-[10px]">
|
||||||
|
<div className="flex justify-center items-center text-sm gap-5 px-2 py-2">
|
||||||
|
{yearOptions.map((year) => (
|
||||||
|
<button
|
||||||
|
onClick={() => updateDate(updateDateWithYear(year.label, currentDate))}
|
||||||
|
className={` ${
|
||||||
|
isSameYear(year.value, currentDate)
|
||||||
|
? "text-sm font-medium text-gray-800"
|
||||||
|
: "text-xs text-gray-400 "
|
||||||
|
} hover:text-sm hover:text-gray-800 hover:font-medium `}
|
||||||
|
>
|
||||||
|
{year.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 px-2 border-t border-gray-200">
|
||||||
|
{monthOptions.map((month) => (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
updateDate(updateDateWithMonth(month.value, currentDate))
|
||||||
|
}
|
||||||
|
className={`text-gray-400 text-xs px-2 py-2 hover:font-medium hover:text-gray-800 ${
|
||||||
|
isSameMonth(month.value, currentDate)
|
||||||
|
? "font-medium text-gray-800"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{month.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Popover.Panel>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
if (isMonthlyView) {
|
||||||
|
updateDate(subtractMonths(currentDate, 1));
|
||||||
|
} else {
|
||||||
|
setCurrentDate(subtract7DaysToDate(currentDate));
|
||||||
|
setCalendarDateRange({
|
||||||
|
startDate: getCurrentWeekStartDate(subtract7DaysToDate(currentDate)),
|
||||||
|
endDate: getCurrentWeekEndDate(subtract7DaysToDate(currentDate)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
if (isMonthlyView) {
|
||||||
|
updateDate(addMonths(currentDate, 1));
|
||||||
|
} else {
|
||||||
|
setCurrentDate(addSevenDaysToDate(currentDate));
|
||||||
|
setCalendarDateRange({
|
||||||
|
startDate: getCurrentWeekStartDate(addSevenDaysToDate(currentDate)),
|
||||||
|
endDate: getCurrentWeekEndDate(addSevenDaysToDate(currentDate)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full gap-2 items-center justify-end">
|
||||||
|
<button
|
||||||
|
className="group flex cursor-pointer items-center gap-2 rounded-md border bg-white px-4 py-1.5 text-sm hover:bg-gray-100 hover:text-gray-900 focus:outline-none"
|
||||||
|
onClick={() => {
|
||||||
|
if (isMonthlyView) {
|
||||||
|
updateDate(new Date());
|
||||||
|
} else {
|
||||||
|
setCurrentDate(new Date());
|
||||||
|
setCalendarDateRange({
|
||||||
|
startDate: getCurrentWeekStartDate(new Date()),
|
||||||
|
endDate: getCurrentWeekEndDate(new Date()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Today{" "}
|
||||||
|
</button>
|
||||||
|
<CustomMenu
|
||||||
|
customButton={
|
||||||
|
<div
|
||||||
|
className={`group flex cursor-pointer items-center gap-2 rounded-md border bg-white px-3 py-1.5 text-sm hover:bg-gray-100 hover:text-gray-900 focus:outline-none `}
|
||||||
|
>
|
||||||
|
{isMonthlyView ? "Monthly" : "Weekly"}
|
||||||
|
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setIsMonthlyView(true);
|
||||||
|
setCalendarDateRange({
|
||||||
|
startDate: startOfWeek(currentDate),
|
||||||
|
endDate: lastDayOfWeek(currentDate),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-52 text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
<div className="flex w-full max-w-[260px] items-center justify-between gap-2">
|
||||||
|
<span className="flex items-center gap-2">Monthly View</span>
|
||||||
|
<CheckIcon
|
||||||
|
className={`h-4 w-4 flex-shrink-0 ${
|
||||||
|
isMonthlyView ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setIsMonthlyView(false);
|
||||||
|
setCalendarDateRange({
|
||||||
|
startDate: getCurrentWeekStartDate(currentDate),
|
||||||
|
endDate: getCurrentWeekEndDate(currentDate),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-52 text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
|
<span className="flex items-center gap-2">Weekly View</span>
|
||||||
|
<CheckIcon
|
||||||
|
className={`h-4 w-4 flex-shrink-0 ${
|
||||||
|
isMonthlyView ? "opacity-0" : "opacity-100"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<div className="mt-1 flex w-52 items-center justify-between border-t border-gray-200 py-2 px-1 text-sm text-gray-600">
|
||||||
|
<h4>Show weekends</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`relative inline-flex h-3.5 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||||
|
showWeekEnds ? "bg-green-500" : "bg-gray-200"
|
||||||
|
}`}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={showWeekEnds}
|
||||||
|
onClick={() => setShowWeekEnds(!showWeekEnds)}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Show weekends</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`inline-block h-2.5 w-2.5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
showWeekEnds ? "translate-x-2.5" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CustomMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
|
||||||
|
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{weeks.map((date, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`flex items-center justify-start p-1.5 gap-2 border-gray-300 bg-gray-100 text-base font-medium text-gray-600 ${
|
||||||
|
!isMonthlyView
|
||||||
|
? showWeekEnds
|
||||||
|
? (index + 1) % 7 === 0
|
||||||
|
? ""
|
||||||
|
: "border-r"
|
||||||
|
: (index + 1) % 5 === 0
|
||||||
|
? ""
|
||||||
|
: "border-r"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{isMonthlyView ? formatDate(date, "eee").substring(0, 3) : formatDate(date, "eee")}
|
||||||
|
</span>
|
||||||
|
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`grid h-full auto-rows-[minmax(150px,1fr)] ${
|
||||||
|
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
|
||||||
|
} `}
|
||||||
|
>
|
||||||
|
{currentViewDaysData.map((date, index) => (
|
||||||
|
<StrictModeDroppable droppableId={date.date}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
className={`flex flex-col gap-1.5 border-t border-gray-300 p-2.5 text-left text-sm font-medium hover:bg-gray-100 ${
|
||||||
|
showWeekEnds
|
||||||
|
? (index + 1) % 7 === 0
|
||||||
|
? ""
|
||||||
|
: "border-r"
|
||||||
|
: (index + 1) % 5 === 0
|
||||||
|
? ""
|
||||||
|
: "border-r"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isMonthlyView && <span>{formatDate(new Date(date.date), "d")}</span>}
|
||||||
|
{date.issues.length > 0 &&
|
||||||
|
date.issues.map((issue: IIssue, index) => (
|
||||||
|
<Draggable draggableId={issue.id} index={index}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
className={`w-full cursor-pointer truncate rounded bg-white p-1.5 hover:scale-105 ${
|
||||||
|
snapshot.isDragging ? "shadow-lg" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{issue.name}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</StrictModeDroppable>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DragDropContext>
|
||||||
|
);
|
||||||
|
};
|
1
apps/app/components/core/calendar-view/index.ts
Normal file
1
apps/app/components/core/calendar-view/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./calendar"
|
@ -1,17 +1,25 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import { mutate } from "swr";
|
||||||
|
|
||||||
// react-hook-form
|
// react-hook-form
|
||||||
import { Controller, SubmitHandler, useForm } from "react-hook-form";
|
import { Controller, SubmitHandler, useForm } from "react-hook-form";
|
||||||
// hooks
|
|
||||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
|
||||||
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
|
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
// headless ui
|
// headless ui
|
||||||
|
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
import useIssuesView from "hooks/use-issues-view";
|
||||||
// ui
|
// ui
|
||||||
import { Button } from "components/ui";
|
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||||
|
// icons
|
||||||
|
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||||
import { LayerDiagonalIcon } from "components/icons";
|
import { LayerDiagonalIcon } from "components/icons";
|
||||||
// types
|
// types
|
||||||
import { IIssue } from "types";
|
import { IIssue } from "types";
|
||||||
|
// fetch-keys
|
||||||
|
import { CYCLE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
|
||||||
|
|
||||||
type FormInput = {
|
type FormInput = {
|
||||||
issues: string[];
|
issues: string[];
|
||||||
@ -32,8 +40,13 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { cycleId, moduleId } = router.query;
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const { params } = useIssuesView();
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onClose();
|
onClose();
|
||||||
setQuery("");
|
setQuery("");
|
||||||
@ -63,6 +76,9 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await handleOnSubmit(data);
|
await handleOnSubmit(data);
|
||||||
|
if (cycleId) mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
|
||||||
|
if (moduleId) mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
|
||||||
|
|
||||||
handleClose();
|
handleClose();
|
||||||
|
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
@ -180,17 +196,10 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
{filteredIssues.length > 0 && (
|
{filteredIssues.length > 0 && (
|
||||||
<div className="flex items-center justify-end gap-2 p-3">
|
<div className="flex items-center justify-end gap-2 p-3">
|
||||||
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
|
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||||
Cancel
|
<PrimaryButton onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSubmit(onSubmit)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Adding..." : "Add selected issues"}
|
{isSubmitting ? "Adding..." : "Add selected issues"}
|
||||||
</Button>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
337
apps/app/components/core/filter-list.tsx
Normal file
337
apps/app/components/core/filter-list.tsx
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
// icons
|
||||||
|
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
|
||||||
|
// ui
|
||||||
|
import { Avatar } from "components/ui";
|
||||||
|
// helpers
|
||||||
|
import { getStatesList } from "helpers/state.helper";
|
||||||
|
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||||
|
// services
|
||||||
|
import issuesService from "services/issues.service";
|
||||||
|
import projectService from "services/project.service";
|
||||||
|
import stateService from "services/state.service";
|
||||||
|
// types
|
||||||
|
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATE_LIST } from "constants/fetch-keys";
|
||||||
|
import { IIssueFilterOptions } from "types";
|
||||||
|
|
||||||
|
export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId, viewId } = router.query;
|
||||||
|
|
||||||
|
const { data: members } = useSWR(
|
||||||
|
projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||||
|
workspaceSlug && projectId
|
||||||
|
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: issueLabels } = useSWR(
|
||||||
|
projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null,
|
||||||
|
workspaceSlug && projectId
|
||||||
|
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId.toString())
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: stateGroups } = useSWR(
|
||||||
|
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
|
||||||
|
workspaceSlug
|
||||||
|
? () => stateService.getStates(workspaceSlug as string, projectId as string)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const states = getStatesList(stateGroups ?? {});
|
||||||
|
|
||||||
|
if (!filters) return <></>;
|
||||||
|
|
||||||
|
const nullFilters = Object.keys(filters).filter(
|
||||||
|
(key) => filters[key as keyof IIssueFilterOptions] === null
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-wrap items-center gap-2 text-xs">
|
||||||
|
{Object.keys(filters).map((key) => {
|
||||||
|
if (filters[key as keyof typeof filters] !== null)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center gap-x-2 rounded-full border bg-white px-2 py-1"
|
||||||
|
>
|
||||||
|
<span className="font-medium capitalize text-gray-500">
|
||||||
|
{replaceUnderscoreIfSnakeCase(key)}:
|
||||||
|
</span>
|
||||||
|
{filters[key as keyof IIssueFilterOptions] === null ||
|
||||||
|
(filters[key as keyof IIssueFilterOptions]?.length ?? 0) <= 0 ? (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 font-medium">None</span>
|
||||||
|
) : Array.isArray(filters[key as keyof IIssueFilterOptions]) ? (
|
||||||
|
<div className="space-x-2">
|
||||||
|
{key === "state" ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{filters.state?.map((stateId: any) => {
|
||||||
|
const state = states?.find((s) => s.id === stateId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
key={state?.id}
|
||||||
|
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 font-medium text-white"
|
||||||
|
style={{
|
||||||
|
color: state?.color,
|
||||||
|
backgroundColor: `${state?.color}20`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{getStateGroupIcon(
|
||||||
|
state?.group ?? "backlog",
|
||||||
|
"12",
|
||||||
|
"12",
|
||||||
|
state?.color
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>{state?.name ?? ""}</span>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters(
|
||||||
|
{
|
||||||
|
state: filters.state?.filter((s: any) => s !== stateId),
|
||||||
|
},
|
||||||
|
!Boolean(viewId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
state: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : key === "priority" ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{filters.priority?.map((priority: any) => (
|
||||||
|
<p
|
||||||
|
key={priority}
|
||||||
|
className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 font-medium capitalize text-white ${
|
||||||
|
priority === "urgent"
|
||||||
|
? "bg-red-100 text-red-600 hover:bg-red-100"
|
||||||
|
: priority === "high"
|
||||||
|
? "bg-orange-100 text-orange-500 hover:bg-orange-100"
|
||||||
|
: priority === "medium"
|
||||||
|
? "bg-yellow-100 text-yellow-500 hover:bg-yellow-100"
|
||||||
|
: priority === "low"
|
||||||
|
? "bg-green-100 text-green-500 hover:bg-green-100"
|
||||||
|
: "bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{getPriorityIcon(priority)}</span>
|
||||||
|
<span>{priority}</span>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters(
|
||||||
|
{
|
||||||
|
priority: filters.priority?.filter((p: any) => p !== priority),
|
||||||
|
},
|
||||||
|
!Boolean(viewId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
priority: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : key === "assignees" ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{filters.assignees?.map((memberId: string) => {
|
||||||
|
const member = members?.find((m) => m.member.id === memberId)?.member;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={memberId}
|
||||||
|
className="inline-flex items-center gap-x-1 rounded-full px-1 font-medium capitalize"
|
||||||
|
>
|
||||||
|
<Avatar user={member} />
|
||||||
|
<span>{member?.first_name}</span>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters(
|
||||||
|
{
|
||||||
|
assignees: filters.assignees?.filter(
|
||||||
|
(p: any) => p !== memberId
|
||||||
|
),
|
||||||
|
},
|
||||||
|
!Boolean(viewId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
assignees: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (key as keyof IIssueFilterOptions) === "created_by" ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{filters.created_by?.map((memberId: string) => {
|
||||||
|
const member = members?.find((m) => m.member.id === memberId)?.member;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${memberId}-${key}`}
|
||||||
|
className="inline-flex items-center gap-x-1 rounded-full px-1 font-medium capitalize"
|
||||||
|
>
|
||||||
|
<Avatar user={member} />
|
||||||
|
<span>{member?.first_name}</span>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters(
|
||||||
|
{
|
||||||
|
created_by: filters.created_by?.filter(
|
||||||
|
(p: any) => p !== memberId
|
||||||
|
),
|
||||||
|
},
|
||||||
|
!Boolean(viewId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
created_by: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : key === "labels" ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{filters.labels?.map((labelId: string) => {
|
||||||
|
const label = issueLabels?.find((l) => l.id === labelId);
|
||||||
|
|
||||||
|
if (!label) return null;
|
||||||
|
const color = label.color !== "" ? label.color : "#0f172a";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 font-medium"
|
||||||
|
style={{
|
||||||
|
background: `${color}33`, // add 20% opacity
|
||||||
|
}}
|
||||||
|
key={labelId}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters(
|
||||||
|
{
|
||||||
|
labels: filters.labels?.filter((l: any) => l !== labelId),
|
||||||
|
},
|
||||||
|
!Boolean(viewId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon
|
||||||
|
className="h-3 w-3"
|
||||||
|
style={{
|
||||||
|
color: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
labels: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(filters[key as keyof IIssueFilterOptions] as any)?.join(", ")
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="capitalize">{filters[key as keyof typeof filters]}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
state: null,
|
||||||
|
priority: null,
|
||||||
|
assignees: null,
|
||||||
|
labels: null,
|
||||||
|
created_by: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex items-center gap-x-1 rounded-full border bg-white px-3 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<span className="font-medium">Clear all filters</span>
|
||||||
|
<XMarkIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
204
apps/app/components/core/gpt-assistant-modal.tsx
Normal file
204
apps/app/components/core/gpt-assistant-modal.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
// react-hook-form
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
// services
|
||||||
|
import aiService from "services/ai.service";
|
||||||
|
import trackEventServices from "services/track-event.service";
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
// ui
|
||||||
|
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||||
|
|
||||||
|
import { IIssue, IPageBlock } from "types";
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
handleClose: () => void;
|
||||||
|
inset?: string;
|
||||||
|
content: string;
|
||||||
|
htmlContent?: string;
|
||||||
|
onResponse: (response: string) => void;
|
||||||
|
projectId: string;
|
||||||
|
block?: IPageBlock;
|
||||||
|
issue?: IIssue;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormData = {
|
||||||
|
prompt: string;
|
||||||
|
task: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GptAssistantModal: React.FC<Props> = ({
|
||||||
|
isOpen,
|
||||||
|
handleClose,
|
||||||
|
inset = "top-0 left-0",
|
||||||
|
content,
|
||||||
|
htmlContent,
|
||||||
|
onResponse,
|
||||||
|
projectId,
|
||||||
|
block,
|
||||||
|
issue,
|
||||||
|
}) => {
|
||||||
|
const [response, setResponse] = useState("");
|
||||||
|
const [invalidResponse, setInvalidResponse] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
reset,
|
||||||
|
setFocus,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
} = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
prompt: content,
|
||||||
|
task: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
handleClose();
|
||||||
|
setResponse("");
|
||||||
|
setInvalidResponse(false);
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResponse = async (formData: FormData) => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
|
if (formData.task === "") {
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Please enter some task to get AI assistance.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await aiService
|
||||||
|
.createGptTask(workspaceSlug as string, projectId as string, {
|
||||||
|
prompt: content && content !== "" ? content : htmlContent ?? "",
|
||||||
|
task: formData.task,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
setResponse(res.response_html);
|
||||||
|
setFocus("task");
|
||||||
|
|
||||||
|
if (res.response === "") setInvalidResponse(true);
|
||||||
|
else setInvalidResponse(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.status === 429)
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message:
|
||||||
|
"You have reached the maximum number of requests of 50 requests per month per user.",
|
||||||
|
});
|
||||||
|
else
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Some error occurred. Please try again.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) setFocus("task");
|
||||||
|
}, [isOpen, setFocus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border bg-white p-4 shadow ${
|
||||||
|
isOpen ? "block" : "hidden"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{((content && content !== "") || htmlContent) && (
|
||||||
|
<div className="text-sm page-block-section">
|
||||||
|
Content:
|
||||||
|
<RemirrorRichTextEditor
|
||||||
|
value={htmlContent ?? <p>{content}</p>}
|
||||||
|
customClassName="-mx-3 -my-3"
|
||||||
|
noBorder
|
||||||
|
borderOnFocus={false}
|
||||||
|
editable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{response !== "" && (
|
||||||
|
<div className="text-sm page-block-section">
|
||||||
|
Response:
|
||||||
|
<RemirrorRichTextEditor
|
||||||
|
value={`<p>${response}</p>`}
|
||||||
|
customClassName="-mx-3 -my-3"
|
||||||
|
noBorder
|
||||||
|
borderOnFocus={false}
|
||||||
|
editable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invalidResponse && (
|
||||||
|
<div className="text-sm text-red-500">
|
||||||
|
No response could be generated. This may be due to insufficient content or task
|
||||||
|
information. Please try again.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="task"
|
||||||
|
register={register}
|
||||||
|
placeholder={`${
|
||||||
|
content && content !== ""
|
||||||
|
? "Tell AI what action to perform on this content..."
|
||||||
|
: "Ask AI anything..."
|
||||||
|
}`}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<div className={`flex gap-2 ${response === "" ? "justify-end" : "justify-between"}`}>
|
||||||
|
{response !== "" && (
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => {
|
||||||
|
onResponse(response);
|
||||||
|
onClose();
|
||||||
|
if (block)
|
||||||
|
trackEventServices.trackUseGPTResponseEvent(
|
||||||
|
block,
|
||||||
|
"USE_GPT_RESPONSE_IN_PAGE_BLOCK"
|
||||||
|
);
|
||||||
|
else if (issue)
|
||||||
|
trackEventServices.trackUseGPTResponseEvent(issue, "USE_GPT_RESPONSE_IN_ISSUE");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use this response
|
||||||
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SecondaryButton onClick={onClose}>Close</SecondaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit(handleResponse)}
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? "Generating response..."
|
||||||
|
: response === ""
|
||||||
|
? "Generate response"
|
||||||
|
: "Generate again"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -13,8 +13,7 @@ import { Tab, Transition, Popover } from "@headlessui/react";
|
|||||||
import fileService from "services/file.service";
|
import fileService from "services/file.service";
|
||||||
|
|
||||||
// components
|
// components
|
||||||
import { Input, Spinner } from "components/ui";
|
import { Input, Spinner, PrimaryButton } from "components/ui";
|
||||||
import { PrimaryButton } from "components/ui/button/primary-button";
|
|
||||||
// hooks
|
// hooks
|
||||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
|
|
||||||
|
@ -3,27 +3,32 @@ import React, { useCallback, useState } from "react";
|
|||||||
import NextImage from "next/image";
|
import NextImage from "next/image";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
// react-dropzone
|
||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone } from "react-dropzone";
|
||||||
|
// headless ui
|
||||||
import { Transition, Dialog } from "@headlessui/react";
|
import { Transition, Dialog } from "@headlessui/react";
|
||||||
|
|
||||||
// services
|
// services
|
||||||
import fileServices from "services/file.service";
|
import fileServices from "services/file.service";
|
||||||
// icon
|
|
||||||
import { UserCircleIcon } from "components/icons";
|
|
||||||
// ui
|
// ui
|
||||||
import { Button } from "components/ui";
|
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||||
|
// icons
|
||||||
|
import { UserCircleIcon } from "components/icons";
|
||||||
|
|
||||||
type TImageUploadModalProps = {
|
type Props = {
|
||||||
value?: string | null;
|
value?: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onSuccess: (url: string) => void;
|
onSuccess: (url: string) => void;
|
||||||
|
userImage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
|
export const ImageUploadModal: React.FC<Props> = ({
|
||||||
const { value, onSuccess, isOpen, onClose } = props;
|
value,
|
||||||
|
onSuccess,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
userImage,
|
||||||
|
}) => {
|
||||||
const [image, setImage] = useState<File | null>(null);
|
const [image, setImage] = useState<File | null>(null);
|
||||||
const [isImageUploading, setIsImageUploading] = useState(false);
|
const [isImageUploading, setIsImageUploading] = useState(false);
|
||||||
|
|
||||||
@ -34,37 +39,62 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
|
|||||||
setImage(acceptedFiles[0]);
|
setImage(acceptedFiles[0]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const {
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
getRootProps,
|
|
||||||
getInputProps,
|
|
||||||
isDragActive,
|
|
||||||
open: openFileDialog,
|
|
||||||
} = useDropzone({
|
|
||||||
onDrop,
|
onDrop,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setIsImageUploading(true);
|
setIsImageUploading(true);
|
||||||
|
|
||||||
if (image === null || !workspaceSlug) return;
|
if (!image || !workspaceSlug) return;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("asset", image);
|
formData.append("asset", image);
|
||||||
formData.append("attributes", JSON.stringify({}));
|
formData.append("attributes", JSON.stringify({}));
|
||||||
|
|
||||||
fileServices
|
if (userImage) {
|
||||||
.uploadFile(workspaceSlug as string, formData)
|
fileServices
|
||||||
.then((res) => {
|
.uploadUserFile(formData)
|
||||||
const imageUrl = res.asset;
|
.then((res) => {
|
||||||
onSuccess(imageUrl);
|
const imageUrl = res.asset;
|
||||||
setIsImageUploading(false);
|
|
||||||
})
|
onSuccess(imageUrl);
|
||||||
.catch((err) => {
|
setIsImageUploading(false);
|
||||||
console.error(err);
|
setImage(null);
|
||||||
});
|
|
||||||
|
if (value) {
|
||||||
|
const index = value.indexOf(".com");
|
||||||
|
const asset = value.substring(index + 5);
|
||||||
|
|
||||||
|
fileServices.deleteUserFile(asset);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
} else
|
||||||
|
fileServices
|
||||||
|
.uploadFile(workspaceSlug as string, formData)
|
||||||
|
.then((res) => {
|
||||||
|
const imageUrl = res.asset;
|
||||||
|
onSuccess(imageUrl);
|
||||||
|
setIsImageUploading(false);
|
||||||
|
setImage(null);
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
const index = value.indexOf(".com");
|
||||||
|
const asset = value.substring(index + 5);
|
||||||
|
|
||||||
|
fileServices.deleteFile(asset);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
setImage(null);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -109,11 +139,10 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
|
|||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{image !== null || (value && value !== null && value !== "") ? (
|
{image !== null || (value && value !== "") ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openFileDialog}
|
|
||||||
className="absolute top-0 right-0 z-40 translate-x-1/2 -translate-y-1/2 rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600"
|
className="absolute top-0 right-0 z-40 translate-x-1/2 -translate-y-1/2 rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
@ -142,16 +171,14 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
||||||
<Button theme="secondary" onClick={handleClose}>
|
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||||
Cancel
|
<PrimaryButton
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isImageUploading || image === null}
|
disabled={!image}
|
||||||
|
loading={isImageUploading}
|
||||||
>
|
>
|
||||||
{isImageUploading ? "Uploading..." : "Upload & Save"}
|
{isImageUploading ? "Uploading..." : "Upload & Save"}
|
||||||
</Button>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
@ -3,10 +3,11 @@ export * from "./list-view";
|
|||||||
export * from "./sidebar";
|
export * from "./sidebar";
|
||||||
export * from "./bulk-delete-issues-modal";
|
export * from "./bulk-delete-issues-modal";
|
||||||
export * from "./existing-issues-list-modal";
|
export * from "./existing-issues-list-modal";
|
||||||
|
export * from "./gpt-assistant-modal";
|
||||||
export * from "./image-upload-modal";
|
export * from "./image-upload-modal";
|
||||||
export * from "./issues-view-filter";
|
export * from "./issues-view-filter";
|
||||||
export * from "./issues-view";
|
export * from "./issues-view";
|
||||||
export * from "./link-modal";
|
export * from "./link-modal";
|
||||||
export * from "./not-authorized-view";
|
export * from "./not-authorized-view";
|
||||||
export * from "./multi-level-select";
|
|
||||||
export * from "./image-picker-popover";
|
export * from "./image-picker-popover";
|
||||||
|
export * from "./filter-list";
|
||||||
|
@ -4,42 +4,41 @@ import { useRouter } from "next/router";
|
|||||||
|
|
||||||
// hooks
|
// hooks
|
||||||
import useIssuesProperties from "hooks/use-issue-properties";
|
import useIssuesProperties from "hooks/use-issue-properties";
|
||||||
import useIssueView from "hooks/use-issue-view";
|
import useIssuesView from "hooks/use-issues-view";
|
||||||
// headless ui
|
// headless ui
|
||||||
import { Popover, Transition } from "@headlessui/react";
|
import { Popover, Transition } from "@headlessui/react";
|
||||||
|
// components
|
||||||
|
import { SelectFilters } from "components/views";
|
||||||
// ui
|
// ui
|
||||||
import { CustomMenu } from "components/ui";
|
import { CustomMenu } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
import { ChevronDownIcon, ListBulletIcon } from "@heroicons/react/24/outline";
|
import { ChevronDownIcon, ListBulletIcon, CalendarDaysIcon } from "@heroicons/react/24/outline";
|
||||||
import { Squares2X2Icon } from "@heroicons/react/20/solid";
|
import { Squares2X2Icon } from "@heroicons/react/20/solid";
|
||||||
// helpers
|
// helpers
|
||||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||||
// types
|
// types
|
||||||
import { IIssue, Properties } from "types";
|
import { Properties } from "types";
|
||||||
// common
|
// constants
|
||||||
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
|
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
|
||||||
|
|
||||||
type Props = {
|
export const IssuesFilterView: React.FC = () => {
|
||||||
issues?: IIssue[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IssuesFilterView: React.FC<Props> = ({ issues }) => {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId, viewId } = router.query;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
issueView,
|
issueView,
|
||||||
setIssueViewToList,
|
setIssueView,
|
||||||
setIssueViewToKanban,
|
|
||||||
groupByProperty,
|
groupByProperty,
|
||||||
setGroupByProperty,
|
setGroupByProperty,
|
||||||
setOrderBy,
|
|
||||||
setFilterIssue,
|
|
||||||
orderBy,
|
orderBy,
|
||||||
filterIssue,
|
setOrderBy,
|
||||||
|
showEmptyGroups,
|
||||||
|
setShowEmptyGroups,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
resetFilterToDefault,
|
resetFilterToDefault,
|
||||||
setNewFilterDefaultView,
|
setNewFilterDefaultView,
|
||||||
} = useIssueView(issues ?? []);
|
} = useIssuesView();
|
||||||
|
|
||||||
const [properties, setProperties] = useIssuesProperties(
|
const [properties, setProperties] = useIssuesProperties(
|
||||||
workspaceSlug as string,
|
workspaceSlug as string,
|
||||||
@ -47,172 +46,215 @@ export const IssuesFilterView: React.FC<Props> = ({ issues }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex items-center gap-2">
|
||||||
{issues && issues.length > 0 && (
|
<div className="flex items-center gap-x-1">
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
<div className="flex items-center gap-x-1">
|
type="button"
|
||||||
<button
|
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
||||||
type="button"
|
issueView === "list" ? "bg-gray-200" : ""
|
||||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
}`}
|
||||||
issueView === "list" ? "bg-gray-200" : ""
|
onClick={() => setIssueView("list")}
|
||||||
}`}
|
>
|
||||||
onClick={() => setIssueViewToList()}
|
<ListBulletIcon className="h-4 w-4" />
|
||||||
>
|
</button>
|
||||||
<ListBulletIcon className="h-4 w-4" />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
||||||
type="button"
|
issueView === "kanban" ? "bg-gray-200" : ""
|
||||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
}`}
|
||||||
issueView === "kanban" ? "bg-gray-200" : ""
|
onClick={() => setIssueView("kanban")}
|
||||||
}`}
|
>
|
||||||
onClick={() => setIssueViewToKanban()}
|
<Squares2X2Icon className="h-4 w-4" />
|
||||||
>
|
</button>
|
||||||
<Squares2X2Icon className="h-4 w-4" />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</div>
|
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
||||||
<Popover className="relative">
|
issueView === "calendar" ? "bg-gray-200" : ""
|
||||||
{({ open }) => (
|
}`}
|
||||||
<>
|
onClick={() => setIssueView("calendar")}
|
||||||
<Popover.Button
|
>
|
||||||
className={`group flex items-center gap-2 rounded-md border bg-transparent p-2 text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none ${
|
<CalendarDaysIcon className="h-4 w-4" />
|
||||||
open ? "bg-gray-100 text-gray-900" : "text-gray-500"
|
</button>
|
||||||
}`}
|
</div>
|
||||||
>
|
<SelectFilters
|
||||||
<span>View</span>
|
filters={filters}
|
||||||
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
|
onSelect={(option) => {
|
||||||
</Popover.Button>
|
const key = option.key as keyof typeof filters;
|
||||||
|
|
||||||
<Transition
|
const valueExists = filters[key]?.includes(option.value);
|
||||||
as={React.Fragment}
|
|
||||||
enter="transition ease-out duration-200"
|
|
||||||
enterFrom="opacity-0 translate-y-1"
|
|
||||||
enterTo="opacity-100 translate-y-0"
|
|
||||||
leave="transition ease-in duration-150"
|
|
||||||
leaveFrom="opacity-100 translate-y-0"
|
|
||||||
leaveTo="opacity-0 translate-y-1"
|
|
||||||
>
|
|
||||||
<Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform overflow-hidden rounded-lg bg-white p-3 shadow-lg">
|
|
||||||
<div className="relative divide-y-2">
|
|
||||||
{issues && (
|
|
||||||
<div className="space-y-4 pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h4 className="text-sm text-gray-600">Group by</h4>
|
|
||||||
<CustomMenu
|
|
||||||
label={
|
|
||||||
GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
|
|
||||||
?.name ?? "Select"
|
|
||||||
}
|
|
||||||
width="lg"
|
|
||||||
>
|
|
||||||
{GROUP_BY_OPTIONS.map((option) =>
|
|
||||||
issueView === "kanban" && option.key === null ? null : (
|
|
||||||
<CustomMenu.MenuItem
|
|
||||||
key={option.key}
|
|
||||||
onClick={() => setGroupByProperty(option.key)}
|
|
||||||
>
|
|
||||||
{option.name}
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</CustomMenu>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h4 className="text-sm text-gray-600">Order by</h4>
|
|
||||||
<CustomMenu
|
|
||||||
label={
|
|
||||||
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
|
|
||||||
"Select"
|
|
||||||
}
|
|
||||||
width="lg"
|
|
||||||
>
|
|
||||||
{ORDER_BY_OPTIONS.map((option) =>
|
|
||||||
groupByProperty === "priority" &&
|
|
||||||
option.key === "priority" ? null : (
|
|
||||||
<CustomMenu.MenuItem
|
|
||||||
key={option.key}
|
|
||||||
onClick={() => {
|
|
||||||
setOrderBy(option.key);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.name}
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</CustomMenu>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h4 className="text-sm text-gray-600">Issue type</h4>
|
|
||||||
<CustomMenu
|
|
||||||
label={
|
|
||||||
FILTER_ISSUE_OPTIONS.find((option) => option.key === filterIssue)
|
|
||||||
?.name ?? "Select"
|
|
||||||
}
|
|
||||||
width="lg"
|
|
||||||
>
|
|
||||||
{FILTER_ISSUE_OPTIONS.map((option) => (
|
|
||||||
<CustomMenu.MenuItem
|
|
||||||
key={option.key}
|
|
||||||
onClick={() => setFilterIssue(option.key)}
|
|
||||||
>
|
|
||||||
{option.name}
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
))}
|
|
||||||
</CustomMenu>
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-end gap-x-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs"
|
|
||||||
onClick={() => resetFilterToDefault()}
|
|
||||||
>
|
|
||||||
Reset to default
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs font-medium text-theme"
|
|
||||||
onClick={() => setNewFilterDefaultView()}
|
|
||||||
>
|
|
||||||
Set as default
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2 py-3">
|
|
||||||
<h4 className="text-sm text-gray-600">Display Properties</h4>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{Object.keys(properties).map((key) => {
|
|
||||||
if (
|
|
||||||
issueView === "kanban" &&
|
|
||||||
((groupByProperty === "state_detail.name" && key === "state") ||
|
|
||||||
(groupByProperty === "priority" && key === "priority"))
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
return (
|
if (valueExists) {
|
||||||
<button
|
setFilters(
|
||||||
key={key}
|
{
|
||||||
type="button"
|
...(filters ?? {}),
|
||||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
[option.key]: ((filters[key] ?? []) as any[])?.filter(
|
||||||
properties[key as keyof Properties]
|
(val) => val !== option.value
|
||||||
? "border-theme bg-theme text-white"
|
),
|
||||||
: "border-gray-300"
|
},
|
||||||
}`}
|
!Boolean(viewId)
|
||||||
onClick={() => setProperties(key as keyof Properties)}
|
);
|
||||||
>
|
} else {
|
||||||
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
|
setFilters(
|
||||||
</button>
|
{
|
||||||
);
|
...(filters ?? {}),
|
||||||
})}
|
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
|
||||||
</div>
|
},
|
||||||
</div>
|
!Boolean(viewId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
direction="left"
|
||||||
|
height="rg"
|
||||||
|
/>
|
||||||
|
<Popover className="relative">
|
||||||
|
{({ open }) => (
|
||||||
|
<>
|
||||||
|
<Popover.Button
|
||||||
|
className={`group flex items-center gap-2 rounded-md border bg-transparent px-3 py-1.5 text-xs hover:bg-gray-100 hover:text-gray-900 focus:outline-none ${
|
||||||
|
open ? "bg-gray-100 text-gray-900" : "text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||||
|
</Popover.Button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
as={React.Fragment}
|
||||||
|
enter="transition ease-out duration-200"
|
||||||
|
enterFrom="opacity-0 translate-y-1"
|
||||||
|
enterTo="opacity-100 translate-y-0"
|
||||||
|
leave="transition ease-in duration-150"
|
||||||
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
|
leaveTo="opacity-0 translate-y-1"
|
||||||
|
>
|
||||||
|
<Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform overflow-hidden rounded-lg bg-white p-3 shadow-lg">
|
||||||
|
<div className="relative divide-y-2">
|
||||||
|
<div className="space-y-4 pb-3 text-xs">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-gray-600">Group by</h4>
|
||||||
|
<CustomMenu
|
||||||
|
label={
|
||||||
|
GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)?.name ??
|
||||||
|
"Select"
|
||||||
|
}
|
||||||
|
width="lg"
|
||||||
|
>
|
||||||
|
{GROUP_BY_OPTIONS.map((option) =>
|
||||||
|
issueView === "kanban" && option.key === null ? null : (
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
key={option.key}
|
||||||
|
onClick={() => setGroupByProperty(option.key)}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</CustomMenu>
|
||||||
</div>
|
</div>
|
||||||
</Popover.Panel>
|
<div className="flex items-center justify-between">
|
||||||
</Transition>
|
<h4 className="text-gray-600">Order by</h4>
|
||||||
</>
|
<CustomMenu
|
||||||
)}
|
label={
|
||||||
</Popover>
|
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
|
||||||
</div>
|
"Select"
|
||||||
)}
|
}
|
||||||
</>
|
width="lg"
|
||||||
|
>
|
||||||
|
{ORDER_BY_OPTIONS.map((option) =>
|
||||||
|
groupByProperty === "priority" && option.key === "priority" ? null : (
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
key={option.key}
|
||||||
|
onClick={() => {
|
||||||
|
setOrderBy(option.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</CustomMenu>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-gray-600">Issue type</h4>
|
||||||
|
<CustomMenu
|
||||||
|
label={
|
||||||
|
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters.type)
|
||||||
|
?.name ?? "Select"
|
||||||
|
}
|
||||||
|
width="lg"
|
||||||
|
>
|
||||||
|
{FILTER_ISSUE_OPTIONS.map((option) => (
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
key={option.key}
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
type: option.key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
))}
|
||||||
|
</CustomMenu>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-gray-600">Show empty states</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`relative inline-flex h-3.5 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||||
|
showEmptyGroups ? "bg-green-500" : "bg-gray-200"
|
||||||
|
}`}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={showEmptyGroups}
|
||||||
|
onClick={() => setShowEmptyGroups(!showEmptyGroups)}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Show empty groups</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`inline-block h-2.5 w-2.5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
showEmptyGroups ? "translate-x-2.5" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-end gap-x-3">
|
||||||
|
<button type="button" onClick={() => resetFilterToDefault()}>
|
||||||
|
Reset to default
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="font-medium text-theme"
|
||||||
|
onClick={() => setNewFilterDefaultView()}
|
||||||
|
>
|
||||||
|
Set as default
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 py-3">
|
||||||
|
<h4 className="text-sm text-gray-600">Display Properties</h4>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{Object.keys(properties).map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||||
|
properties[key as keyof Properties]
|
||||||
|
? "border-theme bg-theme text-white"
|
||||||
|
: "border-gray-300"
|
||||||
|
}`}
|
||||||
|
onClick={() => setProperties(key as keyof Properties)}
|
||||||
|
>
|
||||||
|
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover.Panel>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -9,49 +9,68 @@ import { DragDropContext, DropResult } from "react-beautiful-dnd";
|
|||||||
// services
|
// services
|
||||||
import issuesService from "services/issues.service";
|
import issuesService from "services/issues.service";
|
||||||
import stateService from "services/state.service";
|
import stateService from "services/state.service";
|
||||||
import projectService from "services/project.service";
|
|
||||||
import modulesService from "services/modules.service";
|
import modulesService from "services/modules.service";
|
||||||
// hooks
|
// hooks
|
||||||
import useIssueView from "hooks/use-issue-view";
|
import useToast from "hooks/use-toast";
|
||||||
|
import useIssuesView from "hooks/use-issues-view";
|
||||||
// components
|
// components
|
||||||
import { AllLists, AllBoards } from "components/core";
|
import { AllLists, AllBoards, FilterList } from "components/core";
|
||||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||||
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
||||||
|
import { CreateUpdateViewModal } from "components/views";
|
||||||
|
import { TransferIssuesModal } from "components/cycles";
|
||||||
|
// ui
|
||||||
|
import { EmptySpace, EmptySpaceItem, PrimaryButton, Spinner } from "components/ui";
|
||||||
|
import { CalendarView } from "./calendar-view";
|
||||||
// icons
|
// icons
|
||||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
import {
|
||||||
|
ListBulletIcon,
|
||||||
|
PlusIcon,
|
||||||
|
RectangleStackIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
import { ExclamationIcon, getStateGroupIcon, TransferIcon } from "components/icons";
|
||||||
// helpers
|
// helpers
|
||||||
import { getStatesList } from "helpers/state.helper";
|
import { getStatesList } from "helpers/state.helper";
|
||||||
// types
|
// types
|
||||||
import { CycleIssueResponse, IIssue, ModuleIssueResponse, UserAuth } from "types";
|
import {
|
||||||
|
CycleIssueResponse,
|
||||||
|
IIssue,
|
||||||
|
IIssueFilterOptions,
|
||||||
|
ModuleIssueResponse,
|
||||||
|
UserAuth,
|
||||||
|
} from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import {
|
import {
|
||||||
CYCLE_ISSUES,
|
CYCLE_ISSUES,
|
||||||
|
CYCLE_ISSUES_WITH_PARAMS,
|
||||||
MODULE_ISSUES,
|
MODULE_ISSUES,
|
||||||
PROJECT_ISSUES_LIST,
|
MODULE_ISSUES_WITH_PARAMS,
|
||||||
PROJECT_MEMBERS,
|
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||||
STATE_LIST,
|
STATE_LIST,
|
||||||
} from "constants/fetch-keys";
|
} from "constants/fetch-keys";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
type?: "issue" | "cycle" | "module";
|
type?: "issue" | "cycle" | "module";
|
||||||
issues: IIssue[];
|
|
||||||
openIssuesListModal?: () => void;
|
openIssuesListModal?: () => void;
|
||||||
|
isCompleted?: boolean;
|
||||||
userAuth: UserAuth;
|
userAuth: UserAuth;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IssuesView: React.FC<Props> = ({
|
export const IssuesView: React.FC<Props> = ({
|
||||||
type = "issue",
|
type = "issue",
|
||||||
issues,
|
|
||||||
openIssuesListModal,
|
openIssuesListModal,
|
||||||
|
isCompleted = false,
|
||||||
userAuth,
|
userAuth,
|
||||||
}) => {
|
}) => {
|
||||||
// create issue modal
|
// create issue modal
|
||||||
const [createIssueModal, setCreateIssueModal] = useState(false);
|
const [createIssueModal, setCreateIssueModal] = useState(false);
|
||||||
|
const [createViewModal, setCreateViewModal] = useState<any>(null);
|
||||||
const [preloadedData, setPreloadedData] = useState<
|
const [preloadedData, setPreloadedData] = useState<
|
||||||
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
|
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
// updates issue modal
|
// update issue modal
|
||||||
const [editIssueModal, setEditIssueModal] = useState(false);
|
const [editIssueModal, setEditIssueModal] = useState(false);
|
||||||
const [issueToEdit, setIssueToEdit] = useState<
|
const [issueToEdit, setIssueToEdit] = useState<
|
||||||
(IIssue & { actionType: "edit" | "delete" }) | undefined
|
(IIssue & { actionType: "edit" | "delete" }) | undefined
|
||||||
@ -64,15 +83,24 @@ export const IssuesView: React.FC<Props> = ({
|
|||||||
// trash box
|
// trash box
|
||||||
const [trashBox, setTrashBox] = useState(false);
|
const [trashBox, setTrashBox] = useState(false);
|
||||||
|
|
||||||
|
// transfer issue
|
||||||
|
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
issueView,
|
|
||||||
groupedByIssues,
|
groupedByIssues,
|
||||||
|
issueView,
|
||||||
groupByProperty: selectedGroup,
|
groupByProperty: selectedGroup,
|
||||||
orderBy,
|
orderBy,
|
||||||
} = useIssueView(issues);
|
filters,
|
||||||
|
isNotEmpty,
|
||||||
|
setFilters,
|
||||||
|
params,
|
||||||
|
} = useIssuesView();
|
||||||
|
|
||||||
const { data: stateGroups } = useSWR(
|
const { data: stateGroups } = useSWR(
|
||||||
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
|
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
|
||||||
@ -82,13 +110,6 @@ export const IssuesView: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
const states = getStatesList(stateGroups ?? {});
|
const states = getStatesList(stateGroups ?? {});
|
||||||
|
|
||||||
const { data: members } = useSWR(
|
|
||||||
projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
|
||||||
workspaceSlug && projectId
|
|
||||||
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDeleteIssue = useCallback(
|
const handleDeleteIssue = useCallback(
|
||||||
(issue: IIssue) => {
|
(issue: IIssue) => {
|
||||||
setDeleteIssueModal(true);
|
setDeleteIssueModal(true);
|
||||||
@ -101,7 +122,7 @@ export const IssuesView: React.FC<Props> = ({
|
|||||||
(result: DropResult) => {
|
(result: DropResult) => {
|
||||||
setTrashBox(false);
|
setTrashBox(false);
|
||||||
|
|
||||||
if (!result.destination || !workspaceSlug || !projectId) return;
|
if (!result.destination || !workspaceSlug || !projectId || !groupedByIssues) return;
|
||||||
|
|
||||||
const { source, destination } = result;
|
const { source, destination } = result;
|
||||||
|
|
||||||
@ -156,90 +177,99 @@ export const IssuesView: React.FC<Props> = ({
|
|||||||
draggedItem.sort_order = newSortOrder;
|
draggedItem.sort_order = newSortOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orderBy === "sort_order" || source.droppableId !== destination.droppableId) {
|
const destinationGroup = destination.droppableId; // destination group id
|
||||||
const sourceGroup = source.droppableId; // source group id
|
|
||||||
const destinationGroup = destination.droppableId; // destination group id
|
|
||||||
|
|
||||||
if (!sourceGroup || !destinationGroup) return;
|
if (orderBy === "sort_order" || source.droppableId !== destination.droppableId) {
|
||||||
|
// different group/column;
|
||||||
|
|
||||||
|
// source.droppableId !== destination.droppableId -> even if order by is not sort_order,
|
||||||
|
// if the issue is moved to a different group, then we will change the group of the
|
||||||
|
// dragged item(or issue)
|
||||||
|
|
||||||
if (selectedGroup === "priority") draggedItem.priority = destinationGroup;
|
if (selectedGroup === "priority") draggedItem.priority = destinationGroup;
|
||||||
else if (selectedGroup === "state_detail.name") {
|
else if (selectedGroup === "state") draggedItem.state = destinationGroup;
|
||||||
const destinationState = states?.find((s) => s.name === destinationGroup);
|
}
|
||||||
|
|
||||||
if (!destinationState) return;
|
const sourceGroup = source.droppableId; // source group id
|
||||||
|
|
||||||
draggedItem.state = destinationState.id;
|
// TODO: move this mutation logic to a separate function
|
||||||
draggedItem.state_detail = destinationState;
|
if (cycleId)
|
||||||
}
|
mutate<{
|
||||||
|
[key: string]: IIssue[];
|
||||||
if (cycleId)
|
}>(
|
||||||
mutate<CycleIssueResponse[]>(
|
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params),
|
||||||
CYCLE_ISSUES(cycleId as string),
|
|
||||||
(prevData) => {
|
|
||||||
if (!prevData) return prevData;
|
|
||||||
const updatedIssues = prevData.map((issue) => {
|
|
||||||
if (issue.issue_detail.id === draggedItem.id) {
|
|
||||||
return {
|
|
||||||
...issue,
|
|
||||||
issue_detail: draggedItem,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return issue;
|
|
||||||
});
|
|
||||||
return [...updatedIssues];
|
|
||||||
},
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
if (moduleId)
|
|
||||||
mutate<ModuleIssueResponse[]>(
|
|
||||||
MODULE_ISSUES(moduleId as string),
|
|
||||||
(prevData) => {
|
|
||||||
if (!prevData) return prevData;
|
|
||||||
const updatedIssues = prevData.map((issue) => {
|
|
||||||
if (issue.issue_detail.id === draggedItem.id) {
|
|
||||||
return {
|
|
||||||
...issue,
|
|
||||||
issue_detail: draggedItem,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return issue;
|
|
||||||
});
|
|
||||||
return [...updatedIssues];
|
|
||||||
},
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
mutate<IIssue[]>(
|
|
||||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
|
||||||
(prevData) => {
|
(prevData) => {
|
||||||
if (!prevData) return prevData;
|
if (!prevData) return prevData;
|
||||||
|
|
||||||
const updatedIssues = prevData.map((i) => {
|
const sourceGroupArray = prevData[sourceGroup];
|
||||||
if (i.id === draggedItem.id) return draggedItem;
|
const destinationGroupArray = groupedByIssues[destinationGroup];
|
||||||
|
|
||||||
return i;
|
sourceGroupArray.splice(source.index, 1);
|
||||||
});
|
destinationGroupArray.splice(destination.index, 0, draggedItem);
|
||||||
|
|
||||||
return updatedIssues;
|
return {
|
||||||
|
...prevData,
|
||||||
|
[sourceGroup]: sourceGroupArray,
|
||||||
|
[destinationGroup]: destinationGroupArray,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
else if (moduleId)
|
||||||
|
mutate<{
|
||||||
|
[key: string]: IIssue[];
|
||||||
|
}>(
|
||||||
|
MODULE_ISSUES_WITH_PARAMS(moduleId as string, params),
|
||||||
|
(prevData) => {
|
||||||
|
if (!prevData) return prevData;
|
||||||
|
|
||||||
|
const sourceGroupArray = prevData[sourceGroup];
|
||||||
|
const destinationGroupArray = groupedByIssues[destinationGroup];
|
||||||
|
|
||||||
|
sourceGroupArray.splice(source.index, 1);
|
||||||
|
destinationGroupArray.splice(destination.index, 0, draggedItem);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prevData,
|
||||||
|
[sourceGroup]: sourceGroupArray,
|
||||||
|
[destinationGroup]: destinationGroupArray,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
else
|
||||||
|
mutate<{ [key: string]: IIssue[] }>(
|
||||||
|
PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
|
||||||
|
(prevData) => {
|
||||||
|
if (!prevData) return prevData;
|
||||||
|
|
||||||
|
const sourceGroupArray = prevData[sourceGroup];
|
||||||
|
const destinationGroupArray = groupedByIssues[destinationGroup];
|
||||||
|
|
||||||
|
sourceGroupArray.splice(source.index, 1);
|
||||||
|
destinationGroupArray.splice(destination.index, 0, draggedItem);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prevData,
|
||||||
|
[sourceGroup]: sourceGroupArray,
|
||||||
|
[destinationGroup]: destinationGroupArray,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
// patch request
|
// patch request
|
||||||
issuesService
|
issuesService
|
||||||
.patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, {
|
.patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, {
|
||||||
priority: draggedItem.priority,
|
priority: draggedItem.priority,
|
||||||
state: draggedItem.state,
|
state: draggedItem.state,
|
||||||
sort_order: draggedItem.sort_order,
|
sort_order: draggedItem.sort_order,
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then(() => {
|
||||||
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
|
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
|
||||||
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
|
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
|
||||||
|
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
|
||||||
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@ -250,17 +280,16 @@ export const IssuesView: React.FC<Props> = ({
|
|||||||
projectId,
|
projectId,
|
||||||
selectedGroup,
|
selectedGroup,
|
||||||
orderBy,
|
orderBy,
|
||||||
states,
|
|
||||||
handleDeleteIssue,
|
handleDeleteIssue,
|
||||||
|
params,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const addIssueToState = useCallback(
|
const addIssueToState = useCallback(
|
||||||
(groupTitle: string, stateId: string | null) => {
|
(groupTitle: string) => {
|
||||||
setCreateIssueModal(true);
|
setCreateIssueModal(true);
|
||||||
if (selectedGroup)
|
if (selectedGroup)
|
||||||
setPreloadedData({
|
setPreloadedData({
|
||||||
state: stateId ?? undefined,
|
|
||||||
[selectedGroup]: groupTitle,
|
[selectedGroup]: groupTitle,
|
||||||
actionType: "createIssue",
|
actionType: "createIssue",
|
||||||
});
|
});
|
||||||
@ -352,8 +381,17 @@ export const IssuesView: React.FC<Props> = ({
|
|||||||
[trashBox, setTrashBox]
|
[trashBox, setTrashBox]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const nullFilters = Object.keys(filters).filter(
|
||||||
|
(key) => filters[key as keyof IIssueFilterOptions] === null
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<CreateUpdateViewModal
|
||||||
|
isOpen={createViewModal !== null}
|
||||||
|
handleClose={() => setCreateViewModal(null)}
|
||||||
|
preLoadedData={createViewModal}
|
||||||
|
/>
|
||||||
<CreateUpdateIssueModal
|
<CreateUpdateIssueModal
|
||||||
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
|
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
|
||||||
handleClose={() => setCreateIssueModal(false)}
|
handleClose={() => setCreateIssueModal(false)}
|
||||||
@ -372,69 +410,163 @@ export const IssuesView: React.FC<Props> = ({
|
|||||||
isOpen={deleteIssueModal}
|
isOpen={deleteIssueModal}
|
||||||
data={issueToDelete}
|
data={issueToDelete}
|
||||||
/>
|
/>
|
||||||
|
<TransferIssuesModal
|
||||||
<div className="relative">
|
handleClose={() => setTransferIssuesModal(false)}
|
||||||
<DragDropContext onDragEnd={handleOnDragEnd}>
|
isOpen={transferIssuesModal}
|
||||||
<StrictModeDroppable droppableId="trashBox">
|
/>
|
||||||
{(provided, snapshot) => (
|
<div className="mb-5 -mt-4">
|
||||||
<div
|
<div className="flex items-center justify-between gap-2">
|
||||||
className={`${
|
<FilterList filters={filters} setFilters={setFilters} />
|
||||||
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
|
{Object.keys(filters).length > 0 &&
|
||||||
} fixed top-9 right-9 z-20 flex h-28 w-96 items-center justify-center gap-2 rounded border-2 border-red-500 bg-red-100 p-3 text-xs font-medium italic text-red-500 ${
|
nullFilters.length !== Object.keys(filters).length && (
|
||||||
snapshot.isDraggingOver ? "bg-red-500 text-white" : ""
|
<PrimaryButton
|
||||||
} duration-200`}
|
onClick={() => {
|
||||||
ref={provided.innerRef}
|
if (viewId) {
|
||||||
{...provided.droppableProps}
|
setFilters({}, true);
|
||||||
|
setToastAlert({
|
||||||
|
title: "View updated",
|
||||||
|
message: "Your view has been updated",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
} else
|
||||||
|
setCreateViewModal({
|
||||||
|
query: filters,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<TrashIcon className="h-4 w-4" />
|
{!viewId && <PlusIcon className="h-4 w-4" />}
|
||||||
Drop issue here to delete
|
{viewId ? "Update" : "Save"} view
|
||||||
</div>
|
</PrimaryButton>
|
||||||
)}
|
)}
|
||||||
</StrictModeDroppable>
|
</div>
|
||||||
{issueView === "list" ? (
|
|
||||||
<AllLists
|
|
||||||
type={type}
|
|
||||||
issues={issues}
|
|
||||||
states={states}
|
|
||||||
members={members}
|
|
||||||
addIssueToState={addIssueToState}
|
|
||||||
makeIssueCopy={makeIssueCopy}
|
|
||||||
handleEditIssue={handleEditIssue}
|
|
||||||
handleDeleteIssue={handleDeleteIssue}
|
|
||||||
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
|
|
||||||
removeIssue={
|
|
||||||
type === "cycle"
|
|
||||||
? removeIssueFromCycle
|
|
||||||
: type === "module"
|
|
||||||
? removeIssueFromModule
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
userAuth={userAuth}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<AllBoards
|
|
||||||
type={type}
|
|
||||||
issues={issues}
|
|
||||||
states={states}
|
|
||||||
members={members}
|
|
||||||
addIssueToState={addIssueToState}
|
|
||||||
makeIssueCopy={makeIssueCopy}
|
|
||||||
handleEditIssue={handleEditIssue}
|
|
||||||
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
|
|
||||||
handleDeleteIssue={handleDeleteIssue}
|
|
||||||
handleTrashBox={handleTrashBox}
|
|
||||||
removeIssue={
|
|
||||||
type === "cycle"
|
|
||||||
? removeIssueFromCycle
|
|
||||||
: type === "module"
|
|
||||||
? removeIssueFromModule
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
userAuth={userAuth}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DragDropContext>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
|
||||||
|
<div className="mb-5 border-t" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DragDropContext onDragEnd={handleOnDragEnd}>
|
||||||
|
<StrictModeDroppable droppableId="trashBox">
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
|
||||||
|
} fixed top-9 right-9 z-20 flex h-28 w-96 flex-col items-center justify-center gap-2 rounded border-2 border-red-500 bg-red-100 p-3 text-xs font-medium italic text-red-500 ${
|
||||||
|
snapshot.isDraggingOver ? "bg-red-500 text-white" : ""
|
||||||
|
} duration-200`}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
Drop issue here to delete
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</StrictModeDroppable>
|
||||||
|
{groupedByIssues ? (
|
||||||
|
isNotEmpty ? (
|
||||||
|
<>
|
||||||
|
{isCompleted && (
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<ExclamationIcon height={14} width={14} />
|
||||||
|
<span>Completed cycles are not editable.</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => setTransferIssuesModal(true)}
|
||||||
|
className="flex items-center gap-3 rounded-lg"
|
||||||
|
>
|
||||||
|
<TransferIcon className="h-4 w-4" />
|
||||||
|
<span>Transfer Issues</span>
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{issueView === "list" ? (
|
||||||
|
<AllLists
|
||||||
|
type={type}
|
||||||
|
states={states}
|
||||||
|
addIssueToState={addIssueToState}
|
||||||
|
makeIssueCopy={makeIssueCopy}
|
||||||
|
handleEditIssue={handleEditIssue}
|
||||||
|
handleDeleteIssue={handleDeleteIssue}
|
||||||
|
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
|
||||||
|
removeIssue={
|
||||||
|
type === "cycle"
|
||||||
|
? removeIssueFromCycle
|
||||||
|
: type === "module"
|
||||||
|
? removeIssueFromModule
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
isCompleted={isCompleted}
|
||||||
|
userAuth={userAuth}
|
||||||
|
/>
|
||||||
|
) : issueView === "kanban" ? (
|
||||||
|
<AllBoards
|
||||||
|
type={type}
|
||||||
|
states={states}
|
||||||
|
addIssueToState={addIssueToState}
|
||||||
|
makeIssueCopy={makeIssueCopy}
|
||||||
|
handleEditIssue={handleEditIssue}
|
||||||
|
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
|
||||||
|
handleDeleteIssue={handleDeleteIssue}
|
||||||
|
handleTrashBox={handleTrashBox}
|
||||||
|
removeIssue={
|
||||||
|
type === "cycle"
|
||||||
|
? removeIssueFromCycle
|
||||||
|
: type === "module"
|
||||||
|
? removeIssueFromModule
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
isCompleted={isCompleted}
|
||||||
|
userAuth={userAuth}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CalendarView />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||||
|
<EmptySpace
|
||||||
|
title="You don't have any issue yet."
|
||||||
|
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
|
||||||
|
Icon={RectangleStackIcon}
|
||||||
|
>
|
||||||
|
<EmptySpaceItem
|
||||||
|
title="Create a new issue"
|
||||||
|
description={
|
||||||
|
<span>
|
||||||
|
Use <pre className="inline rounded bg-gray-200 px-2 py-1">C</pre> shortcut to
|
||||||
|
create a new issue
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
Icon={PlusIcon}
|
||||||
|
action={() => {
|
||||||
|
const e = new KeyboardEvent("keydown", {
|
||||||
|
key: "c",
|
||||||
|
});
|
||||||
|
document.dispatchEvent(e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{openIssuesListModal && (
|
||||||
|
<EmptySpaceItem
|
||||||
|
title="Add an existing issue"
|
||||||
|
description="Open list"
|
||||||
|
Icon={ListBulletIcon}
|
||||||
|
action={openIssuesListModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</EmptySpace>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DragDropContext>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,15 +1,11 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
|
|
||||||
import { mutate } from "swr";
|
|
||||||
|
|
||||||
// react-hook-form
|
// react-hook-form
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
// headless ui
|
// headless ui
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
// ui
|
// ui
|
||||||
import { Button, Input } from "components/ui";
|
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||||
// types
|
// types
|
||||||
import type { IIssueLink, ModuleLink } from "types";
|
import type { IIssueLink, ModuleLink } from "types";
|
||||||
|
|
||||||
@ -116,12 +112,10 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex justify-end gap-2">
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
<Button theme="secondary" onClick={onClose}>
|
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||||
Cancel
|
<PrimaryButton onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? "Adding Link..." : "Add Link"}
|
{isSubmitting ? "Adding Link..." : "Add Link"}
|
||||||
</Button>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
|
@ -1,66 +1,69 @@
|
|||||||
// hooks
|
// hooks
|
||||||
import useIssueView from "hooks/use-issue-view";
|
import useIssuesView from "hooks/use-issues-view";
|
||||||
// components
|
// components
|
||||||
import { SingleList } from "components/core/list-view/single-list";
|
import { SingleList } from "components/core/list-view/single-list";
|
||||||
// types
|
// types
|
||||||
import { IIssue, IProjectMember, IState, UserAuth } from "types";
|
import { IIssue, IState, UserAuth } from "types";
|
||||||
|
|
||||||
// types
|
// types
|
||||||
type Props = {
|
type Props = {
|
||||||
type: "issue" | "cycle" | "module";
|
type: "issue" | "cycle" | "module";
|
||||||
issues: IIssue[];
|
|
||||||
states: IState[] | undefined;
|
states: IState[] | undefined;
|
||||||
members: IProjectMember[] | undefined;
|
addIssueToState: (groupTitle: string) => void;
|
||||||
addIssueToState: (groupTitle: string, stateId: string | null) => void;
|
|
||||||
makeIssueCopy: (issue: IIssue) => void;
|
makeIssueCopy: (issue: IIssue) => void;
|
||||||
handleEditIssue: (issue: IIssue) => void;
|
handleEditIssue: (issue: IIssue) => void;
|
||||||
handleDeleteIssue: (issue: IIssue) => void;
|
handleDeleteIssue: (issue: IIssue) => void;
|
||||||
openIssuesListModal?: (() => void) | null;
|
openIssuesListModal?: (() => void) | null;
|
||||||
removeIssue: ((bridgeId: string) => void) | null;
|
removeIssue: ((bridgeId: string) => void) | null;
|
||||||
|
isCompleted?: boolean;
|
||||||
userAuth: UserAuth;
|
userAuth: UserAuth;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AllLists: React.FC<Props> = ({
|
export const AllLists: React.FC<Props> = ({
|
||||||
type,
|
type,
|
||||||
issues,
|
|
||||||
states,
|
states,
|
||||||
members,
|
|
||||||
addIssueToState,
|
addIssueToState,
|
||||||
makeIssueCopy,
|
makeIssueCopy,
|
||||||
openIssuesListModal,
|
openIssuesListModal,
|
||||||
handleEditIssue,
|
handleEditIssue,
|
||||||
handleDeleteIssue,
|
handleDeleteIssue,
|
||||||
removeIssue,
|
removeIssue,
|
||||||
|
isCompleted = false,
|
||||||
userAuth,
|
userAuth,
|
||||||
}) => {
|
}) => {
|
||||||
const { groupedByIssues, groupByProperty: selectedGroup } = useIssueView(issues);
|
const { groupedByIssues, groupByProperty: selectedGroup, showEmptyGroups } = useIssuesView();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-5">
|
<>
|
||||||
{Object.keys(groupedByIssues).map((singleGroup) => {
|
{groupedByIssues && (
|
||||||
const stateId =
|
<div className="flex flex-col space-y-5">
|
||||||
selectedGroup === "state_detail.name"
|
{Object.keys(groupedByIssues).map((singleGroup) => {
|
||||||
? states?.find((s) => s.name === singleGroup)?.id ?? null
|
const currentState =
|
||||||
: null;
|
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
|
||||||
|
|
||||||
return (
|
if (!showEmptyGroups && groupedByIssues[singleGroup].length === 0) return null;
|
||||||
<SingleList
|
|
||||||
key={singleGroup}
|
return (
|
||||||
type={type}
|
<SingleList
|
||||||
groupTitle={singleGroup}
|
key={singleGroup}
|
||||||
groupedByIssues={groupedByIssues}
|
type={type}
|
||||||
selectedGroup={selectedGroup}
|
groupTitle={singleGroup}
|
||||||
members={members}
|
groupedByIssues={groupedByIssues}
|
||||||
addIssueToState={() => addIssueToState(singleGroup, stateId)}
|
selectedGroup={selectedGroup}
|
||||||
makeIssueCopy={makeIssueCopy}
|
currentState={currentState}
|
||||||
handleEditIssue={handleEditIssue}
|
addIssueToState={() => addIssueToState(singleGroup)}
|
||||||
handleDeleteIssue={handleDeleteIssue}
|
makeIssueCopy={makeIssueCopy}
|
||||||
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
|
handleEditIssue={handleEditIssue}
|
||||||
removeIssue={removeIssue}
|
handleDeleteIssue={handleDeleteIssue}
|
||||||
userAuth={userAuth}
|
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
|
||||||
/>
|
removeIssue={removeIssue}
|
||||||
);
|
isCompleted={isCompleted}
|
||||||
})}
|
userAuth={userAuth}
|
||||||
</div>
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -16,7 +16,8 @@ import {
|
|||||||
ViewPrioritySelect,
|
ViewPrioritySelect,
|
||||||
ViewStateSelect,
|
ViewStateSelect,
|
||||||
} from "components/issues/view-select";
|
} from "components/issues/view-select";
|
||||||
|
// hooks
|
||||||
|
import useIssueView from "hooks/use-issues-view";
|
||||||
// ui
|
// ui
|
||||||
import { Tooltip, CustomMenu, ContextMenu } from "components/ui";
|
import { Tooltip, CustomMenu, ContextMenu } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
@ -25,22 +26,34 @@ import {
|
|||||||
LinkIcon,
|
LinkIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
|
XMarkIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
// helpers
|
// helpers
|
||||||
import { copyTextToClipboard } from "helpers/string.helper";
|
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||||
|
import { handleIssuesMutation } from "constants/issue";
|
||||||
// types
|
// types
|
||||||
import { CycleIssueResponse, IIssue, ModuleIssueResponse, Properties, UserAuth } from "types";
|
import { IIssue, Properties, UserAuth } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
import {
|
||||||
|
CYCLE_DETAILS,
|
||||||
|
CYCLE_ISSUES_WITH_PARAMS,
|
||||||
|
MODULE_DETAILS,
|
||||||
|
MODULE_ISSUES_WITH_PARAMS,
|
||||||
|
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||||
|
} from "constants/fetch-keys";
|
||||||
|
import { DIVIDER } from "@blueprintjs/core/lib/esm/common/classes";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
type?: string;
|
type?: string;
|
||||||
issue: IIssue;
|
issue: IIssue;
|
||||||
properties: Properties;
|
properties: Properties;
|
||||||
|
groupTitle?: string;
|
||||||
editIssue: () => void;
|
editIssue: () => void;
|
||||||
|
index: number;
|
||||||
makeIssueCopy: () => void;
|
makeIssueCopy: () => void;
|
||||||
removeIssue?: (() => void) | null;
|
removeIssue?: (() => void) | null;
|
||||||
handleDeleteIssue: (issue: IIssue) => void;
|
handleDeleteIssue: (issue: IIssue) => void;
|
||||||
|
isCompleted?: boolean;
|
||||||
userAuth: UserAuth;
|
userAuth: UserAuth;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -49,9 +62,12 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
issue,
|
issue,
|
||||||
properties,
|
properties,
|
||||||
editIssue,
|
editIssue,
|
||||||
|
index,
|
||||||
makeIssueCopy,
|
makeIssueCopy,
|
||||||
removeIssue,
|
removeIssue,
|
||||||
|
groupTitle,
|
||||||
handleDeleteIssue,
|
handleDeleteIssue,
|
||||||
|
isCompleted = false,
|
||||||
userAuth,
|
userAuth,
|
||||||
}) => {
|
}) => {
|
||||||
// context menu
|
// context menu
|
||||||
@ -63,80 +79,63 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const { groupByProperty: selectedGroup, params } = useIssueView();
|
||||||
|
|
||||||
const partialUpdateIssue = useCallback(
|
const partialUpdateIssue = useCallback(
|
||||||
(formData: Partial<IIssue>) => {
|
(formData: Partial<IIssue>) => {
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
if (cycleId)
|
if (cycleId)
|
||||||
mutate<CycleIssueResponse[]>(
|
mutate<
|
||||||
CYCLE_ISSUES(cycleId as string),
|
| {
|
||||||
(prevData) => {
|
[key: string]: IIssue[];
|
||||||
const updatedIssues = (prevData ?? []).map((p) => {
|
}
|
||||||
if (p.issue_detail.id === issue.id) {
|
| IIssue[]
|
||||||
return {
|
>(
|
||||||
...p,
|
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params),
|
||||||
issue_detail: {
|
(prevData) =>
|
||||||
...p.issue_detail,
|
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
|
||||||
...formData,
|
|
||||||
assignees: formData.assignees_list ?? p.issue_detail.assignees_list,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
});
|
|
||||||
return [...updatedIssues];
|
|
||||||
},
|
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
if (moduleId)
|
if (moduleId)
|
||||||
mutate<ModuleIssueResponse[]>(
|
mutate<
|
||||||
MODULE_ISSUES(moduleId as string),
|
| {
|
||||||
(prevData) => {
|
[key: string]: IIssue[];
|
||||||
const updatedIssues = (prevData ?? []).map((p) => {
|
}
|
||||||
if (p.issue_detail.id === issue.id) {
|
| IIssue[]
|
||||||
return {
|
>(
|
||||||
...p,
|
MODULE_ISSUES_WITH_PARAMS(moduleId as string, params),
|
||||||
issue_detail: {
|
(prevData) =>
|
||||||
...p.issue_detail,
|
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
|
||||||
...formData,
|
|
||||||
assignees: formData.assignees_list ?? p.issue_detail.assignees_list,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
});
|
|
||||||
return [...updatedIssues];
|
|
||||||
},
|
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
mutate<IIssue[]>(
|
mutate<
|
||||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
| {
|
||||||
|
[key: string]: IIssue[];
|
||||||
|
}
|
||||||
|
| IIssue[]
|
||||||
|
>(
|
||||||
|
PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
|
||||||
(prevData) =>
|
(prevData) =>
|
||||||
(prevData ?? []).map((p) => {
|
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
|
||||||
if (p.id === issue.id)
|
|
||||||
return { ...p, ...formData, assignees: formData.assignees_list ?? p.assignees_list };
|
|
||||||
|
|
||||||
return p;
|
|
||||||
}),
|
|
||||||
|
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
issuesService
|
issuesService
|
||||||
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
|
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
|
||||||
.then((res) => {
|
.then(() => {
|
||||||
if (cycleId) mutate(CYCLE_ISSUES(cycleId as string));
|
if (cycleId) {
|
||||||
if (moduleId) mutate(MODULE_ISSUES(moduleId as string));
|
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
|
||||||
|
mutate(CYCLE_DETAILS(cycleId as string));
|
||||||
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
|
} else if (moduleId) {
|
||||||
})
|
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
|
||||||
.catch((error) => {
|
mutate(MODULE_DETAILS(moduleId as string));
|
||||||
console.log(error);
|
} else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[workspaceSlug, projectId, cycleId, moduleId, issue]
|
[workspaceSlug, projectId, cycleId, moduleId, issue, groupTitle, index, selectedGroup, params]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCopyText = () => {
|
const handleCopyText = () => {
|
||||||
@ -153,7 +152,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -163,123 +162,138 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
isOpen={contextMenu}
|
isOpen={contextMenu}
|
||||||
setIsOpen={setContextMenu}
|
setIsOpen={setContextMenu}
|
||||||
>
|
>
|
||||||
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
|
{!isNotAllowed && (
|
||||||
Edit issue
|
<>
|
||||||
</ContextMenu.Item>
|
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
|
||||||
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
|
Edit issue
|
||||||
Make a copy...
|
</ContextMenu.Item>
|
||||||
</ContextMenu.Item>
|
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
|
||||||
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
|
Make a copy...
|
||||||
Delete issue
|
</ContextMenu.Item>
|
||||||
</ContextMenu.Item>
|
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
|
||||||
|
Delete issue
|
||||||
|
</ContextMenu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
|
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
|
||||||
Copy issue link
|
Copy issue link
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
<div
|
<div className="border-b border-gray-300 last:border-b-0">
|
||||||
className="flex items-center justify-between gap-2 px-4 py-3 text-sm"
|
<div
|
||||||
onContextMenu={(e) => {
|
className="flex items-center justify-between gap-2 px-4 py-3"
|
||||||
e.preventDefault();
|
onContextMenu={(e) => {
|
||||||
setContextMenu(true);
|
e.preventDefault();
|
||||||
setContextMenuPosition({ x: e.pageX, y: e.pageY });
|
setContextMenu(true);
|
||||||
}}
|
setContextMenuPosition({ x: e.pageX, y: e.pageY });
|
||||||
>
|
}}
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<span
|
|
||||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: issue.state_detail.color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
|
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
|
||||||
<a className="group relative flex items-center gap-2">
|
<a className="group relative flex items-center gap-2">
|
||||||
{properties.key && (
|
{properties.key && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
tooltipHeading="ID"
|
tooltipHeading="Issue ID"
|
||||||
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
|
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
|
||||||
>
|
>
|
||||||
<span className="flex-shrink-0 text-xs text-gray-500">
|
<span className="flex-shrink-0 text-xs text-gray-400">
|
||||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
|
||||||
<span className="w-auto max-w-lg overflow-hidden text-ellipsis whitespace-nowrap">
|
<span className="text-sm text-gray-800">{truncateText(issue.name, 50)}</span>
|
||||||
{issue.name}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
|
||||||
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
{properties.priority && (
|
{properties.priority && (
|
||||||
<ViewPrioritySelect
|
<ViewPrioritySelect
|
||||||
issue={issue}
|
issue={issue}
|
||||||
partialUpdateIssue={partialUpdateIssue}
|
partialUpdateIssue={partialUpdateIssue}
|
||||||
position="right"
|
position="right"
|
||||||
isNotAllowed={isNotAllowed}
|
isNotAllowed={isNotAllowed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{properties.state && (
|
{properties.state && (
|
||||||
<ViewStateSelect
|
<ViewStateSelect
|
||||||
issue={issue}
|
issue={issue}
|
||||||
partialUpdateIssue={partialUpdateIssue}
|
partialUpdateIssue={partialUpdateIssue}
|
||||||
position="right"
|
position="right"
|
||||||
isNotAllowed={isNotAllowed}
|
isNotAllowed={isNotAllowed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{properties.due_date && (
|
{properties.due_date && (
|
||||||
<ViewDueDateSelect
|
<ViewDueDateSelect
|
||||||
issue={issue}
|
issue={issue}
|
||||||
partialUpdateIssue={partialUpdateIssue}
|
partialUpdateIssue={partialUpdateIssue}
|
||||||
isNotAllowed={isNotAllowed}
|
isNotAllowed={isNotAllowed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{properties.sub_issue_count && (
|
{properties.sub_issue_count && (
|
||||||
<div className="flex flex-shrink-0 items-center gap-1 rounded-md border px-3 py-1.5 text-xs shadow-sm">
|
<div className="flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs shadow-sm">
|
||||||
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{properties.labels && (
|
{properties.labels && issue.label_details.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{issue.label_details.map((label) => (
|
{issue.label_details.map((label) => (
|
||||||
<span
|
|
||||||
key={label.id}
|
|
||||||
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
key={label.id}
|
||||||
style={{
|
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
||||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
|
>
|
||||||
}}
|
<span
|
||||||
/>
|
className="h-1.5 w-1.5 rounded-full"
|
||||||
{label.name}
|
style={{
|
||||||
</span>
|
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
|
||||||
))}
|
}}
|
||||||
</div>
|
/>
|
||||||
)}
|
{label.name}
|
||||||
{properties.assignee && (
|
</span>
|
||||||
<ViewAssigneeSelect
|
))}
|
||||||
issue={issue}
|
</div>
|
||||||
partialUpdateIssue={partialUpdateIssue}
|
) : (
|
||||||
position="right"
|
""
|
||||||
isNotAllowed={isNotAllowed}
|
)}
|
||||||
/>
|
{properties.assignee && (
|
||||||
)}
|
<ViewAssigneeSelect
|
||||||
{type && !isNotAllowed && (
|
issue={issue}
|
||||||
<CustomMenu width="auto" ellipsis>
|
partialUpdateIssue={partialUpdateIssue}
|
||||||
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
|
position="right"
|
||||||
{type !== "issue" && removeIssue && (
|
isNotAllowed={isNotAllowed}
|
||||||
<CustomMenu.MenuItem onClick={removeIssue}>
|
/>
|
||||||
<>Remove from {type}</>
|
)}
|
||||||
|
{type && !isNotAllowed && (
|
||||||
|
<CustomMenu width="auto" ellipsis>
|
||||||
|
<CustomMenu.MenuItem onClick={editIssue}>
|
||||||
|
<div className="flex items-center justify-start gap-2">
|
||||||
|
<PencilIcon className="h-4 w-4" />
|
||||||
|
<span>Edit issue</span>
|
||||||
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
)}
|
{type !== "issue" && removeIssue && (
|
||||||
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
|
<CustomMenu.MenuItem onClick={removeIssue}>
|
||||||
Delete issue
|
<div className="flex items-center justify-start gap-2">
|
||||||
</CustomMenu.MenuItem>
|
<XMarkIcon className="h-4 w-4" />
|
||||||
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
|
<span>Remove from {type}</span>
|
||||||
</CustomMenu>
|
</div>
|
||||||
)}
|
</CustomMenu.MenuItem>
|
||||||
|
)}
|
||||||
|
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
|
||||||
|
<div className="flex items-center justify-start gap-2">
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
<span>Delete issue</span>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||||
|
<div className="flex items-center justify-start gap-2">
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
<span>Copy issue link</span>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
</CustomMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -1,48 +1,61 @@
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
// headless ui
|
// headless ui
|
||||||
import { Disclosure, Transition } from "@headlessui/react";
|
import { Disclosure, Transition } from "@headlessui/react";
|
||||||
|
// services
|
||||||
|
import issuesService from "services/issues.service";
|
||||||
|
import projectService from "services/project.service";
|
||||||
// hooks
|
// hooks
|
||||||
import useIssuesProperties from "hooks/use-issue-properties";
|
import useIssuesProperties from "hooks/use-issue-properties";
|
||||||
// components
|
// components
|
||||||
import { SingleListIssue } from "components/core";
|
import { SingleListIssue } from "components/core";
|
||||||
|
// ui
|
||||||
|
import { CustomMenu } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/24/outline";
|
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { getStateGroupIcon } from "components/icons";
|
||||||
// helpers
|
// helpers
|
||||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||||
// types
|
// types
|
||||||
import { IIssue, IProjectMember, NestedKeyOf, UserAuth } from "types";
|
import { IIssue, IIssueLabels, IState, TIssueGroupByOptions, UserAuth } from "types";
|
||||||
import { CustomMenu } from "components/ui";
|
// fetch-keys
|
||||||
|
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
type?: "issue" | "cycle" | "module";
|
type?: "issue" | "cycle" | "module";
|
||||||
|
currentState?: IState | null;
|
||||||
|
bgColor?: string;
|
||||||
groupTitle: string;
|
groupTitle: string;
|
||||||
groupedByIssues: {
|
groupedByIssues: {
|
||||||
[key: string]: IIssue[];
|
[key: string]: IIssue[];
|
||||||
};
|
};
|
||||||
selectedGroup: NestedKeyOf<IIssue> | null;
|
selectedGroup: TIssueGroupByOptions;
|
||||||
members: IProjectMember[] | undefined;
|
|
||||||
addIssueToState: () => void;
|
addIssueToState: () => void;
|
||||||
makeIssueCopy: (issue: IIssue) => void;
|
makeIssueCopy: (issue: IIssue) => void;
|
||||||
handleEditIssue: (issue: IIssue) => void;
|
handleEditIssue: (issue: IIssue) => void;
|
||||||
handleDeleteIssue: (issue: IIssue) => void;
|
handleDeleteIssue: (issue: IIssue) => void;
|
||||||
openIssuesListModal?: (() => void) | null;
|
openIssuesListModal?: (() => void) | null;
|
||||||
removeIssue: ((bridgeId: string) => void) | null;
|
removeIssue: ((bridgeId: string) => void) | null;
|
||||||
|
isCompleted?: boolean;
|
||||||
userAuth: UserAuth;
|
userAuth: UserAuth;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SingleList: React.FC<Props> = ({
|
export const SingleList: React.FC<Props> = ({
|
||||||
type,
|
type,
|
||||||
|
currentState,
|
||||||
|
bgColor,
|
||||||
groupTitle,
|
groupTitle,
|
||||||
groupedByIssues,
|
groupedByIssues,
|
||||||
selectedGroup,
|
selectedGroup,
|
||||||
members,
|
|
||||||
addIssueToState,
|
addIssueToState,
|
||||||
makeIssueCopy,
|
makeIssueCopy,
|
||||||
handleEditIssue,
|
handleEditIssue,
|
||||||
handleDeleteIssue,
|
handleDeleteIssue,
|
||||||
openIssuesListModal,
|
openIssuesListModal,
|
||||||
removeIssue,
|
removeIssue,
|
||||||
|
isCompleted = false,
|
||||||
userAuth,
|
userAuth,
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -50,107 +63,90 @@ export const SingleList: React.FC<Props> = ({
|
|||||||
|
|
||||||
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
||||||
|
|
||||||
const createdBy =
|
const { data: issueLabels } = useSWR<IIssueLabels[]>(
|
||||||
selectedGroup === "created_by"
|
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
|
||||||
? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "Loading..."
|
workspaceSlug && projectId
|
||||||
: null;
|
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
let assignees: any;
|
const { data: members } = useSWR(
|
||||||
if (selectedGroup === "assignees") {
|
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||||
assignees = groupTitle && groupTitle !== "" ? groupTitle.split(",") : [];
|
workspaceSlug && projectId
|
||||||
assignees =
|
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||||
assignees.length > 0
|
: null
|
||||||
? assignees
|
);
|
||||||
.map((a: string) => members?.find((m) => m.member.id === a)?.member.first_name)
|
|
||||||
.join(", ")
|
const getGroupTitle = () => {
|
||||||
: "No assignee";
|
let title = addSpaceIfCamelCase(groupTitle);
|
||||||
}
|
|
||||||
|
switch (selectedGroup) {
|
||||||
|
case "state":
|
||||||
|
title = addSpaceIfCamelCase(currentState?.name ?? "");
|
||||||
|
break;
|
||||||
|
case "labels":
|
||||||
|
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None";
|
||||||
|
break;
|
||||||
|
case "created_by":
|
||||||
|
const member = members?.find((member) => member.member.id === groupTitle)?.member;
|
||||||
|
title =
|
||||||
|
member?.first_name && member.first_name !== ""
|
||||||
|
? `${member.first_name} ${member.last_name}`
|
||||||
|
: member?.email ?? "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return title;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Disclosure key={groupTitle} as="div" defaultOpen>
|
<Disclosure key={groupTitle} as="div" defaultOpen>
|
||||||
{({ open }) => (
|
{({ open }) => (
|
||||||
<div className="rounded-lg bg-white">
|
<div className="rounded-[10px] border border-gray-300 bg-white">
|
||||||
<div className="rounded-t-lg bg-gray-100 px-4 py-3">
|
<div
|
||||||
|
className={`flex items-center justify-between bg-gray-100 px-5 py-3 ${
|
||||||
|
open ? "rounded-t-[10px]" : "rounded-[10px]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<Disclosure.Button>
|
<Disclosure.Button>
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-3">
|
||||||
<span>
|
{selectedGroup !== null && selectedGroup === "state" ? (
|
||||||
<ChevronDownIcon
|
<span>
|
||||||
className={`h-4 w-4 text-gray-500 ${!open ? "-rotate-90 transform" : ""}`}
|
{currentState && getStateGroupIcon(currentState.group, "16", "16", bgColor)}
|
||||||
/>
|
</span>
|
||||||
</span>
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
{selectedGroup !== null ? (
|
{selectedGroup !== null ? (
|
||||||
<h2 className="font-medium capitalize leading-5">
|
<h2 className="text-base font-semibold capitalize leading-6 text-gray-800">
|
||||||
{selectedGroup === "created_by"
|
{getGroupTitle()}
|
||||||
? createdBy
|
|
||||||
: selectedGroup === "assignees"
|
|
||||||
? assignees
|
|
||||||
: addSpaceIfCamelCase(groupTitle)}
|
|
||||||
</h2>
|
</h2>
|
||||||
) : (
|
) : (
|
||||||
<h2 className="font-medium leading-5">All Issues</h2>
|
<h2 className="font-medium leading-5">All Issues</h2>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-gray-500">
|
<span className="rounded-full bg-gray-200 py-0.5 px-3 text-sm text-black">
|
||||||
{groupedByIssues[groupTitle as keyof IIssue].length}
|
{groupedByIssues[groupTitle as keyof IIssue].length}
|
||||||
</p>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Disclosure.Button>
|
</Disclosure.Button>
|
||||||
</div>
|
|
||||||
<Transition
|
|
||||||
show={open}
|
|
||||||
enter="transition duration-100 ease-out"
|
|
||||||
enterFrom="transform opacity-0"
|
|
||||||
enterTo="transform opacity-100"
|
|
||||||
leave="transition duration-75 ease-out"
|
|
||||||
leaveFrom="transform opacity-100"
|
|
||||||
leaveTo="transform opacity-0"
|
|
||||||
>
|
|
||||||
<Disclosure.Panel>
|
|
||||||
<div className="divide-y-2">
|
|
||||||
{groupedByIssues[groupTitle] ? (
|
|
||||||
groupedByIssues[groupTitle].length > 0 ? (
|
|
||||||
groupedByIssues[groupTitle].map((issue: IIssue) => (
|
|
||||||
<SingleListIssue
|
|
||||||
key={issue.id}
|
|
||||||
type={type}
|
|
||||||
issue={issue}
|
|
||||||
properties={properties}
|
|
||||||
editIssue={() => handleEditIssue(issue)}
|
|
||||||
makeIssueCopy={() => makeIssueCopy(issue)}
|
|
||||||
handleDeleteIssue={handleDeleteIssue}
|
|
||||||
removeIssue={() => {
|
|
||||||
removeIssue && removeIssue(issue.bridge);
|
|
||||||
}}
|
|
||||||
userAuth={userAuth}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="px-4 py-3 text-sm text-gray-500">No issues.</p>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">Loading...</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Disclosure.Panel>
|
|
||||||
</Transition>
|
|
||||||
<div className="p-3">
|
|
||||||
{type === "issue" ? (
|
{type === "issue" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-gray-100"
|
className="p-1 text-gray-500 hover:bg-gray-100"
|
||||||
onClick={addIssueToState}
|
onClick={addIssueToState}
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-3 w-3" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
Add issue
|
|
||||||
</button>
|
</button>
|
||||||
|
) : isCompleted ? (
|
||||||
|
""
|
||||||
) : (
|
) : (
|
||||||
<CustomMenu
|
<CustomMenu
|
||||||
label={
|
customButton={
|
||||||
<span className="flex items-center gap-1">
|
<div className="flex items-center cursor-pointer">
|
||||||
<PlusIcon className="h-3 w-3" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
Add issue
|
</div>
|
||||||
</span>
|
|
||||||
}
|
}
|
||||||
optionsPosition="left"
|
optionsPosition="right"
|
||||||
noBorder
|
noBorder
|
||||||
>
|
>
|
||||||
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
|
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
|
||||||
@ -162,6 +158,44 @@ export const SingleList: React.FC<Props> = ({
|
|||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Transition
|
||||||
|
show={open}
|
||||||
|
enter="transition duration-100 ease-out"
|
||||||
|
enterFrom="transform opacity-0"
|
||||||
|
enterTo="transform opacity-100"
|
||||||
|
leave="transition duration-75 ease-out"
|
||||||
|
leaveFrom="transform opacity-100"
|
||||||
|
leaveTo="transform opacity-0"
|
||||||
|
>
|
||||||
|
<Disclosure.Panel>
|
||||||
|
{groupedByIssues[groupTitle] ? (
|
||||||
|
groupedByIssues[groupTitle].length > 0 ? (
|
||||||
|
groupedByIssues[groupTitle].map((issue, index) => (
|
||||||
|
<SingleListIssue
|
||||||
|
key={issue.id}
|
||||||
|
type={type}
|
||||||
|
issue={issue}
|
||||||
|
properties={properties}
|
||||||
|
groupTitle={groupTitle}
|
||||||
|
index={index}
|
||||||
|
editIssue={() => handleEditIssue(issue)}
|
||||||
|
makeIssueCopy={() => makeIssueCopy(issue)}
|
||||||
|
handleDeleteIssue={handleDeleteIssue}
|
||||||
|
removeIssue={() => {
|
||||||
|
if (removeIssue !== null && issue.bridge_id) removeIssue(issue.bridge_id);
|
||||||
|
}}
|
||||||
|
isCompleted={isCompleted}
|
||||||
|
userAuth={userAuth}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="px-4 py-3 text-sm text-gray-500">No issues.</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">Loading...</div>
|
||||||
|
)}
|
||||||
|
</Disclosure.Panel>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
// next
|
// next
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
// layouts
|
// layouts
|
||||||
import DefaultLayout from "layouts/default-layout";
|
import DefaultLayout from "layouts/default-layout";
|
||||||
// hooks
|
// hooks
|
||||||
import useUser from "hooks/use-user";
|
import useUser from "hooks/use-user";
|
||||||
// icons
|
// img
|
||||||
import { LockIcon } from "components/icons";
|
import ProjectSettingImg from "public/project-setting.svg";
|
||||||
|
|
||||||
type TNotAuthorizedViewProps = {
|
type TNotAuthorizedViewProps = {
|
||||||
actionButton?: React.ReactNode;
|
actionButton?: React.ReactNode;
|
||||||
@ -27,25 +28,27 @@ export const NotAuthorizedView: React.FC<TNotAuthorizedViewProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
|
||||||
<LockIcon className="h-16 w-16 text-gray-400" />
|
<div className="h-44 w-72">
|
||||||
|
<Image src={ProjectSettingImg} height="176" width="288" alt="ProjectSettingImg" />
|
||||||
|
</div>
|
||||||
<h1 className="text-xl font-medium text-gray-900">
|
<h1 className="text-xl font-medium text-gray-900">
|
||||||
Oops! You are not authorized to view this page
|
Oops! You are not authorized to view this page
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="w-full md:w-1/3">
|
<div className="w-full text-base text-gray-500 max-w-md ">
|
||||||
{user ? (
|
{user ? (
|
||||||
<p className="text-base font-light">
|
<p className="">
|
||||||
You have signed in as <span className="font-medium">{user.email}</span>.{" "}
|
You have signed in as {user.email}.{" "}
|
||||||
<Link href={`/signin?next=${currentPath}`}>
|
<Link href={`/signin?next=${currentPath}`}>
|
||||||
<a className="font-medium">Sign in</a>
|
<a className="text-gray-900 font-medium">Sign in</a>
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
with different account that has access to this page.
|
with different account that has access to this page.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-base font-light">
|
<p className="">
|
||||||
You need to{" "}
|
You need to{" "}
|
||||||
<Link href={`/signin?next=${currentPath}`}>
|
<Link href={`/signin?next=${currentPath}`}>
|
||||||
<a className="font-medium">Sign in</a>
|
<a className="text-gray-900 font-medium">Sign in</a>
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
with an account that has access to this page.
|
with an account that has access to this page.
|
||||||
</p>
|
</p>
|
||||||
|
@ -54,11 +54,14 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }
|
|||||||
<LinkIcon className="h-3.5 w-3.5" />
|
<LinkIcon className="h-3.5 w-3.5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h5 className="w-4/5">{link.title}</h5>
|
<h5 className="w-4/5 break-all">{link.title}</h5>
|
||||||
<p className="mt-0.5 text-gray-500">
|
<p className="mt-0.5 text-gray-500">
|
||||||
Added {timeAgo(link.created_at)}
|
Added {timeAgo(link.created_at)}
|
||||||
<br />
|
<br />
|
||||||
by {link.created_by_detail.email}
|
by{" "}
|
||||||
|
{link.created_by_detail.is_bot
|
||||||
|
? link.created_by_detail.first_name + " Bot"
|
||||||
|
: link.created_by_detail.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
@ -39,7 +39,6 @@ const ProgressChart: React.FC<Props> = ({ issues, start, end }) => {
|
|||||||
|
|
||||||
const CustomTooltip = ({ active, payload }: TooltipProps<ValueType, NameType>) => {
|
const CustomTooltip = ({ active, payload }: TooltipProps<ValueType, NameType>) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
console.log(payload[0].payload.currentDate);
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-sm bg-gray-300 p-1 text-xs text-gray-800">
|
<div className="rounded-sm bg-gray-300 p-1 text-xs text-gray-800">
|
||||||
<p>{payload[0].payload.currentDate}</p>
|
<p>{payload[0].payload.currentDate}</p>
|
||||||
|
@ -12,13 +12,13 @@ import issuesServices from "services/issues.service";
|
|||||||
import projectService from "services/project.service";
|
import projectService from "services/project.service";
|
||||||
// hooks
|
// hooks
|
||||||
import useLocalStorage from "hooks/use-local-storage";
|
import useLocalStorage from "hooks/use-local-storage";
|
||||||
|
import useIssuesView from "hooks/use-issues-view";
|
||||||
// components
|
// components
|
||||||
import { LinksList, SingleProgressStats } from "components/core";
|
import { SingleProgressStats } from "components/core";
|
||||||
// ui
|
// ui
|
||||||
import { Avatar } from "components/ui";
|
import { Avatar } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
import User from "public/user.png";
|
import User from "public/user.png";
|
||||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
|
||||||
// types
|
// types
|
||||||
import { IIssue, IIssueLabels, IModule, UserAuth } from "types";
|
import { IIssue, IIssueLabels, IModule, UserAuth } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
@ -28,8 +28,6 @@ type Props = {
|
|||||||
groupedIssues: any;
|
groupedIssues: any;
|
||||||
issues: IIssue[];
|
issues: IIssue[];
|
||||||
module?: IModule;
|
module?: IModule;
|
||||||
setModuleLinkModal?: any;
|
|
||||||
handleDeleteLink?: any;
|
|
||||||
userAuth?: UserAuth;
|
userAuth?: UserAuth;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -47,13 +45,13 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
groupedIssues,
|
groupedIssues,
|
||||||
issues,
|
issues,
|
||||||
module,
|
module,
|
||||||
setModuleLinkModal,
|
|
||||||
handleDeleteLink,
|
|
||||||
userAuth,
|
userAuth,
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
const { filters, setFilters } = useIssuesView();
|
||||||
|
|
||||||
const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees");
|
const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees");
|
||||||
|
|
||||||
const { data: issueLabels } = useSWR<IIssueLabels[]>(
|
const { data: issueLabels } = useSWR<IIssueLabels[]>(
|
||||||
@ -72,14 +70,12 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
|
|
||||||
const currentValue = (tab: string | null) => {
|
const currentValue = (tab: string | null) => {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "Links":
|
|
||||||
return 0;
|
|
||||||
case "Assignees":
|
case "Assignees":
|
||||||
return 1;
|
return 0;
|
||||||
case "Labels":
|
case "Labels":
|
||||||
return 2;
|
return 1;
|
||||||
case "States":
|
case "States":
|
||||||
return 3;
|
return 2;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 3;
|
return 3;
|
||||||
@ -91,12 +87,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
onChange={(i) => {
|
onChange={(i) => {
|
||||||
switch (i) {
|
switch (i) {
|
||||||
case 0:
|
case 0:
|
||||||
return setTab("Links");
|
|
||||||
case 1:
|
|
||||||
return setTab("Assignees");
|
return setTab("Assignees");
|
||||||
case 2:
|
case 1:
|
||||||
return setTab("Labels");
|
return setTab("Labels");
|
||||||
case 3:
|
case 2:
|
||||||
return setTab("States");
|
return setTab("States");
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@ -109,20 +103,6 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
className={`flex w-full items-center justify-between rounded-md bg-gray-100 px-1 py-1.5
|
className={`flex w-full items-center justify-between rounded-md bg-gray-100 px-1 py-1.5
|
||||||
${module ? "text-xs" : "text-sm"} `}
|
${module ? "text-xs" : "text-sm"} `}
|
||||||
>
|
>
|
||||||
{module ? (
|
|
||||||
<Tab
|
|
||||||
className={({ selected }) =>
|
|
||||||
`w-full rounded px-3 py-1 text-gray-900 ${
|
|
||||||
selected ? " bg-theme text-white" : " hover:bg-hover-gray"
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Links
|
|
||||||
</Tab>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tab
|
<Tab
|
||||||
className={({ selected }) =>
|
className={({ selected }) =>
|
||||||
`w-full rounded px-3 py-1 text-gray-900 ${
|
`w-full rounded px-3 py-1 text-gray-900 ${
|
||||||
@ -134,7 +114,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({ selected }) =>
|
className={({ selected }) =>
|
||||||
`w-full rounded px-3 py-1 text-gray-900 ${
|
`w-full rounded px-3 py-1 text-gray-900 ${
|
||||||
selected ? " bg-theme text-white" : " hover:bg-hover-gray"
|
selected ? " bg-theme text-white" : " hover:bg-hover-gray"
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
@ -151,34 +131,12 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
States
|
States
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels className="flex w-full items-center justify-between p-1">
|
<Tab.Panels className="flex w-full items-center justify-between pt-1">
|
||||||
{module ? (
|
<Tab.Panel as="div" className="flex w-full flex-col text-xs">
|
||||||
<Tab.Panel as="div" className="flex w-full flex-col text-xs ">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center justify-start gap-2 rounded px-4 py-2 hover:bg-theme/5"
|
|
||||||
onClick={() => setModuleLinkModal(true)}
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-4 w-4" /> <span>Add Link</span>
|
|
||||||
</button>
|
|
||||||
<div className="mt-2 space-y-2 hover:bg-theme/5">
|
|
||||||
{userAuth && module.link_module && module.link_module.length > 0 ? (
|
|
||||||
<LinksList
|
|
||||||
links={module.link_module}
|
|
||||||
handleDeleteLink={handleDeleteLink}
|
|
||||||
userAuth={userAuth}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Tab.Panel>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tab.Panel as="div" className="flex w-full flex-col text-xs ">
|
|
||||||
{members?.map((member, index) => {
|
{members?.map((member, index) => {
|
||||||
const totalArray = issues?.filter((i) => i.assignees?.includes(member.member.id));
|
const totalArray = issues?.filter((i) => i.assignees?.includes(member.member.id));
|
||||||
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
|
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
|
||||||
|
|
||||||
if (totalArray.length > 0) {
|
if (totalArray.length > 0) {
|
||||||
return (
|
return (
|
||||||
<SingleProgressStats
|
<SingleProgressStats
|
||||||
@ -191,6 +149,15 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
completed={completeArray.length}
|
completed={completeArray.length}
|
||||||
total={totalArray.length}
|
total={totalArray.length}
|
||||||
|
onClick={() => {
|
||||||
|
if (filters.assignees?.includes(member.member.id))
|
||||||
|
setFilters({
|
||||||
|
assignees: filters.assignees?.filter((a) => a !== member.member.id),
|
||||||
|
});
|
||||||
|
else
|
||||||
|
setFilters({ assignees: [...(filters?.assignees ?? []), member.member.id] });
|
||||||
|
}}
|
||||||
|
selected={filters.assignees?.includes(member.member.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -222,10 +189,11 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
""
|
""
|
||||||
)}
|
)}
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel as="div" className="flex w-full flex-col ">
|
<Tab.Panel as="div" className="w-full space-y-1">
|
||||||
{issueLabels?.map((issue, index) => {
|
{issueLabels?.map((label, index) => {
|
||||||
const totalArray = issues?.filter((i) => i.labels?.includes(issue.id));
|
const totalArray = issues?.filter((i) => i.labels?.includes(label.id));
|
||||||
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
|
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
|
||||||
|
|
||||||
if (totalArray.length > 0) {
|
if (totalArray.length > 0) {
|
||||||
return (
|
return (
|
||||||
<SingleProgressStats
|
<SingleProgressStats
|
||||||
@ -233,16 +201,25 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="block h-3 w-3 rounded-full "
|
className="block h-3 w-3 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: issue.color,
|
backgroundColor:
|
||||||
|
label.color && label.color !== "" ? label.color : "#000000",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs capitalize">{issue.name}</span>
|
<span className="text-xs capitalize">{label.name}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
completed={completeArray.length}
|
completed={completeArray.length}
|
||||||
total={totalArray.length}
|
total={totalArray.length}
|
||||||
|
onClick={() => {
|
||||||
|
if (filters.labels?.includes(label.id))
|
||||||
|
setFilters({
|
||||||
|
labels: filters.labels?.filter((l) => l !== label.id),
|
||||||
|
});
|
||||||
|
else setFilters({ labels: [...(filters?.labels ?? []), label.id] });
|
||||||
|
}}
|
||||||
|
selected={filters.labels?.includes(label.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -263,7 +240,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
<span className="text-xs capitalize">{group}</span>
|
<span className="text-xs capitalize">{group}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
completed={groupedIssues[group].length}
|
completed={groupedIssues[group]}
|
||||||
total={issues.length}
|
total={issues.length}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
@ -6,17 +6,26 @@ type TSingleProgressStatsProps = {
|
|||||||
title: any;
|
title: any;
|
||||||
completed: number;
|
completed: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
onClick?: () => void;
|
||||||
|
selected?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
|
export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
|
||||||
title,
|
title,
|
||||||
completed,
|
completed,
|
||||||
total,
|
total,
|
||||||
|
onClick,
|
||||||
|
selected = false,
|
||||||
}) => (
|
}) => (
|
||||||
<div className="flex w-full items-center justify-between py-3 text-xs">
|
<div
|
||||||
|
className={`flex w-full items-center justify-between rounded p-2 text-xs ${
|
||||||
|
onClick ? "cursor-pointer hover:bg-gray-100" : ""
|
||||||
|
} ${selected ? "bg-gray-100" : ""}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
<div className="flex w-1/2 items-center justify-start gap-2">{title}</div>
|
<div className="flex w-1/2 items-center justify-start gap-2">{title}</div>
|
||||||
<div className="flex w-1/2 items-center justify-end gap-1 px-2">
|
<div className="flex w-1/2 items-center justify-end gap-1 px-2">
|
||||||
<div className="flex h-5 items-center justify-center gap-1 ">
|
<div className="flex h-5 items-center justify-center gap-1">
|
||||||
<span className="h-4 w-4 ">
|
<span className="h-4 w-4 ">
|
||||||
<ProgressBar value={completed} maxValue={total} />
|
<ProgressBar value={completed} maxValue={total} />
|
||||||
</span>
|
</span>
|
||||||
|
@ -9,12 +9,14 @@ import cyclesService from "services/cycles.service";
|
|||||||
// components
|
// components
|
||||||
import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
|
import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
|
||||||
// icons
|
// icons
|
||||||
import { CompletedCycleIcon } from "components/icons";
|
import { CompletedCycleIcon, ExclamationIcon } from "components/icons";
|
||||||
// types
|
// types
|
||||||
import { ICycle, SelectCycleType } from "types";
|
import { ICycle, SelectCycleType } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { CYCLE_COMPLETE_LIST } from "constants/fetch-keys";
|
import { CYCLE_COMPLETE_LIST } from "constants/fetch-keys";
|
||||||
import { Loader } from "components/ui";
|
import { EmptyState, Loader } from "components/ui";
|
||||||
|
// image
|
||||||
|
import emptyCycle from "public/empty-state/empty-cycle.svg";
|
||||||
|
|
||||||
export interface CompletedCyclesListProps {
|
export interface CompletedCyclesListProps {
|
||||||
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
|
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
@ -61,24 +63,31 @@ export const CompletedCyclesList: React.FC<CompletedCyclesListProps> = ({
|
|||||||
/>
|
/>
|
||||||
{completedCycles ? (
|
{completedCycles ? (
|
||||||
completedCycles.completed_cycles.length > 0 ? (
|
completedCycles.completed_cycles.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
<div className="flex flex-col gap-4">
|
||||||
{completedCycles.completed_cycles.map((cycle) => (
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
<SingleCycleCard
|
<ExclamationIcon height={14} width={14} />
|
||||||
key={cycle.id}
|
<span>Completed cycles are not editable.</span>
|
||||||
cycle={cycle}
|
</div>
|
||||||
handleDeleteCycle={() => handleDeleteCycle(cycle)}
|
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
||||||
handleEditCycle={() => handleEditCycle(cycle)}
|
{completedCycles.completed_cycles.map((cycle) => (
|
||||||
/>
|
<SingleCycleCard
|
||||||
))}
|
key={cycle.id}
|
||||||
|
cycle={cycle}
|
||||||
|
handleDeleteCycle={() => handleDeleteCycle(cycle)}
|
||||||
|
handleEditCycle={() => handleEditCycle(cycle)}
|
||||||
|
isCompleted
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
<EmptyState
|
||||||
<CompletedCycleIcon height="56" width="56" />
|
type="cycle"
|
||||||
<h3 className="text-gray-500">
|
title="Create New Cycle"
|
||||||
No completed cycles yet. Create with{" "}
|
description="Sprint more effectively with Cycles by confining your project
|
||||||
<pre className="inline rounded bg-gray-200 px-2 py-1">Q</pre>.
|
to a fixed amount of time. Create new cycle now."
|
||||||
</h3>
|
imgURL={emptyCycle}
|
||||||
</div>
|
/>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
@ -2,11 +2,13 @@ import { useState } from "react";
|
|||||||
|
|
||||||
// components
|
// components
|
||||||
import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
|
import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
|
||||||
// icons
|
import { EmptyState, Loader } from "components/ui";
|
||||||
import { CompletedCycleIcon, CurrentCycleIcon, UpcomingCycleIcon } from "components/icons";
|
// image
|
||||||
|
import emptyCycle from "public/empty-state/empty-cycle.svg";
|
||||||
|
// icon
|
||||||
|
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
// types
|
// types
|
||||||
import { ICycle, SelectCycleType } from "types";
|
import { ICycle, SelectCycleType } from "types";
|
||||||
import { Loader } from "components/ui";
|
|
||||||
|
|
||||||
type TCycleStatsViewProps = {
|
type TCycleStatsViewProps = {
|
||||||
cycles: ICycle[] | undefined;
|
cycles: ICycle[] | undefined;
|
||||||
@ -23,6 +25,7 @@ export const CyclesList: React.FC<TCycleStatsViewProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
|
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
|
||||||
const [selectedCycleForDelete, setSelectedCycleForDelete] = useState<SelectCycleType>();
|
const [selectedCycleForDelete, setSelectedCycleForDelete] = useState<SelectCycleType>();
|
||||||
|
const [showNoCurrentCycleMessage, setShowNoCurrentCycleMessage] = useState(true);
|
||||||
|
|
||||||
const handleDeleteCycle = (cycle: ICycle) => {
|
const handleDeleteCycle = (cycle: ICycle) => {
|
||||||
setSelectedCycleForDelete({ ...cycle, actionType: "delete" });
|
setSelectedCycleForDelete({ ...cycle, actionType: "delete" });
|
||||||
@ -57,24 +60,27 @@ export const CyclesList: React.FC<TCycleStatsViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : type === "current" ? (
|
||||||
|
showNoCurrentCycleMessage && (
|
||||||
|
<div className="flex items-center justify-between bg-white w-full px-6 py-4 rounded-[10px]">
|
||||||
|
<h3 className="text-base font-medium text-black "> No current cycle is present.</h3>
|
||||||
|
<button onClick={() => setShowNoCurrentCycleMessage(false)}>
|
||||||
|
<XMarkIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
<EmptyState
|
||||||
{type === "upcoming" ? (
|
type="cycle"
|
||||||
<UpcomingCycleIcon height="56" width="56" />
|
title="Create New Cycle"
|
||||||
) : type === "draft" ? (
|
description="Sprint more effectively with Cycles by confining your project
|
||||||
<CompletedCycleIcon height="56" width="56" />
|
to a fixed amount of time. Create new cycle now."
|
||||||
) : (
|
imgURL={emptyCycle}
|
||||||
<CurrentCycleIcon height="56" width="56" />
|
/>
|
||||||
)}
|
|
||||||
<h3 className="text-gray-500">
|
|
||||||
No {type} {type === "current" ? "cycle" : "cycles"} yet. Create with{" "}
|
|
||||||
<pre className="inline rounded bg-gray-200 px-2 py-1">Q</pre>.
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Loader.Item height="300px" />
|
<Loader.Item height="200px" />
|
||||||
</Loader>
|
</Loader>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useState } from "react";
|
||||||
// next
|
// next
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
// swr
|
// swr
|
||||||
@ -10,29 +10,39 @@ import cycleService from "services/cycles.service";
|
|||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// ui
|
// ui
|
||||||
import { Button } from "components/ui";
|
import { DangerButton, SecondaryButton } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
// types
|
// types
|
||||||
import type { ICycle } from "types";
|
import type {
|
||||||
|
CompletedCyclesResponse,
|
||||||
|
CurrentAndUpcomingCyclesResponse,
|
||||||
|
DraftCyclesResponse,
|
||||||
|
ICycle,
|
||||||
|
} from "types";
|
||||||
type TConfirmCycleDeletionProps = {
|
type TConfirmCycleDeletionProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
data?: ICycle;
|
data?: ICycle;
|
||||||
};
|
};
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { CYCLE_LIST } from "constants/fetch-keys";
|
import {
|
||||||
|
CYCLE_COMPLETE_LIST,
|
||||||
|
CYCLE_CURRENT_AND_UPCOMING_LIST,
|
||||||
|
CYCLE_DRAFT_LIST,
|
||||||
|
CYCLE_LIST,
|
||||||
|
} from "constants/fetch-keys";
|
||||||
|
import { getDateRangeStatus } from "helpers/date-time.helper";
|
||||||
|
|
||||||
export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
|
export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
setIsOpen,
|
setIsOpen,
|
||||||
data,
|
data,
|
||||||
}) => {
|
}) => {
|
||||||
const cancelButtonRef = useRef(null);
|
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
@ -42,16 +52,68 @@ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeletion = async () => {
|
const handleDeletion = async () => {
|
||||||
setIsDeleteLoading(true);
|
|
||||||
if (!data || !workspaceSlug) return;
|
if (!data || !workspaceSlug) return;
|
||||||
|
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
await cycleService
|
await cycleService
|
||||||
.deleteCycle(workspaceSlug as string, data.project, data.id)
|
.deleteCycle(workspaceSlug as string, data.project, data.id)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
mutate<ICycle[]>(
|
switch (getDateRangeStatus(data.start_date, data.end_date)) {
|
||||||
CYCLE_LIST(data.project),
|
case "completed":
|
||||||
(prevData) => prevData?.filter((cycle) => cycle.id !== data?.id),
|
mutate<CompletedCyclesResponse>(
|
||||||
false
|
CYCLE_COMPLETE_LIST(projectId as string),
|
||||||
);
|
(prevData) => {
|
||||||
|
if (!prevData) return;
|
||||||
|
|
||||||
|
return {
|
||||||
|
completed_cycles: prevData.completed_cycles?.filter(
|
||||||
|
(cycle) => cycle.id !== data?.id
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "current":
|
||||||
|
mutate<CurrentAndUpcomingCyclesResponse>(
|
||||||
|
CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string),
|
||||||
|
(prevData) => {
|
||||||
|
if (!prevData) return;
|
||||||
|
return {
|
||||||
|
current_cycle: prevData.current_cycle?.filter((c) => c.id !== data?.id),
|
||||||
|
upcoming_cycle: prevData.upcoming_cycle,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "upcoming":
|
||||||
|
mutate<CurrentAndUpcomingCyclesResponse>(
|
||||||
|
CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string),
|
||||||
|
(prevData) => {
|
||||||
|
if (!prevData) return;
|
||||||
|
|
||||||
|
return {
|
||||||
|
current_cycle: prevData.current_cycle,
|
||||||
|
upcoming_cycle: prevData.upcoming_cycle?.filter((c) => c.id !== data?.id),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
mutate<DraftCyclesResponse>(
|
||||||
|
CYCLE_DRAFT_LIST(projectId as string),
|
||||||
|
(prevData) => {
|
||||||
|
if (!prevData) return;
|
||||||
|
return {
|
||||||
|
draft_cycles: prevData.draft_cycles?.filter((cycle) => cycle.id !== data?.id),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
handleClose();
|
handleClose();
|
||||||
|
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
@ -60,20 +122,14 @@ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
|
|||||||
message: "Cycle deleted successfully",
|
message: "Cycle deleted successfully",
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(() => {
|
||||||
console.log(error);
|
|
||||||
setIsDeleteLoading(false);
|
setIsDeleteLoading(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||||
<Dialog
|
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||||
as="div"
|
|
||||||
className="relative z-20"
|
|
||||||
initialFocus={cancelButtonRef}
|
|
||||||
onClose={handleClose}
|
|
||||||
>
|
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={React.Fragment}
|
as={React.Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
@ -97,7 +153,7 @@ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
|
|||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-[40rem]">
|
||||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
<div className="sm:flex sm:items-start">
|
<div className="sm:flex sm:items-start">
|
||||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||||
@ -112,34 +168,19 @@ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
|
|||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Are you sure you want to delete cycle - {`"`}
|
Are you sure you want to delete cycle-{" "}
|
||||||
<span className="italic">{data?.name}</span>
|
<span className="font-bold">{data?.name}</span>? All of the data related
|
||||||
{`"`} ? All of the data related to the cycle will be permanently removed.
|
to the cycle will be permanently removed. This action cannot be undone.
|
||||||
This action cannot be undone.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
|
<div className="flex justify-end gap-2 bg-gray-50 p-4 sm:px-6">
|
||||||
<Button
|
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||||
type="button"
|
<DangerButton onClick={handleDeletion} loading={isDeleteLoading}>
|
||||||
onClick={handleDeletion}
|
|
||||||
theme="danger"
|
|
||||||
disabled={isDeleteLoading}
|
|
||||||
className="inline-flex sm:ml-3"
|
|
||||||
>
|
|
||||||
{isDeleteLoading ? "Deleting..." : "Delete"}
|
{isDeleteLoading ? "Deleting..." : "Delete"}
|
||||||
</Button>
|
</DangerButton>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
theme="secondary"
|
|
||||||
className="inline-flex sm:ml-3"
|
|
||||||
onClick={handleClose}
|
|
||||||
ref={cancelButtonRef}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
78
apps/app/components/cycles/empty-cycle.tsx
Normal file
78
apps/app/components/cycles/empty-cycle.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { LinearProgressIndicator } from "components/ui";
|
||||||
|
|
||||||
|
export const EmptyCycle = () => {
|
||||||
|
const emptyCycleData = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "backlog",
|
||||||
|
value: 20,
|
||||||
|
color: "#DEE2E6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "unstarted",
|
||||||
|
value: 14,
|
||||||
|
color: "#26B5CE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "started",
|
||||||
|
value: 27,
|
||||||
|
color: "#F7AE59",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: "cancelled",
|
||||||
|
value: 15,
|
||||||
|
color: "#D687FF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: "completed",
|
||||||
|
value: 14,
|
||||||
|
color: "#09A953",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-5 ">
|
||||||
|
<div className="relative h-32 w-72">
|
||||||
|
<div className="absolute right-0 top-0 flex w-64 flex-col rounded-[10px] bg-white text-xs shadow">
|
||||||
|
<div className="flex flex-col items-start justify-center gap-2.5 p-3.5">
|
||||||
|
<span className="text-sm font-semibold text-black">Cycle Name</span>
|
||||||
|
<div className="flex h-full w-full items-center gap-4">
|
||||||
|
<span className="h-2 w-20 rounded-full bg-gray-200" />
|
||||||
|
<span className="h-2 w-20 rounded-full bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 bg-gray-100 px-4 py-3">
|
||||||
|
<LinearProgressIndicator data={emptyCycleData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute left-0 bottom-0 flex w-64 flex-col rounded-[10px] bg-white text-xs shadow">
|
||||||
|
<div className="flex flex-col items-start justify-center gap-2.5 p-3.5">
|
||||||
|
<span className="text-sm font-semibold text-black">Cycle Name</span>
|
||||||
|
<div className="flex h-full w-full items-center gap-4">
|
||||||
|
<span className="h-2 w-20 rounded-full bg-gray-200" />
|
||||||
|
<span className="h-2 w-20 rounded-full bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 bg-gray-100 px-4 py-3">
|
||||||
|
<LinearProgressIndicator data={emptyCycleData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 text-center ">
|
||||||
|
<h3 className="text-xl font-semibold">Create New Cycle</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Sprint more effectively with Cycles by confining your project <br /> to a fixed amount of
|
||||||
|
time. Create new cycle now.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user