Merge pull request #643 from makeplane/develop

promote: develop to stage release
This commit is contained in:
Vamsi Kurama 2023-03-31 04:41:24 +05:30 committed by GitHub
commit ed60707bae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
444 changed files with 20963 additions and 11838 deletions

View 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

View 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
View 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

View 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 }}

View 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 }}

View File

@ -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

View File

@ -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

View File

@ -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

View 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__"

View File

@ -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",
]

View File

@ -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)

View 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",
]

View File

@ -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

View File

@ -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

View File

@ -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",
] ]

View File

@ -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

View File

@ -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
] ]

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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,
)

View 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,
)

View 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,
)

View File

@ -2,6 +2,7 @@ from .base import IntegrationViewSet, WorkspaceIntegrationViewSet
from .github import ( from .github import (
GithubRepositorySyncViewSet, GithubRepositorySyncViewSet,
GithubIssueSyncViewSet, GithubIssueSyncViewSet,
BulkCreateGithubIssueSyncEndpoint,
GithubCommentSyncViewSet, GithubCommentSyncViewSet,
GithubRepositoriesEndpoint, GithubRepositoriesEndpoint,
) )

View File

@ -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()

View File

@ -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

View File

@ -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,
)

View File

@ -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

View File

@ -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,

View 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,
)

View File

@ -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,
)

View File

@ -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

View 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,
)

View File

@ -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,
)

View File

@ -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,
)

View 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

View File

@ -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

View File

@ -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

View 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',
),
]

View 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')},
},
),
]

View 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'),
),
]

View File

@ -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

View File

@ -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.

View 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}>"

View File

@ -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"

View 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}"

View File

@ -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"""

View File

@ -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)

View File

@ -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}>"

View File

@ -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 = {

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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):

View 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"}

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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>
</> </>

View File

@ -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>
</> </>
); );
}; };

View File

@ -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>
); );
}; };

View File

@ -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} />
</> </>
); );
}; };

View File

@ -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>

View 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>
))}
</>
);
};

View File

@ -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>
))}
</>
);
};

View 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

View File

@ -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";

View File

@ -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>

View File

@ -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>
)}
</> </>
); );
}; };

View File

@ -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>
); );

View File

@ -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>
); );

View File

@ -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>
)} )}

View File

@ -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>

View 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>
);
};

View File

@ -0,0 +1 @@
export * from "./calendar"

View File

@ -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>

View 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>
);
};

View 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>
);
};

View File

@ -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";

View File

@ -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>

View File

@ -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";

View File

@ -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>
); );
}; };

View File

@ -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>
</> </>
); );
}; };

View File

@ -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>

View File

@ -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>
)}
</>
); );
}; };

View File

@ -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>
</> </>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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}
/> />
))} ))}

View File

@ -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>

View File

@ -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">

View File

@ -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>
)} )}
</> </>

View File

@ -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>

View 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