Compare commits

..
472 changed files with 3995 additions and 10186 deletions
+62 -23
View File
@@ -1,5 +1,6 @@
# Environment Variables
Environment variables are distributed in various files. Please refer them carefully.
## {PROJECT_FOLDER}/.env
@@ -8,13 +9,17 @@ File is available in the project root folder
```
# Database Settings
POSTGRES_USER="plane"
POSTGRES_PASSWORD="plane"
POSTGRES_DB="plane"
PGDATA="/var/lib/postgresql/data"
PGUSER="plane"
PGPASSWORD="plane"
PGHOST="plane-db"
PGDATABASE="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
# Redis Settings
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
@@ -24,39 +29,63 @@ AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # deprecated
# Settings related to Docker
DOCKERIZED=1 # deprecated
# set to 1 If using the pre-configured minio setup
USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
```
## {PROJECT_FOLDER}/web/.env.example
```
# Public boards deploy URL
NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"
```
## {PROJECT_FOLDER}/apiserver/.env
```
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
CORS_ALLOWED_ORIGINS="http://localhost"
# Error logs
SENTRY_DSN=""
SENTRY_ENVIRONMENT="development"
# Database Settings
POSTGRES_USER="plane"
POSTGRES_PASSWORD="plane"
POSTGRES_HOST="plane-db"
POSTGRES_DB="plane"
POSTGRES_PORT=5432
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
PGUSER="plane"
PGPASSWORD="plane"
PGHOST="plane-db"
PGDATABASE="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
# Redis Settings
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/"
# Email Settings
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS="1"
EMAIL_USE_SSL="0"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
@@ -66,25 +95,35 @@ AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # deprecated
# Settings related to Docker
DOCKERIZED=1 # deprecated
DOCKERIZED=1 # Deprecated
# Github
GITHUB_CLIENT_SECRET="" # For fetching release notes
# set to 1 If using the pre-configured minio setup
USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# Email redirections and minio domain settings
# SignUps
ENABLE_SIGNUP="1"
# Email Redirection URL
WEB_URL="http://localhost"
# Gunicorn Workers
GUNICORN_WORKERS=2
# Base URLs
ADMIN_BASE_URL=
SPACE_BASE_URL=
APP_BASE_URL=
SECRET_KEY="gxoytl7dmnc1y37zahah820z5iq3iozu38cnfjtu3yaau9cd9z"
```
## Updates
- The environment variable NEXT_PUBLIC_API_BASE_URL has been removed from both the web and space projects.
- The naming convention for containers and images has been updated.
- The plane-worker image will no longer be maintained, as it has been merged with plane-backend.
- The Tiptap pro-extension dependency has been removed, eliminating the need for Tiptap API keys.
+1 -2
View File
@@ -14,7 +14,6 @@
"@headlessui/react": "^1.7.19",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/constants": "*",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
@@ -47,4 +46,4 @@
"tsconfig": "*",
"typescript": "^5.4.2"
}
}
}
+6 -2
View File
@@ -11,6 +11,7 @@ from rest_framework import serializers
# Module imports
from plane.db.models import (
Issue,
IssueType,
IssueActivity,
IssueAssignee,
IssueAttachment,
@@ -55,6 +56,7 @@ class IssueSerializer(BaseSerializer):
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
exclude = [
@@ -130,9 +132,11 @@ class IssueSerializer(BaseSerializer):
workspace_id = self.context["workspace_id"]
default_assignee_id = self.context["default_assignee_id"]
# Get the issue type from the project
issue_type = IssueType.objects.filter(project_id=project_id).first()
issue = Issue.objects.create(
**validated_data,
project_id=project_id,
**validated_data, project_id=project_id, type=issue_type
)
# Issue Audit Users
-2
View File
@@ -4,7 +4,6 @@ from .issue import urlpatterns as issue_patterns
from .cycle import urlpatterns as cycle_patterns
from .module import urlpatterns as module_patterns
from .inbox import urlpatterns as inbox_patterns
from .member import urlpatterns as member_patterns
urlpatterns = [
*project_patterns,
@@ -13,5 +12,4 @@ urlpatterns = [
*cycle_patterns,
*module_patterns,
*inbox_patterns,
*member_patterns,
]
-6
View File
@@ -7,7 +7,6 @@ from plane.api.views import (
IssueCommentAPIEndpoint,
IssueActivityAPIEndpoint,
WorkspaceIssueAPIEndpoint,
IssueAttachmentEndpoint,
)
urlpatterns = [
@@ -66,9 +65,4 @@ urlpatterns = [
IssueActivityAPIEndpoint.as_view(),
name="activity",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
IssueAttachmentEndpoint.as_view(),
name="attachment",
),
]
-13
View File
@@ -1,13 +0,0 @@
from django.urls import path
from plane.api.views import (
WorkspaceMemberAPIEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/members/",
WorkspaceMemberAPIEndpoint.as_view(),
name="users",
),
]
-3
View File
@@ -9,7 +9,6 @@ from .issue import (
IssueLinkAPIEndpoint,
IssueCommentAPIEndpoint,
IssueActivityAPIEndpoint,
IssueAttachmentEndpoint,
)
from .cycle import (
@@ -25,6 +24,4 @@ from .module import (
ModuleArchiveUnarchiveAPIEndpoint,
)
from .member import WorkspaceMemberAPIEndpoint
from .inbox import InboxIssueAPIEndpoint
+12
View File
@@ -393,6 +393,7 @@ class CycleAPIEndpoint(BaseAPIView):
class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@@ -646,6 +647,17 @@ class CycleIssueAPIEndpoint(BaseAPIView):
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,
)
issues = Issue.objects.filter(
pk__in=issues, workspace__slug=slug, project_id=project_id
).values_list("id", flat=True)
+7 -25
View File
@@ -3,11 +3,8 @@ import json
# Django improts
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Q
from django.utils import timezone
from django.db.models import Q, Value, UUIDField
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
# Third party imports
from rest_framework import status
@@ -19,6 +16,7 @@ from plane.app.permissions import ProjectLitePermission
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Inbox,
IssueType,
InboxIssue,
Issue,
Project,
@@ -144,6 +142,8 @@ class InboxIssueAPIEndpoint(BaseAPIView):
color="#ff7700",
is_triage=True,
)
# Get the issue type
issue_type = IssueType.objects.filter(project_id=project_id).first()
# create an issue
issue = Issue.objects.create(
@@ -155,6 +155,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
priority=request.data.get("issue", {}).get("priority", "none"),
project_id=project_id,
state=state,
type=issue_type,
)
# create an inbox issue
@@ -227,27 +228,8 @@ class InboxIssueAPIEndpoint(BaseAPIView):
issue_data = request.data.pop("issue", False)
if bool(issue_data):
issue = Issue.objects.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
).get(
pk=issue_id,
workspace__slug=slug,
project_id=project_id,
issue = Issue.objects.get(
pk=issue_id, workspace__slug=slug, project_id=project_id
)
# Only allow guests and viewers to edit name and description
if project_member.role <= 10:
-87
View File
@@ -22,11 +22,9 @@ from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser
# Module imports
from plane.api.serializers import (
IssueAttachmentSerializer,
IssueActivitySerializer,
IssueCommentSerializer,
IssueLinkSerializer,
@@ -309,11 +307,6 @@ class IssueAPIEndpoint(BaseAPIView):
)
serializer.save()
# Refetch the issue
issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=serializer.data["id"]).first()
issue.created_at = request.data.get("created_at")
issue.save(update_fields=["created_at"])
# Track the issue
issue_activity.delay(
@@ -881,83 +874,3 @@ class IssueActivityAPIEndpoint(BaseAPIView):
expand=self.expand,
).data,
)
class IssueAttachmentEndpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer
permission_classes = [
ProjectEntityPermission,
]
model = IssueAttachment
parser_classes = (MultiPartParser, FormParser)
def post(self, request, slug, project_id, issue_id):
serializer = IssueAttachmentSerializer(data=request.data)
if (
request.data.get("external_id")
and request.data.get("external_source")
and IssueAttachment.objects.filter(
project_id=project_id,
workspace__slug=slug,
issue_id=issue_id,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).exists()
):
issue_attachment = IssueAttachment.objects.filter(
workspace__slug=slug,
project_id=project_id,
external_id=request.data.get("external_id"),
external_source=request.data.get("external_source"),
).first()
return Response(
{
"error": "Issue attachment with the same external id and external source already exists",
"id": str(issue_attachment.id),
},
status=status.HTTP_409_CONFLICT,
)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
issue_activity.delay(
type="attachment.activity.created",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
serializer.data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment = IssueAttachment.objects.get(pk=pk)
issue_attachment.asset.delete(save=False)
issue_attachment.delete()
issue_activity.delay(
type="attachment.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
def get(self, request, slug, project_id, issue_id):
issue_attachments = IssueAttachment.objects.filter(
issue_id=issue_id, workspace__slug=slug, project_id=project_id
)
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
-147
View File
@@ -1,147 +0,0 @@
# Python imports
import uuid
# Django imports
from django.contrib.auth.hashers import make_password
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .base import BaseAPIView
from plane.api.serializers import UserLiteSerializer
from plane.db.models import (
User,
Workspace,
Project,
WorkspaceMember,
ProjectMember,
)
# API endpoint to get and insert users inside the workspace
class WorkspaceMemberAPIEndpoint(BaseAPIView):
# Get all the users that are present inside the workspace
def get(self, request, slug):
# Check if the workspace exists
if not Workspace.objects.filter(slug=slug).exists():
return Response(
{"error": "Provided workspace does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the workspace members that are present inside the workspace
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug
)
# Get all the users that are present inside the workspace
users = UserLiteSerializer(
User.objects.filter(
id__in=workspace_members.values_list("member_id", flat=True)
),
many=True,
).data
return Response(users, status=status.HTTP_200_OK)
# Insert a new user inside the workspace, and assign the user to the project
def post(self, request, slug):
# Check if user with email already exists, and send bad request if it's
# not present, check for workspace and valid project mandat
# ------------------- Validation -------------------
if (
request.data.get("email") is None
or request.data.get("display_name") is None
or request.data.get("project_id") is None
):
return Response(
{
"error": "Expected email, display_name, workspace_slug, project_id, one or more of the fields are missing."
},
status=status.HTTP_400_BAD_REQUEST,
)
email = request.data.get("email")
try:
validate_email(email)
except ValidationError:
return Response(
{"error": "Invalid email provided"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.filter(slug=slug).first()
project = Project.objects.filter(
pk=request.data.get("project_id")
).first()
if not all([workspace, project]):
return Response(
{"error": "Provided workspace or project does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if user exists
user = User.objects.filter(email=email).first()
workspace_member = None
project_member = None
if user:
# Check if user is part of the workspace
workspace_member = WorkspaceMember.objects.filter(
workspace=workspace, member=user
).first()
if workspace_member:
# Check if user is part of the project
project_member = ProjectMember.objects.filter(
project=project, member=user
).first()
if project_member:
return Response(
{
"error": "User is already part of the workspace and project"
},
status=status.HTTP_400_BAD_REQUEST,
)
# If user does not exist, create the user
if not user:
user = User.objects.create(
email=email,
display_name=request.data.get("display_name"),
first_name=request.data.get("first_name", ""),
last_name=request.data.get("last_name", ""),
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
is_active=False,
)
user.save()
# Create a workspace member for the user if not already a member
if not workspace_member:
workspace_member = WorkspaceMember.objects.create(
workspace=workspace,
member=user,
role=request.data.get("role", 10),
)
workspace_member.save()
# Create a project member for the user if not already a member
if not project_member:
project_member = ProjectMember.objects.create(
project=project,
member=user,
role=request.data.get("role", 10),
)
project_member.save()
# Serialize the user and return the response
user_data = UserLiteSerializer(user).data
return Response(user_data, status=status.HTTP_201_CREATED)
+11 -3
View File
@@ -19,13 +19,14 @@ from plane.app.permissions import ProjectBasePermission
from plane.db.models import (
Cycle,
Inbox,
IssueUserProperty,
IssueProperty,
Module,
Project,
DeployBoard,
ProjectMember,
State,
Workspace,
IssueType,
)
from plane.bgtasks.webhook_task import model_activity
from .base import BaseAPIView
@@ -165,7 +166,7 @@ class ProjectAPIEndpoint(BaseAPIView):
role=20,
)
# Also create the issue property for the user
_ = IssueUserProperty.objects.create(
_ = IssueProperty.objects.create(
project_id=serializer.data["id"],
user=request.user,
)
@@ -179,7 +180,7 @@ class ProjectAPIEndpoint(BaseAPIView):
role=20,
)
# Also create the issue property for the user
IssueUserProperty.objects.create(
IssueProperty.objects.create(
project_id=serializer.data["id"],
user_id=serializer.data["project_lead"],
)
@@ -241,6 +242,13 @@ class ProjectAPIEndpoint(BaseAPIView):
.first()
)
# Create the Issue Types
IssueType.objects.create(
name="Task",
description="A task that needs to be done",
project_id=project.id,
)
# Model activity
model_activity.delay(
model_name="project",
+1 -1
View File
@@ -50,7 +50,7 @@ from .issue import (
IssueCreateSerializer,
IssueActivitySerializer,
IssueCommentSerializer,
IssueUserPropertySerializer,
IssuePropertySerializer,
IssueAssigneeSerializer,
LabelSerializer,
IssueSerializer,
+7 -5
View File
@@ -17,7 +17,7 @@ from plane.db.models import (
Issue,
IssueActivity,
IssueComment,
IssueUserProperty,
IssueProperty,
IssueAssignee,
IssueSubscriber,
IssueLabel,
@@ -33,6 +33,7 @@ from plane.db.models import (
IssueVote,
IssueRelation,
State,
IssueType,
)
@@ -135,10 +136,11 @@ class IssueCreateSerializer(BaseSerializer):
workspace_id = self.context["workspace_id"]
default_assignee_id = self.context["default_assignee_id"]
# Get Issue Type
issue_type = IssueType.objects.filter(project_id=project_id).first()
# Create Issue
issue = Issue.objects.create(
**validated_data,
project_id=project_id,
**validated_data, project_id=project_id, type=issue_type
)
# Issue Audit Users
@@ -252,9 +254,9 @@ class IssueActivitySerializer(BaseSerializer):
fields = "__all__"
class IssueUserPropertySerializer(BaseSerializer):
class IssuePropertySerializer(BaseSerializer):
class Meta:
model = IssueUserProperty
model = IssueProperty
fields = "__all__"
read_only_fields = [
"user",
@@ -12,7 +12,6 @@ class NotificationSerializer(BaseSerializer):
read_only=True, source="triggered_by"
)
is_inbox_issue = serializers.BooleanField(read_only=True)
is_mentioned_notification = serializers.BooleanField(read_only=True)
class Meta:
model = Notification
+2 -2
View File
@@ -23,7 +23,7 @@ class IssueViewSerializer(DynamicBaseSerializer):
]
def create(self, validated_data):
query_params = validated_data.get("filters", {})
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
@@ -31,7 +31,7 @@ class IssueViewSerializer(DynamicBaseSerializer):
return IssueView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("filters", {})
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
+2 -2
View File
@@ -233,13 +233,13 @@ urlpatterns = [
name="project-issue-comment-reactions",
),
## End Comment Reactions
## IssueUserProperty
## IssueProperty
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-properties/",
IssueUserDisplayPropertyEndpoint.as_view(),
name="project-issue-display-properties",
),
## IssueUserProperty End
## IssueProperty End
## Issue Archives
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/",
-10
View File
@@ -66,16 +66,6 @@ urlpatterns = [
),
name="project-pages-lock-unlock",
),
# private and public page
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/access/",
PageViewSet.as_view(
{
"post": "access",
}
),
name="project-pages-access",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/",
PageLogEndpoint.as_view(),
-8
View File
@@ -645,8 +645,6 @@ class CycleViewSet(BaseViewSet):
"progress_snapshot",
"logo_props",
# meta fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"cancelled_issues",
"total_issues",
@@ -656,7 +654,6 @@ class CycleViewSet(BaseViewSet):
"backlog_issues",
"assignee_ids",
"status",
"created_by",
)
.first()
)
@@ -742,8 +739,6 @@ class CycleViewSet(BaseViewSet):
"progress_snapshot",
"logo_props",
# meta fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"total_issues",
"cancelled_issues",
@@ -753,7 +748,6 @@ class CycleViewSet(BaseViewSet):
"backlog_issues",
"assignee_ids",
"status",
"created_by",
).first()
# Send the model activity
@@ -806,8 +800,6 @@ class CycleViewSet(BaseViewSet):
"sub_issues",
"logo_props",
# meta fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"total_issues",
"cancelled_issues",
+1 -3
View File
@@ -41,7 +41,6 @@ class ExportIssuesEndpoint(BaseAPIView):
project=project_ids,
initiated_by=request.user,
provider=provider,
type="issue_exports",
)
issue_export_task.delay(
@@ -66,8 +65,7 @@ class ExportIssuesEndpoint(BaseAPIView):
def get(self, request, slug):
exporter_history = ExporterHistory.objects.filter(
workspace__slug=slug,
type="issue_exports",
workspace__slug=slug
).select_related("workspace", "initiated_by")
if request.GET.get("per_page", False) and request.GET.get(
+7 -38
View File
@@ -32,7 +32,7 @@ from plane.app.permissions import (
from plane.app.serializers import (
IssueCreateSerializer,
IssueDetailSerializer,
IssueUserPropertySerializer,
IssuePropertySerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
@@ -40,7 +40,7 @@ from plane.db.models import (
Issue,
IssueAttachment,
IssueLink,
IssueUserProperty,
IssueProperty,
IssueReaction,
IssueSubscriber,
Project,
@@ -481,38 +481,7 @@ class IssueViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, pk=None):
issue = (
self.get_queryset()
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.filter(pk=pk)
.first()
)
issue = self.get_queryset().filter(pk=pk).first()
if not issue:
return Response(
@@ -570,7 +539,7 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
]
def patch(self, request, slug, project_id):
issue_property = IssueUserProperty.objects.get(
issue_property = IssueProperty.objects.get(
user=request.user,
project_id=project_id,
)
@@ -585,14 +554,14 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
"display_properties", issue_property.display_properties
)
issue_property.save()
serializer = IssueUserPropertySerializer(issue_property)
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug, project_id):
issue_property, _ = IssueUserProperty.objects.get_or_create(
issue_property, _ = IssueProperty.objects.get_or_create(
user=request.user, project_id=project_id
)
serializer = IssueUserPropertySerializer(issue_property)
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_200_OK)
+23 -2
View File
@@ -37,6 +37,24 @@ class IssueRelationViewSet(BaseViewSet):
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(issue_id=self.kwargs.get("issue_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.select_related("project")
.select_related("workspace")
.select_related("issue")
.distinct()
)
def list(self, request, slug, project_id, issue_id):
issue_relations = (
IssueRelation.objects.filter(
@@ -80,8 +98,11 @@ class IssueRelationViewSet(BaseViewSet):
).values_list("issue_id", flat=True)
queryset = (
Issue.issue_objects
.filter(workspace__slug=slug)
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
@@ -1,5 +1,5 @@
# Django imports
from django.db.models import Exists, OuterRef, Q, Case, When, BooleanField
from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
# Third party imports
@@ -60,13 +60,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
)
.filter(entity_name="issue")
.annotate(is_inbox_issue=Exists(inbox_issue))
.annotate(
is_mentioned_notification=Case(
When(sender__icontains="mentioned", then=True),
default=False,
output_field=BooleanField(),
)
)
.select_related("workspace", "project", "triggered_by", "receiver")
.order_by("snoozed_till", "-created_at")
)
-28
View File
@@ -245,28 +245,6 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
def access(self, request, slug, project_id, pk):
access = request.data.get("access", 0)
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
).first()
# Only update access if the page owner is the requesting user
if (
page.access != request.data.get("access", page.access)
and page.owned_by_id != request.user.id
):
return Response(
{
"error": "Access cannot be updated since this page is owned by someone else"
},
status=status.HTTP_400_BAD_REQUEST,
)
page.access = access
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
pages = PageSerializer(queryset, many=True).data
@@ -492,12 +470,6 @@ class PagesDescriptionViewSet(BaseViewSet):
.first()
)
if page is None:
return Response(
{"error": "Page not found"},
status=404,
)
if page.is_locked:
return Response(
{"error": "Page is locked"},
+11 -4
View File
@@ -39,7 +39,7 @@ from plane.db.models import (
Cycle,
Inbox,
DeployBoard,
IssueUserProperty,
IssueProperty,
Issue,
Module,
Project,
@@ -47,6 +47,7 @@ from plane.db.models import (
ProjectMember,
State,
Workspace,
IssueType,
)
from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity
@@ -266,7 +267,7 @@ class ProjectViewSet(BaseViewSet):
role=20,
)
# Also create the issue property for the user
_ = IssueUserProperty.objects.create(
_ = IssueProperty.objects.create(
project_id=serializer.data["id"],
user=request.user,
)
@@ -280,7 +281,7 @@ class ProjectViewSet(BaseViewSet):
role=20,
)
# Also create the issue property for the user
IssueUserProperty.objects.create(
IssueProperty.objects.create(
project_id=serializer.data["id"],
user_id=serializer.data["project_lead"],
)
@@ -342,7 +343,13 @@ class ProjectViewSet(BaseViewSet):
.first()
)
# Create the model activity
# Create the issue type
IssueType.objects.create(
name="Task",
description="A task that needs to be done",
project_id=project.id,
)
model_activity.delay(
model_name="project",
model_id=str(project.id),
+3 -3
View File
@@ -25,7 +25,7 @@ from plane.db.models import (
ProjectMemberInvite,
User,
WorkspaceMember,
IssueUserProperty,
IssueProperty,
)
@@ -179,9 +179,9 @@ class UserProjectInvitationsViewset(BaseViewSet):
ignore_conflicts=True,
)
IssueUserProperty.objects.bulk_create(
IssueProperty.objects.bulk_create(
[
IssueUserProperty(
IssueProperty(
project_id=project_id,
user=request.user,
workspace=workspace,
+5 -5
View File
@@ -22,7 +22,7 @@ from plane.db.models import (
ProjectMember,
Workspace,
TeamMember,
IssueUserProperty,
IssueProperty,
)
from plane.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host
@@ -136,7 +136,7 @@ class ProjectMemberViewSet(BaseViewSet):
)
# Create a new issue property
bulk_issue_props.append(
IssueUserProperty(
IssueProperty(
user_id=member.get("member_id"),
project_id=project_id,
workspace_id=project.workspace_id,
@@ -150,7 +150,7 @@ class ProjectMemberViewSet(BaseViewSet):
ignore_conflicts=True,
)
_ = IssueUserProperty.objects.bulk_create(
_ = IssueProperty.objects.bulk_create(
bulk_issue_props, batch_size=10, ignore_conflicts=True
)
@@ -323,7 +323,7 @@ class AddTeamToProjectEndpoint(BaseAPIView):
)
)
issue_props.append(
IssueUserProperty(
IssueProperty(
project_id=project_id,
user_id=member,
workspace=workspace,
@@ -335,7 +335,7 @@ class AddTeamToProjectEndpoint(BaseAPIView):
project_members, batch_size=10, ignore_conflicts=True
)
_ = IssueUserProperty.objects.bulk_create(
_ = IssueProperty.objects.bulk_create(
issue_props, batch_size=10, ignore_conflicts=True
)
@@ -3,7 +3,7 @@ from plane.db.models import Profile, Workspace, WorkspaceMemberInvite
def get_redirection_path(user):
# Handle redirections
profile, _ = Profile.objects.get_or_create(user=user)
profile = Profile.objects.get(user=user)
# Redirect to onboarding if the user is not onboarded yet
if not profile.is_onboarded:
@@ -120,7 +120,7 @@ class MagicSignInEndpoint(View):
callback=post_user_auth_workflow,
)
user = provider.authenticate()
profile, _ = Profile.objects.get_or_create(user=user)
profile = Profile.objects.get(user=user)
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
if user.is_password_autoset and profile.is_onboarded:
@@ -21,6 +21,7 @@ from plane.db.models import (
Cycle,
Module,
Issue,
IssueType,
IssueSequence,
IssueAssignee,
IssueLabel,
@@ -336,6 +337,12 @@ def create_issues(workspace, project, user_id, issue_count):
65535 if largest_sort_order is None else largest_sort_order + 10000
)
issue_type = IssueType.objects.create(
name="Task",
description="A task that needs to be completed.",
project=project,
)
for _ in range(0, issue_count):
start_date = [None, fake.date_this_year()][random.randint(0, 1)]
end_date = (
@@ -364,6 +371,7 @@ def create_issues(workspace, project, user_id, issue_count):
random.randint(0, 4)
],
created_by_id=creators[random.randint(0, len(creators) - 1)],
type=issue_type,
)
)
+12 -18
View File
@@ -582,18 +582,17 @@ def create_issue_activity(
issue_activities,
epoch,
):
issue = Issue.objects.get(pk=issue_id)
issue_activity = IssueActivity.objects.create(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="created the issue",
verb="created",
actor_id=actor_id,
epoch=epoch,
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="created the issue",
verb="created",
actor_id=actor_id,
epoch=epoch,
)
)
issue_activity.created_at = issue.created_at
issue_activity.save(update_fields=["created_at"])
requested_data = (
json.loads(requested_data) if requested_data is not None else None
)
@@ -1392,7 +1391,6 @@ def create_issue_relation_activity(
workspace_id=workspace_id,
comment=f"added {requested_data.get('relation_type')} relation",
old_identifier=related_issue,
epoch=epoch,
)
)
issue = Issue.objects.get(pk=issue_id)
@@ -1718,16 +1716,12 @@ def issue_activity(
event=(
"issue_comment"
if activity.field == "comment"
else "inbox_issue"
if inbox
else "issue"
else "inbox_issue" if inbox else "issue"
),
event_id=(
activity.issue_comment_id
if activity.field == "comment"
else inbox
if inbox
else activity.issue_id
else inbox if inbox else activity.issue_id
),
verb=activity.verb,
field=(
+1 -1
View File
@@ -30,7 +30,7 @@ def page_version(
workspace_id=page.workspace_id,
description_html=page.description_html,
description_binary=page.description_binary,
owned_by_id=user_id,
ownned_by_id=user_id,
last_saved_at=page.updated_at,
)
@@ -161,7 +161,7 @@ class Migration(migrations.Migration):
options={
"verbose_name": "Workspace User Property",
"verbose_name_plural": "Workspace User Property",
"db_table": "workspace_user_properties",
"db_table": "Workspace_user_properties",
"ordering": ("-created_at",),
"unique_together": {("workspace", "user")},
},
@@ -1,106 +0,0 @@
# Generated by Django 4.2.11 on 2024-07-10 13:59
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0069_alter_account_provider_and_more'),
]
operations = [
migrations.AddField(
model_name='apitoken',
name='is_service',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='exporterhistory',
name='filters',
field=models.JSONField(blank=True, null=True),
),
migrations.AddField(
model_name='exporterhistory',
name='name',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Exporter Name'),
),
migrations.AddField(
model_name='exporterhistory',
name='type',
field=models.CharField(choices=[('issue_exports', 'Issue Exports'), ('issue_worklogs', 'Issue Worklogs')], default='issue_exports', max_length=50),
),
migrations.AddField(
model_name='project',
name='is_time_tracking_enabled',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='project',
name='start_date',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='project',
name='target_date',
field=models.DateTimeField(blank=True, null=True),
),
migrations.CreateModel(
name='PageVersion',
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)),
('last_saved_at', models.DateTimeField(default=django.utils.timezone.now)),
('description_binary', models.BinaryField(null=True)),
('description_html', models.TextField(blank=True, default='<p></p>')),
('description_stripped', models.TextField(blank=True, null=True)),
('description_json', models.JSONField(blank=True, default=dict)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_versions', to=settings.AUTH_USER_MODEL)),
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_versions', to='db.page')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_versions', to='db.workspace')),
],
options={
'verbose_name': 'Page Version',
'verbose_name_plural': 'Page Versions',
'db_table': 'page_versions',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='IssueType',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.CharField(max_length=255)),
('description', models.TextField(blank=True)),
('logo_props', models.JSONField(default=dict)),
('sort_order', models.FloatField(default=65535)),
('is_default', models.BooleanField(default=True)),
('weight', models.PositiveIntegerField(default=0)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('project', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_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_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Issue Type',
'verbose_name_plural': 'Issue Types',
'db_table': 'issue_types',
'ordering': ('sort_order',),
'unique_together': {('project', 'name')},
},
),
migrations.AddField(
model_name='issue',
name='type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_type', to='db.issuetype'),
),
]
@@ -0,0 +1,275 @@
# Generated by Django 4.2.11 on 2024-07-01 06:10
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
def create_issue_types(apps, schema_editor):
Project = apps.get_model("db", "Project")
Issue = apps.get_model("db", "Issue")
IssueType = apps.get_model("db", "IssueType")
# Create the issue types for all projects
IssueType.objects.bulk_create(
[
IssueType(
name="Task",
description="A task that needs to be completed.",
project_id=project["id"],
workspace_id=project["workspace_id"],
)
for project in Project.objects.values("id", "workspace_id")
],
batch_size=1000,
)
# Update the issue type for all existing issues
issue_types = {
str(issue_type["project_id"]): str(issue_type["id"])
for issue_type in IssueType.objects.values("id", "project_id")
}
# Update the issue type for all existing issues
bulk_issues = []
for issue in Issue.objects.all():
issue.type_id = issue_types[str(issue.project_id)]
bulk_issues.append(issue)
# Update the issue type for all existing issues
Issue.objects.bulk_update(bulk_issues, ["type_id"], batch_size=1000)
def create_page_versions(apps, schema_editor):
Page = apps.get_model("db", "Page")
PageVersion = apps.get_model("db", "PageVersion")
# Create the page versions for all pages
PageVersion.objects.bulk_create(
[
PageVersion(
page_id=page["id"],
workspace_id=page["workspace_id"],
description_html=page["description_html"],
description_binary=page["description_binary"],
description_stripped=page["description_stripped"],
ownned_by_id=page["owned_by_id"],
last_saved_at=page["updated_at"],
)
for page in Page.objects.values(
"id",
"workspace_id",
"description_html",
"description_binary",
"description_stripped",
"owned_by_id",
"updated_at",
)
],
batch_size=1000,
)
class Migration(migrations.Migration):
dependencies = [
("db", "0069_alter_account_provider_and_more"),
]
operations = [
migrations.CreateModel(
name="IssueType",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True)),
("logo_props", models.JSONField(default=dict)),
("sort_order", models.FloatField(default=65535)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_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_%(class)s",
to="db.workspace",
),
),
(
"is_default",
models.BooleanField(default=True),
),
],
options={
"verbose_name": "Issue Type",
"verbose_name_plural": "Issue Types",
"db_table": "issue_types",
"ordering": ("sort_order",),
},
),
migrations.AddField(
model_name="issue",
name="type",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="issue_type",
to="db.issuetype",
),
),
migrations.AddField(
model_name="apitoken",
name="is_service",
field=models.BooleanField(default=False),
),
migrations.RunPython(create_issue_types),
migrations.CreateModel(
name="PageVersion",
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,
),
),
(
"last_saved_at",
models.DateTimeField(default=django.utils.timezone.now),
),
("description_binary", models.BinaryField(null=True)),
(
"description_html",
models.TextField(blank=True, default="<p></p>"),
),
(
"description_stripped",
models.TextField(blank=True, null=True),
),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"ownned_by",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="page_versions",
to=settings.AUTH_USER_MODEL,
),
),
(
"page",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="page_versions",
to="db.page",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="page_versions",
to="db.workspace",
),
),
],
options={
"verbose_name": "Page Version",
"verbose_name_plural": "Page Versions",
"db_table": "page_versions",
"ordering": ("-created_at",),
},
),
migrations.AddField(
model_name="project",
name="start_date",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="project",
name="target_date",
field=models.DateTimeField(blank=True, null=True),
),
migrations.RunPython(create_page_versions),
]
@@ -1,44 +0,0 @@
# Generated by Django 4.2.11 on 2024-07-15 06:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0070_apitoken_is_service_exporterhistory_filters_and_more"),
]
operations = [
migrations.RenameModel(
old_name="IssueProperty",
new_name="IssueUserProperty",
),
migrations.AlterModelOptions(
name="issueuserproperty",
options={
"ordering": ("-created_at",),
"verbose_name": "Issue User Property",
"verbose_name_plural": "Issue User Properties",
},
),
migrations.AlterModelTable(
name="issueuserproperty",
table="issue_user_properties",
),
migrations.AddField(
model_name="issuetype",
name="is_active",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="project",
name="is_issue_type_enabled",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="issuetype",
name="is_default",
field=models.BooleanField(default=False),
),
]
@@ -1,145 +0,0 @@
# Generated by Django 4.2.14 on 2024-07-22 13:22
from django.db import migrations, models
from django.conf import settings
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
("db", "0071_rename_issueproperty_issueuserproperty_and_more"),
]
operations = [
migrations.AddField(
model_name="issueattachment",
name="external_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="issueattachment",
name="external_source",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.CreateModel(
name="UserRecentVisit",
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,
),
),
("entity_identifier", models.UUIDField(null=True)),
(
"entity_name",
models.CharField(
choices=[
("VIEW", "View"),
("PAGE", "Page"),
("ISSUE", "Issue"),
("CYCLE", "Cycle"),
("MODULE", "Module"),
("PROJECT", "Project"),
],
max_length=30,
),
),
("visited_at", models.DateTimeField(auto_now=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_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_recent_visit",
to=settings.AUTH_USER_MODEL,
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "User Recent Visit",
"verbose_name_plural": "User Recent Visits",
"db_table": "user_recent_visits",
"ordering": ("-created_at",),
},
),
migrations.RemoveField(
model_name="project",
name="start_date",
),
migrations.RemoveField(
model_name="project",
name="target_date",
),
migrations.AlterField(
model_name="issuesequence",
name="sequence",
field=models.PositiveBigIntegerField(db_index=True, default=1),
),
migrations.AlterField(
model_name="project",
name="identifier",
field=models.CharField(
db_index=True, max_length=12, verbose_name="Project Identifier"
),
),
migrations.AlterField(
model_name="projectidentifier",
name="name",
field=models.CharField(db_index=True, max_length=12),
),
]
+1 -3
View File
@@ -29,7 +29,7 @@ from .issue import (
IssueLabel,
IssueLink,
IssueMention,
IssueUserProperty,
IssueProperty,
IssueReaction,
IssueRelation,
IssueSequence,
@@ -110,5 +110,3 @@ from .dashboard import Dashboard, DashboardWidget, Widget
from .favorite import UserFavorite
from .issue_type import IssueType
from .recent_visit import UserRecentVisit
-12
View File
@@ -18,17 +18,6 @@ def generate_token():
class ExporterHistory(BaseModel):
name = models.CharField(
max_length=255, verbose_name="Exporter Name", null=True, blank=True
)
type = models.CharField(
max_length=50,
default="issue_exports",
choices=(
("issue_exports", "Issue Exports"),
("issue_worklogs", "Issue Worklogs"),
),
)
workspace = models.ForeignKey(
"db.WorkSpace",
on_delete=models.CASCADE,
@@ -66,7 +55,6 @@ class ExporterHistory(BaseModel):
on_delete=models.CASCADE,
related_name="workspace_exporters",
)
filters = models.JSONField(blank=True, null=True)
class Meta:
verbose_name = "Exporter"
+45 -45
View File
@@ -6,7 +6,9 @@ from django.conf import settings
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
# Module imports
@@ -180,6 +182,7 @@ class Issue(ProjectBaseModel):
ordering = ("-created_at",)
def save(self, *args, **kwargs):
# This means that the model isn't saved to the database yet
if self.state is None:
try:
from plane.db.models import State
@@ -189,6 +192,7 @@ class Issue(ProjectBaseModel):
project=self.project,
default=True,
).first()
# if there is no default state assign any random state
if default_state is None:
random_state = State.objects.filter(
~models.Q(is_triage=True), project=self.project
@@ -202,6 +206,7 @@ class Issue(ProjectBaseModel):
try:
from plane.db.models import State
# Check if the current issue state group is completed or not
if self.state.group == "completed":
self.completed_at = timezone.now()
else:
@@ -210,44 +215,30 @@ class Issue(ProjectBaseModel):
pass
if self._state.adding:
with transaction.atomic():
last_sequence = (
IssueSequence.objects.filter(project=self.project)
.select_for_update()
.aggregate(largest=models.Max("sequence"))["largest"]
)
self.sequence_id = last_sequence + 1 if last_sequence else 1
# 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)
)
largest_sort_order = Issue.objects.filter(
project=self.project, state=self.state
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
# Get the maximum display_id value from the database
last_id = IssueSequence.objects.filter(
project=self.project
).aggregate(largest=models.Max("sequence"))["largest"]
# aggregate can return None! Check it first.
# If it isn't none, just use the last ID specified (which should be the greatest) and add one to it
if last_id:
self.sequence_id = last_id + 1
else:
self.sequence_id = 1
super(Issue, self).save(*args, **kwargs)
largest_sort_order = Issue.objects.filter(
project=self.project, state=self.state
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
IssueSequence.objects.create(
issue=self, sequence=self.sequence_id, project=self.project
)
else:
# 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)
)
super(Issue, self).save(*args, **kwargs)
# 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)
)
super(Issue, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the issue"""
@@ -384,8 +375,6 @@ class IssueAttachment(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="issue_attachment"
)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
class Meta:
verbose_name = "Issue Attachment"
@@ -493,7 +482,7 @@ class IssueComment(ProjectBaseModel):
return str(self.issue)
class IssueUserProperty(ProjectBaseModel):
class IssueProperty(ProjectBaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
@@ -506,9 +495,9 @@ class IssueUserProperty(ProjectBaseModel):
)
class Meta:
verbose_name = "Issue User Property"
verbose_name_plural = "Issue User Properties"
db_table = "issue_user_properties"
verbose_name = "Issue Property"
verbose_name_plural = "Issue Properties"
db_table = "issue_properties"
ordering = ("-created_at",)
unique_together = ["user", "project"]
@@ -578,9 +567,9 @@ class IssueSequence(ProjectBaseModel):
Issue,
on_delete=models.SET_NULL,
related_name="issue_sequence",
null=True, # This is set to null because we want to keep the sequence even if the issue is deleted
null=True,
)
sequence = models.PositiveBigIntegerField(default=1, db_index=True)
sequence = models.PositiveBigIntegerField(default=1)
deleted = models.BooleanField(default=False)
class Meta:
@@ -686,3 +675,14 @@ class IssueVote(ProjectBaseModel):
def __str__(self):
return f"{self.issue.name} {self.actor.email}"
# TODO: Find a better method to save the model
@receiver(post_save, sender=Issue)
def create_issue_sequence(sender, instance, created, **kwargs):
if created:
IssueSequence.objects.create(
issue=instance,
sequence=instance.sequence_id,
project=instance.project,
)
+1 -4
View File
@@ -10,12 +10,9 @@ class IssueType(WorkspaceBaseModel):
description = models.TextField(blank=True)
logo_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
is_default = models.BooleanField(default=False)
weight = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
is_default = models.BooleanField(default=True)
class Meta:
unique_together = ["project", "name"]
verbose_name = "Issue Type"
verbose_name_plural = "Issue Types"
db_table = "issue_types"
+1 -2
View File
@@ -273,7 +273,7 @@ class PageVersion(BaseModel):
related_name="page_versions",
)
last_saved_at = models.DateTimeField(default=timezone.now)
owned_by = models.ForeignKey(
ownned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="page_versions",
@@ -281,7 +281,6 @@ class PageVersion(BaseModel):
description_binary = models.BinaryField(null=True)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_json = models.JSONField(default=dict, blank=True)
class Meta:
verbose_name = "Page Version"
+4 -4
View File
@@ -72,7 +72,6 @@ class Project(BaseModel):
identifier = models.CharField(
max_length=12,
verbose_name="Project Identifier",
db_index=True,
)
default_assignee = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -95,8 +94,6 @@ class Project(BaseModel):
issue_views_view = models.BooleanField(default=True)
page_view = models.BooleanField(default=True)
inbox_view = models.BooleanField(default=False)
is_time_tracking_enabled = models.BooleanField(default=False)
is_issue_type_enabled = models.BooleanField(default=False)
cover_image = models.URLField(blank=True, null=True, max_length=800)
estimate = models.ForeignKey(
"db.Estimate",
@@ -118,6 +115,9 @@ class Project(BaseModel):
related_name="default_state",
)
archived_at = models.DateTimeField(null=True)
# Project start and target date
start_date = models.DateTimeField(null=True, blank=True)
target_date = models.DateTimeField(null=True, blank=True)
def __str__(self):
"""Return name of the project"""
@@ -220,7 +220,7 @@ class ProjectIdentifier(AuditModel):
project = models.OneToOneField(
Project, on_delete=models.CASCADE, related_name="project_identifier"
)
name = models.CharField(max_length=12, db_index=True)
name = models.CharField(max_length=12)
class Meta:
unique_together = ["name", "workspace"]
-38
View File
@@ -1,38 +0,0 @@
# Django imports
from django.db import models
from django.conf import settings
# Module imports
from .workspace import WorkspaceBaseModel
class EntityNameEnum(models.TextChoices):
VIEW = "VIEW", "View"
PAGE = "PAGE", "Page"
ISSUE = "ISSUE", "Issue"
CYCLE = "CYCLE", "Cycle"
MODULE = "MODULE", "Module"
PROJECT = "PROJECT", "Project"
class UserRecentVisit(WorkspaceBaseModel):
entity_identifier = models.UUIDField(null=True)
entity_name = models.CharField(
max_length=30,
choices=EntityNameEnum.choices,
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="user_recent_visit",
)
visited_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "User Recent Visit"
verbose_name_plural = "User Recent Visits"
db_table = "user_recent_visits"
ordering = ("-created_at",)
def __str__(self):
return f"{self.entity_name} {self.user.email}"
-21
View File
@@ -6,7 +6,6 @@ from django.db import models
from .base import BaseModel
from .project import ProjectBaseModel
from .workspace import WorkspaceBaseModel
from plane.utils.issue_filters import issue_filters
def get_default_filters():
@@ -117,26 +116,6 @@ class IssueView(WorkspaceBaseModel):
db_table = "issue_views"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
query_params = self.filters
self.query = (
issue_filters(query_params, "POST") if query_params else {}
)
if self._state.adding:
if self.project:
largest_sort_order = IssueView.objects.filter(
project=self.project
).aggregate(largest=models.Max("sort_order"))["largest"]
else:
largest_sort_order = IssueView.objects.filter(
workspace=self.workspace, project__isnull=True
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(IssueView, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the View"""
return f"{self.name} <{self.project.name}>"
+1 -1
View File
@@ -294,7 +294,7 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get(
send_default_pii=True,
environment=os.environ.get("SENTRY_ENVIRONMENT", "development"),
profiles_sample_rate=float(
os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0)
os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0.5)
),
)
+1 -5
View File
@@ -1,9 +1,5 @@
from .user import UserLiteSerializer
from .issue import (
LabelLiteSerializer,
StateLiteSerializer,
IssuePublicSerializer,
)
from .issue import LabelLiteSerializer, StateLiteSerializer
from .state import StateSerializer, StateLiteSerializer
+23 -23
View File
@@ -188,16 +188,11 @@ class IssueAttachmentSerializer(BaseSerializer):
class IssueReactionSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueReaction
fields = [
"issue",
"reaction",
"workspace",
"project",
"actor",
]
fields = "__all__"
read_only_fields = [
"workspace",
"project",
@@ -459,6 +454,20 @@ class IssueCreateSerializer(BaseSerializer):
return super().update(instance, validated_data)
class IssueReactionSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueReaction
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"issue",
"actor",
]
class CommentReactionSerializer(BaseSerializer):
class Meta:
model = CommentReaction
@@ -467,6 +476,7 @@ class CommentReactionSerializer(BaseSerializer):
class IssueVoteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueVote
@@ -476,45 +486,35 @@ class IssueVoteSerializer(BaseSerializer):
"workspace",
"project",
"actor",
"actor_detail",
]
read_only_fields = fields
class IssuePublicSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateLiteSerializer(read_only=True, source="state")
reactions = IssueReactionSerializer(
read_only=True, many=True, source="issue_reactions"
)
votes = IssueVoteSerializer(read_only=True, many=True)
module_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
label_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
assignee_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
class Meta:
model = Issue
fields = [
"id",
"name",
"description_html",
"sequence_id",
"state",
"state_detail",
"project",
"project_detail",
"workspace",
"priority",
"target_date",
"reactions",
"votes",
"module_ids",
"created_by",
"label_ids",
"assignee_ids",
]
read_only_fields = fields
+12
View File
@@ -3,6 +3,7 @@ from django.urls import path
from plane.space.views import (
InboxIssuePublicViewSet,
IssueVotePublicViewSet,
WorkspaceProjectDeployBoardEndpoint,
)
@@ -29,6 +30,17 @@ urlpatterns = [
),
name="inbox-issue",
),
path(
"anchor/<str:anchor>/issues/<uuid:issue_id>/votes/",
IssueVotePublicViewSet.as_view(
{
"get": "list",
"post": "create",
"delete": "destroy",
}
),
name="issue-vote-project-board",
),
path(
"workspaces/<str:slug>/project-boards/",
WorkspaceProjectDeployBoardEndpoint.as_view(),
-12
View File
@@ -6,7 +6,6 @@ from plane.space.views import (
IssueCommentPublicViewSet,
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
IssueVotePublicViewSet,
)
urlpatterns = [
@@ -74,15 +73,4 @@ urlpatterns = [
),
name="comment-reactions-project-board",
),
path(
"anchor/<str:anchor>/issues/<uuid:issue_id>/votes/",
IssueVotePublicViewSet.as_view(
{
"get": "list",
"post": "create",
"delete": "destroy",
}
),
name="issue-vote-project-board",
),
]
-30
View File
@@ -5,11 +5,6 @@ from plane.space.views import (
ProjectDeployBoardPublicSettingsEndpoint,
ProjectIssuesPublicEndpoint,
WorkspaceProjectAnchorEndpoint,
ProjectCyclesEndpoint,
ProjectModulesEndpoint,
ProjectStatesEndpoint,
ProjectLabelsEndpoint,
ProjectMembersEndpoint,
)
urlpatterns = [
@@ -28,29 +23,4 @@ urlpatterns = [
WorkspaceProjectAnchorEndpoint.as_view(),
name="project-deploy-board",
),
path(
"anchor/<str:anchor>/cycles/",
ProjectCyclesEndpoint.as_view(),
name="project-cycles",
),
path(
"anchor/<str:anchor>/modules/",
ProjectModulesEndpoint.as_view(),
name="project-modules",
),
path(
"anchor/<str:anchor>/states/",
ProjectStatesEndpoint.as_view(),
name="project-states",
),
path(
"anchor/<str:anchor>/labels/",
ProjectLabelsEndpoint.as_view(),
name="project-labels",
),
path(
"anchor/<str:anchor>/members/",
ProjectMembersEndpoint.as_view(),
name="project-members",
),
]
-248
View File
@@ -1,248 +0,0 @@
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Q, UUIDField, Value, F, Case, When, JSONField
from django.db.models.functions import Coalesce, JSONObject
# Module imports
from plane.db.models import (
Cycle,
Issue,
Label,
Module,
Project,
ProjectMember,
State,
WorkspaceMember,
)
def issue_queryset_grouper(queryset, group_by, sub_group_by):
FIELD_MAPPER = {
"label_ids": "labels__id",
"assignee_ids": "assignees__id",
"module_ids": "issue_module__module_id",
}
annotations_map = {
"assignee_ids": ("assignees__id", ~Q(assignees__id__isnull=True)),
"label_ids": ("labels__id", ~Q(labels__id__isnull=True)),
"module_ids": (
"issue_module__module_id",
~Q(issue_module__module_id__isnull=True),
),
}
default_annotations = {
key: Coalesce(
ArrayAgg(
field,
distinct=True,
filter=condition,
),
Value([], output_field=ArrayField(UUIDField())),
)
for key, (field, condition) in annotations_map.items()
if FIELD_MAPPER.get(key) != group_by
or FIELD_MAPPER.get(key) != sub_group_by
}
return queryset.annotate(**default_annotations)
def issue_on_results(issues, group_by, sub_group_by):
FIELD_MAPPER = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",
"issue_module__module_id": "module_ids",
}
original_list = ["assignee_ids", "label_ids", "module_ids"]
required_fields = [
"id",
"name",
"state_id",
"sort_order",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"created_by",
"state__group",
]
if group_by in FIELD_MAPPER:
original_list.remove(FIELD_MAPPER[group_by])
original_list.append(group_by)
if sub_group_by in FIELD_MAPPER:
original_list.remove(FIELD_MAPPER[sub_group_by])
original_list.append(sub_group_by)
required_fields.extend(original_list)
issues = issues.annotate(
vote_items=ArrayAgg(
Case(
When(
votes__isnull=False,
then=JSONObject(
vote=F("votes__vote"),
actor_details=JSONObject(
id=F("votes__actor__id"),
first_name=F("votes__actor__first_name"),
last_name=F("votes__actor__last_name"),
avatar=F("votes__actor__avatar"),
display_name=F("votes__actor__display_name"),
)
),
),
default=None,
output_field=JSONField(),
),
filter=Case(
When(votes__isnull=False, then=True),
default=False,
output_field=JSONField(),
),
distinct=True,
),
reaction_items=ArrayAgg(
Case(
When(
issue_reactions__isnull=False,
then=JSONObject(
reaction=F("issue_reactions__reaction"),
actor_details=JSONObject(
id=F("issue_reactions__actor__id"),
first_name=F("issue_reactions__actor__first_name"),
last_name=F("issue_reactions__actor__last_name"),
avatar=F("issue_reactions__actor__avatar"),
display_name=F("issue_reactions__actor__display_name"),
),
),
),
default=None,
output_field=JSONField(),
),
filter=Case(
When(issue_reactions__isnull=False, then=True),
default=False,
output_field=JSONField(),
),
distinct=True,
),
).values(*required_fields, "vote_items", "reaction_items")
return issues
def issue_group_values(field, slug, project_id=None, filters=dict):
if field == "state_id":
queryset = State.objects.filter(
is_triage=False,
workspace__slug=slug,
).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "labels__id":
queryset = Label.objects.filter(workspace__slug=slug).values_list(
"id", flat=True
)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "assignees__id":
if project_id:
return ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
is_active=True,
).values_list("member_id", flat=True)
else:
return list(
WorkspaceMember.objects.filter(
workspace__slug=slug, is_active=True
).values_list("member_id", flat=True)
)
if field == "issue_module__module_id":
queryset = Module.objects.filter(
workspace__slug=slug,
).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "cycle_id":
queryset = Cycle.objects.filter(
workspace__slug=slug,
).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "project_id":
queryset = Project.objects.filter(workspace__slug=slug).values_list(
"id", flat=True
)
return list(queryset)
if field == "priority":
return [
"low",
"medium",
"high",
"urgent",
"none",
]
if field == "state__group":
return [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
if field == "target_date":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("target_date", flat=True)
.distinct()
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "start_date":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("start_date", flat=True)
.distinct()
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "created_by":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("created_by", flat=True)
.distinct()
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
return []
-9
View File
@@ -2,7 +2,6 @@ from .project import (
ProjectDeployBoardPublicSettingsEndpoint,
WorkspaceProjectDeployBoardEndpoint,
WorkspaceProjectAnchorEndpoint,
ProjectMembersEndpoint,
)
from .issue import (
@@ -15,11 +14,3 @@ from .issue import (
)
from .inbox import InboxIssuePublicViewSet
from .cycle import ProjectCyclesEndpoint
from .module import ProjectModulesEndpoint
from .state import ProjectStatesEndpoint
from .label import ProjectLabelsEndpoint
-35
View File
@@ -1,35 +0,0 @@
# Third Party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import (
DeployBoard,
Cycle,
)
class ProjectCyclesEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response(
{"error": "Invalid anchor"},
status=status.HTTP_404_NOT_FOUND,
)
cycles = Cycle.objects.filter(
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
).values("id", "name")
return Response(
cycles,
status=status.HTTP_200_OK,
)
+188 -317
View File
@@ -1,51 +1,34 @@
# Python imports
import json
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce, JSONObject
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from django.db.models import (
Exists,
F,
Func,
OuterRef,
Q,
Prefetch,
UUIDField,
Case,
When,
JSONField,
CharField,
IntegerField,
Value,
OuterRef,
Func
Max,
)
# Third Party imports
from rest_framework.response import Response
# Django imports
from django.utils import timezone
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
# Third Party imports
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView, BaseViewSet
# fetch the space app grouper function separately
from plane.space.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
from plane.app.serializers import (
CommentReactionSerializer,
IssueCommentSerializer,
IssuePublicSerializer,
IssueReactionSerializer,
IssueVoteSerializer,
)
@@ -53,183 +36,21 @@ from plane.db.models import (
Issue,
IssueComment,
IssueLink,
IssueReaction,
IssueAttachment,
ProjectMember,
IssueReaction,
CommentReaction,
DeployBoard,
IssueVote,
ProjectPublicMember,
IssueAttachment,
State,
Label,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
class ProjectIssuesPublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).first()
if not deploy_board:
return Response(
{"error": "Project is not published"},
status=status.HTTP_404_NOT_FOUND,
)
project_id = deploy_board.entity_identifier
slug = deploy_board.workspace.slug
issue_queryset = (
Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.prefetch_related(
Prefetch(
"votes",
queryset=IssueVote.objects.select_related("actor"),
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
issue_queryset = issue_queryset.filter(**filters)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset,
group_by=group_by,
sub_group_by=sub_group_by,
)
if group_by:
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
)
# Module imports
from .base import BaseAPIView, BaseViewSet
class IssueCommentPublicViewSet(BaseViewSet):
@@ -682,50 +503,67 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
]
def get(self, request, anchor, issue_id):
deploy_board = DeployBoard.objects.get(anchor=anchor)
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
issue = Issue.objects.get(
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
pk=issue_id,
)
serializer = IssuePublicSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
class ProjectIssuesPublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).first()
if not deploy_board:
return Response(
{"error": "Project is not published"},
status=status.HTTP_404_NOT_FOUND,
)
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at")
project_id = deploy_board.entity_identifier
slug = deploy_board.workspace.slug
issue_queryset = (
Issue.issue_objects.filter(
pk=issue_id,
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related(
"issue", "actor"
),
queryset=IssueReaction.objects.select_related("actor"),
)
)
.prefetch_related(
@@ -734,91 +572,124 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
queryset=IssueVote.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
vote_items=ArrayAgg(
Case(
When(
votes__isnull=False,
then=JSONObject(
vote=F("votes__vote"),
actor_details=JSONObject(
id=F("votes__actor__id"),
first_name=F("votes__actor__first_name"),
last_name=F("votes__actor__last_name"),
avatar=F("votes__actor__avatar"),
display_name=F(
"votes__actor__display_name"
),
),
),
),
default=None,
output_field=JSONField(),
),
filter=Case(
When(votes__isnull=False, then=True),
default=False,
output_field=JSONField(),
),
distinct=True,
),
reaction_items=ArrayAgg(
Case(
When(
issue_reactions__isnull=False,
then=JSONObject(
reaction=F("issue_reactions__reaction"),
actor_details=JSONObject(
id=F("issue_reactions__actor__id"),
first_name=F(
"issue_reactions__actor__first_name"
),
last_name=F(
"issue_reactions__actor__last_name"
),
avatar=F("issue_reactions__actor__avatar"),
display_name=F(
"issue_reactions__actor__display_name"
),
),
),
),
default=None,
output_field=JSONField(),
),
filter=Case(
When(issue_reactions__isnull=False, then=True),
default=False,
output_field=JSONField(),
),
distinct=True,
),
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.values(
"id",
"name",
"state_id",
"sort_order",
"description",
"description_html",
"description_stripped",
"description_binary",
"module_ids",
"label_ids",
"assignee_ids",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"created_by",
"state__group",
"vote_items",
"reaction_items",
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).first()
)
return Response(issue_queryset, status=status.HTTP_200_OK)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssuePublicSerializer(issue_queryset, many=True).data
state_group_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
states = (
State.objects.filter(
~Q(name="Triage"),
workspace__slug=slug,
project_id=project_id,
)
.annotate(
custom_order=Case(
*[
When(group=value, then=Value(index))
for index, value in enumerate(state_group_order)
],
default=Value(len(state_group_order)),
output_field=IntegerField(),
),
)
.values("name", "group", "color", "id")
.order_by("custom_order", "sequence")
)
labels = Label.objects.filter(
workspace__slug=slug, project_id=project_id
).values("id", "name", "color", "parent")
return Response(
{
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
)
-35
View File
@@ -1,35 +0,0 @@
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
# Module imports
from .base import BaseAPIView
from plane.db.models import (
DeployBoard,
Label,
)
class ProjectLabelsEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response(
{"error": "Invalid anchor"},
status=status.HTTP_404_NOT_FOUND,
)
labels = Label.objects.filter(
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
).values("id", "name", "color", "parent")
return Response(
labels,
status=status.HTTP_200_OK,
)
-35
View File
@@ -1,35 +0,0 @@
# Third Party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import (
DeployBoard,
Module,
)
class ProjectModulesEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response(
{"error": "Invalid anchor"},
status=status.HTTP_404_NOT_FOUND,
)
modules = Module.objects.filter(
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
).values("id", "name")
return Response(
modules,
status=status.HTTP_200_OK,
)
+4 -25
View File
@@ -12,7 +12,10 @@ from rest_framework.permissions import AllowAny
# Module imports
from .base import BaseAPIView
from plane.app.serializers import DeployBoardSerializer
from plane.db.models import Project, DeployBoard, ProjectMember
from plane.db.models import (
Project,
DeployBoard,
)
class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
@@ -73,27 +76,3 @@ class WorkspaceProjectAnchorEndpoint(BaseAPIView):
)
serializer = DeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
class ProjectMembersEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
members = ProjectMember.objects.filter(
project=deploy_board.project,
workspace=deploy_board.workspace,
is_active=True,
).values(
"id",
"member",
"member__first_name",
"member__last_name",
"member__display_name",
"project",
"workspace",
)
return Response(members, status=status.HTTP_200_OK)
-42
View File
@@ -1,42 +0,0 @@
# Django imports
from django.db.models import Q
# Third Party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import (
DeployBoard,
State,
)
class ProjectStatesEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response(
{"error": "Invalid anchor"},
status=status.HTTP_404_NOT_FOUND,
)
states = (
State.objects.filter(
~Q(name="Triage"),
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
)
.values("name", "group", "color", "id")
)
return Response(
states,
status=status.HTTP_200_OK,
)
+112 -142
View File
@@ -23,42 +23,42 @@ def filter_valid_uuids(uuid_list):
# Get the 2_weeks, 3_months
def string_date_filter(
issue_filter, duration, subsequent, term, date_filter, offset
filter, duration, subsequent, term, date_filter, offset
):
now = timezone.now().date()
if term == "months":
if subsequent == "after":
if offset == "fromnow":
issue_filter[f"{date_filter}__gte"] = now + timedelta(
filter[f"{date_filter}__gte"] = now + timedelta(
days=duration * 30
)
else:
issue_filter[f"{date_filter}__gte"] = now - timedelta(
filter[f"{date_filter}__gte"] = now - timedelta(
days=duration * 30
)
else:
if offset == "fromnow":
issue_filter[f"{date_filter}__lte"] = now + timedelta(
filter[f"{date_filter}__lte"] = now + timedelta(
days=duration * 30
)
else:
issue_filter[f"{date_filter}__lte"] = now - timedelta(
filter[f"{date_filter}__lte"] = now - timedelta(
days=duration * 30
)
if term == "weeks":
if subsequent == "after":
if offset == "fromnow":
issue_filter[f"{date_filter}__gte"] = now + timedelta(weeks=duration)
filter[f"{date_filter}__gte"] = now + timedelta(weeks=duration)
else:
issue_filter[f"{date_filter}__gte"] = now - timedelta(weeks=duration)
filter[f"{date_filter}__gte"] = now - timedelta(weeks=duration)
else:
if offset == "fromnow":
issue_filter[f"{date_filter}__lte"] = now + timedelta(weeks=duration)
filter[f"{date_filter}__lte"] = now + timedelta(weeks=duration)
else:
issue_filter[f"{date_filter}__lte"] = now - timedelta(weeks=duration)
filter[f"{date_filter}__lte"] = now - timedelta(weeks=duration)
def date_filter(issue_filter, date_term, queries):
def date_filter(filter, date_term, queries):
"""
Handle all date filters
"""
@@ -71,7 +71,7 @@ def date_filter(issue_filter, date_term, queries):
if len(date_query) == 3:
digit, term = date_query[0].split("_")
string_date_filter(
issue_filter=issue_filter,
filter=filter,
duration=int(digit),
subsequent=date_query[1],
term=term,
@@ -80,32 +80,32 @@ def date_filter(issue_filter, date_term, queries):
)
else:
if "after" in date_query:
issue_filter[f"{date_term}__gte"] = date_query[0]
filter[f"{date_term}__gte"] = date_query[0]
else:
issue_filter[f"{date_term}__lte"] = date_query[0]
filter[f"{date_term}__lte"] = date_query[0]
else:
issue_filter[f"{date_term}__contains"] = date_query[0]
filter[f"{date_term}__contains"] = date_query[0]
def filter_state(params, issue_filter, method, prefix=""):
def filter_state(params, filter, method, prefix=""):
if method == "GET":
states = [
item for item in params.get("state").split(",") if item != "null"
]
states = filter_valid_uuids(states)
if len(states) and "" not in states:
issue_filter[f"{prefix}state__in"] = states
filter[f"{prefix}state__in"] = states
else:
if (
params.get("state", None)
and len(params.get("state"))
and params.get("state") != "null"
):
issue_filter[f"{prefix}state__in"] = params.get("state")
return issue_filter
filter[f"{prefix}state__in"] = params.get("state")
return filter
def filter_state_group(params, issue_filter, method, prefix=""):
def filter_state_group(params, filter, method, prefix=""):
if method == "GET":
state_group = [
item
@@ -113,18 +113,18 @@ def filter_state_group(params, issue_filter, method, prefix=""):
if item != "null"
]
if len(state_group) and "" not in state_group:
issue_filter[f"{prefix}state__group__in"] = state_group
filter[f"{prefix}state__group__in"] = state_group
else:
if (
params.get("state_group", None)
and len(params.get("state_group"))
and params.get("state_group") != "null"
):
issue_filter[f"{prefix}state__group__in"] = params.get("state_group")
return issue_filter
filter[f"{prefix}state__group__in"] = params.get("state_group")
return filter
def filter_estimate_point(params, issue_filter, method, prefix=""):
def filter_estimate_point(params, filter, method, prefix=""):
if method == "GET":
estimate_points = [
item
@@ -132,20 +132,20 @@ def filter_estimate_point(params, issue_filter, method, prefix=""):
if item != "null"
]
if len(estimate_points) and "" not in estimate_points:
issue_filter[f"{prefix}estimate_point__in"] = estimate_points
filter[f"{prefix}estimate_point__in"] = estimate_points
else:
if (
params.get("estimate_point", None)
and len(params.get("estimate_point"))
and params.get("estimate_point") != "null"
):
issue_filter[f"{prefix}estimate_point__in"] = params.get(
filter[f"{prefix}estimate_point__in"] = params.get(
"estimate_point"
)
return issue_filter
return filter
def filter_priority(params, issue_filter, method, prefix=""):
def filter_priority(params, filter, method, prefix=""):
if method == "GET":
priorities = [
item
@@ -153,58 +153,51 @@ def filter_priority(params, issue_filter, method, prefix=""):
if item != "null"
]
if len(priorities) and "" not in priorities:
issue_filter[f"{prefix}priority__in"] = priorities
else:
if (
params.get("priority", None)
and len(params.get("priority"))
and params.get("priority") != "null"
):
issue_filter[f"{prefix}priority__in"] = params.get("priority")
return issue_filter
filter[f"{prefix}priority__in"] = priorities
return filter
def filter_parent(params, issue_filter, method, prefix=""):
def filter_parent(params, filter, method, prefix=""):
if method == "GET":
parents = [
item for item in params.get("parent").split(",") if item != "null"
]
if "None" in parents:
issue_filter[f"{prefix}parent__isnull"] = True
filter[f"{prefix}parent__isnull"] = True
parents = filter_valid_uuids(parents)
if len(parents) and "" not in parents:
issue_filter[f"{prefix}parent__in"] = parents
filter[f"{prefix}parent__in"] = parents
else:
if (
params.get("parent", None)
and len(params.get("parent"))
and params.get("parent") != "null"
):
issue_filter[f"{prefix}parent__in"] = params.get("parent")
return issue_filter
filter[f"{prefix}parent__in"] = params.get("parent")
return filter
def filter_labels(params, issue_filter, method, prefix=""):
def filter_labels(params, filter, method, prefix=""):
if method == "GET":
labels = [
item for item in params.get("labels").split(",") if item != "null"
]
if "None" in labels:
issue_filter[f"{prefix}labels__isnull"] = True
filter[f"{prefix}labels__isnull"] = True
labels = filter_valid_uuids(labels)
if len(labels) and "" not in labels:
issue_filter[f"{prefix}labels__in"] = labels
filter[f"{prefix}labels__in"] = labels
else:
if (
params.get("labels", None)
and len(params.get("labels"))
and params.get("labels") != "null"
):
issue_filter[f"{prefix}labels__in"] = params.get("labels")
return issue_filter
filter[f"{prefix}labels__in"] = params.get("labels")
return filter
def filter_assignees(params, issue_filter, method, prefix=""):
def filter_assignees(params, filter, method, prefix=""):
if method == "GET":
assignees = [
item
@@ -212,21 +205,21 @@ def filter_assignees(params, issue_filter, method, prefix=""):
if item != "null"
]
if "None" in assignees:
issue_filter[f"{prefix}assignees__isnull"] = True
filter[f"{prefix}assignees__isnull"] = True
assignees = filter_valid_uuids(assignees)
if len(assignees) and "" not in assignees:
issue_filter[f"{prefix}assignees__in"] = assignees
filter[f"{prefix}assignees__in"] = assignees
else:
if (
params.get("assignees", None)
and len(params.get("assignees"))
and params.get("assignees") != "null"
):
issue_filter[f"{prefix}assignees__in"] = params.get("assignees")
return issue_filter
filter[f"{prefix}assignees__in"] = params.get("assignees")
return filter
def filter_mentions(params, issue_filter, method, prefix=""):
def filter_mentions(params, filter, method, prefix=""):
if method == "GET":
mentions = [
item
@@ -235,20 +228,20 @@ def filter_mentions(params, issue_filter, method, prefix=""):
]
mentions = filter_valid_uuids(mentions)
if len(mentions) and "" not in mentions:
issue_filter[f"{prefix}issue_mention__mention__id__in"] = mentions
filter[f"{prefix}issue_mention__mention__id__in"] = mentions
else:
if (
params.get("mentions", None)
and len(params.get("mentions"))
and params.get("mentions") != "null"
):
issue_filter[f"{prefix}issue_mention__mention__id__in"] = params.get(
filter[f"{prefix}issue_mention__mention__id__in"] = params.get(
"mentions"
)
return issue_filter
return filter
def filter_created_by(params, issue_filter, method, prefix=""):
def filter_created_by(params, filter, method, prefix=""):
if method == "GET":
created_bys = [
item
@@ -256,100 +249,100 @@ def filter_created_by(params, issue_filter, method, prefix=""):
if item != "null"
]
if "None" in created_bys:
issue_filter[f"{prefix}created_by__isnull"] = True
filter[f"{prefix}created_by__isnull"] = True
created_bys = filter_valid_uuids(created_bys)
if len(created_bys) and "" not in created_bys:
issue_filter[f"{prefix}created_by__in"] = created_bys
filter[f"{prefix}created_by__in"] = created_bys
else:
if (
params.get("created_by", None)
and len(params.get("created_by"))
and params.get("created_by") != "null"
):
issue_filter[f"{prefix}created_by__in"] = params.get("created_by")
return issue_filter
filter[f"{prefix}created_by__in"] = params.get("created_by")
return filter
def filter_name(params, issue_filter, method, prefix=""):
def filter_name(params, filter, method, prefix=""):
if params.get("name", "") != "":
issue_filter[f"{prefix}name__icontains"] = params.get("name")
return issue_filter
filter[f"{prefix}name__icontains"] = params.get("name")
return filter
def filter_created_at(params, issue_filter, method, prefix=""):
def filter_created_at(params, filter, method, prefix=""):
if method == "GET":
created_ats = params.get("created_at").split(",")
if len(created_ats) and "" not in created_ats:
date_filter(
issue_filter=issue_filter,
filter=filter,
date_term=f"{prefix}created_at__date",
queries=created_ats,
)
else:
if params.get("created_at", None) and len(params.get("created_at")):
date_filter(
issue_filter=issue_filter,
filter=filter,
date_term=f"{prefix}created_at__date",
queries=params.get("created_at", []),
)
return issue_filter
return filter
def filter_updated_at(params, issue_filter, method, prefix=""):
def filter_updated_at(params, filter, method, prefix=""):
if method == "GET":
updated_ats = params.get("updated_at").split(",")
if len(updated_ats) and "" not in updated_ats:
date_filter(
issue_filter=issue_filter,
filter=filter,
date_term=f"{prefix}created_at__date",
queries=updated_ats,
)
else:
if params.get("updated_at", None) and len(params.get("updated_at")):
date_filter(
issue_filter=issue_filter,
filter=filter,
date_term=f"{prefix}created_at__date",
queries=params.get("updated_at", []),
)
return issue_filter
return filter
def filter_start_date(params, issue_filter, method, prefix=""):
def filter_start_date(params, filter, method, prefix=""):
if method == "GET":
start_dates = params.get("start_date").split(",")
if len(start_dates) and "" not in start_dates:
date_filter(
issue_filter=issue_filter,
filter=filter,
date_term=f"{prefix}start_date",
queries=start_dates,
)
else:
if params.get("start_date", None) and len(params.get("start_date")):
issue_filter[f"{prefix}start_date"] = params.get("start_date")
return issue_filter
filter[f"{prefix}start_date"] = params.get("start_date")
return filter
def filter_target_date(params, issue_filter, method, prefix=""):
def filter_target_date(params, filter, method, prefix=""):
if method == "GET":
target_dates = params.get("target_date").split(",")
if len(target_dates) and "" not in target_dates:
date_filter(
issue_filter=issue_filter,
filter=filter,
date_term=f"{prefix}target_date",
queries=target_dates,
)
else:
if params.get("target_date", None) and len(params.get("target_date")):
issue_filter[f"{prefix}target_date"] = params.get("target_date")
return issue_filter
filter[f"{prefix}target_date"] = params.get("target_date")
return filter
def filter_completed_at(params, issue_filter, method, prefix=""):
def filter_completed_at(params, filter, method, prefix=""):
if method == "GET":
completed_ats = params.get("completed_at").split(",")
if len(completed_ats) and "" not in completed_ats:
date_filter(
issue_filter=issue_filter,
filter=filter,
date_term=f"{prefix}completed_at__date",
queries=completed_ats,
)
@@ -358,14 +351,14 @@ def filter_completed_at(params, issue_filter, method, prefix=""):
params.get("completed_at")
):
date_filter(
issue_filter=issue_filter,
filter=filter,
date_term=f"{prefix}completed_at__date",
queries=params.get("completed_at", []),
)
return issue_filter
return filter
def filter_issue_state_type(params, issue_filter, method, prefix=""):
def filter_issue_state_type(params, filter, method, prefix=""):
type = params.get("type", "all")
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
if type == "backlog":
@@ -373,71 +366,71 @@ def filter_issue_state_type(params, issue_filter, method, prefix=""):
if type == "active":
group = ["unstarted", "started"]
issue_filter[f"{prefix}state__group__in"] = group
return issue_filter
filter[f"{prefix}state__group__in"] = group
return filter
def filter_project(params, issue_filter, method, prefix=""):
def filter_project(params, filter, method, prefix=""):
if method == "GET":
projects = [
item for item in params.get("project").split(",") if item != "null"
]
projects = filter_valid_uuids(projects)
if len(projects) and "" not in projects:
issue_filter[f"{prefix}project__in"] = projects
filter[f"{prefix}project__in"] = projects
else:
if (
params.get("project", None)
and len(params.get("project"))
and params.get("project") != "null"
):
issue_filter[f"{prefix}project__in"] = params.get("project")
return issue_filter
filter[f"{prefix}project__in"] = params.get("project")
return filter
def filter_cycle(params, issue_filter, method, prefix=""):
def filter_cycle(params, filter, method, prefix=""):
if method == "GET":
cycles = [
item for item in params.get("cycle").split(",") if item != "null"
]
if "None" in cycles:
issue_filter[f"{prefix}issue_cycle__cycle_id__isnull"] = True
filter[f"{prefix}issue_cycle__cycle_id__isnull"] = True
cycles = filter_valid_uuids(cycles)
if len(cycles) and "" not in cycles:
issue_filter[f"{prefix}issue_cycle__cycle_id__in"] = cycles
filter[f"{prefix}issue_cycle__cycle_id__in"] = cycles
else:
if (
params.get("cycle", None)
and len(params.get("cycle"))
and params.get("cycle") != "null"
):
issue_filter[f"{prefix}issue_cycle__cycle_id__in"] = params.get("cycle")
return issue_filter
filter[f"{prefix}issue_cycle__cycle_id__in"] = params.get("cycle")
return filter
def filter_module(params, issue_filter, method, prefix=""):
def filter_module(params, filter, method, prefix=""):
if method == "GET":
modules = [
item for item in params.get("module").split(",") if item != "null"
]
if "None" in modules:
issue_filter[f"{prefix}issue_module__module_id__isnull"] = True
filter[f"{prefix}issue_module__module_id__isnull"] = True
modules = filter_valid_uuids(modules)
if len(modules) and "" not in modules:
issue_filter[f"{prefix}issue_module__module_id__in"] = modules
filter[f"{prefix}issue_module__module_id__in"] = modules
else:
if (
params.get("module", None)
and len(params.get("module"))
and params.get("module") != "null"
):
issue_filter[f"{prefix}issue_module__module_id__in"] = params.get(
filter[f"{prefix}issue_module__module_id__in"] = params.get(
"module"
)
return issue_filter
return filter
def filter_inbox_status(params, issue_filter, method, prefix=""):
def filter_inbox_status(params, filter, method, prefix=""):
if method == "GET":
status = [
item
@@ -445,32 +438,32 @@ def filter_inbox_status(params, issue_filter, method, prefix=""):
if item != "null"
]
if len(status) and "" not in status:
issue_filter[f"{prefix}issue_inbox__status__in"] = status
filter[f"{prefix}issue_inbox__status__in"] = status
else:
if (
params.get("inbox_status", None)
and len(params.get("inbox_status"))
and params.get("inbox_status") != "null"
):
issue_filter[f"{prefix}issue_inbox__status__in"] = params.get(
filter[f"{prefix}issue_inbox__status__in"] = params.get(
"inbox_status"
)
return issue_filter
return filter
def filter_sub_issue_toggle(params, issue_filter, method, prefix=""):
def filter_sub_issue_toggle(params, filter, method, prefix=""):
if method == "GET":
sub_issue = params.get("sub_issue", "false")
if sub_issue == "false":
issue_filter[f"{prefix}parent__isnull"] = True
filter[f"{prefix}parent__isnull"] = True
else:
sub_issue = params.get("sub_issue", "false")
if sub_issue == "false":
issue_filter[f"{prefix}parent__isnull"] = True
return issue_filter
filter[f"{prefix}parent__isnull"] = True
return filter
def filter_subscribed_issues(params, issue_filter, method, prefix=""):
def filter_subscribed_issues(params, filter, method, prefix=""):
if method == "GET":
subscribers = [
item
@@ -479,7 +472,7 @@ def filter_subscribed_issues(params, issue_filter, method, prefix=""):
]
subscribers = filter_valid_uuids(subscribers)
if len(subscribers) and "" not in subscribers:
issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
subscribers
)
else:
@@ -488,44 +481,22 @@ def filter_subscribed_issues(params, issue_filter, method, prefix=""):
and len(params.get("subscriber"))
and params.get("subscriber") != "null"
):
issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
params.get("subscriber")
)
return issue_filter
return filter
def filter_start_target_date_issues(params, issue_filter, method, prefix=""):
def filter_start_target_date_issues(params, filter, method, prefix=""):
start_target_date = params.get("start_target_date", "false")
if start_target_date == "true":
issue_filter[f"{prefix}target_date__isnull"] = False
issue_filter[f"{prefix}start_date__isnull"] = False
return issue_filter
def filter_logged_by(params, issue_filter, method, prefix=""):
if method == "GET":
logged_bys = [
item
for item in params.get("logged_by").split(",")
if item != "null"
]
if "None" in logged_bys:
issue_filter[f"{prefix}logged_by__isnull"] = True
logged_bys = filter_valid_uuids(logged_bys)
if len(logged_bys) and "" not in logged_bys:
issue_filter[f"{prefix}logged_by__in"] = logged_bys
else:
if (
params.get("logged_by", None)
and len(params.get("logged_by"))
and params.get("logged_by") != "null"
):
issue_filter[f"{prefix}logged_by__in"] = params.get("logged_by")
return issue_filter
filter[f"{prefix}target_date__isnull"] = False
filter[f"{prefix}start_date__isnull"] = False
return filter
def issue_filters(query_params, method, prefix=""):
issue_filter = {}
filter = {}
ISSUE_FILTER = {
"state": filter_state,
@@ -537,7 +508,6 @@ def issue_filters(query_params, method, prefix=""):
"assignees": filter_assignees,
"mentions": filter_mentions,
"created_by": filter_created_by,
"logged_by": filter_logged_by,
"name": filter_name,
"created_at": filter_created_at,
"updated_at": filter_updated_at,
@@ -557,5 +527,5 @@ def issue_filters(query_params, method, prefix=""):
for key, value in ISSUE_FILTER.items():
if key in query_params:
func = value
func(query_params, issue_filter, method, prefix)
return issue_filter
func(query_params, filter, method, prefix)
return filter
+2 -2
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.14
Django==4.2.11
# rest framework
djangorestframework==3.15.2
# postgres
@@ -26,7 +26,7 @@ django-filter==24.2
# json model
jsonmodels==2.7.0
# sentry
sentry-sdk==2.8.0
sentry-sdk==2.0.1
# storage
django-storages==1.14.2
# user management
+1 -54
View File
@@ -372,60 +372,8 @@ Backup completed successfully. Backup files are stored in /....../plane-app/back
---
### Restore Data
When you want to restore the previously backed-up data, follow the instructions below.
1. Make sure that Plane-CE is installed, started, and then stopped. This ensures that the Docker volumes are created.
1. Download the restore script using the command below. We suggest downloading it in the same folder as `setup.sh`.
```bash
curl -fsSL -o restore.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/restore.sh
chmod +x restore.sh
```
1. Execute the command below to restore your data.
```bash
./restore.sh <path to backup folder containing *.tar.gz files>
```
As an example, for a backup folder `/opt/plane-selfhost/plane-app/backup/20240722-0914`, expect the response below:
```bash
--------------------------------------------
____ _ /////////
| _ \| | __ _ _ __ ___ /////////
| |_) | |/ _` | '_ \ / _ \ ///// /////
| __/| | (_| | | | | __/ ///// /////
|_| |_|\__,_|_| |_|\___| ////
////
--------------------------------------------
Project management tool from the future
--------------------------------------------
Found /opt/plane-selfhost/plane-app/backup/20240722-0914/pgdata.tar.gz
.....Restoring plane-app_pgdata
.....Successfully restored volume plane-app_pgdata from pgdata.tar.gz
Found /opt/plane-selfhost/plane-app/backup/20240722-0914/redisdata.tar.gz
.....Restoring plane-app_redisdata
.....Successfully restored volume plane-app_redisdata from redisdata.tar.gz
Found /opt/plane-selfhost/plane-app/backup/20240722-0914/uploads.tar.gz
.....Restoring plane-app_uploads
.....Successfully restored volume plane-app_uploads from uploads.tar.gz
Restore completed successfully.
```
1. Start the Plane instance using `./setup.sh start`.
---
<details>
<summary><h2>Upgrading from v0.13.2 to v0.14.x</h2></summary>
## Upgrading from v0.13.2 to v0.14.x
This is one time activity for users who are upgrading from v0.13.2 to v0.14.0
@@ -497,4 +445,3 @@ In case the suffixes are wrong or the mentioned volumes are not found, you will
In case of successful migration, it will be a silent exit without error.
Now its time to restart v0.14.0 setup.
</details>
-121
View File
@@ -1,121 +0,0 @@
#!/bin/bash
function print_header() {
clear
cat <<"EOF"
--------------------------------------------
____ _ /////////
| _ \| | __ _ _ __ ___ /////////
| |_) | |/ _` | '_ \ / _ \ ///// /////
| __/| | (_| | | | | __/ ///// /////
|_| |_|\__,_|_| |_|\___| ////
////
--------------------------------------------
Project management tool from the future
--------------------------------------------
EOF
}
function restoreSingleVolume() {
selectedVolume=$1
backupFolder=$2
restoreFile=$3
docker volume rm "$selectedVolume" > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo "Error: Failed to remove volume $selectedVolume"
echo ""
return 1
fi
docker volume create "$selectedVolume" > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo "Error: Failed to create volume $selectedVolume"
echo ""
return 1
fi
docker run --rm \
-e TAR_NAME="$restoreFile" \
-v "$selectedVolume":"/vol" \
-v "$backupFolder":/backup \
busybox sh -c 'mkdir -p /restore && tar -xzf "/backup/${TAR_NAME}.tar.gz" -C /restore && mv /restore/${TAR_NAME}/* /vol'
if [ $? -ne 0 ]; then
echo "Error: Failed to restore volume ${selectedVolume} from ${restoreFile}.tar.gz"
echo ""
return 1
fi
echo ".....Successfully restored volume $selectedVolume from ${restoreFile}.tar.gz"
echo ""
}
function restoreData() {
print_header
local BACKUP_FOLDER=${1:-$PWD}
local dockerServiceStatus
dockerServiceStatus=$($COMPOSE_CMD ls --filter name=plane-app --format=json | jq -r .[0].Status)
local dockerServicePrefix
dockerServicePrefix="running"
if [[ $dockerServiceStatus == $dockerServicePrefix* ]]; then
echo "Plane App is running. Please STOP the Plane App before restoring data."
exit 1
fi
local volumes
volumes=$(docker volume ls -f "name=plane-app" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
# Check if there are any matching volumes
if [ -z "$volumes" ]; then
echo ".....No volumes found starting with 'plane-app'"
exit 1
fi
for BACKUP_FILE in $BACKUP_FOLDER/*.tar.gz; do
if [ -e "$BACKUP_FILE" ]; then
local restoreFileName
restoreFileName=$(basename "$BACKUP_FILE")
restoreFileName="${restoreFileName%.tar.gz}"
local restoreVolName
restoreVolName="plane-app_${restoreFileName}"
echo "Found $BACKUP_FILE"
local docVol
docVol=$(docker volume ls -f "name=$restoreVolName" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
if [ -z "$docVol" ]; then
echo "Skipping: No volume found with name $restoreVolName"
else
echo ".....Restoring $docVol"
restoreSingleVolume "$docVol" "$BACKUP_FOLDER" "$restoreFileName"
fi
else
echo "No .tar.gz files found in the current directory."
echo ""
echo "Please provide the path to the backup file."
echo ""
echo "Usage: ./restore.sh /path/to/backup"
exit 1
fi
done
echo ""
echo "Restore completed successfully."
echo ""
}
# if docker-compose is installed
if command -v docker-compose &> /dev/null
then
COMPOSE_CMD="docker-compose"
else
COMPOSE_CMD="docker compose"
fi
restoreData "$@"
+1 -1
View File
@@ -34,7 +34,7 @@
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^2.0.9"
"turbo": "^2.0.6"
},
"resolutions": {
"@types/react": "18.2.48"
-1
View File
@@ -1,2 +1 @@
export * from "./auth";
export * from "./issue";
-27
View File
@@ -1,27 +0,0 @@
export const ALL_ISSUES = "All Issues";
export enum EIssueGroupByToServerOptions {
"state" = "state_id",
"priority" = "priority",
"labels" = "labels__id",
"state_detail.group" = "state__group",
"assignees" = "assignees__id",
"cycle" = "cycle_id",
"module" = "issue_module__module_id",
"target_date" = "target_date",
"project" = "project_id",
"created_by" = "created_by",
}
export enum EServerGroupByToFilterOptions {
"state_id" = "state",
"priority" = "priority",
"labels__id" = "labels",
"state__group" = "state_group",
"assignees__id" = "assignees",
"cycle_id" = "cycle",
"issue_module__module_id" = "module",
"target_date" = "target_date",
"project_id" = "project",
"created_by" = "created_by",
}
@@ -34,7 +34,6 @@ export const EditorWrapper: React.FC<Props> = (props) => {
const editor = useEditor({
editorClassName,
enableHistory: true,
extensions,
fileHandler,
forwardedRef,
@@ -1,5 +1,3 @@
import { Selection } from "@tiptap/pm/state";
import { Editor } from "@tiptap/react";
import {
BoldIcon,
Heading1,
@@ -21,6 +19,8 @@ import {
CaseSensitive,
LucideIcon,
} from "lucide-react";
import { Selection } from "@tiptap/pm/state";
import { Editor } from "@tiptap/react";
// helpers
import {
insertImageCommand,
@@ -43,151 +43,168 @@ import {
toggleUnderline,
} from "@/helpers/editor-commands";
// types
import { TEditorCommands, UploadImage } from "@/types";
import { UploadImage } from "@/types";
export interface EditorMenuItem {
key: TEditorCommands;
key: string;
name: string;
isActive: () => boolean;
command: () => void;
icon: LucideIcon;
}
export const TextItem = (editor: Editor): EditorMenuItem => ({
key: "text",
name: "Text",
isActive: () => editor.isActive("paragraph"),
command: () => setText(editor),
icon: CaseSensitive,
});
export const TextItem = (editor: Editor) =>
({
key: "text",
name: "Text",
isActive: () => editor.isActive("paragraph"),
command: () => setText(editor),
icon: CaseSensitive,
}) as const satisfies EditorMenuItem;
export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({
key: "h1",
name: "Heading 1",
isActive: () => editor.isActive("heading", { level: 1 }),
command: () => toggleHeadingOne(editor),
icon: Heading1,
});
export const HeadingOneItem = (editor: Editor) =>
({
key: "h1",
name: "Heading 1",
isActive: () => editor.isActive("heading", { level: 1 }),
command: () => toggleHeadingOne(editor),
icon: Heading1,
}) as const satisfies EditorMenuItem;
export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({
key: "h2",
name: "Heading 2",
isActive: () => editor.isActive("heading", { level: 2 }),
command: () => toggleHeadingTwo(editor),
icon: Heading2,
});
export const HeadingTwoItem = (editor: Editor) =>
({
key: "h2",
name: "Heading 2",
isActive: () => editor.isActive("heading", { level: 2 }),
command: () => toggleHeadingTwo(editor),
icon: Heading2,
}) as const satisfies EditorMenuItem;
export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({
key: "h3",
name: "Heading 3",
isActive: () => editor.isActive("heading", { level: 3 }),
command: () => toggleHeadingThree(editor),
icon: Heading3,
});
export const HeadingThreeItem = (editor: Editor) =>
({
key: "h3",
name: "Heading 3",
isActive: () => editor.isActive("heading", { level: 3 }),
command: () => toggleHeadingThree(editor),
icon: Heading3,
}) as const satisfies EditorMenuItem;
export const HeadingFourItem = (editor: Editor): EditorMenuItem => ({
key: "h4",
name: "Heading 4",
isActive: () => editor.isActive("heading", { level: 4 }),
command: () => toggleHeadingFour(editor),
icon: Heading4,
});
export const HeadingFourItem = (editor: Editor) =>
({
key: "h4",
name: "Heading 4",
isActive: () => editor.isActive("heading", { level: 4 }),
command: () => toggleHeadingFour(editor),
icon: Heading4,
}) as const satisfies EditorMenuItem;
export const HeadingFiveItem = (editor: Editor): EditorMenuItem => ({
key: "h5",
name: "Heading 5",
isActive: () => editor.isActive("heading", { level: 5 }),
command: () => toggleHeadingFive(editor),
icon: Heading5,
});
export const HeadingFiveItem = (editor: Editor) =>
({
key: "h5",
name: "Heading 5",
isActive: () => editor.isActive("heading", { level: 5 }),
command: () => toggleHeadingFive(editor),
icon: Heading5,
}) as const satisfies EditorMenuItem;
export const HeadingSixItem = (editor: Editor): EditorMenuItem => ({
key: "h6",
name: "Heading 6",
isActive: () => editor.isActive("heading", { level: 6 }),
command: () => toggleHeadingSix(editor),
icon: Heading6,
});
export const HeadingSixItem = (editor: Editor) =>
({
key: "h6",
name: "Heading 6",
isActive: () => editor.isActive("heading", { level: 6 }),
command: () => toggleHeadingSix(editor),
icon: Heading6,
}) as const satisfies EditorMenuItem;
export const BoldItem = (editor: Editor): EditorMenuItem => ({
key: "bold",
name: "Bold",
isActive: () => editor?.isActive("bold"),
command: () => toggleBold(editor),
icon: BoldIcon,
});
export const BoldItem = (editor: Editor) =>
({
key: "bold",
name: "Bold",
isActive: () => editor?.isActive("bold"),
command: () => toggleBold(editor),
icon: BoldIcon,
}) as const satisfies EditorMenuItem;
export const ItalicItem = (editor: Editor): EditorMenuItem => ({
key: "italic",
name: "Italic",
isActive: () => editor?.isActive("italic"),
command: () => toggleItalic(editor),
icon: ItalicIcon,
});
export const ItalicItem = (editor: Editor) =>
({
key: "italic",
name: "Italic",
isActive: () => editor?.isActive("italic"),
command: () => toggleItalic(editor),
icon: ItalicIcon,
}) as const satisfies EditorMenuItem;
export const UnderLineItem = (editor: Editor): EditorMenuItem => ({
key: "underline",
name: "Underline",
isActive: () => editor?.isActive("underline"),
command: () => toggleUnderline(editor),
icon: UnderlineIcon,
});
export const UnderLineItem = (editor: Editor) =>
({
key: "underline",
name: "Underline",
isActive: () => editor?.isActive("underline"),
command: () => toggleUnderline(editor),
icon: UnderlineIcon,
}) as const satisfies EditorMenuItem;
export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({
key: "strikethrough",
name: "Strikethrough",
isActive: () => editor?.isActive("strike"),
command: () => toggleStrike(editor),
icon: StrikethroughIcon,
});
export const StrikeThroughItem = (editor: Editor) =>
({
key: "strikethrough",
name: "Strikethrough",
isActive: () => editor?.isActive("strike"),
command: () => toggleStrike(editor),
icon: StrikethroughIcon,
}) as const satisfies EditorMenuItem;
export const BulletListItem = (editor: Editor): EditorMenuItem => ({
key: "bulleted-list",
name: "Bulleted list",
isActive: () => editor?.isActive("bulletList"),
command: () => toggleBulletList(editor),
icon: ListIcon,
});
export const BulletListItem = (editor: Editor) =>
({
key: "bulleted-list",
name: "Bulleted list",
isActive: () => editor?.isActive("bulletList"),
command: () => toggleBulletList(editor),
icon: ListIcon,
}) as const satisfies EditorMenuItem;
export const NumberedListItem = (editor: Editor): EditorMenuItem => ({
key: "numbered-list",
name: "Numbered list",
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
});
export const NumberedListItem = (editor: Editor) =>
({
key: "numbered-list",
name: "Numbered list",
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
}) as const satisfies EditorMenuItem;
export const TodoListItem = (editor: Editor): EditorMenuItem => ({
key: "to-do-list",
name: "To-do list",
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
icon: CheckSquare,
});
export const TodoListItem = (editor: Editor) =>
({
key: "to-do-list",
name: "To-do list",
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
icon: CheckSquare,
}) as const satisfies EditorMenuItem;
export const QuoteItem = (editor: Editor): EditorMenuItem => ({
key: "quote",
name: "Quote",
isActive: () => editor?.isActive("blockquote"),
command: () => toggleBlockquote(editor),
icon: QuoteIcon,
});
export const QuoteItem = (editor: Editor) =>
({
key: "quote",
name: "Quote",
isActive: () => editor?.isActive("blockquote"),
command: () => toggleBlockquote(editor),
icon: QuoteIcon,
}) as const satisfies EditorMenuItem;
export const CodeItem = (editor: Editor): EditorMenuItem => ({
key: "code",
name: "Code",
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
command: () => toggleCodeBlock(editor),
icon: CodeIcon,
});
export const CodeItem = (editor: Editor) =>
({
key: "code",
name: "Code",
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
command: () => toggleCodeBlock(editor),
icon: CodeIcon,
}) as const satisfies EditorMenuItem;
export const TableItem = (editor: Editor): EditorMenuItem => ({
key: "table",
name: "Table",
isActive: () => editor?.isActive("table"),
command: () => insertTableCommand(editor),
icon: TableIcon,
});
export const TableItem = (editor: Editor) =>
({
key: "table",
name: "Table",
isActive: () => editor?.isActive("table"),
command: () => insertTableCommand(editor),
icon: TableIcon,
}) as const satisfies EditorMenuItem;
export const ImageItem = (editor: Editor, uploadFile: UploadImage) =>
({
@@ -223,3 +240,6 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
ImageItem(editor, uploadFile),
];
}
export type EditorMenuItemNames =
ReturnType<typeof getEditorMenuItems> extends (infer U)[] ? (U extends { key: infer N } ? N : never) : never;
@@ -33,7 +33,7 @@ export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
return {
HTMLAttributes: {
class:
"rounded bg-custom-background-80 px-[6px] py-[1.5px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200",
"rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200",
spellcheck: "false",
},
};
@@ -40,9 +40,9 @@ export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node })
<button
type="button"
className={cn(
"group/button hidden group-hover/code:flex items-center justify-center absolute top-2 right-2 z-10 size-8 rounded-md bg-custom-background-80 border border-custom-border-200 transition duration-150 ease-in-out backdrop-blur-sm",
"group/button hidden group-hover/code:flex items-center justify-center absolute top-2 right-2 z-10 size-8 rounded-md bg-custom-background-80 border border-custom-border-200 transition duration-150 ease-in-out",
{
"bg-green-500/30 hover:bg-green-500/30 active:bg-green-500/30": copied,
"bg-green-500/10 hover:bg-green-500/10 active:bg-green-500/10": copied,
}
)}
onClick={copyToClipboard}
@@ -55,7 +55,7 @@ export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node })
</button>
</Tooltip>
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-4 my-2">
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-8 pl-9 pr-4">
<NodeViewContent as="code" className="whitespace-[pre-wrap]" />
</pre>
</NodeViewWrapper>
@@ -30,25 +30,23 @@ import { isValidHttpUrl } from "@/helpers/common";
import { DeleteImage, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types";
type TArguments = {
enableHistory: boolean;
mentionConfig: {
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
mentionHighlights?: () => Promise<IMentionHighlight[]>;
};
fileConfig: {
deleteFile: DeleteImage;
restoreFile: RestoreImage;
cancelUploadImage?: () => void;
uploadFile: UploadImage;
};
mentionConfig: {
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
mentionHighlights?: () => Promise<IMentionHighlight[]>;
};
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
};
export const CoreEditorExtensions = ({
enableHistory,
fileConfig: { deleteFile, restoreFile, cancelUploadImage, uploadFile },
mentionConfig,
fileConfig: { deleteFile, restoreFile, cancelUploadImage, uploadFile },
placeholder,
tabIndex,
}: TArguments) => [
@@ -72,11 +70,11 @@ export const CoreEditorExtensions = ({
codeBlock: false,
horizontalRule: false,
blockquote: false,
history: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 1,
},
...(enableHistory ? {} : { history: false }),
}),
CustomQuoteExtension,
DropHandlerExtension(uploadFile),
@@ -146,11 +146,10 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
<div className="text-center text-custom-text-400">Loading...</div>
) : items.length ? (
items.map((item, index) => (
<button
<div
key={item.id}
type="button"
className={cn(
"w-full text-left flex cursor-pointer items-center gap-2 rounded px-1 py-1.5 hover:bg-custom-background-80 text-custom-text-200",
"flex cursor-pointer items-center gap-2 rounded px-1 py-1.5 hover:bg-custom-background-80 text-custom-text-200",
{
"bg-custom-background-80": index === selectedIndex,
}
@@ -159,7 +158,7 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
>
<Avatar name={item?.title} src={item?.avatar} />
<span className="flex-grow truncate">{item.title}</span>
</button>
</div>
))
) : (
<div className="text-center text-custom-text-400">No results</div>
@@ -9,9 +9,6 @@ import {
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
ImageIcon,
List,
ListOrdered,
@@ -32,9 +29,6 @@ import {
toggleHeadingOne,
toggleHeadingTwo,
toggleHeadingThree,
toggleHeadingFour,
toggleHeadingFive,
toggleHeadingSix,
} from "@/helpers/editor-commands";
// types
import { CommandProps, ISlashCommandItem, UploadImage } from "@/types";
@@ -97,7 +91,7 @@ const getSuggestionItems =
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <CaseSensitive className="size-3.5" />,
icon: <CaseSensitive className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
if (range) {
editor.chain().focus().deleteRange(range).clearNodes().run();
@@ -106,91 +100,61 @@ const getSuggestionItems =
},
},
{
key: "h1",
key: "heading_1",
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: <Heading1 className="size-3.5" />,
icon: <Heading1 className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingOne(editor, range);
},
},
{
key: "h2",
key: "heading_2",
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 className="size-3.5" />,
icon: <Heading2 className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingTwo(editor, range);
},
},
{
key: "h3",
key: "heading_3",
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 className="size-3.5" />,
icon: <Heading3 className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingThree(editor, range);
},
},
{
key: "h4",
title: "Heading 4",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading4 className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingFour(editor, range);
},
},
{
key: "h5",
title: "Heading 5",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading5 className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingFive(editor, range);
},
},
{
key: "h6",
title: "Heading 6",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading6 className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingSix(editor, range);
},
},
{
key: "to-do-list",
key: "todo_list",
title: "To do",
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: <ListTodo className="size-3.5" />,
icon: <ListTodo className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleTaskList(editor, range);
},
},
{
key: "bulleted-list",
key: "bullet_list",
title: "Bullet list",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List className="size-3.5" />,
icon: <List className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleBulletList(editor, range);
},
},
{
key: "numbered-list",
key: "numbered_list",
title: "Numbered list",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered className="size-3.5" />,
icon: <ListOrdered className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleOrderedList(editor, range);
},
@@ -200,25 +164,25 @@ const getSuggestionItems =
title: "Table",
description: "Create a table",
searchTerms: ["table", "cell", "db", "data", "tabular"],
icon: <Table className="size-3.5" />,
icon: <Table className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
insertTableCommand(editor, range);
},
},
{
key: "quote",
key: "quote_block",
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <Quote className="size-3.5" />,
icon: <Quote className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => toggleBlockquote(editor, range),
},
{
key: "code",
key: "code_block",
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code2 className="size-3.5" />,
icon: <Code2 className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
@@ -226,7 +190,7 @@ const getSuggestionItems =
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["img", "photo", "picture", "media"],
icon: <ImageIcon className="size-3.5" />,
icon: <ImageIcon className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
insertImageCommand(editor, uploadFile, null, range);
},
@@ -236,7 +200,7 @@ const getSuggestionItems =
title: "Divider",
description: "Visually divide blocks.",
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
icon: <MinusSquare className="size-3.5" />,
icon: <MinusSquare className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
@@ -87,7 +87,6 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
id,
editorProps,
editorClassName,
enableHistory: false,
fileHandler,
handleEditorReady,
forwardedRef,
+32 -43
View File
@@ -3,7 +3,7 @@ import { Selection } from "@tiptap/pm/state";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
// components
import { getEditorMenuItems } from "@/components/menus";
import { EditorMenuItemNames, getEditorMenuItems } from "@/components/menus";
// extensions
import { CoreEditorExtensions } from "@/extensions";
// helpers
@@ -14,15 +14,7 @@ import { CollaborationProvider } from "@/plane-editor/providers";
// props
import { CoreEditorProps } from "@/props";
// types
import {
DeleteImage,
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
RestoreImage,
TEditorCommands,
UploadImage,
} from "@/types";
import { DeleteImage, EditorRefApi, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types";
export type TFileHandler = {
cancel: () => void;
@@ -32,44 +24,42 @@ export type TFileHandler = {
};
export interface CustomEditorProps {
editorClassName: string;
editorProps?: EditorProps;
enableHistory: boolean;
extensions?: any;
fileHandler: TFileHandler;
forwardedRef?: MutableRefObject<EditorRefApi | null>;
handleEditorReady?: (value: boolean) => void;
id?: string;
fileHandler: TFileHandler;
initialValue?: string;
editorClassName: string;
// undefined when prop is not passed, null if intentionally passed to stop
// swr syncing
value?: string | null | undefined;
provider?: CollaborationProvider;
onChange?: (json: object, html: string) => void;
extensions?: any;
editorProps?: EditorProps;
forwardedRef?: MutableRefObject<EditorRefApi | null>;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
suggestions?: () => Promise<IMentionSuggestion[]>;
};
onChange?: (json: object, html: string) => void;
handleEditorReady?: (value: boolean) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
provider?: CollaborationProvider;
tabIndex?: number;
// undefined when prop is not passed, null if intentionally passed to stop
// swr syncing
value?: string | null | undefined;
}
export const useEditor = ({
editorClassName,
id = "",
editorProps = {},
enableHistory,
initialValue,
editorClassName,
value,
extensions = [],
fileHandler,
forwardedRef,
handleEditorReady,
id = "",
initialValue,
mentionHandler,
onChange,
placeholder,
provider,
forwardedRef,
tabIndex,
value,
handleEditorReady,
provider,
mentionHandler,
placeholder,
}: CustomEditorProps) => {
const editor = useCustomEditor({
editorProps: {
@@ -78,17 +68,16 @@ export const useEditor = ({
},
extensions: [
...CoreEditorExtensions({
enableHistory,
mentionConfig: {
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
mentionHighlights: mentionHandler.highlights ?? [],
},
fileConfig: {
uploadFile: fileHandler.upload,
deleteFile: fileHandler.delete,
restoreFile: fileHandler.restore,
cancelUploadImage: fileHandler.cancel,
},
mentionConfig: {
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
mentionHighlights: mentionHandler.highlights ?? [],
},
placeholder,
tabIndex,
}),
@@ -155,12 +144,12 @@ export const useEditor = ({
insertContentAtSavedSelection(editorRef, content, savedSelection);
}
},
executeMenuItemCommand: (itemKey: TEditorCommands) => {
executeMenuItemCommand: (itemName: EditorMenuItemNames) => {
const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
const item = getEditorMenuItem(itemKey);
const item = getEditorMenuItem(itemName);
if (item) {
if (item.key === "image") {
item.command(savedSelectionRef.current);
@@ -168,13 +157,13 @@ export const useEditor = ({
item.command();
}
} else {
console.warn(`No command found for item: ${itemKey}`);
console.warn(`No command found for item: ${itemName}`);
}
},
isMenuItemActive: (itemName: TEditorCommands): boolean => {
isMenuItemActive: (itemName: EditorMenuItemNames): boolean => {
const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
const item = getEditorMenuItem(itemName);
return item ? item.isActive() : false;
},
+5 -3
View File
@@ -1,9 +1,11 @@
// components
import { EditorMenuItemNames } from "@/components/menus";
// helpers
import { IMarking } from "@/helpers/scroll-to-node";
// hooks
import { TFileHandler } from "@/hooks/use-editor";
// types
import { IMentionHighlight, IMentionSuggestion, TEditorCommands } from "@/types";
import { IMentionHighlight, IMentionSuggestion } from "@/types";
export type EditorReadOnlyRefApi = {
getMarkDown: () => string;
@@ -15,8 +17,8 @@ export type EditorReadOnlyRefApi = {
export interface EditorRefApi extends EditorReadOnlyRefApi {
setEditorValueAtCursorPosition: (content: string) => void;
executeMenuItemCommand: (itemKey: TEditorCommands) => void;
isMenuItemActive: (itemKey: TEditorCommands) => boolean;
executeMenuItemCommand: (itemName: EditorMenuItemNames) => void;
isMenuItemActive: (itemName: EditorMenuItemNames) => boolean;
onStateChange: (callback: () => void) => () => void;
setFocusAtPosition: (position: number) => void;
isEditorReadyToDiscard: () => boolean;
@@ -1,34 +1,13 @@
import { ReactNode } from "react";
import { Editor, Range } from "@tiptap/core";
export type TEditorCommands =
| "text"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "bold"
| "italic"
| "underline"
| "strikethrough"
| "bulleted-list"
| "numbered-list"
| "to-do-list"
| "quote"
| "code"
| "table"
| "image"
| "divider";
export type CommandProps = {
editor: Editor;
range: Range;
};
export type ISlashCommandItem = {
key: TEditorCommands;
key: string;
title: string;
description: string;
searchTerms: string[];
@@ -270,7 +270,7 @@ module.exports = {
"--tw-prose-headings": convertToRGB("--color-text-100"),
"--tw-prose-lead": convertToRGB("--color-text-100"),
"--tw-prose-links": convertToRGB("--color-primary-100"),
"--tw-prose-bold": "inherit",
"--tw-prose-bold": convertToRGB("--color-text-100"),
"--tw-prose-counters": convertToRGB("--color-text-100"),
"--tw-prose-bullets": convertToRGB("--color-text-100"),
"--tw-prose-hr": convertToRGB("--color-text-100"),
+2 -7
View File
@@ -1,4 +1,4 @@
import type {TIssue, IIssueFilterOptions} from "@plane/types";
import type { TIssue, IIssueFilterOptions } from "@plane/types";
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
@@ -61,10 +61,6 @@ export type TProgressSnapshot = {
estimate_distribution?: TCycleEstimateDistribution;
};
export interface IProjectDetails {
id: string;
}
export interface ICycle extends TProgressSnapshot {
progress_snapshot: TProgressSnapshot | undefined;
@@ -89,7 +85,6 @@ export interface ICycle extends TProgressSnapshot {
filters: IIssueFilterOptions;
};
workspace_id: string;
project_detail: IProjectDetails;
}
export interface CycleIssueResponse {
@@ -107,7 +102,7 @@ export interface CycleIssueResponse {
}
export type SelectCycleType =
| (ICycle & {actionType: "edit" | "delete" | "create-issue"})
| (ICycle & { actionType: "edit" | "delete" | "create-issue" })
| undefined;
export type CycleDateCheckData = {
-5
View File
@@ -55,9 +55,4 @@ export type TIssueActivityComment =
id: string;
activity_type: "ACTIVITY";
created_at?: string;
}
| {
id: string;
activity_type: "WORKLOG";
created_at?: string;
};
+4 -10
View File
@@ -42,10 +42,13 @@ export type TBaseIssue = {
export type TIssue = TBaseIssue & {
description_html?: string;
is_subscribed?: boolean;
parent?: partial<TIssue>;
issue_reactions?: TIssueReaction[];
issue_attachment?: TIssueAttachment[];
issue_link?: TIssueLink[];
// tempId is used for optimistic updates. It is not a part of the API response.
tempId?: string;
};
@@ -81,7 +84,7 @@ export type TIssuesResponse = {
total_pages: number;
extra_stats: null;
results: TIssueResponseResults;
};
}
export type TBulkIssueProperties = Pick<
TIssue,
@@ -91,18 +94,9 @@ export type TBulkIssueProperties = Pick<
| "assignee_ids"
| "start_date"
| "target_date"
| "module_ids"
| "cycle_id"
| "estimate_point"
>;
export type TBulkOperationsPayload = {
issue_ids: string[];
properties: Partial<TBulkIssueProperties>;
};
export type TIssueDetailWidget =
| "sub-issues"
| "relations"
| "links"
| "attachments";
-1
View File
@@ -35,7 +35,6 @@ export interface IProject {
anchor: string | null;
is_favorite: boolean;
is_member: boolean;
is_time_tracking_enabled: boolean;
logo_props: TLogoProps;
member_role: EUserProjectRoles | null;
members: IProjectMemberLite[];
-2
View File
@@ -202,6 +202,4 @@ export interface IssuePaginationOptions {
before?: string;
after?: string;
groupedBy?: TIssueGroupByOptions;
subGroupedBy?: TIssueGroupByOptions;
orderBy?: TIssueOrderByOptions;
}
-12
View File
@@ -25,21 +25,9 @@ export interface IProjectView {
workspace: string;
logo_props: TLogoProps | undefined;
is_locked: boolean;
anchor?: string;
owned_by: string;
}
export type TPublishViewSettings = {
is_comments_enabled: boolean;
is_reactions_enabled: boolean;
is_votes_enabled: boolean;
};
export type TPublishViewDetails = TPublishViewSettings & {
id: string;
anchor: string;
};
export type TViewFiltersSortKey = "name" | "created_at" | "updated_at";
export type TViewFiltersSortBy = "asc" | "desc";
-1
View File
@@ -51,7 +51,6 @@ export type TNotification = {
archived_at: string | undefined;
snoozed_till: string | undefined;
is_inbox_issue: boolean | undefined;
is_mentioned_notification: boolean | undefined;
workspace: string | undefined;
project: string | undefined;
created_at: string | undefined;
+2 -32
View File
@@ -1,6 +1,5 @@
import {EUserWorkspaceRoles} from "@/constants/workspace";
import { EUserWorkspaceRoles } from "@/constants/workspace";
import type {
ICycle,
IProjectMember,
IUser,
IUserLite,
@@ -47,7 +46,7 @@ export interface IWorkspaceMemberInvitation {
}
export interface IWorkspaceBulkInviteFormData {
emails: {email: string; role: EUserWorkspaceRoles}[];
emails: { email: string; role: EUserWorkspaceRoles }[];
}
export type Properties = {
@@ -70,13 +69,6 @@ export interface IWorkspaceMember {
id: string;
member: IUserLite;
role: EUserWorkspaceRoles;
created_at?: string;
avatar?: string;
email?: string;
first_name?: string;
last_name?: string;
joining_date?: string;
display_name?: string;
}
export interface IWorkspaceMemberMe {
@@ -198,25 +190,3 @@ export interface IProductUpdateResponse {
eyes: number;
};
}
export interface IWorkspaceActiveCyclesResponse {
count: number;
extra_stats: null;
next_cursor: string;
next_page_results: boolean;
prev_cursor: string;
prev_page_results: boolean;
results: ICycle[];
total_pages: number;
}
export interface IWorkspaceProgressResponse {
completed_issues: number;
total_issues: number;
started_issues: number;
cancelled_issues: number;
unstarted_issues: number;
}
export interface IWorkspaceAnalyticsResponse {
completion_chart: any;
}
+2 -2
View File
@@ -26,7 +26,7 @@ const ToggleSwitch: React.FC<IToggleSwitchProps> = (props) => {
"h-4 w-6": size === "sm",
"h-5 w-8": size === "md",
"bg-custom-primary-100": value,
"cursor-not-allowed bg-custom-background-80": disabled,
"cursor-not-allowed": disabled,
},
className
)}
@@ -43,7 +43,7 @@ const ToggleSwitch: React.FC<IToggleSwitchProps> = (props) => {
"translate-x-3": value && size === "sm",
"translate-x-4": value && size === "md",
"translate-x-0.5 bg-custom-background-90": !value,
"cursor-not-allowed bg-custom-background-90": disabled,
"cursor-not-allowed": disabled,
}
)}
/>
@@ -13,7 +13,7 @@ type Props = {
export const CollapsibleButton: FC<Props> = (props) => {
const { isOpen, title, hideChevron = false, indicatorElement, actionItemElement } = props;
return (
<div className="flex items-center justify-between gap-3 h-12 px-2.5 py-3 border-b border-custom-border-200">
<div className="flex items-center justify-between gap-3 h-12 px-2.5 py-3 border-b border-custom-border-100">
<div className="flex items-center gap-3.5">
<div className="flex items-center gap-3">
{!hideChevron && (
+2 -3
View File
@@ -4,7 +4,6 @@ import { Disclosure, Transition } from "@headlessui/react";
export type TCollapsibleProps = {
title: string | React.ReactNode;
children: React.ReactNode;
className?: string;
buttonClassName?: string;
isOpen?: boolean;
onToggle?: () => void;
@@ -12,7 +11,7 @@ export type TCollapsibleProps = {
};
export const Collapsible: FC<TCollapsibleProps> = (props) => {
const { title, children, className, buttonClassName, isOpen, onToggle, defaultOpen } = props;
const { title, children, buttonClassName, isOpen, onToggle, defaultOpen } = props;
// state
const [localIsOpen, setLocalIsOpen] = useState<boolean>(isOpen || defaultOpen ? true : false);
@@ -32,7 +31,7 @@ export const Collapsible: FC<TCollapsibleProps> = (props) => {
}, [isOpen, onToggle]);
return (
<Disclosure as="div" className={className}>
<Disclosure>
<Disclosure.Button className={buttonClassName} onClick={handleOnClick}>
{title}
</Disclosure.Button>
@@ -137,7 +137,6 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
onClick={handleMenuButtonClick}
className={customButtonClassName}
tabIndex={customButtonTabIndex}
disabled={disabled}
>
{customButton}
</button>
@@ -173,7 +172,6 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
} ${buttonClassName}`}
onClick={handleMenuButtonClick}
tabIndex={customButtonTabIndex}
disabled={disabled}
>
{label}
{!noChevron && <ChevronDown className="h-3.5 w-3.5" />}
@@ -115,7 +115,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
onClick={toggleDropdown}
>
{label}
{!noChevron && !disabled && <ChevronDown className="h-3 w-3 flex-shrink-0" aria-hidden="true" />}
{!noChevron && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
</button>
</Combobox.Button>
)}
+5 -10
View File
@@ -82,16 +82,11 @@ const CustomSelect = (props: ICustomSelectProps) => {
<button
ref={setReferenceElement}
type="button"
className={cn(
"flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300",
{
"px-3 py-2 text-sm": input,
"px-2 py-1 text-xs": !input,
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer hover:bg-custom-background-80": !disabled,
},
buttonClassName
)}
className={`flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 ${
input ? "px-3 py-2 text-sm" : "px-2 py-1 text-xs"
} ${
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
onClick={toggleDropdown}
>
{label}
-2
View File
@@ -22,5 +22,3 @@ export * from "./side-panel-icon";
export * from "./transfer-icon";
export * from "./info-icon";
export * from "./dropdown-icon";
export * from "./intake";
export * from "./user-activity-icon";
-22
View File
@@ -1,22 +0,0 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const Intake: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg
viewBox="0 0 16 16"
className={`${className}`}
stroke="currentColor"
fill="none"
xmlns="http://www.w3.org/2000/svg"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
{...rest}
>
<path d="M12.1599 3.59961V9.60688L8.04358 12.0796V6.04325L12.1599 3.59961Z" />
<path d="M5.98547 10.8657V4.82938L10.1018 2.38574" />
<path d="M3.89087 9.60695V3.57059L8.00723 1.12695" />
<path d="M1.06909 8.77051V13.3887C1.06909 14.1814 1.71636 14.8287 2.50909 14.8287H13.4909C14.2836 14.8287 14.9309 14.1814 14.9309 13.3887V8.77051" />
</svg>
);
@@ -1,21 +0,0 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const UserActivityIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg
viewBox="0 0 24 24"
className={`${className} stroke-2`}
stroke="currentColor"
fill="none"
strokeWidth="1.5"
xmlns="http://www.w3.org/2000/svg"
strokeLinecap="round"
strokeLinejoin="round"
{...rest}
>
<path d="M10.5 13C12.9853 13 15 10.9853 15 8.5C15 6.01472 12.9853 4 10.5 4C8.01472 4 6 6.01472 6 8.5C6 10.9853 8.01472 13 10.5 13Z" />
<path d="M13.9062 13.5903C12.8368 13.1001 11.661 12.8876 10.4877 12.9725C9.31437 13.0574 8.18144 13.437 7.19379 14.0761C6.20613 14.7152 5.39567 15.5931 4.83744 16.6286C4.2792 17.6641 3.99124 18.8237 4.0002 20" />
<path d="M21 16.5H19.6L18.2 20L16.8 13L15.4 16.5H14" />
</svg>
);
-1
View File
@@ -22,4 +22,3 @@ export * from "./favorite-star";
export * from "./loader";
export * from "./collapsible";
export * from "./popovers";
export * from "./tables";
+1 -1
View File
@@ -13,7 +13,7 @@ export type TModalVariant = "danger" | "primary";
type Props = {
content: React.ReactNode | string;
handleClose: () => void;
handleSubmit: () => void;
handleSubmit: () => Promise<void>;
hideIcon?: boolean;
isSubmitting: boolean;
isOpen: boolean;
-2
View File
@@ -8,6 +8,4 @@ export enum EModalWidth {
XXL = "sm:max-w-2xl",
XXXL = "sm:max-w-3xl",
XXXXL = "sm:max-w-4xl",
VXL = "sm:max-w-5xl",
VIXL = "sm:max-w-6xl",
}
+2 -11
View File
@@ -11,17 +11,9 @@ type Props = {
isOpen: boolean;
position?: EModalPosition;
width?: EModalWidth;
className?: string;
};
export const ModalCore: React.FC<Props> = (props) => {
const {
children,
handleClose,
isOpen,
position = EModalPosition.CENTER,
width = EModalWidth.XXL,
className = "",
} = props;
const { children, handleClose, isOpen, position = EModalPosition.CENTER, width = EModalWidth.XXL } = props;
return (
<Transition.Root show={isOpen} as={Fragment}>
@@ -52,8 +44,7 @@ export const ModalCore: React.FC<Props> = (props) => {
<Dialog.Panel
className={cn(
"relative transform rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all w-full",
width,
className
width
)}
>
{children}
@@ -12,13 +12,10 @@ export const PopoverMenu = <T,>(props: TPopoverMenu<T>) => {
popperPadding = 0,
buttonClassName = "",
button,
disabled,
panelClassName = "",
data,
popoverClassName = "",
keyExtractor,
render,
popoverButtonRef,
} = props;
return (
@@ -27,13 +24,10 @@ export const PopoverMenu = <T,>(props: TPopoverMenu<T>) => {
popperPadding={popperPadding}
buttonClassName={buttonClassName}
button={button}
disabled={disabled}
panelClassName={cn(
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2 text-xs shadow-custom-shadow-rg focus:outline-none",
panelClassName
)}
popoverClassName={popoverClassName}
popoverButtonRef={popoverButtonRef}
>
<Fragment>
{data.map((item, index) => (

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