mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
56 Commits
fix-pagina
...
test-paral
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28c9245fe1 | ||
|
|
d5cbe3283b | ||
|
|
ae931f8172 | ||
|
|
a8c6483c60 | ||
|
|
9c761a614f | ||
|
|
adf88a0f13 | ||
|
|
5d2983d027 | ||
|
|
8339daa3ee | ||
|
|
4a9e09a54a | ||
|
|
2c609670c8 | ||
|
|
dfcba4dfc1 | ||
|
|
d0e68cdcfb | ||
|
|
43103a1445 | ||
|
|
1c155f6cbe | ||
|
|
1707f4f282 | ||
|
|
c2c2ad0d7a | ||
|
|
1bf8f82ccb | ||
|
|
3bdd91e577 | ||
|
|
1f9c7a4b67 | ||
|
|
d1828c9496 | ||
|
|
3f87d8b99d | ||
|
|
aba6e603a3 | ||
|
|
b4f2176ffa | ||
|
|
4d978c1a8c | ||
|
|
58f203dd38 | ||
|
|
ca088a464f | ||
|
|
0d6e581789 | ||
|
|
c92129ef41 | ||
|
|
d22b633d50 | ||
|
|
a8b2bcc838 | ||
|
|
78481d45d4 | ||
|
|
3a6d3d4e82 | ||
|
|
66c2cbe7d6 | ||
|
|
f5027f4268 | ||
|
|
31fe9a1a02 | ||
|
|
2978593c63 | ||
|
|
8a05cd442c | ||
|
|
c6cdc12165 | ||
|
|
7b6a2343cb | ||
|
|
66aedafe8a | ||
|
|
7af9c7bc33 | ||
|
|
0839666d81 | ||
|
|
68a211d00e | ||
|
|
3545d94025 | ||
|
|
17e46c812a | ||
|
|
73455c8040 | ||
|
|
9c1c0ed166 | ||
|
|
ae45ff158a | ||
|
|
c6909604b1 | ||
|
|
b95d7716e2 | ||
|
|
8577a56068 | ||
|
|
2ee6cd20d8 | ||
|
|
8771c80c9b | ||
|
|
2ad1047323 | ||
|
|
1956da2b90 | ||
|
|
eca79f33b6 |
@@ -55,7 +55,6 @@ class IssueSerializer(BaseSerializer):
|
||||
"project",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
exclude = [
|
||||
|
||||
@@ -4,6 +4,7 @@ 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,
|
||||
@@ -12,4 +13,5 @@ urlpatterns = [
|
||||
*cycle_patterns,
|
||||
*module_patterns,
|
||||
*inbox_patterns,
|
||||
*member_patterns,
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ from plane.api.views import (
|
||||
IssueCommentAPIEndpoint,
|
||||
IssueActivityAPIEndpoint,
|
||||
WorkspaceIssueAPIEndpoint,
|
||||
IssueAttachmentEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -65,4 +66,9 @@ 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
apiserver/plane/api/urls/member.py
Normal file
13
apiserver/plane/api/urls/member.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views import (
|
||||
WorkspaceMemberAPIEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/members/",
|
||||
WorkspaceMemberAPIEndpoint.as_view(),
|
||||
name="users",
|
||||
),
|
||||
]
|
||||
@@ -9,6 +9,7 @@ from .issue import (
|
||||
IssueLinkAPIEndpoint,
|
||||
IssueCommentAPIEndpoint,
|
||||
IssueActivityAPIEndpoint,
|
||||
IssueAttachmentEndpoint,
|
||||
)
|
||||
|
||||
from .cycle import (
|
||||
@@ -24,4 +25,6 @@ from .module import (
|
||||
ModuleArchiveUnarchiveAPIEndpoint,
|
||||
)
|
||||
|
||||
from .member import WorkspaceMemberAPIEndpoint
|
||||
|
||||
from .inbox import InboxIssueAPIEndpoint
|
||||
|
||||
@@ -34,6 +34,7 @@ from plane.db.models import (
|
||||
Project,
|
||||
IssueAttachment,
|
||||
IssueLink,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
@@ -363,14 +364,28 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, slug, project_id, pk):
|
||||
cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
if cycle.owned_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the cycle"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(
|
||||
cycle_id=self.kwargs.get("pk")
|
||||
).values_list("issue", flat=True)
|
||||
)
|
||||
cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.deleted",
|
||||
@@ -393,7 +408,6 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
@@ -647,17 +661,6 @@ 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)
|
||||
|
||||
@@ -3,8 +3,11 @@ 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
|
||||
@@ -224,8 +227,27 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
||||
issue_data = request.data.pop("issue", False)
|
||||
|
||||
if bool(issue_data):
|
||||
issue = Issue.objects.get(
|
||||
pk=issue_id, workspace__slug=slug, project_id=project_id
|
||||
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,
|
||||
)
|
||||
# Only allow guests and viewers to edit name and description
|
||||
if project_member.role <= 10:
|
||||
@@ -368,29 +390,26 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
||||
inbox_id=inbox.id,
|
||||
)
|
||||
|
||||
# Get the project member
|
||||
project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Check the inbox issue created
|
||||
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
|
||||
request.user.id
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot delete inbox issue"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check the issue status
|
||||
if inbox_issue.status in [-2, -1, 0, 2]:
|
||||
# Delete the issue also
|
||||
Issue.objects.filter(
|
||||
issue = Issue.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=issue_id
|
||||
).delete()
|
||||
).first()
|
||||
if issue.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
issue.delete()
|
||||
|
||||
inbox_issue.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -22,9 +22,11 @@ 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,
|
||||
@@ -307,6 +309,14 @@ 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(
|
||||
@@ -379,6 +389,19 @@ class IssueAPIEndpoint(BaseAPIView):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
if issue.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
current_instance = json.dumps(
|
||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
@@ -874,3 +897,83 @@ 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
apiserver/plane/api/views/member.py
Normal file
147
apiserver/plane/api/views/member.py
Normal file
@@ -0,0 +1,147 @@
|
||||
# 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)
|
||||
@@ -27,6 +27,7 @@ from plane.db.models import (
|
||||
ModuleIssue,
|
||||
ModuleLink,
|
||||
Project,
|
||||
ProjectMember,
|
||||
)
|
||||
|
||||
from .base import BaseAPIView
|
||||
@@ -265,6 +266,20 @@ class ModuleAPIEndpoint(BaseAPIView):
|
||||
module = Module.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
if module.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the module"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
module_issues = list(
|
||||
ModuleIssue.objects.filter(module_id=pk).values_list(
|
||||
"issue", flat=True
|
||||
|
||||
@@ -47,6 +47,7 @@ from plane.db.models import (
|
||||
Label,
|
||||
User,
|
||||
Project,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
@@ -1039,14 +1040,28 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
if cycle.owned_by_id != request.user.id and not (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or owner can delete the cycle"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(
|
||||
cycle_id=self.kwargs.get("pk")
|
||||
).values_list("issue", flat=True)
|
||||
)
|
||||
cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.deleted",
|
||||
|
||||
@@ -41,6 +41,7 @@ class ExportIssuesEndpoint(BaseAPIView):
|
||||
project=project_ids,
|
||||
initiated_by=request.user,
|
||||
provider=provider,
|
||||
type="issue_exports",
|
||||
)
|
||||
|
||||
issue_export_task.delay(
|
||||
@@ -65,7 +66,8 @@ class ExportIssuesEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request, slug):
|
||||
exporter_history = ExporterHistory.objects.filter(
|
||||
workspace__slug=slug
|
||||
workspace__slug=slug,
|
||||
type="issue_exports",
|
||||
).select_related("workspace", "initiated_by")
|
||||
|
||||
if request.GET.get("per_page", False) and request.GET.get(
|
||||
|
||||
@@ -553,28 +553,27 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
project_id=project_id,
|
||||
inbox_id=inbox_id,
|
||||
)
|
||||
# Get the project member
|
||||
project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
|
||||
request.user.id
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot delete inbox issue"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check the issue status
|
||||
if inbox_issue.status in [-2, -1, 0, 2]:
|
||||
# Delete the issue also
|
||||
Issue.objects.filter(
|
||||
issue = Issue.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=issue_id
|
||||
).delete()
|
||||
).first()
|
||||
if issue.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
issue.delete()
|
||||
|
||||
inbox_issue.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -44,6 +44,7 @@ from plane.db.models import (
|
||||
IssueReaction,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
@@ -549,6 +550,20 @@ class IssueViewSet(BaseViewSet):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
if issue.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
issue.delete()
|
||||
issue_activity.delay(
|
||||
type="issue.activity.deleted",
|
||||
@@ -602,6 +617,19 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
|
||||
]
|
||||
|
||||
def delete(self, request, slug, project_id):
|
||||
if ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists():
|
||||
|
||||
return Response(
|
||||
{"error": "Only admin can perform this action"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
issue_ids = request.data.get("issue_ids", [])
|
||||
|
||||
if not len(issue_ids):
|
||||
|
||||
@@ -40,6 +40,7 @@ from plane.db.models import (
|
||||
IssueReaction,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
@@ -380,6 +381,19 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
if issue.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
issue.delete()
|
||||
issue_activity.delay(
|
||||
type="issue_draft.activity.deleted",
|
||||
|
||||
@@ -37,24 +37,6 @@ 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(
|
||||
@@ -98,11 +80,8 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
).values_list("issue_id", flat=True)
|
||||
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
Issue.issue_objects
|
||||
.filter(workspace__slug=slug)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
|
||||
@@ -48,6 +48,7 @@ from plane.db.models import (
|
||||
ModuleLink,
|
||||
ModuleUserProperties,
|
||||
Project,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
@@ -737,6 +738,21 @@ class ModuleViewSet(BaseViewSet):
|
||||
module = Module.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
|
||||
if module.created_by_id != request.user.id and (
|
||||
not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or creator can delete the module"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
module_issues = list(
|
||||
ModuleIssue.objects.filter(module_id=pk).values_list(
|
||||
"issue", flat=True
|
||||
|
||||
@@ -333,6 +333,20 @@ class PageViewSet(BaseViewSet):
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
)
|
||||
|
||||
if not page.owned_by_id != request.user.id and not (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Only admin or owner can delete the page"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# only the owner and admin can delete the page
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
|
||||
@@ -116,6 +116,20 @@ class WorkspaceViewViewSet(BaseViewSet):
|
||||
pk=pk,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
if not (
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role=20,
|
||||
is_active=True,
|
||||
).exists()
|
||||
and workspace_view.owned_by_id != request.user.id
|
||||
):
|
||||
return Response(
|
||||
{"error": "You do not have permission to delete this view"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
workspace_member = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
@@ -412,14 +426,16 @@ class IssueViewViewSet(BaseViewSet):
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
project_member = ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=20,
|
||||
is_active=True,
|
||||
)
|
||||
if project_member.exists() or project_view.owned_by == request.user:
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=20,
|
||||
is_active=True,
|
||||
).exists()
|
||||
or project_view.owned_by_id == request.user.id
|
||||
):
|
||||
project_view.delete()
|
||||
else:
|
||||
return Response(
|
||||
|
||||
@@ -582,17 +582,18 @@ def create_issue_activity(
|
||||
issue_activities,
|
||||
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 = 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_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
|
||||
)
|
||||
@@ -1717,12 +1718,16 @@ 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=(
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -110,3 +110,5 @@ from .dashboard import Dashboard, DashboardWidget, Widget
|
||||
from .favorite import UserFavorite
|
||||
|
||||
from .issue_type import IssueType
|
||||
|
||||
from .recent_visit import UserRecentVisit
|
||||
@@ -7,8 +7,6 @@ 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.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
@@ -386,6 +384,8 @@ 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"
|
||||
@@ -578,9 +578,9 @@ class IssueSequence(ProjectBaseModel):
|
||||
Issue,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="issue_sequence",
|
||||
null=True,
|
||||
null=True, # This is set to null because we want to keep the sequence even if the issue is deleted
|
||||
)
|
||||
sequence = models.PositiveBigIntegerField(default=1)
|
||||
sequence = models.PositiveBigIntegerField(default=1, db_index=True)
|
||||
deleted = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -72,6 +72,7 @@ class Project(BaseModel):
|
||||
identifier = models.CharField(
|
||||
max_length=12,
|
||||
verbose_name="Project Identifier",
|
||||
db_index=True,
|
||||
)
|
||||
default_assignee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
@@ -117,9 +118,6 @@ 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"""
|
||||
@@ -222,7 +220,7 @@ class ProjectIdentifier(AuditModel):
|
||||
project = models.OneToOneField(
|
||||
Project, on_delete=models.CASCADE, related_name="project_identifier"
|
||||
)
|
||||
name = models.CharField(max_length=12)
|
||||
name = models.CharField(max_length=12, db_index=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "workspace"]
|
||||
|
||||
38
apiserver/plane/db/models/recent_visit.py
Normal file
38
apiserver/plane/db/models/recent_visit.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# 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}"
|
||||
@@ -3,7 +3,6 @@ from django.urls import path
|
||||
|
||||
from plane.space.views import (
|
||||
InboxIssuePublicViewSet,
|
||||
IssueVotePublicViewSet,
|
||||
WorkspaceProjectDeployBoardEndpoint,
|
||||
)
|
||||
|
||||
@@ -30,17 +29,6 @@ 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(),
|
||||
|
||||
@@ -6,6 +6,7 @@ from plane.space.views import (
|
||||
IssueCommentPublicViewSet,
|
||||
IssueReactionPublicViewSet,
|
||||
CommentReactionPublicViewSet,
|
||||
IssueVotePublicViewSet,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -73,4 +74,15 @@ 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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# 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,
|
||||
@@ -14,15 +17,15 @@ from django.db.models import (
|
||||
When,
|
||||
JSONField,
|
||||
Value,
|
||||
OuterRef,
|
||||
Func
|
||||
)
|
||||
|
||||
# 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
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView, BaseViewSet
|
||||
@@ -43,7 +46,6 @@ from plane.utils.paginator import (
|
||||
from plane.app.serializers import (
|
||||
CommentReactionSerializer,
|
||||
IssueCommentSerializer,
|
||||
IssuePublicSerializer,
|
||||
IssueReactionSerializer,
|
||||
IssueVoteSerializer,
|
||||
)
|
||||
@@ -57,6 +59,7 @@ from plane.db.models import (
|
||||
DeployBoard,
|
||||
IssueVote,
|
||||
ProjectPublicMember,
|
||||
IssueAttachment,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
@@ -102,6 +105,28 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
.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)
|
||||
|
||||
@@ -23,42 +23,42 @@ def filter_valid_uuids(uuid_list):
|
||||
|
||||
# Get the 2_weeks, 3_months
|
||||
def string_date_filter(
|
||||
filter, duration, subsequent, term, date_filter, offset
|
||||
issue_filter, duration, subsequent, term, date_filter, offset
|
||||
):
|
||||
now = timezone.now().date()
|
||||
if term == "months":
|
||||
if subsequent == "after":
|
||||
if offset == "fromnow":
|
||||
filter[f"{date_filter}__gte"] = now + timedelta(
|
||||
issue_filter[f"{date_filter}__gte"] = now + timedelta(
|
||||
days=duration * 30
|
||||
)
|
||||
else:
|
||||
filter[f"{date_filter}__gte"] = now - timedelta(
|
||||
issue_filter[f"{date_filter}__gte"] = now - timedelta(
|
||||
days=duration * 30
|
||||
)
|
||||
else:
|
||||
if offset == "fromnow":
|
||||
filter[f"{date_filter}__lte"] = now + timedelta(
|
||||
issue_filter[f"{date_filter}__lte"] = now + timedelta(
|
||||
days=duration * 30
|
||||
)
|
||||
else:
|
||||
filter[f"{date_filter}__lte"] = now - timedelta(
|
||||
issue_filter[f"{date_filter}__lte"] = now - timedelta(
|
||||
days=duration * 30
|
||||
)
|
||||
if term == "weeks":
|
||||
if subsequent == "after":
|
||||
if offset == "fromnow":
|
||||
filter[f"{date_filter}__gte"] = now + timedelta(weeks=duration)
|
||||
issue_filter[f"{date_filter}__gte"] = now + timedelta(weeks=duration)
|
||||
else:
|
||||
filter[f"{date_filter}__gte"] = now - timedelta(weeks=duration)
|
||||
issue_filter[f"{date_filter}__gte"] = now - timedelta(weeks=duration)
|
||||
else:
|
||||
if offset == "fromnow":
|
||||
filter[f"{date_filter}__lte"] = now + timedelta(weeks=duration)
|
||||
issue_filter[f"{date_filter}__lte"] = now + timedelta(weeks=duration)
|
||||
else:
|
||||
filter[f"{date_filter}__lte"] = now - timedelta(weeks=duration)
|
||||
issue_filter[f"{date_filter}__lte"] = now - timedelta(weeks=duration)
|
||||
|
||||
|
||||
def date_filter(filter, date_term, queries):
|
||||
def date_filter(issue_filter, date_term, queries):
|
||||
"""
|
||||
Handle all date filters
|
||||
"""
|
||||
@@ -71,7 +71,7 @@ def date_filter(filter, date_term, queries):
|
||||
if len(date_query) == 3:
|
||||
digit, term = date_query[0].split("_")
|
||||
string_date_filter(
|
||||
filter=filter,
|
||||
issue_filter=issue_filter,
|
||||
duration=int(digit),
|
||||
subsequent=date_query[1],
|
||||
term=term,
|
||||
@@ -80,32 +80,32 @@ def date_filter(filter, date_term, queries):
|
||||
)
|
||||
else:
|
||||
if "after" in date_query:
|
||||
filter[f"{date_term}__gte"] = date_query[0]
|
||||
issue_filter[f"{date_term}__gte"] = date_query[0]
|
||||
else:
|
||||
filter[f"{date_term}__lte"] = date_query[0]
|
||||
issue_filter[f"{date_term}__lte"] = date_query[0]
|
||||
else:
|
||||
filter[f"{date_term}__contains"] = date_query[0]
|
||||
issue_filter[f"{date_term}__contains"] = date_query[0]
|
||||
|
||||
|
||||
def filter_state(params, filter, method, prefix=""):
|
||||
def filter_state(params, issue_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:
|
||||
filter[f"{prefix}state__in"] = states
|
||||
issue_filter[f"{prefix}state__in"] = states
|
||||
else:
|
||||
if (
|
||||
params.get("state", None)
|
||||
and len(params.get("state"))
|
||||
and params.get("state") != "null"
|
||||
):
|
||||
filter[f"{prefix}state__in"] = params.get("state")
|
||||
return filter
|
||||
issue_filter[f"{prefix}state__in"] = params.get("state")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_state_group(params, filter, method, prefix=""):
|
||||
def filter_state_group(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
state_group = [
|
||||
item
|
||||
@@ -113,18 +113,18 @@ def filter_state_group(params, filter, method, prefix=""):
|
||||
if item != "null"
|
||||
]
|
||||
if len(state_group) and "" not in state_group:
|
||||
filter[f"{prefix}state__group__in"] = state_group
|
||||
issue_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"
|
||||
):
|
||||
filter[f"{prefix}state__group__in"] = params.get("state_group")
|
||||
return filter
|
||||
issue_filter[f"{prefix}state__group__in"] = params.get("state_group")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_estimate_point(params, filter, method, prefix=""):
|
||||
def filter_estimate_point(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
estimate_points = [
|
||||
item
|
||||
@@ -132,20 +132,20 @@ def filter_estimate_point(params, filter, method, prefix=""):
|
||||
if item != "null"
|
||||
]
|
||||
if len(estimate_points) and "" not in estimate_points:
|
||||
filter[f"{prefix}estimate_point__in"] = estimate_points
|
||||
issue_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"
|
||||
):
|
||||
filter[f"{prefix}estimate_point__in"] = params.get(
|
||||
issue_filter[f"{prefix}estimate_point__in"] = params.get(
|
||||
"estimate_point"
|
||||
)
|
||||
return filter
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_priority(params, filter, method, prefix=""):
|
||||
def filter_priority(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
priorities = [
|
||||
item
|
||||
@@ -153,58 +153,58 @@ def filter_priority(params, filter, method, prefix=""):
|
||||
if item != "null"
|
||||
]
|
||||
if len(priorities) and "" not in priorities:
|
||||
filter[f"{prefix}priority__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"
|
||||
):
|
||||
filter[f"{prefix}priority__in"] = params.get("priority")
|
||||
return filter
|
||||
issue_filter[f"{prefix}priority__in"] = params.get("priority")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_parent(params, filter, method, prefix=""):
|
||||
def filter_parent(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
parents = [
|
||||
item for item in params.get("parent").split(",") if item != "null"
|
||||
]
|
||||
if "None" in parents:
|
||||
filter[f"{prefix}parent__isnull"] = True
|
||||
issue_filter[f"{prefix}parent__isnull"] = True
|
||||
parents = filter_valid_uuids(parents)
|
||||
if len(parents) and "" not in parents:
|
||||
filter[f"{prefix}parent__in"] = parents
|
||||
issue_filter[f"{prefix}parent__in"] = parents
|
||||
else:
|
||||
if (
|
||||
params.get("parent", None)
|
||||
and len(params.get("parent"))
|
||||
and params.get("parent") != "null"
|
||||
):
|
||||
filter[f"{prefix}parent__in"] = params.get("parent")
|
||||
return filter
|
||||
issue_filter[f"{prefix}parent__in"] = params.get("parent")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_labels(params, filter, method, prefix=""):
|
||||
def filter_labels(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
labels = [
|
||||
item for item in params.get("labels").split(",") if item != "null"
|
||||
]
|
||||
if "None" in labels:
|
||||
filter[f"{prefix}labels__isnull"] = True
|
||||
issue_filter[f"{prefix}labels__isnull"] = True
|
||||
labels = filter_valid_uuids(labels)
|
||||
if len(labels) and "" not in labels:
|
||||
filter[f"{prefix}labels__in"] = labels
|
||||
issue_filter[f"{prefix}labels__in"] = labels
|
||||
else:
|
||||
if (
|
||||
params.get("labels", None)
|
||||
and len(params.get("labels"))
|
||||
and params.get("labels") != "null"
|
||||
):
|
||||
filter[f"{prefix}labels__in"] = params.get("labels")
|
||||
return filter
|
||||
issue_filter[f"{prefix}labels__in"] = params.get("labels")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_assignees(params, filter, method, prefix=""):
|
||||
def filter_assignees(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
assignees = [
|
||||
item
|
||||
@@ -212,21 +212,21 @@ def filter_assignees(params, filter, method, prefix=""):
|
||||
if item != "null"
|
||||
]
|
||||
if "None" in assignees:
|
||||
filter[f"{prefix}assignees__isnull"] = True
|
||||
issue_filter[f"{prefix}assignees__isnull"] = True
|
||||
assignees = filter_valid_uuids(assignees)
|
||||
if len(assignees) and "" not in assignees:
|
||||
filter[f"{prefix}assignees__in"] = assignees
|
||||
issue_filter[f"{prefix}assignees__in"] = assignees
|
||||
else:
|
||||
if (
|
||||
params.get("assignees", None)
|
||||
and len(params.get("assignees"))
|
||||
and params.get("assignees") != "null"
|
||||
):
|
||||
filter[f"{prefix}assignees__in"] = params.get("assignees")
|
||||
return filter
|
||||
issue_filter[f"{prefix}assignees__in"] = params.get("assignees")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_mentions(params, filter, method, prefix=""):
|
||||
def filter_mentions(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
mentions = [
|
||||
item
|
||||
@@ -235,20 +235,20 @@ def filter_mentions(params, filter, method, prefix=""):
|
||||
]
|
||||
mentions = filter_valid_uuids(mentions)
|
||||
if len(mentions) and "" not in mentions:
|
||||
filter[f"{prefix}issue_mention__mention__id__in"] = mentions
|
||||
issue_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"
|
||||
):
|
||||
filter[f"{prefix}issue_mention__mention__id__in"] = params.get(
|
||||
issue_filter[f"{prefix}issue_mention__mention__id__in"] = params.get(
|
||||
"mentions"
|
||||
)
|
||||
return filter
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_created_by(params, filter, method, prefix=""):
|
||||
def filter_created_by(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
created_bys = [
|
||||
item
|
||||
@@ -256,100 +256,100 @@ def filter_created_by(params, filter, method, prefix=""):
|
||||
if item != "null"
|
||||
]
|
||||
if "None" in created_bys:
|
||||
filter[f"{prefix}created_by__isnull"] = True
|
||||
issue_filter[f"{prefix}created_by__isnull"] = True
|
||||
created_bys = filter_valid_uuids(created_bys)
|
||||
if len(created_bys) and "" not in created_bys:
|
||||
filter[f"{prefix}created_by__in"] = created_bys
|
||||
issue_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"
|
||||
):
|
||||
filter[f"{prefix}created_by__in"] = params.get("created_by")
|
||||
return filter
|
||||
issue_filter[f"{prefix}created_by__in"] = params.get("created_by")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_name(params, filter, method, prefix=""):
|
||||
def filter_name(params, issue_filter, method, prefix=""):
|
||||
if params.get("name", "") != "":
|
||||
filter[f"{prefix}name__icontains"] = params.get("name")
|
||||
return filter
|
||||
issue_filter[f"{prefix}name__icontains"] = params.get("name")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_created_at(params, filter, method, prefix=""):
|
||||
def filter_created_at(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
created_ats = params.get("created_at").split(",")
|
||||
if len(created_ats) and "" not in created_ats:
|
||||
date_filter(
|
||||
filter=filter,
|
||||
issue_filter=issue_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(
|
||||
filter=filter,
|
||||
issue_filter=issue_filter,
|
||||
date_term=f"{prefix}created_at__date",
|
||||
queries=params.get("created_at", []),
|
||||
)
|
||||
return filter
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_updated_at(params, filter, method, prefix=""):
|
||||
def filter_updated_at(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
updated_ats = params.get("updated_at").split(",")
|
||||
if len(updated_ats) and "" not in updated_ats:
|
||||
date_filter(
|
||||
filter=filter,
|
||||
issue_filter=issue_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(
|
||||
filter=filter,
|
||||
issue_filter=issue_filter,
|
||||
date_term=f"{prefix}created_at__date",
|
||||
queries=params.get("updated_at", []),
|
||||
)
|
||||
return filter
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_start_date(params, filter, method, prefix=""):
|
||||
def filter_start_date(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
start_dates = params.get("start_date").split(",")
|
||||
if len(start_dates) and "" not in start_dates:
|
||||
date_filter(
|
||||
filter=filter,
|
||||
issue_filter=issue_filter,
|
||||
date_term=f"{prefix}start_date",
|
||||
queries=start_dates,
|
||||
)
|
||||
else:
|
||||
if params.get("start_date", None) and len(params.get("start_date")):
|
||||
filter[f"{prefix}start_date"] = params.get("start_date")
|
||||
return filter
|
||||
issue_filter[f"{prefix}start_date"] = params.get("start_date")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_target_date(params, filter, method, prefix=""):
|
||||
def filter_target_date(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
target_dates = params.get("target_date").split(",")
|
||||
if len(target_dates) and "" not in target_dates:
|
||||
date_filter(
|
||||
filter=filter,
|
||||
issue_filter=issue_filter,
|
||||
date_term=f"{prefix}target_date",
|
||||
queries=target_dates,
|
||||
)
|
||||
else:
|
||||
if params.get("target_date", None) and len(params.get("target_date")):
|
||||
filter[f"{prefix}target_date"] = params.get("target_date")
|
||||
return filter
|
||||
issue_filter[f"{prefix}target_date"] = params.get("target_date")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_completed_at(params, filter, method, prefix=""):
|
||||
def filter_completed_at(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
completed_ats = params.get("completed_at").split(",")
|
||||
if len(completed_ats) and "" not in completed_ats:
|
||||
date_filter(
|
||||
filter=filter,
|
||||
issue_filter=issue_filter,
|
||||
date_term=f"{prefix}completed_at__date",
|
||||
queries=completed_ats,
|
||||
)
|
||||
@@ -358,14 +358,14 @@ def filter_completed_at(params, filter, method, prefix=""):
|
||||
params.get("completed_at")
|
||||
):
|
||||
date_filter(
|
||||
filter=filter,
|
||||
issue_filter=issue_filter,
|
||||
date_term=f"{prefix}completed_at__date",
|
||||
queries=params.get("completed_at", []),
|
||||
)
|
||||
return filter
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_issue_state_type(params, filter, method, prefix=""):
|
||||
def filter_issue_state_type(params, issue_filter, method, prefix=""):
|
||||
type = params.get("type", "all")
|
||||
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
if type == "backlog":
|
||||
@@ -373,71 +373,71 @@ def filter_issue_state_type(params, filter, method, prefix=""):
|
||||
if type == "active":
|
||||
group = ["unstarted", "started"]
|
||||
|
||||
filter[f"{prefix}state__group__in"] = group
|
||||
return filter
|
||||
issue_filter[f"{prefix}state__group__in"] = group
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_project(params, filter, method, prefix=""):
|
||||
def filter_project(params, issue_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:
|
||||
filter[f"{prefix}project__in"] = projects
|
||||
issue_filter[f"{prefix}project__in"] = projects
|
||||
else:
|
||||
if (
|
||||
params.get("project", None)
|
||||
and len(params.get("project"))
|
||||
and params.get("project") != "null"
|
||||
):
|
||||
filter[f"{prefix}project__in"] = params.get("project")
|
||||
return filter
|
||||
issue_filter[f"{prefix}project__in"] = params.get("project")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_cycle(params, filter, method, prefix=""):
|
||||
def filter_cycle(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
cycles = [
|
||||
item for item in params.get("cycle").split(",") if item != "null"
|
||||
]
|
||||
if "None" in cycles:
|
||||
filter[f"{prefix}issue_cycle__cycle_id__isnull"] = True
|
||||
issue_filter[f"{prefix}issue_cycle__cycle_id__isnull"] = True
|
||||
cycles = filter_valid_uuids(cycles)
|
||||
if len(cycles) and "" not in cycles:
|
||||
filter[f"{prefix}issue_cycle__cycle_id__in"] = cycles
|
||||
issue_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"
|
||||
):
|
||||
filter[f"{prefix}issue_cycle__cycle_id__in"] = params.get("cycle")
|
||||
return filter
|
||||
issue_filter[f"{prefix}issue_cycle__cycle_id__in"] = params.get("cycle")
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_module(params, filter, method, prefix=""):
|
||||
def filter_module(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
modules = [
|
||||
item for item in params.get("module").split(",") if item != "null"
|
||||
]
|
||||
if "None" in modules:
|
||||
filter[f"{prefix}issue_module__module_id__isnull"] = True
|
||||
issue_filter[f"{prefix}issue_module__module_id__isnull"] = True
|
||||
modules = filter_valid_uuids(modules)
|
||||
if len(modules) and "" not in modules:
|
||||
filter[f"{prefix}issue_module__module_id__in"] = modules
|
||||
issue_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"
|
||||
):
|
||||
filter[f"{prefix}issue_module__module_id__in"] = params.get(
|
||||
issue_filter[f"{prefix}issue_module__module_id__in"] = params.get(
|
||||
"module"
|
||||
)
|
||||
return filter
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_inbox_status(params, filter, method, prefix=""):
|
||||
def filter_inbox_status(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
status = [
|
||||
item
|
||||
@@ -445,32 +445,32 @@ def filter_inbox_status(params, filter, method, prefix=""):
|
||||
if item != "null"
|
||||
]
|
||||
if len(status) and "" not in status:
|
||||
filter[f"{prefix}issue_inbox__status__in"] = status
|
||||
issue_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"
|
||||
):
|
||||
filter[f"{prefix}issue_inbox__status__in"] = params.get(
|
||||
issue_filter[f"{prefix}issue_inbox__status__in"] = params.get(
|
||||
"inbox_status"
|
||||
)
|
||||
return filter
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_sub_issue_toggle(params, filter, method, prefix=""):
|
||||
def filter_sub_issue_toggle(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
sub_issue = params.get("sub_issue", "false")
|
||||
if sub_issue == "false":
|
||||
filter[f"{prefix}parent__isnull"] = True
|
||||
issue_filter[f"{prefix}parent__isnull"] = True
|
||||
else:
|
||||
sub_issue = params.get("sub_issue", "false")
|
||||
if sub_issue == "false":
|
||||
filter[f"{prefix}parent__isnull"] = True
|
||||
return filter
|
||||
issue_filter[f"{prefix}parent__isnull"] = True
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_subscribed_issues(params, filter, method, prefix=""):
|
||||
def filter_subscribed_issues(params, issue_filter, method, prefix=""):
|
||||
if method == "GET":
|
||||
subscribers = [
|
||||
item
|
||||
@@ -479,7 +479,7 @@ def filter_subscribed_issues(params, filter, method, prefix=""):
|
||||
]
|
||||
subscribers = filter_valid_uuids(subscribers)
|
||||
if len(subscribers) and "" not in subscribers:
|
||||
filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
|
||||
issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
|
||||
subscribers
|
||||
)
|
||||
else:
|
||||
@@ -488,22 +488,44 @@ def filter_subscribed_issues(params, filter, method, prefix=""):
|
||||
and len(params.get("subscriber"))
|
||||
and params.get("subscriber") != "null"
|
||||
):
|
||||
filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
|
||||
issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
|
||||
params.get("subscriber")
|
||||
)
|
||||
return filter
|
||||
return issue_filter
|
||||
|
||||
|
||||
def filter_start_target_date_issues(params, filter, method, prefix=""):
|
||||
def filter_start_target_date_issues(params, issue_filter, method, prefix=""):
|
||||
start_target_date = params.get("start_target_date", "false")
|
||||
if start_target_date == "true":
|
||||
filter[f"{prefix}target_date__isnull"] = False
|
||||
filter[f"{prefix}start_date__isnull"] = False
|
||||
return filter
|
||||
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
|
||||
|
||||
|
||||
def issue_filters(query_params, method, prefix=""):
|
||||
filter = {}
|
||||
issue_filter = {}
|
||||
|
||||
ISSUE_FILTER = {
|
||||
"state": filter_state,
|
||||
@@ -515,6 +537,7 @@ 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,
|
||||
@@ -534,5 +557,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, filter, method, prefix)
|
||||
return filter
|
||||
func(query_params, issue_filter, method, prefix)
|
||||
return issue_filter
|
||||
|
||||
@@ -10,7 +10,7 @@ Let's get started!
|
||||
|
||||
<details>
|
||||
<summary>Option 1 - Using Cloud Server</summary>
|
||||
<p>Best way to start is to create EC2 maching on AWS. It must of minimum t3.medium/t3a/medium</p>
|
||||
<p>Best way to start is to create EC2 machine on AWS. It must have minimum of 2vCPU and 4GB RAM.</p>
|
||||
<p>Run the below command to install docker engine.</p>
|
||||
|
||||
`curl -fsSL https://get.docker.com | sh -`
|
||||
@@ -67,23 +67,6 @@ curl -fsSL -o setup.sh https://raw.githubusercontent.com/makeplane/plane/master/
|
||||
chmod +x setup.sh
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Downloading Preview Release</summary>
|
||||
|
||||
```
|
||||
mkdir plane-selfhost
|
||||
|
||||
cd plane-selfhost
|
||||
|
||||
export RELEASE=preview
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/install.sh | sed 's@BRANCH=master@BRANCH='"$RELEASE"'@' > setup.sh
|
||||
|
||||
chmod +x setup.sh
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Proceed with setup
|
||||
@@ -114,7 +97,7 @@ This will create a create a folder `plane-app` or `plane-app-preview` (in case o
|
||||
- `docker-compose.yaml`
|
||||
- `plane.env`
|
||||
|
||||
Again the `options [1-7]` will be popped up and this time hit `7` to exit.
|
||||
Again the `options [1-8]` will be popped up and this time hit `8` to exit.
|
||||
|
||||
---
|
||||
|
||||
@@ -236,7 +219,7 @@ Select a Action you want to perform:
|
||||
Action [2]: 5
|
||||
```
|
||||
|
||||
By choosing this, it will stop the services and then will download the latest `docker-compose.yaml` and `variables-upgrade.env`. Here system will not replace `.env` with the new one.
|
||||
By choosing this, it will stop the services and then will download the latest `docker-compose.yaml` and `plane.env`.
|
||||
|
||||
You must expect the below message
|
||||
|
||||
@@ -244,7 +227,7 @@ You must expect the below message
|
||||
|
||||
Once done, choose `8` to exit from prompt.
|
||||
|
||||
> It is very important for you to compare the 2 files `variables-upgrade.env` and `.env`. Copy the newly added variable from downloaded file to `.env` and set the expected values.
|
||||
> It is very important for you to validate the `plane.env` for the new changes.
|
||||
|
||||
Once done with making changes in `plane.env` file, jump on to `Start Server`
|
||||
|
||||
@@ -372,8 +355,60 @@ Backup completed successfully. Backup files are stored in /....../plane-app/back
|
||||
|
||||
---
|
||||
|
||||
### Restore Data
|
||||
|
||||
## Upgrading from v0.13.2 to v0.14.x
|
||||
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>
|
||||
|
||||
This is one time activity for users who are upgrading from v0.13.2 to v0.14.0
|
||||
|
||||
@@ -445,3 +480,4 @@ 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>
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
web:
|
||||
image: ${DOCKERHUB_USER:-local}/plane-frontend:${APP_RELEASE:-latest}
|
||||
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: ${PULL_POLICY:-always}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: node web/server.js web
|
||||
deploy:
|
||||
@@ -57,7 +57,7 @@ services:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: ${PULL_POLICY:-always}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: node space/server.js space
|
||||
deploy:
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: ${PULL_POLICY:-always}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: node admin/server.js admin
|
||||
deploy:
|
||||
@@ -84,7 +84,7 @@ services:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: ${PULL_POLICY:-always}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: ./bin/docker-entrypoint-api.sh
|
||||
deploy:
|
||||
@@ -99,7 +99,7 @@ services:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: ${PULL_POLICY:-always}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: ./bin/docker-entrypoint-worker.sh
|
||||
volumes:
|
||||
@@ -113,7 +113,7 @@ services:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: ${PULL_POLICY:-always}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: ./bin/docker-entrypoint-beat.sh
|
||||
volumes:
|
||||
@@ -127,7 +127,7 @@ services:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: ${PULL_POLICY:-always}
|
||||
pull_policy: if_not_present
|
||||
restart: "no"
|
||||
command: ./bin/docker-entrypoint-migrator.sh
|
||||
volumes:
|
||||
@@ -167,7 +167,8 @@ services:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: ${PULL_POLICY:-always}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${NGINX_PORT}:80
|
||||
depends_on:
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
BRANCH=master
|
||||
BRANCH=${BRANCH:-master}
|
||||
SCRIPT_DIR=$PWD
|
||||
SERVICE_FOLDER=plane-app
|
||||
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
|
||||
export APP_RELEASE=$BRANCH
|
||||
export APP_RELEASE="stable"
|
||||
export DOCKERHUB_USER=makeplane
|
||||
export PULL_POLICY=always
|
||||
USE_GLOBAL_IMAGES=1
|
||||
export PULL_POLICY=${PULL_POLICY:-if_not_present}
|
||||
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m' # No Color
|
||||
CPU_ARCH=$(uname -m)
|
||||
|
||||
mkdir -p $PLANE_INSTALL_DIR/archive
|
||||
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env
|
||||
|
||||
function print_header() {
|
||||
clear
|
||||
@@ -31,65 +31,191 @@ Project management tool from the future
|
||||
EOF
|
||||
}
|
||||
|
||||
function buildLocalImage() {
|
||||
if [ "$1" == "--force-build" ]; then
|
||||
DO_BUILD="1"
|
||||
elif [ "$1" == "--skip-build" ]; then
|
||||
DO_BUILD="2"
|
||||
else
|
||||
printf "\n" >&2
|
||||
printf "${YELLOW}You are on ${CPU_ARCH} cpu architecture. ${NC}\n" >&2
|
||||
printf "${YELLOW}Since the prebuilt ${CPU_ARCH} compatible docker images are not available for, we will be running the docker build on this system. ${NC} \n" >&2
|
||||
printf "${YELLOW}This might take ${YELLOW}5-30 min based on your system's hardware configuration. \n ${NC} \n" >&2
|
||||
printf "\n" >&2
|
||||
printf "${GREEN}Select an option to proceed: ${NC}\n" >&2
|
||||
printf " 1) Build Fresh Images \n" >&2
|
||||
printf " 2) Skip Building Images \n" >&2
|
||||
printf " 3) Exit \n" >&2
|
||||
printf "\n" >&2
|
||||
read -p "Select Option [1]: " DO_BUILD
|
||||
until [[ -z "$DO_BUILD" || "$DO_BUILD" =~ ^[1-3]$ ]]; do
|
||||
echo "$DO_BUILD: invalid selection." >&2
|
||||
read -p "Select Option [1]: " DO_BUILD
|
||||
done
|
||||
function spinner() {
|
||||
local pid=$1
|
||||
local delay=.5
|
||||
local spinstr='|/-\'
|
||||
|
||||
if ! ps -p "$pid" > /dev/null; then
|
||||
echo "Invalid PID: $pid"
|
||||
return 1
|
||||
fi
|
||||
while ps -p "$pid" > /dev/null; do
|
||||
local temp=${spinstr#?}
|
||||
printf " [%c] " "$spinstr" >&2
|
||||
local spinstr=$temp${spinstr%"$temp"}
|
||||
sleep $delay
|
||||
printf "\b\b\b\b\b\b" >&2
|
||||
done
|
||||
printf " \b\b\b\b" >&2
|
||||
}
|
||||
|
||||
function initialize(){
|
||||
printf "Please wait while we check the availability of Docker images for the selected release ($APP_RELEASE) with ${CPU_ARCH^^} support." >&2
|
||||
|
||||
if [ "$CUSTOM_BUILD" == "true" ]; then
|
||||
echo "" >&2
|
||||
echo "" >&2
|
||||
echo "${CPU_ARCH^^} images are not available for selected release ($APP_RELEASE)." >&2
|
||||
echo "build"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$DO_BUILD" == "1" ] || [ "$DO_BUILD" == "" ];
|
||||
then
|
||||
REPO=https://github.com/makeplane/plane.git
|
||||
CURR_DIR=$PWD
|
||||
PLANE_TEMP_CODE_DIR=$(mktemp -d)
|
||||
git clone $REPO $PLANE_TEMP_CODE_DIR --branch $BRANCH --single-branch
|
||||
local IMAGE_NAME=makeplane/plane-proxy
|
||||
local IMAGE_TAG=${APP_RELEASE}
|
||||
docker manifest inspect "${IMAGE_NAME}:${IMAGE_TAG}" | grep -q "\"architecture\": \"${CPU_ARCH}\"" &
|
||||
local pid=$!
|
||||
spinner "$pid"
|
||||
|
||||
echo "" >&2
|
||||
|
||||
cp $PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml $PLANE_TEMP_CODE_DIR/build.yml
|
||||
wait "$pid"
|
||||
|
||||
cd $PLANE_TEMP_CODE_DIR
|
||||
if [ "$BRANCH" == "master" ];
|
||||
then
|
||||
export APP_RELEASE=stable
|
||||
fi
|
||||
|
||||
/bin/bash -c "$COMPOSE_CMD -f build.yml build --no-cache" >&2
|
||||
# cd $CURR_DIR
|
||||
# rm -rf $PLANE_TEMP_CODE_DIR
|
||||
echo "build_completed"
|
||||
elif [ "$DO_BUILD" == "2" ];
|
||||
then
|
||||
printf "${YELLOW}Build action skipped by you in lieu of using existing images. ${NC} \n" >&2
|
||||
echo "build_skipped"
|
||||
elif [ "$DO_BUILD" == "3" ];
|
||||
then
|
||||
echo "build_exited"
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Plane supports ${CPU_ARCH}" >&2
|
||||
echo "available"
|
||||
return 0
|
||||
else
|
||||
printf "INVALID OPTION SUPPLIED" >&2
|
||||
echo "" >&2
|
||||
echo "" >&2
|
||||
echo "${CPU_ARCH^^} images are not available for selected release ($APP_RELEASE)." >&2
|
||||
echo "" >&2
|
||||
echo "build"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
function install() {
|
||||
echo "Installing Plane.........."
|
||||
download
|
||||
function getEnvValue() {
|
||||
local key=$1
|
||||
local file=$2
|
||||
|
||||
if [ -z "$key" ] || [ -z "$file" ]; then
|
||||
echo "Invalid arguments supplied"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$file" ]; then
|
||||
grep -q "^$key=" "$file"
|
||||
if [ $? -eq 0 ]; then
|
||||
local value
|
||||
value=$(grep "^$key=" "$file" | cut -d'=' -f2)
|
||||
echo "$value"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
}
|
||||
function updateEnvFile() {
|
||||
local key=$1
|
||||
local value=$2
|
||||
local file=$3
|
||||
|
||||
if [ -z "$key" ] || [ -z "$value" ] || [ -z "$file" ]; then
|
||||
echo "Invalid arguments supplied"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$file" ]; then
|
||||
# check if key exists in the file
|
||||
grep -q "^$key=" "$file"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "$key=$value" >> "$file"
|
||||
return
|
||||
else
|
||||
# if key exists, update the value
|
||||
sed -i "s/^$key=.*/$key=$value/g" "$file"
|
||||
fi
|
||||
else
|
||||
echo "File not found: $file"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function updateCustomVariables(){
|
||||
echo "Updating custom variables..." >&2
|
||||
updateEnvFile "DOCKERHUB_USER" "$DOCKERHUB_USER" "$DOCKER_ENV_PATH"
|
||||
updateEnvFile "APP_RELEASE" "$APP_RELEASE" "$DOCKER_ENV_PATH"
|
||||
updateEnvFile "PULL_POLICY" "$PULL_POLICY" "$DOCKER_ENV_PATH"
|
||||
updateEnvFile "CUSTOM_BUILD" "$CUSTOM_BUILD" "$DOCKER_ENV_PATH"
|
||||
echo "Custom variables updated successfully" >&2
|
||||
}
|
||||
|
||||
function syncEnvFile(){
|
||||
echo "Syncing environment variables..." >&2
|
||||
if [ -f "$PLANE_INSTALL_DIR/plane.env.bak" ]; then
|
||||
updateCustomVariables
|
||||
|
||||
# READ keys of plane.env and update the values from plane.env.bak
|
||||
while IFS= read -r line
|
||||
do
|
||||
# ignore is the line is empty or starts with #
|
||||
if [ -z "$line" ] || [[ $line == \#* ]]; then
|
||||
continue
|
||||
fi
|
||||
key=$(echo "$line" | cut -d'=' -f1)
|
||||
value=$(getEnvValue "$key" "$PLANE_INSTALL_DIR/plane.env.bak")
|
||||
if [ -n "$value" ]; then
|
||||
updateEnvFile "$key" "$value" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
done < "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
echo "Environment variables synced successfully" >&2
|
||||
}
|
||||
|
||||
function buildYourOwnImage(){
|
||||
echo "Building images locally..."
|
||||
|
||||
export DOCKERHUB_USER="myplane"
|
||||
export APP_RELEASE="local"
|
||||
export PULL_POLICY="never"
|
||||
CUSTOM_BUILD="true"
|
||||
|
||||
# checkout the code to ~/tmp/plane folder and build the images
|
||||
local PLANE_TEMP_CODE_DIR=~/tmp/plane
|
||||
rm -rf $PLANE_TEMP_CODE_DIR
|
||||
mkdir -p $PLANE_TEMP_CODE_DIR
|
||||
REPO=https://github.com/makeplane/plane.git
|
||||
git clone "$REPO" "$PLANE_TEMP_CODE_DIR" --branch "$BRANCH" --single-branch --depth 1
|
||||
|
||||
cp "$PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml" "$PLANE_TEMP_CODE_DIR/build.yml"
|
||||
|
||||
cd "$PLANE_TEMP_CODE_DIR" || exit
|
||||
|
||||
/bin/bash -c "$COMPOSE_CMD -f build.yml build --no-cache" >&2
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Build failed. Exiting..."
|
||||
exit 1
|
||||
fi
|
||||
echo "Build completed successfully"
|
||||
echo ""
|
||||
echo "You can now start the services by running the command: ./setup.sh start"
|
||||
echo ""
|
||||
}
|
||||
|
||||
function install() {
|
||||
echo "Begin Installing Plane"
|
||||
echo ""
|
||||
|
||||
local build_image=$(initialize)
|
||||
|
||||
if [ "$build_image" == "build" ]; then
|
||||
# ask for confirmation to continue building the images
|
||||
echo "Do you want to continue with building the Docker images locally?"
|
||||
read -p "Continue? [y/N]: " confirm
|
||||
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Exiting..."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$build_image" == "build" ]; then
|
||||
download "true"
|
||||
else
|
||||
download "false"
|
||||
fi
|
||||
}
|
||||
|
||||
function download() {
|
||||
local LOCAL_BUILD=$1
|
||||
cd $SCRIPT_DIR
|
||||
TS=$(date +%s)
|
||||
if [ -f "$PLANE_INSTALL_DIR/docker-compose.yaml" ]
|
||||
@@ -102,44 +228,48 @@ function download() {
|
||||
|
||||
if [ -f "$DOCKER_ENV_PATH" ];
|
||||
then
|
||||
cp $DOCKER_ENV_PATH $PLANE_INSTALL_DIR/archive/$TS.env
|
||||
else
|
||||
mv $PLANE_INSTALL_DIR/variables-upgrade.env $DOCKER_ENV_PATH
|
||||
cp "$DOCKER_ENV_PATH" "$PLANE_INSTALL_DIR/archive/$TS.env"
|
||||
cp "$DOCKER_ENV_PATH" "$PLANE_INSTALL_DIR/plane.env.bak"
|
||||
fi
|
||||
|
||||
if [ "$BRANCH" != "master" ];
|
||||
then
|
||||
cp $PLANE_INSTALL_DIR/docker-compose.yaml $PLANE_INSTALL_DIR/temp.yaml
|
||||
sed -e 's@${APP_RELEASE:-stable}@'"$BRANCH"'@g' \
|
||||
$PLANE_INSTALL_DIR/temp.yaml > $PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
mv $PLANE_INSTALL_DIR/variables-upgrade.env $DOCKER_ENV_PATH
|
||||
|
||||
rm $PLANE_INSTALL_DIR/temp.yaml
|
||||
fi
|
||||
syncEnvFile
|
||||
|
||||
if [ $USE_GLOBAL_IMAGES == 0 ]; then
|
||||
local res=$(buildLocalImage)
|
||||
# echo $res
|
||||
if [ "$LOCAL_BUILD" == "true" ]; then
|
||||
export DOCKERHUB_USER="myplane"
|
||||
export APP_RELEASE="local"
|
||||
export PULL_POLICY="never"
|
||||
CUSTOM_BUILD="true"
|
||||
|
||||
if [ "$res" == "build_exited" ];
|
||||
then
|
||||
echo
|
||||
echo "Install action cancelled by you. Exiting now."
|
||||
echo
|
||||
exit 0
|
||||
buildYourOwnImage
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo ""
|
||||
echo "Build failed. Exiting..."
|
||||
exit 1
|
||||
fi
|
||||
updateCustomVariables
|
||||
else
|
||||
/bin/bash -c "$COMPOSE_CMD -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH pull"
|
||||
CUSTOM_BUILD="false"
|
||||
updateCustomVariables
|
||||
/bin/bash -c "$COMPOSE_CMD -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH pull --policy always"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo ""
|
||||
echo "Failed to pull the images. Exiting..."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Most recent Stable version is now available for you to use"
|
||||
echo "Most recent version of Plane is now available for you to use"
|
||||
echo ""
|
||||
echo "In case of Upgrade, your new setting file is availabe as 'variables-upgrade.env'. Please compare and set the required values in 'plane.env 'file."
|
||||
echo "In case of 'Upgrade', please check the 'plane.env 'file for any new variables and update them accordingly"
|
||||
echo ""
|
||||
|
||||
}
|
||||
function startServices() {
|
||||
/bin/bash -c "$COMPOSE_CMD -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH up -d --quiet-pull"
|
||||
/bin/bash -c "$COMPOSE_CMD -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH up -d --pull if_not_present --quiet-pull"
|
||||
|
||||
local migrator_container_id=$(docker container ls -aq -f "name=$SERVICE_FOLDER-migrator")
|
||||
if [ -n "$migrator_container_id" ]; then
|
||||
@@ -201,7 +331,7 @@ function upgrade() {
|
||||
|
||||
echo
|
||||
echo "***** DOWNLOADING STABLE VERSION ****"
|
||||
download
|
||||
install
|
||||
|
||||
echo "***** PLEASE VALIDATE AND START SERVICES ****"
|
||||
}
|
||||
@@ -282,7 +412,6 @@ function viewLogs(){
|
||||
echo "INVALID SERVICE NAME SUPPLIED"
|
||||
fi
|
||||
}
|
||||
|
||||
function backupSingleVolume() {
|
||||
backupFolder=$1
|
||||
selectedVolume=$2
|
||||
@@ -299,7 +428,6 @@ function backupSingleVolume() {
|
||||
-v "$backupFolder":/backup \
|
||||
busybox sh -c 'tar -czf "/backup/${TAR_NAME}.tar.gz" /${TAR_NAME}'
|
||||
}
|
||||
|
||||
function backupData() {
|
||||
local datetime=$(date +"%Y%m%d-%H%M")
|
||||
local BACKUP_FOLDER=$PLANE_INSTALL_DIR/backup/$datetime
|
||||
@@ -329,7 +457,7 @@ function askForAction() {
|
||||
then
|
||||
echo
|
||||
echo "Select a Action you want to perform:"
|
||||
echo " 1) Install (${CPU_ARCH})"
|
||||
echo " 1) Install"
|
||||
echo " 2) Start"
|
||||
echo " 3) Stop"
|
||||
echo " 4) Restart"
|
||||
@@ -351,31 +479,31 @@ function askForAction() {
|
||||
echo
|
||||
fi
|
||||
|
||||
if [ "$ACTION" == "1" ] || [ "$DEFAULT_ACTION" == "install" ]
|
||||
if [ "$ACTION" == "1" ] || [ "$DEFAULT_ACTION" == "install" ];
|
||||
then
|
||||
install
|
||||
askForAction
|
||||
elif [ "$ACTION" == "2" ] || [ "$DEFAULT_ACTION" == "start" ]
|
||||
# askForAction
|
||||
elif [ "$ACTION" == "2" ] || [ "$DEFAULT_ACTION" == "start" ];
|
||||
then
|
||||
startServices
|
||||
# askForAction
|
||||
elif [ "$ACTION" == "3" ] || [ "$DEFAULT_ACTION" == "stop" ]
|
||||
elif [ "$ACTION" == "3" ] || [ "$DEFAULT_ACTION" == "stop" ];
|
||||
then
|
||||
stopServices
|
||||
# askForAction
|
||||
elif [ "$ACTION" == "4" ] || [ "$DEFAULT_ACTION" == "restart" ]
|
||||
elif [ "$ACTION" == "4" ] || [ "$DEFAULT_ACTION" == "restart" ];
|
||||
then
|
||||
restartServices
|
||||
# askForAction
|
||||
elif [ "$ACTION" == "5" ] || [ "$DEFAULT_ACTION" == "upgrade" ]
|
||||
elif [ "$ACTION" == "5" ] || [ "$DEFAULT_ACTION" == "upgrade" ];
|
||||
then
|
||||
upgrade
|
||||
askForAction
|
||||
elif [ "$ACTION" == "6" ] || [ "$DEFAULT_ACTION" == "logs" ]
|
||||
# askForAction
|
||||
elif [ "$ACTION" == "6" ] || [ "$DEFAULT_ACTION" == "logs" ];
|
||||
then
|
||||
viewLogs $@
|
||||
viewLogs "$@"
|
||||
askForAction
|
||||
elif [ "$ACTION" == "7" ] || [ "$DEFAULT_ACTION" == "backup" ]
|
||||
elif [ "$ACTION" == "7" ] || [ "$DEFAULT_ACTION" == "backup" ];
|
||||
then
|
||||
backupData
|
||||
elif [ "$ACTION" == "8" ]
|
||||
@@ -394,48 +522,38 @@ else
|
||||
COMPOSE_CMD="docker compose"
|
||||
fi
|
||||
|
||||
# CPU ARCHITECHTURE BASED SETTINGS
|
||||
CPU_ARCH=$(uname -m)
|
||||
if [[ $FORCE_CPU == "amd64" || $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]];
|
||||
then
|
||||
USE_GLOBAL_IMAGES=1
|
||||
DOCKERHUB_USER=makeplane
|
||||
PULL_POLICY=always
|
||||
else
|
||||
USE_GLOBAL_IMAGES=0
|
||||
DOCKERHUB_USER=myplane
|
||||
PULL_POLICY=never
|
||||
if [ "$CPU_ARCH" == "x86_64" ] || [ "$CPU_ARCH" == "amd64" ]; then
|
||||
CPU_ARCH="amd64"
|
||||
elif [ "$CPU_ARCH" == "aarch64" ] || [ "$CPU_ARCH" == "arm64" ]; then
|
||||
CPU_ARCH="arm64"
|
||||
fi
|
||||
|
||||
if [ "$BRANCH" == "master" ];
|
||||
then
|
||||
export APP_RELEASE=stable
|
||||
fi
|
||||
if [ -f "$DOCKER_ENV_PATH" ]; then
|
||||
DOCKERHUB_USER=$(getEnvValue "DOCKERHUB_USER" "$DOCKER_ENV_PATH")
|
||||
APP_RELEASE=$(getEnvValue "APP_RELEASE" "$DOCKER_ENV_PATH")
|
||||
PULL_POLICY=$(getEnvValue "PULL_POLICY" "$DOCKER_ENV_PATH")
|
||||
CUSTOM_BUILD=$(getEnvValue "CUSTOM_BUILD" "$DOCKER_ENV_PATH")
|
||||
|
||||
# REMOVE SPECIAL CHARACTERS FROM BRANCH NAME
|
||||
if [ "$BRANCH" != "master" ];
|
||||
then
|
||||
SERVICE_FOLDER=plane-app-$(echo $BRANCH | sed -r 's@(\/|" "|\.)@-@g')
|
||||
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
|
||||
fi
|
||||
mkdir -p $PLANE_INSTALL_DIR/archive
|
||||
if [ -z "$DOCKERHUB_USER" ]; then
|
||||
DOCKERHUB_USER=makeplane
|
||||
updateEnvFile "DOCKERHUB_USER" "$DOCKERHUB_USER" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
|
||||
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env
|
||||
if [ -z "$APP_RELEASE" ]; then
|
||||
APP_RELEASE=stable
|
||||
updateEnvFile "APP_RELEASE" "$APP_RELEASE" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
|
||||
# BACKWARD COMPATIBILITY
|
||||
OLD_DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/.env
|
||||
if [ -f "$OLD_DOCKER_ENV_PATH" ];
|
||||
then
|
||||
mv "$OLD_DOCKER_ENV_PATH" "$DOCKER_ENV_PATH"
|
||||
OS_NAME=$(uname)
|
||||
if [ "$OS_NAME" == "Darwin" ];
|
||||
then
|
||||
sed -i '' -e 's@APP_RELEASE=latest@APP_RELEASE=stable@' "$DOCKER_ENV_PATH"
|
||||
else
|
||||
sed -i -e 's@APP_RELEASE=latest@APP_RELEASE=stable@' "$DOCKER_ENV_PATH"
|
||||
if [ -z "$PULL_POLICY" ]; then
|
||||
PULL_POLICY=if_not_present
|
||||
updateEnvFile "PULL_POLICY" "$PULL_POLICY" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
|
||||
if [ -z "$CUSTOM_BUILD" ]; then
|
||||
CUSTOM_BUILD=false
|
||||
updateEnvFile "CUSTOM_BUILD" "$CUSTOM_BUILD" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
print_header
|
||||
askForAction $@
|
||||
askForAction "$@"
|
||||
|
||||
121
deploy/selfhost/restore.sh
Executable file
121
deploy/selfhost/restore.sh
Executable file
@@ -0,0 +1,121 @@
|
||||
#!/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,3 +1,4 @@
|
||||
APP_DOMAIN=localhost
|
||||
APP_RELEASE=stable
|
||||
|
||||
WEB_REPLICAS=1
|
||||
@@ -6,11 +7,11 @@ ADMIN_REPLICAS=1
|
||||
API_REPLICAS=1
|
||||
|
||||
NGINX_PORT=80
|
||||
WEB_URL=http://localhost
|
||||
WEB_URL=http://${APP_DOMAIN}
|
||||
DEBUG=0
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENVIRONMENT=production
|
||||
CORS_ALLOWED_ORIGINS=http://localhost
|
||||
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN}
|
||||
|
||||
#DB SETTINGS
|
||||
PGHOST=plane-db
|
||||
@@ -46,4 +47,5 @@ FILE_SIZE_LIMIT=5242880
|
||||
GUNICORN_WORKERS=1
|
||||
|
||||
# UNCOMMENT `DOCKER_PLATFORM` IF YOU ARE ON `ARM64` AND DOCKER IMAGE IS NOT AVAILABLE FOR RESPECTIVE `APP_RELEASE`
|
||||
# DOCKER_PLATFORM=linux/amd64
|
||||
# DOCKER_PLATFORM=linux/amd64
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"prettier": "latest",
|
||||
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"turbo": "^2.0.6"
|
||||
"turbo": "^2.0.9"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "18.2.48"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import {
|
||||
BoldIcon,
|
||||
Heading1,
|
||||
@@ -19,8 +21,6 @@ import {
|
||||
CaseSensitive,
|
||||
LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { Editor } from "@tiptap/react";
|
||||
// helpers
|
||||
import {
|
||||
insertImageCommand,
|
||||
@@ -43,168 +43,151 @@ import {
|
||||
toggleUnderline,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { UploadImage } from "@/types";
|
||||
import { TEditorCommands, UploadImage } from "@/types";
|
||||
|
||||
export interface EditorMenuItem {
|
||||
key: string;
|
||||
key: TEditorCommands;
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const TextItem = (editor: Editor) =>
|
||||
({
|
||||
key: "text",
|
||||
name: "Text",
|
||||
isActive: () => editor.isActive("paragraph"),
|
||||
command: () => setText(editor),
|
||||
icon: CaseSensitive,
|
||||
}) as const satisfies EditorMenuItem;
|
||||
export const TextItem = (editor: Editor): EditorMenuItem => ({
|
||||
key: "text",
|
||||
name: "Text",
|
||||
isActive: () => editor.isActive("paragraph"),
|
||||
command: () => setText(editor),
|
||||
icon: CaseSensitive,
|
||||
});
|
||||
|
||||
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 HeadingOneItem = (editor: Editor): EditorMenuItem => ({
|
||||
key: "h1",
|
||||
name: "Heading 1",
|
||||
isActive: () => editor.isActive("heading", { level: 1 }),
|
||||
command: () => toggleHeadingOne(editor),
|
||||
icon: Heading1,
|
||||
});
|
||||
|
||||
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 HeadingTwoItem = (editor: Editor): EditorMenuItem => ({
|
||||
key: "h2",
|
||||
name: "Heading 2",
|
||||
isActive: () => editor.isActive("heading", { level: 2 }),
|
||||
command: () => toggleHeadingTwo(editor),
|
||||
icon: Heading2,
|
||||
});
|
||||
|
||||
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 HeadingThreeItem = (editor: Editor): EditorMenuItem => ({
|
||||
key: "h3",
|
||||
name: "Heading 3",
|
||||
isActive: () => editor.isActive("heading", { level: 3 }),
|
||||
command: () => toggleHeadingThree(editor),
|
||||
icon: Heading3,
|
||||
});
|
||||
|
||||
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 HeadingFourItem = (editor: Editor): EditorMenuItem => ({
|
||||
key: "h4",
|
||||
name: "Heading 4",
|
||||
isActive: () => editor.isActive("heading", { level: 4 }),
|
||||
command: () => toggleHeadingFour(editor),
|
||||
icon: Heading4,
|
||||
});
|
||||
|
||||
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 HeadingFiveItem = (editor: Editor): EditorMenuItem => ({
|
||||
key: "h5",
|
||||
name: "Heading 5",
|
||||
isActive: () => editor.isActive("heading", { level: 5 }),
|
||||
command: () => toggleHeadingFive(editor),
|
||||
icon: Heading5,
|
||||
});
|
||||
|
||||
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 HeadingSixItem = (editor: Editor): EditorMenuItem => ({
|
||||
key: "h6",
|
||||
name: "Heading 6",
|
||||
isActive: () => editor.isActive("heading", { level: 6 }),
|
||||
command: () => toggleHeadingSix(editor),
|
||||
icon: Heading6,
|
||||
});
|
||||
|
||||
export const BoldItem = (editor: Editor) =>
|
||||
({
|
||||
key: "bold",
|
||||
name: "Bold",
|
||||
isActive: () => editor?.isActive("bold"),
|
||||
command: () => toggleBold(editor),
|
||||
icon: BoldIcon,
|
||||
}) 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 ItalicItem = (editor: Editor) =>
|
||||
({
|
||||
key: "italic",
|
||||
name: "Italic",
|
||||
isActive: () => editor?.isActive("italic"),
|
||||
command: () => toggleItalic(editor),
|
||||
icon: ItalicIcon,
|
||||
}) 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 UnderLineItem = (editor: Editor) =>
|
||||
({
|
||||
key: "underline",
|
||||
name: "Underline",
|
||||
isActive: () => editor?.isActive("underline"),
|
||||
command: () => toggleUnderline(editor),
|
||||
icon: UnderlineIcon,
|
||||
}) 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 StrikeThroughItem = (editor: Editor) =>
|
||||
({
|
||||
key: "strikethrough",
|
||||
name: "Strikethrough",
|
||||
isActive: () => editor?.isActive("strike"),
|
||||
command: () => toggleStrike(editor),
|
||||
icon: StrikethroughIcon,
|
||||
}) 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 BulletListItem = (editor: Editor) =>
|
||||
({
|
||||
key: "bulleted-list",
|
||||
name: "Bulleted list",
|
||||
isActive: () => editor?.isActive("bulletList"),
|
||||
command: () => toggleBulletList(editor),
|
||||
icon: ListIcon,
|
||||
}) 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 NumberedListItem = (editor: Editor) =>
|
||||
({
|
||||
key: "numbered-list",
|
||||
name: "Numbered list",
|
||||
isActive: () => editor?.isActive("orderedList"),
|
||||
command: () => toggleOrderedList(editor),
|
||||
icon: ListOrderedIcon,
|
||||
}) 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 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 TodoListItem = (editor: Editor): EditorMenuItem => ({
|
||||
key: "to-do-list",
|
||||
name: "To-do list",
|
||||
isActive: () => editor.isActive("taskItem"),
|
||||
command: () => toggleTaskList(editor),
|
||||
icon: CheckSquare,
|
||||
});
|
||||
|
||||
export const QuoteItem = (editor: Editor) =>
|
||||
({
|
||||
key: "quote",
|
||||
name: "Quote",
|
||||
isActive: () => editor?.isActive("blockquote"),
|
||||
command: () => toggleBlockquote(editor),
|
||||
icon: QuoteIcon,
|
||||
}) 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 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 CodeItem = (editor: Editor): EditorMenuItem => ({
|
||||
key: "code",
|
||||
name: "Code",
|
||||
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
|
||||
command: () => toggleCodeBlock(editor),
|
||||
icon: CodeIcon,
|
||||
});
|
||||
|
||||
export const TableItem = (editor: Editor) =>
|
||||
({
|
||||
key: "table",
|
||||
name: "Table",
|
||||
isActive: () => editor?.isActive("table"),
|
||||
command: () => insertTableCommand(editor),
|
||||
icon: TableIcon,
|
||||
}) 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 ImageItem = (editor: Editor, uploadFile: UploadImage) =>
|
||||
({
|
||||
@@ -240,6 +223,3 @@ 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;
|
||||
|
||||
@@ -17,6 +17,7 @@ export const EnterKeyExtension = (onEnterKeyPress?: (descriptionHTML: string) =>
|
||||
editor.commands.first(({ commands }) => [
|
||||
() => commands.newlineInCode(),
|
||||
() => commands.splitListItem("listItem"),
|
||||
() => commands.splitListItem("taskItem"),
|
||||
() => commands.createParagraphNear(),
|
||||
() => commands.liftEmptyBlock(),
|
||||
() => commands.splitBlock(),
|
||||
|
||||
@@ -30,7 +30,7 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
|
||||
return (
|
||||
<>
|
||||
<Moveable
|
||||
target={document.querySelector(".ProseMirror-selectednode") as HTMLElement}
|
||||
target={document.querySelector(".active-editor .ProseMirror-selectednode") as HTMLElement}
|
||||
container={null}
|
||||
origin={false}
|
||||
edge={false}
|
||||
@@ -39,7 +39,7 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
|
||||
resizable
|
||||
throttleResize={0}
|
||||
onResizeStart={() => {
|
||||
const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement;
|
||||
const imageInfo = document.querySelector(".active-editor .ProseMirror-selectednode") as HTMLImageElement;
|
||||
if (imageInfo) {
|
||||
const originalWidth = Number(imageInfo.width);
|
||||
const originalHeight = Number(imageInfo.height);
|
||||
|
||||
@@ -146,10 +146,11 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
|
||||
<div className="text-center text-custom-text-400">Loading...</div>
|
||||
) : items.length ? (
|
||||
items.map((item, index) => (
|
||||
<div
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded px-1 py-1.5 hover:bg-custom-background-80 text-custom-text-200",
|
||||
"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",
|
||||
{
|
||||
"bg-custom-background-80": index === selectedIndex,
|
||||
}
|
||||
@@ -158,7 +159,7 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
|
||||
>
|
||||
<Avatar name={item?.title} src={item?.avatar} />
|
||||
<span className="flex-grow truncate">{item.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-custom-text-400">No results</div>
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
Heading5,
|
||||
Heading6,
|
||||
ImageIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
@@ -29,6 +32,9 @@ import {
|
||||
toggleHeadingOne,
|
||||
toggleHeadingTwo,
|
||||
toggleHeadingThree,
|
||||
toggleHeadingFour,
|
||||
toggleHeadingFive,
|
||||
toggleHeadingSix,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { CommandProps, ISlashCommandItem, UploadImage } from "@/types";
|
||||
@@ -91,7 +97,7 @@ const getSuggestionItems =
|
||||
title: "Text",
|
||||
description: "Just start typing with plain text.",
|
||||
searchTerms: ["p", "paragraph"],
|
||||
icon: <CaseSensitive className="h-3.5 w-3.5" />,
|
||||
icon: <CaseSensitive className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).clearNodes().run();
|
||||
@@ -100,61 +106,91 @@ const getSuggestionItems =
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "heading_1",
|
||||
key: "h1",
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
searchTerms: ["title", "big", "large"],
|
||||
icon: <Heading1 className="h-3.5 w-3.5" />,
|
||||
icon: <Heading1 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingOne(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "heading_2",
|
||||
key: "h2",
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
searchTerms: ["subtitle", "medium"],
|
||||
icon: <Heading2 className="h-3.5 w-3.5" />,
|
||||
icon: <Heading2 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingTwo(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "heading_3",
|
||||
key: "h3",
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading3 className="h-3.5 w-3.5" />,
|
||||
icon: <Heading3 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingThree(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "todo_list",
|
||||
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",
|
||||
title: "To do",
|
||||
description: "Track tasks with a to-do list.",
|
||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||
icon: <ListTodo className="h-3.5 w-3.5" />,
|
||||
icon: <ListTodo className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleTaskList(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "bullet_list",
|
||||
key: "bulleted-list",
|
||||
title: "Bullet list",
|
||||
description: "Create a simple bullet list.",
|
||||
searchTerms: ["unordered", "point"],
|
||||
icon: <List className="h-3.5 w-3.5" />,
|
||||
icon: <List className="size-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="h-3.5 w-3.5" />,
|
||||
icon: <ListOrdered className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleOrderedList(editor, range);
|
||||
},
|
||||
@@ -164,25 +200,25 @@ const getSuggestionItems =
|
||||
title: "Table",
|
||||
description: "Create a table",
|
||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||
icon: <Table className="h-3.5 w-3.5" />,
|
||||
icon: <Table className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertTableCommand(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "quote_block",
|
||||
key: "quote",
|
||||
title: "Quote",
|
||||
description: "Capture a quote.",
|
||||
searchTerms: ["blockquote"],
|
||||
icon: <Quote className="h-3.5 w-3.5" />,
|
||||
icon: <Quote className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => toggleBlockquote(editor, range),
|
||||
},
|
||||
{
|
||||
key: "code_block",
|
||||
key: "code",
|
||||
title: "Code",
|
||||
description: "Capture a code snippet.",
|
||||
searchTerms: ["codeblock"],
|
||||
icon: <Code2 className="h-3.5 w-3.5" />,
|
||||
icon: <Code2 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
@@ -190,7 +226,7 @@ const getSuggestionItems =
|
||||
title: "Image",
|
||||
description: "Upload an image from your computer.",
|
||||
searchTerms: ["img", "photo", "picture", "media"],
|
||||
icon: <ImageIcon className="h-3.5 w-3.5" />,
|
||||
icon: <ImageIcon className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertImageCommand(editor, uploadFile, null, range);
|
||||
},
|
||||
@@ -200,7 +236,7 @@ const getSuggestionItems =
|
||||
title: "Divider",
|
||||
description: "Visually divide blocks.",
|
||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
||||
icon: <MinusSquare className="h-3.5 w-3.5" />,
|
||||
icon: <MinusSquare className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||
},
|
||||
|
||||
@@ -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 { EditorMenuItemNames, getEditorMenuItems } from "@/components/menus";
|
||||
import { getEditorMenuItems } from "@/components/menus";
|
||||
// extensions
|
||||
import { CoreEditorExtensions } from "@/extensions";
|
||||
// helpers
|
||||
@@ -14,7 +14,15 @@ import { CollaborationProvider } from "@/plane-editor/providers";
|
||||
// props
|
||||
import { CoreEditorProps } from "@/props";
|
||||
// types
|
||||
import { DeleteImage, EditorRefApi, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types";
|
||||
import {
|
||||
DeleteImage,
|
||||
EditorRefApi,
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
RestoreImage,
|
||||
TEditorCommands,
|
||||
UploadImage,
|
||||
} from "@/types";
|
||||
|
||||
export type TFileHandler = {
|
||||
cancel: () => void;
|
||||
@@ -147,12 +155,12 @@ export const useEditor = ({
|
||||
insertContentAtSavedSelection(editorRef, content, savedSelection);
|
||||
}
|
||||
},
|
||||
executeMenuItemCommand: (itemName: EditorMenuItemNames) => {
|
||||
executeMenuItemCommand: (itemKey: TEditorCommands) => {
|
||||
const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
|
||||
|
||||
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
|
||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||
|
||||
const item = getEditorMenuItem(itemName);
|
||||
const item = getEditorMenuItem(itemKey);
|
||||
if (item) {
|
||||
if (item.key === "image") {
|
||||
item.command(savedSelectionRef.current);
|
||||
@@ -160,13 +168,13 @@ export const useEditor = ({
|
||||
item.command();
|
||||
}
|
||||
} else {
|
||||
console.warn(`No command found for item: ${itemName}`);
|
||||
console.warn(`No command found for item: ${itemKey}`);
|
||||
}
|
||||
},
|
||||
isMenuItemActive: (itemName: EditorMenuItemNames): boolean => {
|
||||
isMenuItemActive: (itemName: TEditorCommands): boolean => {
|
||||
const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
|
||||
|
||||
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
|
||||
const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName);
|
||||
const item = getEditorMenuItem(itemName);
|
||||
return item ? item.isActive() : false;
|
||||
},
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// 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 } from "@/types";
|
||||
import { IMentionHighlight, IMentionSuggestion, TEditorCommands } from "@/types";
|
||||
|
||||
export type EditorReadOnlyRefApi = {
|
||||
getMarkDown: () => string;
|
||||
@@ -17,8 +15,8 @@ export type EditorReadOnlyRefApi = {
|
||||
|
||||
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
setEditorValueAtCursorPosition: (content: string) => void;
|
||||
executeMenuItemCommand: (itemName: EditorMenuItemNames) => void;
|
||||
isMenuItemActive: (itemName: EditorMenuItemNames) => boolean;
|
||||
executeMenuItemCommand: (itemKey: TEditorCommands) => void;
|
||||
isMenuItemActive: (itemKey: TEditorCommands) => boolean;
|
||||
onStateChange: (callback: () => void) => () => void;
|
||||
setFocusAtPosition: (position: number) => void;
|
||||
isEditorReadyToDiscard: () => boolean;
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
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: string;
|
||||
key: TEditorCommands;
|
||||
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": convertToRGB("--color-text-100"),
|
||||
"--tw-prose-bold": "inherit",
|
||||
"--tw-prose-counters": convertToRGB("--color-text-100"),
|
||||
"--tw-prose-bullets": convertToRGB("--color-text-100"),
|
||||
"--tw-prose-hr": convertToRGB("--color-text-100"),
|
||||
|
||||
9
packages/types/src/cycle/cycle.d.ts
vendored
9
packages/types/src/cycle/cycle.d.ts
vendored
@@ -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,6 +61,10 @@ export type TProgressSnapshot = {
|
||||
estimate_distribution?: TCycleEstimateDistribution;
|
||||
};
|
||||
|
||||
export interface IProjectDetails {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ICycle extends TProgressSnapshot {
|
||||
progress_snapshot: TProgressSnapshot | undefined;
|
||||
|
||||
@@ -85,6 +89,7 @@ export interface ICycle extends TProgressSnapshot {
|
||||
filters: IIssueFilterOptions;
|
||||
};
|
||||
workspace_id: string;
|
||||
project_detail: IProjectDetails;
|
||||
}
|
||||
|
||||
export interface CycleIssueResponse {
|
||||
@@ -102,7 +107,7 @@ export interface CycleIssueResponse {
|
||||
}
|
||||
|
||||
export type SelectCycleType =
|
||||
| (ICycle & { actionType: "edit" | "delete" | "create-issue" })
|
||||
| (ICycle & {actionType: "edit" | "delete" | "create-issue"})
|
||||
| undefined;
|
||||
|
||||
export type CycleDateCheckData = {
|
||||
|
||||
8
packages/types/src/issues/issue.d.ts
vendored
8
packages/types/src/issues/issue.d.ts
vendored
@@ -42,13 +42,10 @@ export type TBaseIssue = {
|
||||
export type TIssue = TBaseIssue & {
|
||||
description_html?: string;
|
||||
is_subscribed?: boolean;
|
||||
|
||||
parent?: partial<TIssue>;
|
||||
|
||||
parent?: Partial<TBaseIssue>;
|
||||
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;
|
||||
};
|
||||
@@ -94,6 +91,9 @@ export type TBulkIssueProperties = Pick<
|
||||
| "assignee_ids"
|
||||
| "start_date"
|
||||
| "target_date"
|
||||
| "module_ids"
|
||||
| "cycle_id"
|
||||
| "estimate_point"
|
||||
>;
|
||||
|
||||
export type TBulkOperationsPayload = {
|
||||
|
||||
1
packages/types/src/project/projects.d.ts
vendored
1
packages/types/src/project/projects.d.ts
vendored
@@ -35,6 +35,7 @@ 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
packages/types/src/view-props.d.ts
vendored
2
packages/types/src/view-props.d.ts
vendored
@@ -202,4 +202,6 @@ export interface IssuePaginationOptions {
|
||||
before?: string;
|
||||
after?: string;
|
||||
groupedBy?: TIssueGroupByOptions;
|
||||
subGroupedBy?: TIssueGroupByOptions;
|
||||
orderBy?: TIssueOrderByOptions;
|
||||
}
|
||||
|
||||
12
packages/types/src/views.d.ts
vendored
12
packages/types/src/views.d.ts
vendored
@@ -25,9 +25,21 @@ 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";
|
||||
|
||||
27
packages/types/src/workspace.d.ts
vendored
27
packages/types/src/workspace.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
import {EUserWorkspaceRoles} from "@/constants/workspace";
|
||||
import type {
|
||||
ICycle,
|
||||
IProjectMember,
|
||||
IUser,
|
||||
IUserLite,
|
||||
@@ -46,7 +47,7 @@ export interface IWorkspaceMemberInvitation {
|
||||
}
|
||||
|
||||
export interface IWorkspaceBulkInviteFormData {
|
||||
emails: { email: string; role: EUserWorkspaceRoles }[];
|
||||
emails: {email: string; role: EUserWorkspaceRoles}[];
|
||||
}
|
||||
|
||||
export type Properties = {
|
||||
@@ -197,3 +198,25 @@ 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;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import useSWR from "swr";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { IssuesNavbarRoot } from "@/components/issues";
|
||||
import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error";
|
||||
// hooks
|
||||
import { useIssueFilter, usePublish, usePublishList } from "@/hooks/store";
|
||||
// assets
|
||||
@@ -27,7 +28,7 @@ const IssuesLayout = observer((props: Props) => {
|
||||
const publishSettings = usePublish(anchor);
|
||||
const { updateLayoutOptions } = useIssueFilter();
|
||||
// fetch publish settings
|
||||
useSWR(
|
||||
const { error } = useSWR(
|
||||
anchor ? `PUBLISH_SETTINGS_${anchor}` : null,
|
||||
anchor
|
||||
? async () => {
|
||||
@@ -45,7 +46,9 @@ const IssuesLayout = observer((props: Props) => {
|
||||
: null
|
||||
);
|
||||
|
||||
if (!publishSettings) return <LogoSpinner />;
|
||||
if (!publishSettings && !error) return <LogoSpinner />;
|
||||
|
||||
if (error) return <SomethingWentWrongError />;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden">
|
||||
|
||||
73
space/app/views/[anchor]/layout.tsx
Normal file
73
space/app/views/[anchor]/layout.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error";
|
||||
// hooks
|
||||
import { usePublish, usePublishList } from "@/hooks/store";
|
||||
// Plane web
|
||||
import { ViewNavbarRoot } from "@/plane-web/components/navbar";
|
||||
import { useView } from "@/plane-web/hooks/store";
|
||||
// assets
|
||||
import planeLogo from "@/public/plane-logo.svg";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
params: {
|
||||
anchor: string;
|
||||
};
|
||||
};
|
||||
|
||||
const IssuesLayout = observer((props: Props) => {
|
||||
const { children, params } = props;
|
||||
// params
|
||||
const { anchor } = params;
|
||||
// store hooks
|
||||
const { fetchPublishSettings } = usePublishList();
|
||||
const { viewData, fetchViewDetails } = useView();
|
||||
const publishSettings = usePublish(anchor);
|
||||
|
||||
// fetch publish settings && view details
|
||||
const { error } = useSWR(
|
||||
anchor ? `PUBLISHED_VIEW_SETTINGS_${anchor}` : null,
|
||||
anchor
|
||||
? async () => {
|
||||
const promises = [];
|
||||
promises.push(fetchPublishSettings(anchor));
|
||||
promises.push(fetchViewDetails(anchor));
|
||||
await Promise.all(promises);
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
if (error) return <SomethingWentWrongError />;
|
||||
|
||||
if (!publishSettings || !viewData) return <LogoSpinner />;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden">
|
||||
<div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100">
|
||||
<ViewNavbarRoot publishSettings={publishSettings} />
|
||||
</div>
|
||||
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">{children}</div>
|
||||
<a
|
||||
href="https://plane.so"
|
||||
className="fixed bottom-2.5 right-5 !z-[999999] flex items-center gap-1 rounded border border-custom-border-200 bg-custom-background-100 px-2 py-1 shadow-custom-shadow-2xs"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<div className="relative grid h-6 w-6 place-items-center">
|
||||
<Image src={planeLogo} alt="Plane logo" className="h-6 w-6" height="24" width="24" />
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
Powered by <span className="font-semibold">Plane Publish</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default IssuesLayout;
|
||||
30
space/app/views/[anchor]/page.tsx
Normal file
30
space/app/views/[anchor]/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// hooks
|
||||
import { usePublish } from "@/hooks/store";
|
||||
// plane-web
|
||||
import { ViewLayoutsRoot } from "@/plane-web/components/issue-layouts/root";
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
anchor: string;
|
||||
};
|
||||
};
|
||||
|
||||
const IssuesPage = observer((props: Props) => {
|
||||
const { params } = props;
|
||||
const { anchor } = params;
|
||||
// params
|
||||
const searchParams = useSearchParams();
|
||||
const peekId = searchParams.get("peekId") || undefined;
|
||||
|
||||
const publishSettings = usePublish(anchor);
|
||||
|
||||
if (!publishSettings) return null;
|
||||
|
||||
return <ViewLayoutsRoot peekId={peekId} publishSettings={publishSettings} />;
|
||||
});
|
||||
|
||||
export default IssuesPage;
|
||||
10
space/ce/components/issue-layouts/root.tsx
Normal file
10
space/ce/components/issue-layouts/root.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PageNotFound } from "@/components/ui/not-found";
|
||||
import { PublishStore } from "@/store/publish/publish.store";
|
||||
|
||||
type Props = {
|
||||
peekId: string | undefined;
|
||||
publishSettings: PublishStore;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const ViewLayoutsRoot = (props: Props) => <PageNotFound />;
|
||||
8
space/ce/components/navbar/index.tsx
Normal file
8
space/ce/components/navbar/index.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { PublishStore } from "@/store/publish/publish.store";
|
||||
|
||||
type Props = {
|
||||
publishSettings: PublishStore;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const ViewNavbarRoot = (props: Props) => <></>;
|
||||
1
space/ce/hooks/store/index.ts
Normal file
1
space/ce/hooks/store/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./use-published-view";
|
||||
5
space/ce/hooks/store/use-published-view.ts
Normal file
5
space/ce/hooks/store/use-published-view.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const useView = () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
fetchViewDetails: (anchor: string) => {},
|
||||
viewData: {},
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
// editor
|
||||
import { EditorMenuItemNames, EditorRefApi } from "@plane/editor";
|
||||
import { EditorRefApi, TEditorCommands } from "@plane/editor";
|
||||
// ui
|
||||
import { Button, Tooltip } from "@plane/ui";
|
||||
// constants
|
||||
@@ -11,7 +11,7 @@ import { TOOLBAR_ITEMS } from "@/constants/editor";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
type Props = {
|
||||
executeCommand: (commandName: EditorMenuItemNames) => void;
|
||||
executeCommand: (commandKey: TEditorCommands) => void;
|
||||
handleSubmit: () => void;
|
||||
isCommentEmpty: boolean;
|
||||
isSubmitting: boolean;
|
||||
|
||||
17
space/core/components/issues/issue-layouts/error.tsx
Normal file
17
space/core/components/issues/issue-layouts/error.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import Image from "next/image";
|
||||
// assets
|
||||
import SomethingWentWrongImage from "public/something-went-wrong.svg";
|
||||
|
||||
export const SomethingWentWrongError = () => (
|
||||
<div className="grid h-full w-full place-items-center p-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto grid h-52 w-52 place-items-center rounded-full bg-custom-background-80">
|
||||
<div className="grid h-32 w-32 place-items-center">
|
||||
<Image src={SomethingWentWrongImage} alt="Oops! Something went wrong" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="mt-12 text-3xl font-semibold">Oops! Something went wrong.</h1>
|
||||
<p className="mt-4 text-custom-text-300">The public board does not exist. Please check the URL.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./kanban";
|
||||
export * from "./list";
|
||||
export * from "./kanban/base-kanban-root";
|
||||
export * from "./list/base-list-root";
|
||||
export * from "./properties";
|
||||
export * from "./root";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { TLoader } from "@plane/types";
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
|
||||
interface Props {
|
||||
children: string | JSX.Element | JSX.Element[];
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
}
|
||||
|
||||
export const IssueLayoutHOC = observer((props: Props) => {
|
||||
const { getIssueLoader, getGroupIssueCount } = props;
|
||||
|
||||
const issueCount = getGroupIssueCount(undefined, undefined, false);
|
||||
|
||||
if (getIssueLoader() === "init-loader" || issueCount === undefined) {
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (getGroupIssueCount(undefined, undefined, false) === 0) {
|
||||
return <div className="flex w-full h-full items-center justify-center">No Issues Found</div>;
|
||||
}
|
||||
|
||||
return <>{props.children}</>;
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
// components
|
||||
import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store";
|
||||
|
||||
import { KanBan } from "./default";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
};
|
||||
export const IssueKanbanLayoutRoot: React.FC<Props> = observer((props: Props) => {
|
||||
const { anchor } = props;
|
||||
// store hooks
|
||||
const { groupedIssueIds, getIssueLoader, fetchNextPublicIssues, getGroupIssueCount, getPaginationData } = useIssue();
|
||||
|
||||
const displayProperties: IIssueDisplayProperties = useMemo(
|
||||
() => ({
|
||||
key: true,
|
||||
state: true,
|
||||
labels: true,
|
||||
priority: true,
|
||||
due_date: true,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const fetchMoreIssues = useCallback(
|
||||
(groupId?: string, subgroupId?: string) => {
|
||||
if (getIssueLoader(groupId, subgroupId) !== "pagination") {
|
||||
fetchNextPublicIssues(anchor, groupId, subgroupId);
|
||||
}
|
||||
},
|
||||
[fetchNextPublicIssues]
|
||||
);
|
||||
|
||||
const debouncedFetchMoreIssues = debounce(
|
||||
(groupId?: string, subgroupId?: string) => fetchMoreIssues(groupId, subgroupId),
|
||||
300,
|
||||
{ leading: true, trailing: false }
|
||||
);
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<IssueLayoutHOC getGroupIssueCount={getGroupIssueCount} getIssueLoader={getIssueLoader}>
|
||||
<div
|
||||
className={`horizontal-scrollbar scrollbar-lg relative flex h-full w-full bg-custom-background-90 overflow-x-auto overflow-y-hidden`}
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
<div className="relative h-full w-max min-w-full bg-custom-background-90">
|
||||
<div className="h-full w-max">
|
||||
<KanBan
|
||||
groupedIssueIds={groupedIssueIds ?? {}}
|
||||
displayProperties={displayProperties}
|
||||
subGroupBy={null}
|
||||
groupBy="state"
|
||||
showEmptyGroup
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
loadMoreIssues={debouncedFetchMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</IssueLayoutHOC>
|
||||
);
|
||||
});
|
||||
@@ -1,81 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { MutableRefObject } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
// plane
|
||||
import { cn } from "@plane/editor";
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { IssueBlockDueDate, IssueBlockPriority, IssueBlockState } from "@/components/issues";
|
||||
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC";
|
||||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { useIssue, useIssueDetails, usePublish } from "@/hooks/store";
|
||||
// interfaces
|
||||
import { useIssueDetails, usePublish } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
import { IIssue } from "@/types/issue";
|
||||
import { IssueProperties } from "../properties/all-properties";
|
||||
import { getIssueBlockId } from "../utils";
|
||||
|
||||
interface IssueBlockProps {
|
||||
issueId: string;
|
||||
};
|
||||
groupId: string;
|
||||
subGroupId: string;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const IssueKanBanBlock: FC<Props> = observer((props) => {
|
||||
const { anchor, issueId } = props;
|
||||
const { getIssueById } = useIssue();
|
||||
interface IssueDetailsBlockProps {
|
||||
issue: IIssue;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
}
|
||||
|
||||
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props) => {
|
||||
const { issue, displayProperties } = props;
|
||||
const { anchor } = useParams();
|
||||
// hooks
|
||||
const { project_details } = usePublish(anchor.toString());
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-3 py-2">
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties || {}} displayPropertyKey="key">
|
||||
<div className="relative">
|
||||
<div className="line-clamp-1 text-xs text-custom-text-300">
|
||||
{project_details?.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
<div className="w-full line-clamp-1 text-sm text-custom-text-100 mb-1.5">
|
||||
<Tooltip tooltipContent={issue.name}>
|
||||
<span>{issue.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<IssueProperties
|
||||
className="flex flex-wrap items-center gap-2 whitespace-nowrap text-custom-text-300 pt-1.5"
|
||||
issue={issue}
|
||||
displayProperties={displayProperties}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
|
||||
const { issueId, groupId, subGroupId, displayProperties } = props;
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
const board = searchParams.get("board");
|
||||
const state = searchParams.get("state");
|
||||
const priority = searchParams.get("priority");
|
||||
const labels = searchParams.get("labels");
|
||||
// store hooks
|
||||
const { project_details } = usePublish(anchor);
|
||||
const { setPeekId } = useIssueDetails();
|
||||
// hooks
|
||||
const { setPeekId, getIsIssuePeeked, getIssueById } = useIssueDetails();
|
||||
|
||||
const { queryParam } = queryParamGenerator({ board, peekId: issueId, priority, state, labels });
|
||||
|
||||
const handleBlockClick = () => {
|
||||
const handleIssuePeekOverview = () => {
|
||||
setPeekId(issueId);
|
||||
};
|
||||
|
||||
const { queryParam } = queryParamGenerator(board ? { board, peekId: issueId } : { peekId: issueId });
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return <></>;
|
||||
if (!issue) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${anchor}?${queryParam}`}
|
||||
onClick={handleBlockClick}
|
||||
className="flex flex-col gap-1.5 space-y-2 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm shadow-custom-shadow-2xs select-none"
|
||||
>
|
||||
{/* id */}
|
||||
<div className="break-words text-xs text-custom-text-300">
|
||||
{project_details?.identifier}-{issue?.sequence_id}
|
||||
</div>
|
||||
|
||||
{/* name */}
|
||||
<h6 role="button" className="line-clamp-2 cursor-pointer break-words text-sm">
|
||||
{issue.name}
|
||||
</h6>
|
||||
|
||||
<div className="hide-horizontal-scrollbar relative flex w-full flex-grow items-end gap-2 overflow-x-scroll">
|
||||
{/* priority */}
|
||||
{issue?.priority && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockPriority priority={issue?.priority} />
|
||||
</div>
|
||||
<div className={cn("group/kanban-block relative p-1.5")}>
|
||||
<Link
|
||||
id={getIssueBlockId(issueId, groupId, subGroupId)}
|
||||
className={cn(
|
||||
"block rounded border-[1px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400",
|
||||
{ "border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id) }
|
||||
)}
|
||||
{/* state */}
|
||||
{issue?.state_id && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockState stateId={issue?.state_id} />
|
||||
</div>
|
||||
)}
|
||||
{/* due date */}
|
||||
{issue?.target_date && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockDueDate due_date={issue?.target_date} stateId={issue?.state_id ?? undefined} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
href={`?${queryParam}`}
|
||||
onClick={handleIssuePeekOverview}
|
||||
>
|
||||
<KanbanIssueDetailsBlock issue={issue} displayProperties={displayProperties} />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
KanbanIssueBlock.displayName = "KanbanIssueBlock";
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MutableRefObject } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
//types
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
// components
|
||||
import { KanbanIssueBlock } from "./block";
|
||||
|
||||
interface IssueBlocksListProps {
|
||||
subGroupId: string;
|
||||
groupId: string;
|
||||
issueIds: string[];
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const KanbanIssueBlocksList: React.FC<IssueBlocksListProps> = observer((props) => {
|
||||
const { subGroupId, groupId, issueIds, displayProperties, scrollableContainerRef } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{issueIds && issueIds.length > 0 ? (
|
||||
<>
|
||||
{issueIds.map((issueId) => {
|
||||
if (!issueId) return null;
|
||||
|
||||
let draggableId = issueId;
|
||||
if (groupId) draggableId = `${draggableId}__${groupId}`;
|
||||
if (subGroupId) draggableId = `${draggableId}__${subGroupId}`;
|
||||
|
||||
return (
|
||||
<KanbanIssueBlock
|
||||
key={draggableId}
|
||||
issueId={issueId}
|
||||
groupId={groupId}
|
||||
subGroupId={subGroupId}
|
||||
displayProperties={displayProperties}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { Icon } from "@/components/ui";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store";
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
// components
|
||||
import { IssueKanBanBlock } from "./block";
|
||||
import { IssueKanBanHeader } from "./header";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
stateId: string;
|
||||
issueIds: string[];
|
||||
};
|
||||
|
||||
export const Column = observer((props: Props) => {
|
||||
const { anchor, stateId, issueIds } = props;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { fetchNextPublicIssues, getPaginationData, getIssueLoader, getGroupIssueCount } = useIssue();
|
||||
|
||||
const loadMoreIssuesInThisGroup = useCallback(() => {
|
||||
fetchNextPublicIssues(anchor, stateId);
|
||||
}, [fetchNextPublicIssues, anchor, stateId]);
|
||||
|
||||
const isPaginating = !!getIssueLoader(stateId);
|
||||
const nextPageResults = getPaginationData(stateId, undefined)?.nextPageResults;
|
||||
|
||||
useIntersectionObserver(
|
||||
containerRef,
|
||||
isPaginating ? null : intersectionElement,
|
||||
loadMoreIssuesInThisGroup,
|
||||
`0% 100% 100% 100%`
|
||||
);
|
||||
|
||||
const groupIssueCount = getGroupIssueCount(stateId, undefined, false);
|
||||
const shouldLoadMore =
|
||||
nextPageResults === undefined && groupIssueCount !== undefined
|
||||
? issueIds?.length < groupIssueCount
|
||||
: !!nextPageResults;
|
||||
|
||||
return (
|
||||
<div key={stateId} className="relative flex h-full w-[340px] flex-shrink-0 flex-col">
|
||||
<div className="flex-shrink-0">
|
||||
<IssueKanBanHeader stateId={stateId} />
|
||||
</div>
|
||||
<div className="hide-vertical-scrollbar h-full w-full overflow-hidden overflow-y-auto" ref={containerRef}>
|
||||
{issueIds && issueIds.length > 0 ? (
|
||||
<div className="space-y-3 px-2 pb-2">
|
||||
{issueIds.map((issueId) => (
|
||||
<IssueKanBanBlock key={issueId} anchor={anchor} issueId={issueId} />
|
||||
))}
|
||||
{shouldLoadMore && (
|
||||
<div className="w-full h-[100px] bg-custom-background-80 animate-pulse" ref={setIntersectionElement} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2 pt-10 text-center text-sm font-medium text-custom-text-200">
|
||||
<Icon iconName="stack" />
|
||||
No issues in this state
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
130
space/core/components/issues/issue-layouts/kanban/default.tsx
Normal file
130
space/core/components/issues/issue-layouts/kanban/default.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { MutableRefObject } from "react";
|
||||
import isNil from "lodash/isNil";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import {
|
||||
GroupByColumnTypes,
|
||||
IGroupByColumn,
|
||||
TGroupedIssues,
|
||||
IIssueDisplayProperties,
|
||||
TSubGroupedIssues,
|
||||
TIssueGroupByOptions,
|
||||
TPaginationData,
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
// hooks
|
||||
import { useMember, useModule, useStates, useLabel, useCycle } from "@/hooks/store";
|
||||
//
|
||||
import { getGroupByColumns } from "../utils";
|
||||
// components
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
import { KanbanGroup } from "./kanban-group";
|
||||
|
||||
export interface IKanBan {
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
subGroupBy: TIssueGroupByOptions | undefined;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
subGroupId?: string;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
showEmptyGroup?: boolean;
|
||||
}
|
||||
|
||||
export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
const {
|
||||
groupedIssueIds,
|
||||
displayProperties,
|
||||
subGroupBy,
|
||||
groupBy,
|
||||
subGroupId = "null",
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
scrollableContainerRef,
|
||||
showEmptyGroup = true,
|
||||
} = props;
|
||||
|
||||
const member = useMember();
|
||||
const label = useLabel();
|
||||
const cycle = useCycle();
|
||||
const modules = useModule();
|
||||
const state = useStates();
|
||||
|
||||
const groupList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member);
|
||||
|
||||
if (!groupList) return null;
|
||||
|
||||
const visibilityGroupBy = (_list: IGroupByColumn): { showGroup: boolean; showIssues: boolean } => {
|
||||
if (subGroupBy) {
|
||||
const groupVisibility = {
|
||||
showGroup: true,
|
||||
showIssues: true,
|
||||
};
|
||||
if (!showEmptyGroup) {
|
||||
groupVisibility.showGroup = (getGroupIssueCount(_list.id, undefined, false) ?? 0) > 0;
|
||||
}
|
||||
return groupVisibility;
|
||||
} else {
|
||||
const groupVisibility = {
|
||||
showGroup: true,
|
||||
showIssues: true,
|
||||
};
|
||||
return groupVisibility;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative w-full flex gap-2 px-2 ${subGroupBy ? "h-full" : "h-full"}`}>
|
||||
{groupList &&
|
||||
groupList.length > 0 &&
|
||||
groupList.map((subList: IGroupByColumn) => {
|
||||
const groupByVisibilityToggle = visibilityGroupBy(subList);
|
||||
|
||||
if (groupByVisibilityToggle.showGroup === false) return <></>;
|
||||
return (
|
||||
<div
|
||||
key={subList.id}
|
||||
className={`group relative flex flex-shrink-0 flex-col ${
|
||||
groupByVisibilityToggle.showIssues ? `w-[350px]` : ``
|
||||
} `}
|
||||
>
|
||||
{isNil(subGroupBy) && (
|
||||
<div className="sticky top-0 z-[2] w-full flex-shrink-0 bg-custom-background-90 py-1">
|
||||
<HeaderGroupByCard
|
||||
groupBy={groupBy}
|
||||
icon={subList.icon}
|
||||
title={subList.name}
|
||||
count={getGroupIssueCount(subList.id, undefined, false) ?? 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupByVisibilityToggle.showIssues && (
|
||||
<KanbanGroup
|
||||
groupId={subList.id}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
displayProperties={displayProperties}
|
||||
subGroupBy={subGroupBy}
|
||||
subGroupId={subGroupId}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssue, useStates } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
stateId: string;
|
||||
};
|
||||
|
||||
export const IssueKanBanHeader: React.FC<Props> = observer((props) => {
|
||||
const { stateId } = props;
|
||||
|
||||
const { getStateById } = useStates();
|
||||
const { getGroupIssueCount } = useIssue();
|
||||
|
||||
const state = getStateById(stateId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 pb-2">
|
||||
<div className="flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} height="14" width="14" />
|
||||
</div>
|
||||
<div className="mr-1 truncate font-medium capitalize text-custom-text-200">{state?.name ?? "State"}</div>
|
||||
<span className="flex-shrink-0 rounded-full text-custom-text-300">
|
||||
{getGroupIssueCount(stateId, undefined, false) ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Circle } from "lucide-react";
|
||||
// types
|
||||
import { TIssueGroupByOptions } from "@plane/types";
|
||||
|
||||
interface IHeaderGroupByCard {
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
|
||||
const { icon, title, count } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`relative flex flex-shrink-0 gap-2 p-1.5 w-full flex-row items-center`}>
|
||||
<div className="flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm">
|
||||
{icon ? icon : <Circle width={14} strokeWidth={2} />}
|
||||
</div>
|
||||
|
||||
<div className={`relative flex items-center gap-1 w-full flex-row overflow-hidden`}>
|
||||
<div className={`line-clamp-1 inline-block overflow-hidden truncate font-medium text-custom-text-100`}>
|
||||
{title}
|
||||
</div>
|
||||
<div className={`flex-shrink-0 text-sm font-medium text-custom-text-300 pl-2`}>{count || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Circle, ChevronDown, ChevronUp } from "lucide-react";
|
||||
// mobx
|
||||
|
||||
interface IHeaderSubGroupByCard {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
isExpanded: boolean;
|
||||
toggleExpanded: () => void;
|
||||
}
|
||||
|
||||
export const HeaderSubGroupByCard: FC<IHeaderSubGroupByCard> = observer((props) => {
|
||||
const { icon, title, count, isExpanded, toggleExpanded } = props;
|
||||
return (
|
||||
<div
|
||||
className={`relative flex w-full flex-shrink-0 flex-row items-center gap-2 rounded-sm p-1.5 cursor-pointer`}
|
||||
onClick={() => toggleExpanded()}
|
||||
>
|
||||
<div className="flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80">
|
||||
{isExpanded ? <ChevronUp width={14} strokeWidth={2} /> : <ChevronDown width={14} strokeWidth={2} />}
|
||||
</div>
|
||||
|
||||
<div className="flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm">
|
||||
{icon ? icon : <Circle width={14} strokeWidth={2} />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-1 text-sm">
|
||||
<div className="line-clamp-1 text-custom-text-100">{title}</div>
|
||||
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./block";
|
||||
export * from "./header";
|
||||
export * from "./root";
|
||||
export * from "./blocks-list";
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { MutableRefObject, forwardRef, useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
//types
|
||||
import {
|
||||
TGroupedIssues,
|
||||
IIssueDisplayProperties,
|
||||
TSubGroupedIssues,
|
||||
TIssueGroupByOptions,
|
||||
TPaginationData,
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
//
|
||||
import { KanbanIssueBlocksList } from ".";
|
||||
|
||||
interface IKanbanGroup {
|
||||
groupId: string;
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
subGroupBy: TIssueGroupByOptions | undefined;
|
||||
subGroupId: string;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
// Loader components
|
||||
const KanbanIssueBlockLoader = forwardRef<HTMLSpanElement>((props, ref) => (
|
||||
<span ref={ref} className="block h-28 m-1.5 animate-pulse bg-custom-background-80 rounded" />
|
||||
));
|
||||
KanbanIssueBlockLoader.displayName = "KanbanIssueBlockLoader";
|
||||
|
||||
export const KanbanGroup = observer((props: IKanbanGroup) => {
|
||||
const {
|
||||
groupId,
|
||||
subGroupId,
|
||||
subGroupBy,
|
||||
displayProperties,
|
||||
groupedIssueIds,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
scrollableContainerRef,
|
||||
} = props;
|
||||
|
||||
// hooks
|
||||
const [intersectionElement, setIntersectionElement] = useState<HTMLSpanElement | null>(null);
|
||||
const columnRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const containerRef = subGroupBy && scrollableContainerRef ? scrollableContainerRef : columnRef;
|
||||
|
||||
const loadMoreIssuesInThisGroup = useCallback(() => {
|
||||
loadMoreIssues(groupId, subGroupId === "null" ? undefined : subGroupId);
|
||||
}, [loadMoreIssues, groupId, subGroupId]);
|
||||
|
||||
const isPaginating = !!getIssueLoader(groupId, subGroupId);
|
||||
|
||||
useIntersectionObserver(
|
||||
containerRef,
|
||||
isPaginating ? null : intersectionElement,
|
||||
loadMoreIssuesInThisGroup,
|
||||
`0% 100% 100% 100%`
|
||||
);
|
||||
|
||||
const isSubGroup = !!subGroupId && subGroupId !== "null";
|
||||
|
||||
const issueIds = isSubGroup
|
||||
? (groupedIssueIds as TSubGroupedIssues)?.[groupId]?.[subGroupId] ?? []
|
||||
: (groupedIssueIds as TGroupedIssues)?.[groupId] ?? [];
|
||||
|
||||
const groupIssueCount = getGroupIssueCount(groupId, subGroupId, false) ?? 0;
|
||||
const nextPageResults = getPaginationData(groupId, subGroupId)?.nextPageResults;
|
||||
|
||||
const loadMore = isPaginating ? (
|
||||
<KanbanIssueBlockLoader />
|
||||
) : (
|
||||
<div
|
||||
className="w-full p-3 text-sm font-medium text-custom-primary-100 hover:text-custom-primary-200 hover:underline cursor-pointer"
|
||||
onClick={loadMoreIssuesInThisGroup}
|
||||
>
|
||||
{" "}
|
||||
Load More ↓
|
||||
</div>
|
||||
);
|
||||
|
||||
const shouldLoadMore = nextPageResults === undefined ? issueIds?.length < groupIssueCount : !!nextPageResults;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`${groupId}__${subGroupId}`}
|
||||
className={cn("relative h-full transition-all min-h-[120px]", { "vertical-scrollbar scrollbar-md": !subGroupBy })}
|
||||
ref={columnRef}
|
||||
>
|
||||
<KanbanIssueBlocksList
|
||||
subGroupId={subGroupId}
|
||||
groupId={groupId}
|
||||
issueIds={issueIds || []}
|
||||
displayProperties={displayProperties}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
/>
|
||||
|
||||
{shouldLoadMore && (isSubGroup ? <>{loadMore}</> : <KanbanIssueBlockLoader ref={setIntersectionElement} />)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// mobx hook
|
||||
import { TGroupedIssues } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store";
|
||||
import { Column } from "./column";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
};
|
||||
|
||||
export const IssueKanbanLayoutRoot: FC<Props> = observer((props) => {
|
||||
const { anchor } = props;
|
||||
// store hooks
|
||||
const { groupedIssueIds } = useIssue();
|
||||
|
||||
const groupedIssues = groupedIssueIds as TGroupedIssues | undefined;
|
||||
|
||||
if (!groupedIssues) return <></>;
|
||||
|
||||
const issueGroupIds = Object.keys(groupedIssues);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full gap-3 overflow-hidden overflow-x-auto">
|
||||
{issueGroupIds?.map((stateId) => {
|
||||
const issueIds = groupedIssues[stateId];
|
||||
return <Column key={stateId} anchor={anchor} stateId={stateId} issueIds={issueIds} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
294
space/core/components/issues/issue-layouts/kanban/swimlanes.tsx
Normal file
294
space/core/components/issues/issue-layouts/kanban/swimlanes.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { MutableRefObject, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import {
|
||||
GroupByColumnTypes,
|
||||
IGroupByColumn,
|
||||
TGroupedIssues,
|
||||
IIssueDisplayProperties,
|
||||
TSubGroupedIssues,
|
||||
TIssueGroupByOptions,
|
||||
TIssueOrderByOptions,
|
||||
TPaginationData,
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
// hooks
|
||||
import { useMember, useModule, useStates, useLabel, useCycle } from "@/hooks/store";
|
||||
//
|
||||
import { getGroupByColumns } from "../utils";
|
||||
import { KanBan } from "./default";
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
import { HeaderSubGroupByCard } from "./headers/sub-group-by-card";
|
||||
|
||||
export interface IKanBanSwimLanes {
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
subGroupBy: TIssueGroupByOptions | undefined;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
showEmptyGroup: boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
orderBy: TIssueOrderByOptions | undefined;
|
||||
}
|
||||
|
||||
export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
const {
|
||||
groupedIssueIds,
|
||||
displayProperties,
|
||||
subGroupBy,
|
||||
groupBy,
|
||||
orderBy,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
showEmptyGroup,
|
||||
scrollableContainerRef,
|
||||
} = props;
|
||||
|
||||
const member = useMember();
|
||||
const label = useLabel();
|
||||
const cycle = useCycle();
|
||||
const modules = useModule();
|
||||
const state = useStates();
|
||||
|
||||
const groupByList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member);
|
||||
const subGroupByList = getGroupByColumns(subGroupBy as GroupByColumnTypes, cycle, modules, label, state, member);
|
||||
|
||||
if (!groupByList || !subGroupByList) return null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="sticky top-0 z-[4] h-[50px] bg-custom-background-90 px-2">
|
||||
<SubGroupSwimlaneHeader
|
||||
groupBy={groupBy}
|
||||
subGroupBy={subGroupBy}
|
||||
groupList={groupByList}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{subGroupBy && (
|
||||
<SubGroupSwimlane
|
||||
groupList={subGroupByList}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
displayProperties={displayProperties}
|
||||
groupBy={groupBy}
|
||||
subGroupBy={subGroupBy}
|
||||
orderBy={orderBy}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ISubGroupSwimlaneHeader {
|
||||
subGroupBy: TIssueGroupByOptions | undefined;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
groupList: IGroupByColumn[];
|
||||
showEmptyGroup: boolean;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
}
|
||||
|
||||
const visibilitySubGroupByGroupCount = (subGroupIssueCount: number, showEmptyGroup: boolean): boolean => {
|
||||
let subGroupHeaderVisibility = true;
|
||||
|
||||
if (showEmptyGroup) subGroupHeaderVisibility = true;
|
||||
else {
|
||||
if (subGroupIssueCount > 0) subGroupHeaderVisibility = true;
|
||||
else subGroupHeaderVisibility = false;
|
||||
}
|
||||
|
||||
return subGroupHeaderVisibility;
|
||||
};
|
||||
|
||||
const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = observer(
|
||||
({ subGroupBy, groupBy, groupList, showEmptyGroup, getGroupIssueCount }) => (
|
||||
<div className="relative flex h-max min-h-full w-full items-center gap-2">
|
||||
{groupList &&
|
||||
groupList.length > 0 &&
|
||||
groupList.map((group: IGroupByColumn) => {
|
||||
const groupCount = getGroupIssueCount(group.id, undefined, false) ?? 0;
|
||||
|
||||
const subGroupByVisibilityToggle = visibilitySubGroupByGroupCount(groupCount, showEmptyGroup);
|
||||
|
||||
if (subGroupByVisibilityToggle === false) return <></>;
|
||||
|
||||
return (
|
||||
<div key={`${subGroupBy}_${group.id}`} className="flex w-[350px] flex-shrink-0 flex-col">
|
||||
<HeaderGroupByCard groupBy={groupBy} icon={group.icon} title={group.name} count={groupCount} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
showEmptyGroup: boolean;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
orderBy: TIssueOrderByOptions | undefined;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
}
|
||||
|
||||
const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
const {
|
||||
groupedIssueIds,
|
||||
subGroupBy,
|
||||
groupBy,
|
||||
groupList,
|
||||
displayProperties,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
showEmptyGroup,
|
||||
scrollableContainerRef,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="relative h-max min-h-full w-full">
|
||||
{groupList &&
|
||||
groupList.length > 0 &&
|
||||
groupList.map((group: IGroupByColumn) => (
|
||||
<SubGroup
|
||||
key={group.id}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
subGroupBy={subGroupBy}
|
||||
groupBy={groupBy}
|
||||
group={group}
|
||||
displayProperties={displayProperties}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ISubGroup {
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
showEmptyGroup: boolean;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
subGroupBy: TIssueGroupByOptions | undefined;
|
||||
group: IGroupByColumn;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
}
|
||||
|
||||
const SubGroup: React.FC<ISubGroup> = observer((props) => {
|
||||
const {
|
||||
groupedIssueIds,
|
||||
subGroupBy,
|
||||
groupBy,
|
||||
group,
|
||||
displayProperties,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
showEmptyGroup,
|
||||
scrollableContainerRef,
|
||||
} = props;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setIsExpanded((prevState) => !prevState);
|
||||
};
|
||||
|
||||
const visibilitySubGroupBy = (
|
||||
_list: IGroupByColumn,
|
||||
subGroupCount: number
|
||||
): { showGroup: boolean; showIssues: boolean } => {
|
||||
const subGroupVisibility = {
|
||||
showGroup: true,
|
||||
showIssues: true,
|
||||
};
|
||||
if (showEmptyGroup) subGroupVisibility.showGroup = true;
|
||||
else {
|
||||
if (subGroupCount > 0) subGroupVisibility.showGroup = true;
|
||||
else subGroupVisibility.showGroup = false;
|
||||
}
|
||||
return subGroupVisibility;
|
||||
};
|
||||
|
||||
const issueCount = getGroupIssueCount(undefined, group.id, true) ?? 0;
|
||||
const subGroupByVisibilityToggle = visibilitySubGroupBy(group, issueCount);
|
||||
if (subGroupByVisibilityToggle.showGroup === false) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-shrink-0 flex-col">
|
||||
<div className="sticky top-[50px] z-[3] py-1 flex w-full items-center bg-custom-background-100 border-y-[0.5px] border-custom-border-200">
|
||||
<div className="sticky left-0 flex-shrink-0">
|
||||
<HeaderSubGroupByCard
|
||||
icon={group.icon}
|
||||
title={group.name || ""}
|
||||
count={issueCount}
|
||||
isExpanded={isExpanded}
|
||||
toggleExpanded={toggleExpanded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{subGroupByVisibilityToggle.showIssues && isExpanded && (
|
||||
<div className="relative">
|
||||
<KanBan
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
displayProperties={displayProperties}
|
||||
subGroupBy={subGroupBy}
|
||||
groupBy={groupBy}
|
||||
subGroupId={group.id}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { IIssueDisplayProperties, TGroupedIssues } from "@plane/types";
|
||||
// constants
|
||||
// components
|
||||
import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store";
|
||||
import { List } from "./default";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
};
|
||||
|
||||
export const IssuesListLayoutRoot = observer((props: Props) => {
|
||||
const { anchor } = props;
|
||||
// store hooks
|
||||
const {
|
||||
groupedIssueIds: storeGroupedIssueIds,
|
||||
fetchNextPublicIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
} = useIssue();
|
||||
|
||||
const groupedIssueIds = storeGroupedIssueIds as TGroupedIssues | undefined;
|
||||
// auth
|
||||
const displayProperties: IIssueDisplayProperties = useMemo(
|
||||
() => ({
|
||||
key: true,
|
||||
state: true,
|
||||
labels: true,
|
||||
priority: true,
|
||||
due_date: true,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const loadMoreIssues = useCallback(
|
||||
(groupId?: string) => {
|
||||
fetchNextPublicIssues(anchor, groupId);
|
||||
},
|
||||
[fetchNextPublicIssues]
|
||||
);
|
||||
|
||||
return (
|
||||
<IssueLayoutHOC getGroupIssueCount={getGroupIssueCount} getIssueLoader={getIssueLoader}>
|
||||
<div className={`relative size-full bg-custom-background-90`}>
|
||||
<List
|
||||
displayProperties={displayProperties}
|
||||
groupBy={"state"}
|
||||
groupedIssueIds={groupedIssueIds ?? {}}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
showEmptyGroup
|
||||
/>
|
||||
</div>
|
||||
</IssueLayoutHOC>
|
||||
);
|
||||
});
|
||||
@@ -1,88 +1,90 @@
|
||||
"use client";
|
||||
import { FC } from "react";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// components
|
||||
import { IssueBlockDueDate, IssueBlockLabels, IssueBlockPriority, IssueBlockState } from "@/components/issues";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
// types
|
||||
import { cn } from "@plane/editor";
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hook
|
||||
import { useIssue, useIssueDetails, usePublish } from "@/hooks/store";
|
||||
// hooks
|
||||
import { useIssueDetails, usePublish } from "@/hooks/store";
|
||||
//
|
||||
import { IssueProperties } from "../properties/all-properties";
|
||||
|
||||
type IssueListBlockProps = {
|
||||
anchor: string;
|
||||
interface IssueBlockProps {
|
||||
issueId: string;
|
||||
};
|
||||
groupId: string;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
}
|
||||
|
||||
export const IssueListLayoutBlock: FC<IssueListBlockProps> = observer((props) => {
|
||||
const { anchor, issueId } = props;
|
||||
const { getIssueById } = useIssue();
|
||||
// query params
|
||||
export const IssueBlock = observer((props: IssueBlockProps) => {
|
||||
const { anchor } = useParams();
|
||||
const { issueId, displayProperties } = props;
|
||||
const searchParams = useSearchParams();
|
||||
const board = searchParams.get("board") || undefined;
|
||||
const state = searchParams.get("state") || undefined;
|
||||
const priority = searchParams.get("priority") || undefined;
|
||||
const labels = searchParams.get("labels") || undefined;
|
||||
// store hooks
|
||||
const { setPeekId } = useIssueDetails();
|
||||
const { project_details } = usePublish(anchor);
|
||||
// query params
|
||||
const board = searchParams.get("board");
|
||||
// ref
|
||||
const issueRef = useRef<HTMLDivElement | null>(null);
|
||||
// hooks
|
||||
const { project_details } = usePublish(anchor.toString());
|
||||
const { getIsIssuePeeked, setPeekId, getIssueById } = useIssueDetails();
|
||||
|
||||
const { queryParam } = queryParamGenerator({ board, peekId: issueId, priority, state, labels });
|
||||
const handleBlockClick = () => {
|
||||
const handleIssuePeekOverview = () => {
|
||||
setPeekId(issueId);
|
||||
};
|
||||
|
||||
const { queryParam } = queryParamGenerator(board ? { board, peekId: issueId } : { peekId: issueId });
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return <></>;
|
||||
if (!issue) return null;
|
||||
|
||||
const projectIdentifier = project_details?.identifier;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${anchor}?${queryParam}`}
|
||||
onClick={handleBlockClick}
|
||||
className="relative flex items-center gap-10 bg-custom-background-100 p-3"
|
||||
<div
|
||||
ref={issueRef}
|
||||
className={cn(
|
||||
"group/list-block min-h-11 relative flex flex-col md:flex-row md:items-center gap-3 bg-custom-background-100 hover:bg-custom-background-90 p-3 pl-1.5 text-sm transition-colors border-b border-b-custom-border-200",
|
||||
{
|
||||
"border-custom-primary-70": getIsIssuePeeked(issue.id),
|
||||
"last:border-b-transparent": !getIsIssuePeeked(issue.id),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="relative flex w-full flex-grow items-center gap-3 overflow-hidden">
|
||||
{/* id */}
|
||||
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
|
||||
{project_details?.identifier}-{issue?.sequence_id}
|
||||
</div>
|
||||
{/* name */}
|
||||
<div onClick={handleBlockClick} className="flex-grow cursor-pointer truncate text-sm">
|
||||
{issue.name}
|
||||
<div className="flex w-full truncate">
|
||||
<div className="flex flex-grow items-center gap-0.5 truncate">
|
||||
<div className="flex items-center gap-1">
|
||||
{displayProperties && displayProperties?.key && (
|
||||
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300 px-4">
|
||||
{projectIdentifier}-{issue.sequence_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
id={`issue-${issue.id}`}
|
||||
href={`?${queryParam}`}
|
||||
onClick={handleIssuePeekOverview}
|
||||
className="w-full truncate cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
<Tooltip tooltipContent={issue.name} position="top-left">
|
||||
<p className="truncate">{issue.name}</p>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex flex-shrink-0 items-center gap-2 text-xs">
|
||||
{/* priority */}
|
||||
{issue?.priority && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockPriority priority={issue?.priority} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* state */}
|
||||
{issue?.state_id && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockState stateId={issue?.state_id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* labels */}
|
||||
{issue?.label_ids && issue?.label_ids.length > 0 && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockLabels labelIds={issue?.label_ids} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* due date */}
|
||||
{issue?.target_date && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockDueDate due_date={issue?.target_date} stateId={issue?.state_id ?? undefined} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<IssueProperties
|
||||
className="relative flex flex-wrap md:flex-grow md:flex-shrink-0 items-center gap-2 whitespace-nowrap"
|
||||
issue={issue}
|
||||
displayProperties={displayProperties}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FC, MutableRefObject } from "react";
|
||||
// types
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
import { IssueBlock } from "./block";
|
||||
|
||||
interface Props {
|
||||
issueIds: string[] | undefined;
|
||||
groupId: string;
|
||||
displayProperties?: IIssueDisplayProperties;
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const IssueBlocksList: FC<Props> = (props) => {
|
||||
const { issueIds = [], groupId, displayProperties } = props;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{issueIds &&
|
||||
issueIds?.length > 0 &&
|
||||
issueIds.map((issueId: string) => (
|
||||
<IssueBlock key={issueId} issueId={issueId} displayProperties={displayProperties} groupId={groupId} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
86
space/core/components/issues/issue-layouts/list/default.tsx
Normal file
86
space/core/components/issues/issue-layouts/list/default.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import {
|
||||
GroupByColumnTypes,
|
||||
TGroupedIssues,
|
||||
IIssueDisplayProperties,
|
||||
TIssueGroupByOptions,
|
||||
IGroupByColumn,
|
||||
TPaginationData,
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
// hooks
|
||||
import { useMember, useModule, useStates, useLabel, useCycle } from "@/hooks/store";
|
||||
//
|
||||
import { getGroupByColumns } from "../utils";
|
||||
import { ListGroup } from "./list-group";
|
||||
|
||||
export interface IList {
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
showEmptyGroup?: boolean;
|
||||
loadMoreIssues: (groupId?: string) => void;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
}
|
||||
|
||||
export const List: React.FC<IList> = observer((props) => {
|
||||
const {
|
||||
groupedIssueIds,
|
||||
groupBy,
|
||||
displayProperties,
|
||||
showEmptyGroup,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
} = props;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const member = useMember();
|
||||
const label = useLabel();
|
||||
const cycle = useCycle();
|
||||
const modules = useModule();
|
||||
const state = useStates();
|
||||
|
||||
const groupList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member, true);
|
||||
|
||||
if (!groupList) return null;
|
||||
|
||||
return (
|
||||
<div className="relative size-full flex flex-col">
|
||||
{groupList && (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="size-full vertical-scrollbar scrollbar-lg relative overflow-auto vertical-scrollbar-margin-top-md"
|
||||
>
|
||||
{groupList.map((group: IGroupByColumn) => (
|
||||
<ListGroup
|
||||
key={group.id}
|
||||
groupIssueIds={groupedIssueIds?.[group.id]}
|
||||
groupBy={groupBy}
|
||||
group={group}
|
||||
displayProperties={displayProperties}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store";
|
||||
// components
|
||||
import { IssueListLayoutBlock } from "./block";
|
||||
import { IssueListLayoutHeader } from "./header";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
stateId: string;
|
||||
issueIds: string[];
|
||||
};
|
||||
|
||||
export const Group = observer((props: Props) => {
|
||||
const { anchor, stateId, issueIds } = props;
|
||||
|
||||
const { fetchNextPublicIssues, getPaginationData, getIssueLoader, getGroupIssueCount } = useIssue();
|
||||
|
||||
const loadMoreIssuesInThisGroup = useCallback(() => {
|
||||
fetchNextPublicIssues(anchor, stateId);
|
||||
}, [stateId]);
|
||||
|
||||
const isPaginating = !!getIssueLoader(stateId);
|
||||
const nextPageResults = getPaginationData(stateId, undefined)?.nextPageResults;
|
||||
|
||||
const groupIssueCount = getGroupIssueCount(stateId, undefined, false);
|
||||
const shouldLoadMore =
|
||||
nextPageResults === undefined && groupIssueCount !== undefined
|
||||
? issueIds?.length < groupIssueCount
|
||||
: !!nextPageResults;
|
||||
|
||||
return (
|
||||
<div key={stateId} className="relative w-full">
|
||||
<IssueListLayoutHeader stateId={stateId} />
|
||||
{issueIds && issueIds.length > 0 ? (
|
||||
<div className="divide-y divide-custom-border-200">
|
||||
{issueIds.map((issueId) => (
|
||||
<IssueListLayoutBlock key={issueId} anchor={anchor} issueId={issueId} />
|
||||
))}
|
||||
{isPaginating ? (
|
||||
<div className="w-full h-[46px] bg-custom-background-80 animate-pulse" />
|
||||
) : (
|
||||
shouldLoadMore && (
|
||||
<div
|
||||
className="w-full min-h-[45px] bg-custom-background-100 p-3 text-sm border-b-[1px] cursor-pointer text-custom-text-350 hover:text-custom-text-300"
|
||||
onClick={loadMoreIssuesInThisGroup}
|
||||
>
|
||||
Load More ↓
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-custom-background-100 p-3 text-sm text-custom-text-400">No issues.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssue, useStates } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
stateId: string;
|
||||
};
|
||||
|
||||
export const IssueListLayoutHeader: React.FC<Props> = observer((props) => {
|
||||
const { stateId } = props;
|
||||
|
||||
const { getStateById } = useStates();
|
||||
const { getGroupIssueCount } = useIssue();
|
||||
|
||||
const state = getStateById(stateId);
|
||||
|
||||
return (
|
||||
<div className="flex sticky top-0 items-center gap-2 p-3 bg-custom-background-90 z-[1] border-b-[1px] border-custom-border-200">
|
||||
<div className="flex h-3.5 w-3.5 items-center justify-center">
|
||||
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} height="14" width="14" />
|
||||
</div>
|
||||
<div className="mr-1 font-medium capitalize">{state?.name}</div>
|
||||
<div className="text-sm font-medium text-custom-text-200">
|
||||
{getGroupIssueCount(stateId, undefined, false) ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { CircleDashed } from "lucide-react";
|
||||
|
||||
interface IHeaderGroupByCard {
|
||||
groupID: string;
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
toggleListGroup: (id: string) => void;
|
||||
}
|
||||
|
||||
export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => {
|
||||
const { groupID, icon, title, count, toggleListGroup } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="group/list-header relative w-full flex-shrink-0 flex items-center gap-2 py-1.5"
|
||||
onClick={() => toggleListGroup(groupID)}
|
||||
>
|
||||
<div className="flex-shrink-0 grid place-items-center overflow-hidden">
|
||||
{icon ?? <CircleDashed className="size-3.5" strokeWidth={2} />}
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-full flex-row items-center gap-1 overflow-hidden cursor-pointer">
|
||||
<div className="inline-block line-clamp-1 truncate font-medium text-custom-text-100">{title}</div>
|
||||
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./block";
|
||||
export * from "./header";
|
||||
export * from "./root";
|
||||
export * from "./blocks-list";
|
||||
|
||||
129
space/core/components/issues/issue-layouts/list/list-group.tsx
Normal file
129
space/core/components/issues/issue-layouts/list/list-group.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, MutableRefObject, forwardRef, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { cn } from "@plane/editor";
|
||||
// plane
|
||||
import { IGroupByColumn, TIssueGroupByOptions, IIssueDisplayProperties, TPaginationData, TLoader } from "@plane/types";
|
||||
// hooks
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
//
|
||||
import { IssueBlocksList } from "./blocks-list";
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
|
||||
interface Props {
|
||||
groupIssueIds: string[] | undefined;
|
||||
group: IGroupByColumn;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>;
|
||||
showEmptyGroup?: boolean;
|
||||
loadMoreIssues: (groupId?: string) => void;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
}
|
||||
|
||||
// List loader component
|
||||
const ListLoaderItemRow = forwardRef<HTMLDivElement>((props, ref) => (
|
||||
<div ref={ref} className="flex items-center justify-between h-11 p-3 border-b border-custom-border-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-10 bg-custom-background-80 rounded animate-pulse" />
|
||||
<span className={`h-5 w-52 bg-custom-background-80 rounded animate-pulse`} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{[...Array(6)].map((_, index) => (
|
||||
<Fragment key={index}>
|
||||
<span key={index} className="h-5 w-5 bg-custom-background-80 rounded animate-pulse" />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
ListLoaderItemRow.displayName = "ListLoaderItemRow";
|
||||
|
||||
export const ListGroup = observer((props: Props) => {
|
||||
const {
|
||||
groupIssueIds = [],
|
||||
group,
|
||||
groupBy,
|
||||
displayProperties,
|
||||
containerRef,
|
||||
showEmptyGroup,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
} = props;
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const groupRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const groupIssueCount = getGroupIssueCount(group.id, undefined, false) ?? 0;
|
||||
const nextPageResults = getPaginationData(group.id, undefined)?.nextPageResults;
|
||||
const isPaginating = !!getIssueLoader(group.id);
|
||||
|
||||
useIntersectionObserver(containerRef, isPaginating ? null : intersectionElement, loadMoreIssues, `100% 0% 100% 0%`);
|
||||
|
||||
const shouldLoadMore =
|
||||
nextPageResults === undefined && groupIssueCount !== undefined && groupIssueIds
|
||||
? groupIssueIds.length < groupIssueCount
|
||||
: !!nextPageResults;
|
||||
|
||||
const loadMore = isPaginating ? (
|
||||
<ListLoaderItemRow />
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
"h-11 relative flex items-center gap-3 bg-custom-background-100 border border-transparent border-t-custom-border-200 pl-6 p-3 text-sm font-medium text-custom-primary-100 hover:text-custom-primary-200 hover:underline cursor-pointer"
|
||||
}
|
||||
onClick={() => loadMoreIssues(group.id)}
|
||||
>
|
||||
Load More ↓
|
||||
</div>
|
||||
);
|
||||
|
||||
const validateEmptyIssueGroups = (issueCount: number = 0) => {
|
||||
if (!showEmptyGroup && issueCount <= 0) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const toggleListGroup = () => {
|
||||
setIsExpanded((prevState) => !prevState);
|
||||
};
|
||||
|
||||
const shouldExpand = (!!groupIssueCount && isExpanded) || !groupBy;
|
||||
|
||||
return validateEmptyIssueGroups(groupIssueCount) ? (
|
||||
<div ref={groupRef} className={cn(`relative flex flex-shrink-0 flex-col border-[1px] border-transparent`)}>
|
||||
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 pl-2 pr-3 py-1">
|
||||
<HeaderGroupByCard
|
||||
groupID={group.id}
|
||||
icon={group.icon}
|
||||
title={group.name || ""}
|
||||
count={groupIssueCount}
|
||||
toggleListGroup={toggleListGroup}
|
||||
/>
|
||||
</div>
|
||||
{shouldExpand && (
|
||||
<div className="relative">
|
||||
{groupIssueIds && (
|
||||
<IssueBlocksList
|
||||
issueIds={groupIssueIds}
|
||||
groupId={group.id}
|
||||
displayProperties={displayProperties}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldLoadMore && (groupBy ? <>{loadMore}</> : <ListLoaderItemRow ref={setIntersectionElement} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { TGroupedIssues } from "@plane/types";
|
||||
// mobx hook
|
||||
import { useIssue } from "@/hooks/store";
|
||||
import { Group } from "./group";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
};
|
||||
|
||||
export const IssuesListLayoutRoot: FC<Props> = observer((props) => {
|
||||
const { anchor } = props;
|
||||
// store hooks
|
||||
const { groupedIssueIds } = useIssue();
|
||||
|
||||
const groupedIssues = groupedIssueIds as TGroupedIssues | undefined;
|
||||
|
||||
if (!groupedIssues) return <></>;
|
||||
|
||||
const issueGroupIds = Object.keys(groupedIssues);
|
||||
|
||||
return (
|
||||
<>
|
||||
{issueGroupIds?.map((stateId) => {
|
||||
const issueIds = groupedIssues[stateId];
|
||||
|
||||
return <Group key={stateId} anchor={anchor} stateId={stateId} issueIds={issueIds} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Layers, Link, Paperclip } from "lucide-react";
|
||||
// types
|
||||
import { cn } from "@plane/editor";
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// ui
|
||||
// components
|
||||
import {
|
||||
IssueBlockDate,
|
||||
IssueBlockLabels,
|
||||
IssueBlockPriority,
|
||||
IssueBlockState,
|
||||
IssueBlockMembers,
|
||||
IssueBlockModules,
|
||||
IssueBlockCycle,
|
||||
} from "@/components/issues";
|
||||
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC";
|
||||
// helpers
|
||||
import { getDate } from "@/helpers/date-time.helper";
|
||||
//// hooks
|
||||
import { IIssue } from "@/types/issue";
|
||||
|
||||
export interface IIssueProperties {
|
||||
issue: IIssue;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
const { issue, displayProperties, className } = props;
|
||||
|
||||
if (!displayProperties || !issue.project_id) return null;
|
||||
|
||||
const minDate = getDate(issue.start_date);
|
||||
minDate?.setDate(minDate.getDate());
|
||||
|
||||
const maxDate = getDate(issue.target_date);
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* basic properties */}
|
||||
{/* state */}
|
||||
{issue.state_id && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
|
||||
<div className="h-5">
|
||||
<IssueBlockState stateId={issue.state_id} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* priority */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
|
||||
<div className="h-5">
|
||||
<IssueBlockPriority priority={issue.priority} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* label */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="labels">
|
||||
<div className="h-5">
|
||||
<IssueBlockLabels labelIds={issue.label_ids} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* start date */}
|
||||
{issue?.start_date && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="start_date">
|
||||
<div className="h-5">
|
||||
<IssueBlockDate
|
||||
due_date={issue?.start_date}
|
||||
stateId={issue?.state_id ?? undefined}
|
||||
shouldHighLight={false}
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* target/due date */}
|
||||
{issue?.target_date && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="due_date">
|
||||
<div className="h-5">
|
||||
<IssueBlockDate due_date={issue?.target_date} stateId={issue?.state_id ?? undefined} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* assignee */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
|
||||
<div className="h-5">
|
||||
<IssueBlockMembers memberIds={issue.assignee_ids} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* modules */}
|
||||
{issue.module_ids && issue.module_ids.length > 0 && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="modules">
|
||||
<div className="h-5">
|
||||
<IssueBlockModules moduleIds={issue.module_ids} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* cycles */}
|
||||
{issue.cycle_id && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="cycle">
|
||||
<div className="h-5">
|
||||
<IssueBlockCycle cycleId={issue.cycle_id} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* estimates */}
|
||||
{/* {projectId && areEstimateEnabledByProjectId(projectId?.toString()) && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="estimate">
|
||||
<div className="h-5">
|
||||
<EstimateDropdown
|
||||
value={issue.estimate_point ?? undefined}
|
||||
onChange={handleEstimate}
|
||||
projectId={issue.project_id}
|
||||
disabled={isReadOnly}
|
||||
buttonVariant="border-with-text"
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)} */}
|
||||
|
||||
{/* extra render properties */}
|
||||
{/* sub-issues */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="sub_issue_count"
|
||||
shouldRenderProperty={(properties) => !!properties.sub_issue_count && !!issue.sub_issues_count}
|
||||
>
|
||||
<Tooltip tooltipHeading="Sub-issues" tooltipContent={`${issue.sub_issues_count}`}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1",
|
||||
{
|
||||
"hover:bg-custom-background-80 cursor-pointer": issue.sub_issues_count,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Layers className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
|
||||
<div className="text-xs">{issue.sub_issues_count}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* attachments */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="attachment_count"
|
||||
shouldRenderProperty={(properties) => !!properties.attachment_count && !!issue.attachment_count}
|
||||
>
|
||||
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
|
||||
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
|
||||
<Paperclip className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
|
||||
<div className="text-xs">{issue.attachment_count}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* link */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="link"
|
||||
shouldRenderProperty={(properties) => !!properties.link && !!issue.link_count}
|
||||
>
|
||||
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
|
||||
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
|
||||
<Link className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
|
||||
<div className="text-xs">{issue.link_count}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</WithDisplayPropertiesHOC>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { cn } from "@plane/editor";
|
||||
import { ContrastIcon, Tooltip } from "@plane/ui";
|
||||
//hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
|
||||
type Props = {
|
||||
cycleId: string | undefined;
|
||||
shouldShowBorder?: boolean;
|
||||
};
|
||||
|
||||
export const IssueBlockCycle = observer(({ cycleId, shouldShowBorder = true }: Props) => {
|
||||
const { getCycleById } = useCycle();
|
||||
|
||||
const cycle = getCycleById(cycleId);
|
||||
|
||||
return (
|
||||
<Tooltip tooltipHeading="Cycle" tooltipContent={cycle?.name ?? "No Cycle"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-between gap-1 rounded px-2.5 py-1 text-xs duration-300 focus:outline-none",
|
||||
{ "border-[0.5px] border-custom-border-300": shouldShowBorder }
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center text-xs gap-1.5">
|
||||
<ContrastIcon className="h-3 w-3 flex-shrink-0" />
|
||||
<div className="max-w-40 flex-grow truncate ">{cycle?.name ?? "No Cycle"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarCheck2 } from "lucide-react";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
@@ -10,27 +11,31 @@ import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||
import { useStates } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
due_date: string;
|
||||
due_date: string | undefined;
|
||||
stateId: string | undefined;
|
||||
shouldHighLight?: boolean;
|
||||
shouldShowBorder?: boolean;
|
||||
};
|
||||
|
||||
export const IssueBlockDueDate = observer((props: Props) => {
|
||||
const { due_date, stateId } = props;
|
||||
export const IssueBlockDate = observer((props: Props) => {
|
||||
const { due_date, stateId, shouldHighLight = true, shouldShowBorder = true } = props;
|
||||
const { getStateById } = useStates();
|
||||
|
||||
const state = getStateById(stateId);
|
||||
|
||||
const formattedDate = renderFormattedDate(due_date);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs text-custom-text-100",
|
||||
{
|
||||
"text-red-500": shouldHighlightIssueDueDate(due_date, state?.group),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<CalendarCheck2 className="size-3 flex-shrink-0" />
|
||||
{renderFormattedDate(due_date)}
|
||||
</div>
|
||||
<Tooltip tooltipHeading="Due Date" tooltipContent={formattedDate}>
|
||||
<div
|
||||
className={cn("flex h-full items-center gap-1 rounded px-2.5 py-1 text-xs text-custom-text-100", {
|
||||
"text-red-500": shouldHighLight && due_date && shouldHighlightIssueDueDate(due_date, state?.group),
|
||||
"border-[0.5px] border-custom-border-300": shouldShowBorder,
|
||||
})}
|
||||
>
|
||||
<CalendarCheck2 className="size-3 flex-shrink-0" />
|
||||
{formattedDate ? formattedDate : "No Date"}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,3 +2,7 @@ export * from "./due-date";
|
||||
export * from "./labels";
|
||||
export * from "./priority";
|
||||
export * from "./state";
|
||||
export * from "./cycle";
|
||||
export * from "./member";
|
||||
export * from "./modules";
|
||||
export * from "./all-properties";
|
||||
|
||||
@@ -1,40 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Tags } from "lucide-react";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { useLabel } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
labelIds: string[];
|
||||
shouldShowLabel?: boolean;
|
||||
};
|
||||
|
||||
export const IssueBlockLabels = observer(({ labelIds }: Props) => {
|
||||
export const IssueBlockLabels = observer(({ labelIds, shouldShowLabel = false }: Props) => {
|
||||
const { getLabelsByIds } = useLabel();
|
||||
|
||||
const labels = getLabelsByIds(labelIds);
|
||||
|
||||
const labelsString = labels.map((label) => label.name).join(", ");
|
||||
const labelsString = labels.length > 0 ? labels.map((label) => label.name).join(", ") : "No Labels";
|
||||
|
||||
if (labels.length <= 0)
|
||||
return (
|
||||
<Tooltip position="top" tooltipHeading="Labels" tooltipContent="None">
|
||||
<div
|
||||
className={`flex h-full items-center justify-center gap-2 rounded px-2.5 py-1 text-xs border-[0.5px] border-custom-border-300`}
|
||||
>
|
||||
<Tags className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
{shouldShowLabel && <span>No Labels</span>}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-wrap items-center gap-1">
|
||||
{labels.length === 1 ? (
|
||||
<div
|
||||
key={labels[0].id}
|
||||
className="flex flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: `${labels[0].color}` }} />
|
||||
<div className="text-xs">{labels[0].name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-5 w-full flex-wrap items-center gap-2 overflow-hidden">
|
||||
{labels.length <= 2 ? (
|
||||
<>
|
||||
{labels.map((label) => (
|
||||
<Tooltip key={label.id} position="top" tooltipHeading="Labels" tooltipContent={label?.name ?? ""}>
|
||||
<div
|
||||
key={label?.id}
|
||||
className={`flex overflow-hidden h-full max-w-full flex-shrink-0 items-center rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs`}
|
||||
>
|
||||
<div className="flex max-w-full items-center gap-1.5 overflow-hidden text-custom-text-200">
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
<div className="line-clamp-1 inline-block w-auto max-w-[100px] truncate">{label?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Tooltip tooltipContent={labelsString}>
|
||||
<div className="flex flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm">
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<div className="text-xs">{labels.length} Labels</div>
|
||||
<div
|
||||
className={`flex h-full flex-shrink-0 items-center rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs cursor-not-allowed"
|
||||
`}
|
||||
>
|
||||
<Tooltip position="top" tooltipHeading="Labels" tooltipContent={labelsString}>
|
||||
<div className="flex h-full items-center gap-1.5 text-custom-text-200">
|
||||
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-custom-primary" />
|
||||
{`${labels.length} Labels`}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { LucideIcon, Users } from "lucide-react";
|
||||
// ui
|
||||
import { cn } from "@plane/editor";
|
||||
import { Avatar, AvatarGroup } from "@plane/ui";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
//
|
||||
import { TPublicMember } from "@/types/member";
|
||||
|
||||
type Props = {
|
||||
memberIds: string[];
|
||||
shouldShowBorder?: boolean;
|
||||
};
|
||||
|
||||
type AvatarProps = {
|
||||
showTooltip: boolean;
|
||||
members: TPublicMember[];
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
export const ButtonAvatars: React.FC<AvatarProps> = observer((props: AvatarProps) => {
|
||||
const { showTooltip, members, icon: Icon } = props;
|
||||
|
||||
if (Array.isArray(members)) {
|
||||
if (members.length > 1) {
|
||||
return (
|
||||
<AvatarGroup size="md" showTooltip={!showTooltip}>
|
||||
{members.map((member) => {
|
||||
if (!member) return;
|
||||
return <Avatar key={member.id} src={member.member__avatar} name={member.member__display_name} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
);
|
||||
} else if (members.length === 1) {
|
||||
return (
|
||||
<Avatar
|
||||
src={members[0].member__avatar}
|
||||
name={members[0].member__display_name}
|
||||
size="md"
|
||||
showTooltip={!showTooltip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Icon ? <Icon className="h-3 w-3 flex-shrink-0" /> : <Users className="h-3 w-3 mx-[4px] flex-shrink-0" />;
|
||||
});
|
||||
|
||||
export const IssueBlockMembers = observer(({ memberIds, shouldShowBorder = true }: Props) => {
|
||||
const { getMembersByIds } = useMember();
|
||||
|
||||
const members = getMembersByIds(memberIds);
|
||||
|
||||
return (
|
||||
<div className="relative h-full flex flex-wrap items-center gap-1">
|
||||
<div
|
||||
className={cn("flex flex-shrink-0 cursor-default items-center rounded-md text-xs", {
|
||||
"border-[0.5px] border-custom-border-300 px-2.5 py-1": shouldShowBorder && !members?.length,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<ButtonAvatars members={members} showTooltip={false} />
|
||||
{!shouldShowBorder && members.length <= 1 && (
|
||||
<span>{members?.[0]?.member__display_name ?? "No Assignees"}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// planes
|
||||
import { cn } from "@plane/editor";
|
||||
import { DiceIcon, Tooltip } from "@plane/ui";
|
||||
// hooks
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
|
||||
type Props = {
|
||||
moduleIds: string[] | undefined;
|
||||
shouldShowBorder?: boolean;
|
||||
};
|
||||
|
||||
export const IssueBlockModules = observer(({ moduleIds, shouldShowBorder = true }: Props) => {
|
||||
const { getModulesByIds } = useModule();
|
||||
|
||||
const modules = getModulesByIds(moduleIds ?? []);
|
||||
|
||||
const modulesString = modules.map((module) => module.name).join(", ");
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-wrap items-center gap-1">
|
||||
<Tooltip tooltipHeading="Modules" tooltipContent={modulesString}>
|
||||
{modules.length <= 1 ? (
|
||||
<div
|
||||
key={modules?.[0]?.id}
|
||||
className={cn("flex h-full flex-shrink-0 cursor-default items-center rounded-md px-2.5 py-1 text-xs", {
|
||||
"border-[0.5px] border-custom-border-300": shouldShowBorder,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<DiceIcon className="h-3 w-3 flex-shrink-0" />
|
||||
<div className="text-xs">{modules?.[0]?.name ?? "No Modules"}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs">
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<div className="text-xs">{modules.length} Modules</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -2,17 +2,29 @@
|
||||
|
||||
// types
|
||||
import { TIssuePriorities } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// constants
|
||||
import { issuePriorityFilter } from "@/constants/issue";
|
||||
|
||||
export const IssueBlockPriority = ({ priority }: { priority: TIssuePriorities | null }) => {
|
||||
export const IssueBlockPriority = ({
|
||||
priority,
|
||||
shouldShowName = false,
|
||||
}: {
|
||||
priority: TIssuePriorities | null;
|
||||
shouldShowName?: boolean;
|
||||
}) => {
|
||||
const priority_detail = priority != null ? issuePriorityFilter(priority) : null;
|
||||
|
||||
if (priority_detail === null) return <></>;
|
||||
|
||||
return (
|
||||
<div className={`grid h-6 w-6 place-items-center rounded border-[0.5px] ${priority_detail?.className}`}>
|
||||
<span className="material-symbols-rounded text-sm">{priority_detail?.icon}</span>
|
||||
</div>
|
||||
<Tooltip tooltipHeading="Priority" tooltipContent={priority_detail?.title}>
|
||||
<div className="flex items-center relative w-full h-full">
|
||||
<div className={`grid h-5 w-5 place-items-center rounded border-[0.5px] gap-2 ${priority_detail?.className}`}>
|
||||
<span className="material-symbols-rounded text-sm">{priority_detail?.icon}</span>
|
||||
</div>
|
||||
{shouldShowName && <span className="pl-2 text-sm">{priority_detail?.title}</span>}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,26 +2,32 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
import { cn } from "@plane/editor";
|
||||
import { StateGroupIcon, Tooltip } from "@plane/ui";
|
||||
//hooks
|
||||
import { useStates } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
stateId: string;
|
||||
stateId: string | undefined;
|
||||
shouldShowBorder?: boolean;
|
||||
};
|
||||
export const IssueBlockState = observer(({ stateId }: Props) => {
|
||||
export const IssueBlockState = observer(({ stateId, shouldShowBorder = true }: Props) => {
|
||||
const { getStateById } = useStates();
|
||||
|
||||
const state = getStateById(stateId);
|
||||
|
||||
if (!state) return <></>;
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs shadow-sm duration-300 focus:outline-none">
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} />
|
||||
<div className="text-xs">{state?.name}</div>
|
||||
<Tooltip tooltipHeading="State" tooltipContent={state?.name ?? "State"}>
|
||||
<div
|
||||
className={cn("flex h-full w-full items-center justify-between gap-1 rounded px-2.5 py-1 text-xs", {
|
||||
"border-[0.5px] border-custom-border-300": shouldShowBorder,
|
||||
})}
|
||||
>
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} />
|
||||
<div className="text-xs">{state?.name ?? "State"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { FC, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { IssueKanbanLayoutRoot, IssuesListLayoutRoot } from "@/components/issues";
|
||||
@@ -13,7 +12,7 @@ import { useIssue, useIssueDetails, useIssueFilter } from "@/hooks/store";
|
||||
// store
|
||||
import { PublishStore } from "@/store/publish/publish.store";
|
||||
// assets
|
||||
import SomethingWentWrongImage from "public/something-went-wrong.svg";
|
||||
import { SomethingWentWrongError } from "./error";
|
||||
|
||||
type Props = {
|
||||
peekId: string | undefined;
|
||||
@@ -24,7 +23,7 @@ export const IssuesLayoutsRoot: FC<Props> = observer((props) => {
|
||||
const { peekId, publishSettings } = props;
|
||||
// store hooks
|
||||
const { getIssueFilters } = useIssueFilter();
|
||||
const { loader, groupedIssueIds, fetchPublicIssues } = useIssue();
|
||||
const { fetchPublicIssues } = useIssue();
|
||||
const issueDetailStore = useIssueDetails();
|
||||
// derived values
|
||||
const { anchor } = publishSettings;
|
||||
@@ -48,46 +47,27 @@ export const IssuesLayoutsRoot: FC<Props> = observer((props) => {
|
||||
|
||||
if (!anchor) return null;
|
||||
|
||||
if (error) return <SomethingWentWrongError />;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
{peekId && <IssuePeekOverview anchor={anchor} peekId={peekId} />}
|
||||
{activeLayout && (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
{/* applied filters */}
|
||||
<IssueAppliedFilters anchor={anchor} />
|
||||
|
||||
{loader && !groupedIssueIds ? (
|
||||
<div className="py-10 text-center text-sm text-custom-text-100">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
{error ? (
|
||||
<div className="grid h-full w-full place-items-center p-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto grid h-52 w-52 place-items-center rounded-full bg-custom-background-80">
|
||||
<div className="grid h-32 w-32 place-items-center">
|
||||
<Image src={SomethingWentWrongImage} alt="Oops! Something went wrong" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="mt-12 text-3xl font-semibold">Oops! Something went wrong.</h1>
|
||||
<p className="mt-4 text-custom-text-300">The public board does not exist. Please check the URL.</p>
|
||||
</div>
|
||||
{activeLayout === "list" && (
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<IssuesListLayoutRoot anchor={anchor} />
|
||||
</div>
|
||||
) : (
|
||||
activeLayout && (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
{/* applied filters */}
|
||||
<IssueAppliedFilters anchor={anchor} />
|
||||
|
||||
{activeLayout === "list" && (
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<IssuesListLayoutRoot anchor={anchor} />
|
||||
</div>
|
||||
)}
|
||||
{activeLayout === "kanban" && (
|
||||
<div className="relative mx-auto h-full w-full p-5">
|
||||
<IssueKanbanLayoutRoot anchor={anchor} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
{activeLayout === "kanban" && (
|
||||
<div className="relative mx-auto h-full w-full p-5">
|
||||
<IssueKanbanLayoutRoot anchor={anchor} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
240
space/core/components/issues/issue-layouts/utils.tsx
Normal file
240
space/core/components/issues/issue-layouts/utils.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import isNil from "lodash/isNil";
|
||||
import { ContrastIcon } from "lucide-react";
|
||||
// types
|
||||
import {
|
||||
GroupByColumnTypes,
|
||||
IGroupByColumn,
|
||||
TCycleGroups,
|
||||
IIssueDisplayProperties,
|
||||
TGroupedIssues,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { Avatar, CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/ui";
|
||||
// components
|
||||
// constants
|
||||
import { ISSUE_PRIORITIES } from "@/constants/issue";
|
||||
// stores
|
||||
import { ICycleStore } from "@/store/cycle.store";
|
||||
import { IIssueLabelStore } from "@/store/label.store";
|
||||
import { IIssueMemberStore } from "@/store/members.store";
|
||||
import { IIssueModuleStore } from "@/store/module.store";
|
||||
import { IStateStore } from "@/store/state.store";
|
||||
|
||||
export const HIGHLIGHT_CLASS = "highlight";
|
||||
export const HIGHLIGHT_WITH_LINE = "highlight-with-line";
|
||||
|
||||
export const getGroupByColumns = (
|
||||
groupBy: GroupByColumnTypes | null,
|
||||
cycle: ICycleStore,
|
||||
module: IIssueModuleStore,
|
||||
label: IIssueLabelStore,
|
||||
projectState: IStateStore,
|
||||
member: IIssueMemberStore,
|
||||
includeNone?: boolean
|
||||
): IGroupByColumn[] | undefined => {
|
||||
switch (groupBy) {
|
||||
case "cycle":
|
||||
return getCycleColumns(cycle);
|
||||
case "module":
|
||||
return getModuleColumns(module);
|
||||
case "state":
|
||||
return getStateColumns(projectState);
|
||||
case "priority":
|
||||
return getPriorityColumns();
|
||||
case "labels":
|
||||
return getLabelsColumns(label) as any;
|
||||
case "assignees":
|
||||
return getAssigneeColumns(member) as any;
|
||||
case "created_by":
|
||||
return getCreatedByColumns(member) as any;
|
||||
default:
|
||||
if (includeNone) return [{ id: `All Issues`, name: `All Issues`, payload: {}, icon: undefined }];
|
||||
}
|
||||
};
|
||||
|
||||
const getCycleColumns = (cycleStore: ICycleStore): IGroupByColumn[] | undefined => {
|
||||
const { cycles } = cycleStore;
|
||||
|
||||
if (!cycles) return;
|
||||
|
||||
const cycleGroups: IGroupByColumn[] = [];
|
||||
|
||||
cycles.map((cycle) => {
|
||||
if (cycle) {
|
||||
const cycleStatus = cycle?.status ? (cycle.status.toLocaleLowerCase() as TCycleGroups) : "draft";
|
||||
cycleGroups.push({
|
||||
id: cycle.id,
|
||||
name: cycle.name,
|
||||
icon: <CycleGroupIcon cycleGroup={cycleStatus as TCycleGroups} className="h-3.5 w-3.5" />,
|
||||
payload: { cycle_id: cycle.id },
|
||||
});
|
||||
}
|
||||
});
|
||||
cycleGroups.push({
|
||||
id: "None",
|
||||
name: "None",
|
||||
icon: <ContrastIcon className="h-3.5 w-3.5" />,
|
||||
payload: { cycle_id: null },
|
||||
});
|
||||
|
||||
return cycleGroups;
|
||||
};
|
||||
|
||||
const getModuleColumns = (moduleStore: IIssueModuleStore): IGroupByColumn[] | undefined => {
|
||||
const { modules } = moduleStore;
|
||||
|
||||
if (!modules) return;
|
||||
|
||||
const moduleGroups: IGroupByColumn[] = [];
|
||||
|
||||
modules.map((moduleInfo) => {
|
||||
if (moduleInfo)
|
||||
moduleGroups.push({
|
||||
id: moduleInfo.id,
|
||||
name: moduleInfo.name,
|
||||
icon: <DiceIcon className="h-3.5 w-3.5" />,
|
||||
payload: { module_ids: [moduleInfo.id] },
|
||||
});
|
||||
}) as any;
|
||||
moduleGroups.push({
|
||||
id: "None",
|
||||
name: "None",
|
||||
icon: <DiceIcon className="h-3.5 w-3.5" />,
|
||||
payload: { module_ids: [] },
|
||||
});
|
||||
|
||||
return moduleGroups as any;
|
||||
};
|
||||
|
||||
const getStateColumns = (projectState: IStateStore): IGroupByColumn[] | undefined => {
|
||||
const { states } = projectState;
|
||||
if (!states) return;
|
||||
|
||||
return states.map((state) => ({
|
||||
id: state.id,
|
||||
name: state.name,
|
||||
icon: (
|
||||
<div className="h-3.5 w-3.5 rounded-full">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} width="14" height="14" />
|
||||
</div>
|
||||
),
|
||||
payload: { state_id: state.id },
|
||||
})) as any;
|
||||
};
|
||||
|
||||
const getPriorityColumns = () => {
|
||||
const priorities = ISSUE_PRIORITIES;
|
||||
|
||||
return priorities.map((priority) => ({
|
||||
id: priority.key,
|
||||
name: priority.title,
|
||||
icon: <PriorityIcon priority={priority?.key} />,
|
||||
payload: { priority: priority.key },
|
||||
}));
|
||||
};
|
||||
|
||||
const getLabelsColumns = (label: IIssueLabelStore) => {
|
||||
const { labels: storeLabels } = label;
|
||||
|
||||
if (!storeLabels) return;
|
||||
|
||||
const labels = [...storeLabels, { id: "None", name: "None", color: "#666" }];
|
||||
|
||||
return labels.map((label) => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
icon: (
|
||||
<div className="h-[12px] w-[12px] rounded-full" style={{ backgroundColor: label.color ? label.color : "#666" }} />
|
||||
),
|
||||
payload: label?.id === "None" ? {} : { label_ids: [label.id] },
|
||||
}));
|
||||
};
|
||||
|
||||
const getAssigneeColumns = (member: IIssueMemberStore) => {
|
||||
const { members } = member;
|
||||
|
||||
if (!members) return;
|
||||
|
||||
const assigneeColumns: any = members.map((member) => ({
|
||||
id: member.id,
|
||||
name: member?.member__display_name || "",
|
||||
icon: <Avatar name={member?.member__display_name} src={undefined} size="md" />,
|
||||
payload: { assignee_ids: [member.id] },
|
||||
}));
|
||||
|
||||
assigneeColumns.push({ id: "None", name: "None", icon: <Avatar size="md" />, payload: {} });
|
||||
|
||||
return assigneeColumns;
|
||||
};
|
||||
|
||||
const getCreatedByColumns = (member: IIssueMemberStore) => {
|
||||
const { members } = member;
|
||||
|
||||
if (!members) return;
|
||||
|
||||
return members.map((member) => ({
|
||||
id: member.id,
|
||||
name: member?.member__display_name || "",
|
||||
icon: <Avatar name={member?.member__display_name} src={undefined} size="md" />,
|
||||
payload: {},
|
||||
}));
|
||||
};
|
||||
|
||||
export const getDisplayPropertiesCount = (
|
||||
displayProperties: IIssueDisplayProperties,
|
||||
ignoreFields?: (keyof IIssueDisplayProperties)[]
|
||||
) => {
|
||||
const propertyKeys = Object.keys(displayProperties) as (keyof IIssueDisplayProperties)[];
|
||||
|
||||
let count = 0;
|
||||
|
||||
for (const propertyKey of propertyKeys) {
|
||||
if (ignoreFields && ignoreFields.includes(propertyKey)) continue;
|
||||
if (displayProperties[propertyKey]) count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
export const getIssueBlockId = (
|
||||
issueId: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId?: string | undefined
|
||||
) => `issue_${issueId}_${groupId}_${subGroupId}`;
|
||||
|
||||
/**
|
||||
* returns empty Array if groupId is None
|
||||
* @param groupId
|
||||
* @returns
|
||||
*/
|
||||
export const getGroupId = (groupId: string) => {
|
||||
if (groupId === "None") return [];
|
||||
return [groupId];
|
||||
};
|
||||
|
||||
/**
|
||||
* method that removes Null or undefined Keys from object
|
||||
* @param obj
|
||||
* @returns
|
||||
*/
|
||||
export const removeNillKeys = <T,>(obj: T) =>
|
||||
Object.fromEntries(Object.entries(obj ?? {}).filter(([key, value]) => key && !isNil(value)));
|
||||
|
||||
/**
|
||||
* This Method returns if the the grouped values are subGrouped
|
||||
* @param groupedIssueIds
|
||||
* @returns
|
||||
*/
|
||||
export const isSubGrouped = (groupedIssueIds: TGroupedIssues) => {
|
||||
if (!groupedIssueIds || Array.isArray(groupedIssueIds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(groupedIssueIds[Object.keys(groupedIssueIds)[0]])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
|
||||
interface IWithDisplayPropertiesHOC {
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
shouldRenderProperty?: (displayProperties: IIssueDisplayProperties) => boolean;
|
||||
displayPropertyKey: keyof IIssueDisplayProperties | (keyof IIssueDisplayProperties)[];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const WithDisplayPropertiesHOC = observer(
|
||||
({ displayProperties, shouldRenderProperty, displayPropertyKey, children }: IWithDisplayPropertiesHOC) => {
|
||||
let shouldDisplayPropertyFromFilters = false;
|
||||
if (Array.isArray(displayPropertyKey))
|
||||
shouldDisplayPropertyFromFilters = displayPropertyKey.every((key) => !!displayProperties[key]);
|
||||
else shouldDisplayPropertyFromFilters = !!displayProperties[displayPropertyKey];
|
||||
|
||||
const renderProperty =
|
||||
shouldDisplayPropertyFromFilters && (shouldRenderProperty ? shouldRenderProperty(displayProperties) : true);
|
||||
|
||||
if (!renderProperty) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
);
|
||||
@@ -6,16 +6,17 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// components
|
||||
import { FullScreenPeekView, SidePeekView } from "@/components/issues/peek-overview";
|
||||
// store
|
||||
// hooks
|
||||
import { useIssue, useIssueDetails } from "@/hooks/store";
|
||||
|
||||
type TIssuePeekOverview = {
|
||||
anchor: string;
|
||||
peekId: string;
|
||||
handlePeekClose?: () => void;
|
||||
};
|
||||
|
||||
export const IssuePeekOverview: FC<TIssuePeekOverview> = observer((props) => {
|
||||
const { anchor, peekId } = props;
|
||||
const { anchor, peekId, handlePeekClose } = props;
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
@@ -34,13 +35,17 @@ export const IssuePeekOverview: FC<TIssuePeekOverview> = observer((props) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (anchor && peekId && issueStore.groupedIssueIds) {
|
||||
if (!issueDetails) {
|
||||
issueDetailStore.fetchIssueDetails(anchor, peekId.toString());
|
||||
}
|
||||
issueDetailStore.fetchIssueDetails(anchor, peekId.toString());
|
||||
}
|
||||
}, [anchor, issueDetailStore, issueDetails, peekId, issueStore.groupedIssueIds]);
|
||||
}, [anchor, issueDetailStore, peekId, issueStore.groupedIssueIds]);
|
||||
|
||||
const handleClose = () => {
|
||||
// if close logic is passed down, call that instead of the below logic
|
||||
if (handlePeekClose) {
|
||||
handlePeekClose();
|
||||
return;
|
||||
}
|
||||
|
||||
issueDetailStore.setPeekId(null);
|
||||
let queryParams: any = {
|
||||
board,
|
||||
|
||||
26
space/core/components/ui/not-found.tsx
Normal file
26
space/core/components/ui/not-found.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
// ui
|
||||
// images
|
||||
import Image404 from "@/public/404.svg";
|
||||
|
||||
export const PageNotFound = () => (
|
||||
<div className={`h-screen w-full overflow-hidden bg-custom-background-100`}>
|
||||
<div className="grid h-full place-items-center p-4">
|
||||
<div className="space-y-8 text-center">
|
||||
<div className="relative mx-auto h-60 w-60 lg:h-80 lg:w-80">
|
||||
<Image src={Image404} layout="fill" alt="404- Page not found" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Oops! Something went wrong.</h3>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is
|
||||
temporarily unavailable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -19,12 +19,12 @@ import {
|
||||
Underline,
|
||||
} from "lucide-react";
|
||||
// editor
|
||||
import { EditorMenuItemNames } from "@plane/editor";
|
||||
import { TEditorCommands } from "@plane/editor";
|
||||
|
||||
type TEditorTypes = "lite" | "document";
|
||||
|
||||
export type ToolbarMenuItem = {
|
||||
key: EditorMenuItemNames;
|
||||
key: TEditorCommands;
|
||||
name: string;
|
||||
icon: LucideIcon;
|
||||
shortcut?: string[];
|
||||
|
||||
@@ -75,4 +75,15 @@ export const issuePriorityFilter = (priorityKey: TIssuePriorities): TIssueFilter
|
||||
|
||||
if (currentIssuePriority) return currentIssuePriority;
|
||||
return undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export const ISSUE_PRIORITIES: {
|
||||
key: TIssuePriorities;
|
||||
title: string;
|
||||
}[] = [
|
||||
{ key: "urgent", title: "Urgent" },
|
||||
{ key: "high", title: "High" },
|
||||
{ key: "medium", title: "Medium" },
|
||||
{ key: "low", title: "Low" },
|
||||
{ key: "none", title: "None" },
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user