Compare commits

...
Author SHA1 Message Date
Palanikannan M 9772cf7bfa fix: change live server initialization 2024-12-10 21:03:32 +05:30
Anmol Singh BhatiaandGitHub 216a69f991 chore: workspace draft and inbox issue local db mutation (#6180) 2024-12-10 19:12:24 +05:30
Vihar KuramaandGitHub 205395e079 fix: changed checkboxes to toggles on notifications settings page (#6175) 2024-12-10 01:02:34 +05:30
Bavisetti NarayanandGitHub ff8bbed6f9 chore: changed the soft deletion logic (#6171) 2024-12-09 20:29:30 +05:30
Vamsi KrishnaandGitHub d04619477b [WEB-2382]chore: notifications code improvement (#6172)
* chore: adjusted  increment/decrement  for unread count

* chore: improved param handling for unread notification count function
2024-12-09 18:06:56 +05:30
sriram veeraghanta 547c138084 fix: ui package module resolution 2024-12-09 15:56:20 +05:30
Anmol Singh BhatiaandGitHub 5c907db0e2 [WEB-2818] chore: project navigation items code refactor (#6170)
* chore: project navigation items code refactor

* fix: build error

* chore: code refactor

* chore: code refactor
2024-12-09 14:37:04 +05:30
Aaryan KhandelwalandGitHub a85e592ada fix: creating a new sub-issue from workspace level (#6169) 2024-12-09 12:15:10 +05:30
sriram veeraghanta b21d190ce0 fix: added github pull request template 2024-12-09 02:55:09 +05:30
sriram veeraghanta cba41e0755 fix: upgrading the express version 2024-12-09 02:35:48 +05:30
sriram veeraghanta 02308eeb15 fix: django version upgrade 2024-12-09 02:28:06 +05:30
guru_sainathandGitHub 9ee41ece98 fix: email check validation to handle case in-sensitive email (#6168) 2024-12-07 17:55:50 +05:30
Vamsi KrishnaandGitHub 666ddf73b6 [WEB-2382]chore:notification snooze modal (#6164)
* modified notification store

* notification snooze types fix

* handled promise

* modified notifications layout

* incresed pagination count for notifications
2024-12-06 16:27:45 +05:30
Satish GandhamandGitHub 4499a5fa25 Sync issues and workspace data when the issue properties like labels/modules/cycles etc are deleted from the project (#6165) 2024-12-06 16:27:07 +05:30
sriram veeraghanta 727dd4002e fix: updated lint command in packages 2024-12-06 15:00:11 +05:30
sriram veeraghantaandGitHub 4b5a2bc4e5 chore: lint related changes and packaging fixes (#6163)
* fix: lint related changes and packaging fixes

* adding color validations
2024-12-06 14:56:49 +05:30
sriram veeraghanta b1c340b199 fix: build branch workflow upload artifacts 2024-12-05 16:51:20 +05:30
rahulrameshaandGitHub a612a17d28 chore remove unnecessary CTA (#6161) 2024-12-05 16:37:55 +05:30
Prateek ShouryaandGitHub d55ee6d5b8 fix: remove unwanted states fetching logic to avoid multiple API calls. (#6158) 2024-12-05 15:26:35 +05:30
Prateek ShouryaandGitHub aa1e192a50 improvement: update fetch map during workspace-level module fetch to reduce redundant API calls (#6159) 2024-12-05 15:26:15 +05:30
guru_sainathandGitHub 6cd8af1092 chore: updated powered by (#6160) 2024-12-05 15:12:37 +05:30
rahulrameshaandGitHub 66652a5d71 refactor project states to ake way for new features (#6156) 2024-12-05 12:46:51 +05:30
sriram veeraghanta 3bccda0c86 chore: formatting and typo fixes 2024-12-04 19:40:37 +05:30
sriram veeraghanta fb3295f5f4 fix: sites opengraph title and description added 2024-12-04 17:58:23 +05:30
sriram veeraghanta fa3aa362a9 fix: lint errors 2024-12-04 17:22:41 +05:30
Bavisetti NarayanandGitHub b73ea37798 chore: improve the cascading logic (#6152) 2024-12-04 16:15:57 +05:30
Vamsi KrishnaandGitHub d537e560e3 [WEB-2802]fix: dorpdown visibility issue in safari (#6151)
* filters drop down fix safari

* added comments for translation

* fixed drop down visibility issue
2024-12-04 15:27:34 +05:30
1b92a18ef8 chore: updated the ssr rendering on sites (#6145)
* fix: refactoring

* fix: site ssr implementation

* chore: fixed auto reload on file change in sites

* chore: updated constant imports and globalised powerBy component

* chore: resolved lint and updated the env

---------

Co-authored-by: sriram veeraghanta <[email protected]>
2024-12-04 14:24:53 +05:30
rahulrameshaandGitHub 31b6d52417 fix root issue store to have updated url params at all times (#6147) 2024-12-04 13:57:33 +05:30
Vamsi KrishnaandGitHub a153de34d6 fixed piority icons shape (#6144) 2024-12-04 13:57:14 +05:30
Aaryan KhandelwalandGitHub 64a44f4fce style: add custom class to editor paragraph and heading blocks (#6143) 2024-12-04 13:43:52 +05:30
guru_sainathandGitHub bb8a156bdd fix: removed changelog endpoint (#6146) 2024-12-04 13:42:15 +05:30
Akshita GoyalandGitHub f02a2b04a5 fix: export btn overlap issue (#6149) 2024-12-04 13:41:48 +05:30
Bavisetti NarayanandGitHub b6ab853c57 chore: filter out the removed cycle from issue detail (#6138) 2024-12-03 16:48:14 +05:30
213 changed files with 3983 additions and 2004 deletions
+20
View File
@@ -0,0 +1,20 @@
### Description
<!-- Provide a detailed description of the changes in this PR -->
### Type of Change
<!-- Put an 'x' in the boxes that apply -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Feature (non-breaking change which adds functionality)
- [ ] Improvement (change that would cause existing functionality to not work as expected)
- [ ] Code refactoring
- [ ] Performance improvements
- [ ] Documentation update
### Screenshots and Media (if applicable)
<!-- Add screenshots to help explain your changes, ideally showcasing before and after -->
### Test Scenarios
<!-- Please describe the tests that you ran to verify your changes -->
### References
<!-- Link related issues if there are any -->
+2 -2
View File
@@ -314,8 +314,8 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
attach_assets_to_build:
if: ${{ needs.branch_build_setup.outputs.build_type == 'Build' }}
name: Attach Assets to Build
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
name: Attach Assets to Release
runs-on: ubuntu-20.04
needs: [branch_build_setup]
steps:
+1 -1
View File
@@ -3,7 +3,7 @@
import { FC, useEffect, useRef } from "react";
import { observer } from "mobx-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
import { useOutsideClickDetector } from "@plane/hooks";
// components
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
// hooks
+3 -2
View File
@@ -14,9 +14,10 @@
"dependencies": {
"@headlessui/react": "^1.7.19",
"@plane/constants": "*",
"@plane/helpers": "*",
"@plane/hooks": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@sentry/nextjs": "^8.32.0",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
@@ -26,7 +27,7 @@
"lucide-react": "^0.356.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.12",
"next": "^14.2.20",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
+1 -3
View File
@@ -68,9 +68,7 @@ urlpatterns = [
# user workspace invitations
path(
"users/me/workspaces/invitations/",
UserWorkspaceInvitationsViewSet.as_view(
{"get": "list", "post": "create"}
),
UserWorkspaceInvitationsViewSet.as_view({"get": "list", "post": "create"}),
name="user-workspace-invitations",
),
path(
+4 -8
View File
@@ -15,8 +15,6 @@ from django.db.models import (
UUIDField,
Value,
Subquery,
Case,
When,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
@@ -445,12 +443,10 @@ class IssueViewSet(BaseViewSet):
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
:1
]
)
)
.annotate(
+9 -31
View File
@@ -16,12 +16,7 @@ from plane.app.permissions import (
WorkspaceUserPermission,
)
from plane.db.models import (
Project,
ProjectMember,
IssueUserProperty,
WorkspaceMember,
)
from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
from plane.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host
from plane.app.permissions.base import allow_permission, ROLE
@@ -83,10 +78,7 @@ class ProjectMemberViewSet(BaseViewSet):
workspace_member_role = WorkspaceMember.objects.get(
workspace__slug=slug, member=member, is_active=True
).role
if workspace_member_role in [20] and member_roles.get(member) in [
5,
15,
]:
if workspace_member_role in [20] and member_roles.get(member) in [5, 15]:
return Response(
{
"error": "You cannot add a user with role lower than the workspace role"
@@ -94,10 +86,7 @@ class ProjectMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
if workspace_member_role in [5] and member_roles.get(member) in [
15,
20,
]:
if workspace_member_role in [5] and member_roles.get(member) in [15, 20]:
return Response(
{
"error": "You cannot add a user with role higher than the workspace role"
@@ -135,8 +124,7 @@ class ProjectMemberViewSet(BaseViewSet):
sort_order = [
project_member.get("sort_order")
for project_member in project_members
if str(project_member.get("member_id"))
== str(member.get("member_id"))
if str(project_member.get("member_id")) == str(member.get("member_id"))
]
# Create a new project member
bulk_project_members.append(
@@ -145,9 +133,7 @@ class ProjectMemberViewSet(BaseViewSet):
role=member.get("role", 5),
project_id=project_id,
workspace_id=project.workspace_id,
sort_order=(
sort_order[0] - 10000 if len(sort_order) else 65535
),
sort_order=(sort_order[0] - 10000 if len(sort_order) else 65535),
)
)
# Create a new issue property
@@ -238,9 +224,7 @@ class ProjectMemberViewSet(BaseViewSet):
> requested_project_member.role
):
return Response(
{
"error": "You cannot update a role that is higher than your own role"
},
{"error": "You cannot update a role that is higher than your own role"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -280,9 +264,7 @@ class ProjectMemberViewSet(BaseViewSet):
# User cannot deactivate higher role
if requesting_project_member.role < project_member.role:
return Response(
{
"error": "You cannot remove a user having role higher than you"
},
{"error": "You cannot remove a user having role higher than you"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -303,10 +285,7 @@ class ProjectMemberViewSet(BaseViewSet):
if (
project_member.role == 20
and not ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
role=20,
is_active=True,
workspace__slug=slug, project_id=project_id, role=20, is_active=True
).count()
> 1
):
@@ -344,7 +323,6 @@ class UserProjectRolesEndpoint(BaseAPIView):
).values("project_id", "role")
project_members = {
str(member["project_id"]): member["role"]
for member in project_members
str(member["project_id"]): member["role"] for member in project_members
}
return Response(project_members, status=status.HTTP_200_OK)
+3 -2
View File
@@ -41,6 +41,7 @@ from django.views.decorators.vary import vary_on_cookie
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.license.utils.instance_value import get_configuration_value
class WorkSpaceViewSet(BaseViewSet):
model = Workspace
serializer_class = WorkSpaceSerializer
@@ -81,12 +82,12 @@ class WorkSpaceViewSet(BaseViewSet):
def create(self, request):
try:
DISABLE_WORKSPACE_CREATION, = get_configuration_value(
(DISABLE_WORKSPACE_CREATION,) = get_configuration_value(
[
{
"key": "DISABLE_WORKSPACE_CREATION",
"default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
},
}
]
)
+8 -33
View File
@@ -1,22 +1,12 @@
# Django imports
from django.db.models import (
Count,
Q,
OuterRef,
Subquery,
IntegerField,
)
from django.db.models import Count, Q, OuterRef, Subquery, IntegerField
from django.db.models.functions import Coalesce
# Third party modules
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
WorkspaceEntityPermission,
allow_permission,
ROLE,
)
from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE
# Module imports
from plane.app.serializers import (
@@ -26,12 +16,7 @@ from plane.app.serializers import (
WorkSpaceMemberSerializer,
)
from plane.app.views.base import BaseAPIView
from plane.db.models import (
Project,
ProjectMember,
WorkspaceMember,
DraftIssue,
)
from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue
from plane.utils.cache import invalidate_cache
from .. import BaseViewSet
@@ -119,9 +104,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
if requesting_workspace_member.role < workspace_member.role:
return Response(
{
"error": "You cannot remove a user having role higher than you"
},
{"error": "You cannot remove a user having role higher than you"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -148,9 +131,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug,
member_id=workspace_member.member_id,
is_active=True,
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False)
workspace_member.is_active = False
@@ -164,9 +145,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
multiple=True,
)
@invalidate_cache(path="/api/users/me/settings/")
@invalidate_cache(
path="api/users/me/workspaces/", user=False, multiple=True
)
@invalidate_cache(path="api/users/me/workspaces/", user=False, multiple=True)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
@@ -213,9 +192,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# # Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug,
member_id=workspace_member.member_id,
is_active=True,
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False)
# # Deactivate the user
@@ -279,9 +256,7 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
project_members = ProjectMember.objects.filter(
workspace__slug=slug, project_id__in=project_ids, is_active=True
).select_related("project", "member", "workspace")
project_members = ProjectMemberRoleSerializer(
project_members, many=True
).data
project_members = ProjectMemberRoleSerializer(project_members, many=True).data
project_members_dict = dict()
@@ -60,6 +60,9 @@ class EmailCheckEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
# Lower the email
email = str(email).lower().strip()
# Validate email
try:
validate_email(email)
@@ -60,6 +60,7 @@ class EmailCheckSpaceEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
email = str(email).lower().strip()
# Validate email
try:
validate_email(email)
+92 -24
View File
@@ -3,7 +3,8 @@ from django.utils import timezone
from django.apps import apps
from django.conf import settings
from django.db import models
from django.core.exceptions import ObjectDoesNotExist
from django.db.models.fields.related import OneToOneRel
# Third party imports
from celery import shared_task
@@ -11,31 +12,98 @@ from celery import shared_task
@shared_task
def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
"""
Soft delete related objects for a given model instance
"""
# Get the model class using app registry
model_class = apps.get_model(app_label, model_name)
instance = model_class.all_objects.get(pk=instance_pk)
related_fields = instance._meta.get_fields()
for field in related_fields:
if field.one_to_many or field.one_to_one:
try:
# Check if the field has CASCADE on delete
if (
not hasattr(field.remote_field, "on_delete")
or field.remote_field.on_delete == models.CASCADE
):
if field.one_to_many:
related_objects = getattr(instance, field.name).all()
elif field.one_to_one:
related_object = getattr(instance, field.name)
related_objects = (
[related_object] if related_object is not None else []
)
for obj in related_objects:
if obj:
obj.deleted_at = timezone.now()
obj.save(using=using)
except ObjectDoesNotExist:
pass
# Get the instance using all_objects to ensure we can get even if it's already soft deleted
try:
instance = model_class.all_objects.get(pk=instance_pk)
except model_class.DoesNotExist:
return
# Get all related fields that are reverse relationships
all_related = [
f
for f in instance._meta.get_fields()
if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete
]
# Handle each related field
for relation in all_related:
related_name = relation.get_accessor_name()
# Skip if the relation doesn't exist
if not hasattr(instance, related_name):
continue
# Get the on_delete behavior name
on_delete_name = (
relation.on_delete.__name__
if hasattr(relation.on_delete, "__name__")
else ""
)
if on_delete_name == "DO_NOTHING":
continue
elif on_delete_name == "SET_NULL":
# Handle SET_NULL relationships
if isinstance(relation, OneToOneRel):
# For OneToOne relationships
related_obj = getattr(instance, related_name, None)
if related_obj and isinstance(related_obj, models.Model):
setattr(related_obj, relation.remote_field.name, None)
related_obj.save(update_fields=[relation.remote_field.name])
else:
# For other relationships
related_queryset = getattr(instance, related_name).all()
related_queryset.update(**{relation.remote_field.name: None})
else:
# Handle CASCADE and other delete behaviors
try:
if relation.one_to_one:
# Handle OneToOne relationships
related_obj = getattr(instance, related_name, None)
if related_obj:
if hasattr(related_obj, "deleted_at"):
if not related_obj.deleted_at:
related_obj.deleted_at = timezone.now()
related_obj.save()
# Recursively handle related objects
soft_delete_related_objects(
related_obj._meta.app_label,
related_obj._meta.model_name,
related_obj.pk,
using,
)
else:
# Handle other relationships
related_queryset = getattr(instance, related_name).all()
for related_obj in related_queryset:
if hasattr(related_obj, "deleted_at"):
if not related_obj.deleted_at:
related_obj.deleted_at = timezone.now()
related_obj.save()
# Recursively handle related objects
soft_delete_related_objects(
related_obj._meta.app_label,
related_obj._meta.model_name,
related_obj.pk,
using,
)
except Exception as e:
# Log the error or handle as needed
print(f"Error handling relation {related_name}: {str(e)}")
continue
# Finally, soft delete the instance itself if it hasn't been deleted yet
if hasattr(instance, "deleted_at") and not instance.deleted_at:
instance.deleted_at = timezone.now()
instance.save()
# @shared_task
+5 -13
View File
@@ -162,8 +162,7 @@ def generate_table_row(issue):
issue["priority"],
(
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"]
and issue["created_by__last_name"]
if issue["created_by__first_name"] and issue["created_by__last_name"]
else ""
),
(
@@ -197,8 +196,7 @@ def generate_json_row(issue):
"Priority": issue["priority"],
"Created By": (
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"]
and issue["created_by__last_name"]
if issue["created_by__first_name"] and issue["created_by__last_name"]
else ""
),
"Assignee": (
@@ -208,17 +206,11 @@ def generate_json_row(issue):
),
"Labels": issue["labels__name"] if issue["labels__name"] else "",
"Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(
issue["issue_cycle__cycle__start_date"]
),
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"],
"Module Start Date": dateConverter(
issue["issue_module__module__start_date"]
),
"Module Target Date": dateConverter(
issue["issue_module__module__target_date"]
),
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
"Created At": dateTimeConverter(issue["created_at"]),
"Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_at"]),
+3 -1
View File
@@ -257,7 +257,9 @@ def notifications(
)
new_mentions = [
str(mention) for mention in new_mentions if mention in set(project_members)
str(mention)
for mention in new_mentions
if mention in set(project_members)
]
removed_mention = get_removed_mentions(
requested_instance=requested_data, current_instance=current_instance
@@ -13,28 +13,14 @@ from plane.db.models import (
class Command(BaseCommand):
help = "Add a member to a project. If present in the workspace"
def add_arguments(self, parser):
# Positional argument
parser.add_argument("--project_id", type=str, nargs="?", help="Project ID")
parser.add_argument("--user_email", type=str, nargs="?", help="User Email")
parser.add_argument(
"--project_id",
type=str,
nargs="?",
help="Project ID",
)
parser.add_argument(
"--user_email",
type=str,
nargs="?",
help="User Email",
)
parser.add_argument(
"--role",
type=int,
nargs="?",
help="Role of the user in the project",
"--role", type=int, nargs="?", help="Role of the user in the project"
)
def handle(self, *args: Any, **options: Any):
@@ -67,9 +53,7 @@ class Command(BaseCommand):
# Get the smallest sort order
smallest_sort_order = (
ProjectMember.objects.filter(
workspace_id=project.workspace_id,
)
ProjectMember.objects.filter(workspace_id=project.workspace_id)
.order_by("sort_order")
.first()
)
@@ -79,22 +63,15 @@ class Command(BaseCommand):
else:
sort_order = 65535
if ProjectMember.objects.filter(
project=project,
member=user,
).exists():
if ProjectMember.objects.filter(project=project, member=user).exists():
# Update the project member
ProjectMember.objects.filter(
project=project,
member=user,
).update(is_active=True, sort_order=sort_order, role=role)
ProjectMember.objects.filter(project=project, member=user).update(
is_active=True, sort_order=sort_order, role=role
)
else:
# Create the project member
ProjectMember.objects.create(
project=project,
member=user,
role=role,
sort_order=sort_order,
project=project, member=user, role=role, sort_order=sort_order
)
# Issue Property
@@ -102,9 +79,7 @@ class Command(BaseCommand):
# Success message
self.stdout.write(
self.style.SUCCESS(
f"User {user_email} added to project {project_id}"
)
self.style.SUCCESS(f"User {user_email} added to project {project_id}")
)
return
except CommandError as e:
-10
View File
@@ -53,7 +53,6 @@ from .project import (
ProjectMemberInvite,
ProjectPublicMember,
)
from .deploy_board import DeployBoard
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State
@@ -69,23 +68,14 @@ from .workspace import (
WorkspaceUserProperties,
)
from .importer import Importer
from .page import Page, PageLog, PageLabel
from .estimate import Estimate, EstimatePoint
from .intake import Intake, IntakeIssue
from .analytic import AnalyticView
from .notification import Notification, UserNotificationPreference, EmailNotificationLog
from .exporter import ExporterHistory
from .webhook import Webhook, WebhookLog
from .dashboard import Dashboard, DashboardWidget, Widget
from .favorite import UserFavorite
+6 -26
View File
@@ -44,45 +44,25 @@ class FileAsset(BaseModel):
"db.User", on_delete=models.CASCADE, null=True, related_name="assets"
)
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
null=True,
related_name="assets",
"db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets"
)
draft_issue = models.ForeignKey(
"db.DraftIssue",
on_delete=models.CASCADE,
null=True,
related_name="assets",
"db.DraftIssue", on_delete=models.CASCADE, null=True, related_name="assets"
)
project = models.ForeignKey(
"db.Project",
on_delete=models.CASCADE,
null=True,
related_name="assets",
"db.Project", on_delete=models.CASCADE, null=True, related_name="assets"
)
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, null=True, related_name="assets"
)
comment = models.ForeignKey(
"db.IssueComment",
on_delete=models.CASCADE,
null=True,
related_name="assets",
"db.IssueComment", on_delete=models.CASCADE, null=True, related_name="assets"
)
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, null=True, related_name="assets"
)
entity_type = models.CharField(
max_length=255,
null=True,
blank=True,
)
entity_identifier = models.CharField(
max_length=255,
null=True,
blank=True,
)
entity_type = models.CharField(max_length=255, null=True, blank=True)
entity_identifier = models.CharField(max_length=255, null=True, blank=True)
is_deleted = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
external_id = models.CharField(max_length=255, null=True, blank=True)
+8 -31
View File
@@ -661,9 +661,7 @@ class IssueVote(ProjectBaseModel):
class IssueVersion(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue",
on_delete=models.CASCADE,
related_name="versions",
"db.Issue", on_delete=models.CASCADE, related_name="versions"
)
PRIORITY_CHOICES = (
("urgent", "Urgent"),
@@ -688,9 +686,7 @@ class IssueVersion(ProjectBaseModel):
)
start_date = models.DateField(null=True, blank=True)
target_date = models.DateField(null=True, blank=True)
sequence_id = models.IntegerField(
default=1, verbose_name="Issue Sequence ID"
)
sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID")
sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
archived_at = models.DateField(null=True)
@@ -700,25 +696,10 @@ class IssueVersion(ProjectBaseModel):
type = models.UUIDField(blank=True, null=True)
last_saved_at = models.DateTimeField(default=timezone.now)
owned_by = models.UUIDField()
assignees = ArrayField(
models.UUIDField(),
blank=True,
default=list,
)
labels = ArrayField(
models.UUIDField(),
blank=True,
default=list,
)
cycle = models.UUIDField(
null=True,
blank=True,
)
modules = ArrayField(
models.UUIDField(),
blank=True,
default=list,
)
assignees = ArrayField(models.UUIDField(), blank=True, default=list)
labels = ArrayField(models.UUIDField(), blank=True, default=list)
cycle = models.UUIDField(null=True, blank=True)
modules = ArrayField(models.UUIDField(), blank=True, default=list)
properties = models.JSONField(default=dict)
meta = models.JSONField(default=dict)
@@ -741,9 +722,7 @@ class IssueVersion(ProjectBaseModel):
Module = apps.get_model("db.Module")
CycleIssue = apps.get_model("db.CycleIssue")
cycle_issue = CycleIssue.objects.filter(
issue=issue,
).first()
cycle_issue = CycleIssue.objects.filter(issue=issue).first()
cls.objects.create(
issue=issue,
@@ -771,9 +750,7 @@ class IssueVersion(ProjectBaseModel):
assignees=issue.assignees,
labels=issue.labels,
cycle=cycle_issue.cycle if cycle_issue else None,
modules=Module.objects.filter(issue=issue).values_list(
"id", flat=True
),
modules=Module.objects.filter(issue=issue).values_list("id", flat=True),
owned_by=user,
)
return True
+1 -3
View File
@@ -29,9 +29,7 @@ def validate_domain(value):
class Webhook(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_webhooks",
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks"
)
url = models.URLField(
validators=[validate_schema, validate_domain], max_length=1024
+7 -25
View File
@@ -102,12 +102,7 @@ def get_default_display_properties():
def get_issue_props():
return {
"subscribed": True,
"assigned": True,
"created": True,
"all_issues": True,
}
return {"subscribed": True, "assigned": True, "created": True, "all_issues": True}
def slug_validator(value):
@@ -136,9 +131,7 @@ class Workspace(BaseModel):
max_length=48, db_index=True, unique=True, validators=[slug_validator]
)
organization_size = models.CharField(max_length=20, blank=True, null=True)
timezone = models.CharField(
max_length=255, default="UTC", choices=TIMEZONE_CHOICES
)
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
def __str__(self):
"""Return name of the Workspace"""
@@ -167,10 +160,7 @@ class WorkspaceBaseModel(BaseModel):
"db.Workspace", models.CASCADE, related_name="workspace_%(class)s"
)
project = models.ForeignKey(
"db.Project",
models.CASCADE,
related_name="project_%(class)s",
null=True,
"db.Project", models.CASCADE, related_name="project_%(class)s", null=True
)
class Meta:
@@ -184,9 +174,7 @@ class WorkspaceBaseModel(BaseModel):
class WorkspaceMember(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_member",
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member"
)
member = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -221,9 +209,7 @@ class WorkspaceMember(BaseModel):
class WorkspaceMemberInvite(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_member_invite",
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite"
)
email = models.CharField(max_length=255)
accepted = models.BooleanField(default=False)
@@ -283,9 +269,7 @@ class WorkspaceTheme(BaseModel):
)
name = models.CharField(max_length=300)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="themes",
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes"
)
colors = models.JSONField(default=dict)
@@ -320,9 +304,7 @@ class WorkspaceUserProperties(BaseModel):
)
filters = models.JSONField(default=get_default_filters)
display_filters = models.JSONField(default=get_default_display_filters)
display_properties = models.JSONField(
default=get_default_display_properties
)
display_properties = models.JSONField(default=get_default_display_properties)
class Meta:
unique_together = ["workspace", "user", "deleted_at"]
@@ -2,4 +2,4 @@ from .instance import InstanceSerializer
from .configuration import InstanceConfigurationSerializer
from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer
from .workspace import WorkspaceSerializer
from .workspace import WorkspaceSerializer
@@ -1,6 +1,8 @@
from .base import BaseSerializer
from plane.db.models import User
class UserLiteSerializer(BaseSerializer):
class Meta:
model = User
fields = ["id", "email", "first_name", "last_name",]
fields = ["id", "email", "first_name", "last_name"]
@@ -13,6 +13,8 @@ from .admin import (
InstanceAdminUserSessionEndpoint,
)
from .changelog import ChangeLogEndpoint
from .workspace import InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint
from .workspace import (
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
)
@@ -1,33 +0,0 @@
# Python imports
import requests
# Django imports
from django.conf import settings
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
# plane imports
from .base import BaseAPIView
class ChangeLogEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def fetch_change_logs(self):
response = requests.get(settings.INSTANCE_CHANGELOG_URL)
response.raise_for_status()
return response.json()
def get(self, request):
# Fetch the changelog
if settings.INSTANCE_CHANGELOG_URL:
data = self.fetch_change_logs()
return Response(data, status=status.HTTP_200_OK)
else:
return Response(
{"error": "could not fetch changelog please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -43,19 +43,19 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
member_count = (
WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False, is_active=True
).select_related("owner")
)
.select_related("owner")
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
workspaces = Workspace.objects.annotate(
total_projects=project_count,
total_members=member_count,
total_projects=project_count, total_members=member_count
)
# Add search functionality
@@ -66,16 +66,14 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=workspaces,
on_results=lambda results: WorkspaceSerializer(
results, many=True,
).data,
on_results=lambda results: WorkspaceSerializer(results, many=True).data,
max_per_page=10,
default_per_page=10,
)
def post(self, request):
try:
serializer = WorkspaceSerializer (data=request.data)
serializer = WorkspaceSerializer(data=request.data)
slug = request.data.get("slug", False)
name = request.data.get("name", False)
+1 -7
View File
@@ -11,14 +11,12 @@ from plane.license.api.views import (
InstanceAdminUserMeEndpoint,
InstanceAdminSignOutEndpoint,
InstanceAdminUserSessionEndpoint,
ChangeLogEndpoint,
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
)
urlpatterns = [
path("", InstanceEndpoint.as_view(), name="instance"),
path("changelog/", ChangeLogEndpoint.as_view(), name="instance-changelog"),
path("admins/", InstanceAdminEndpoint.as_view(), name="instance-admins"),
path("admins/me/", InstanceAdminUserMeEndpoint.as_view(), name="instance-admins"),
path(
@@ -62,9 +60,5 @@ urlpatterns = [
InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(),
name="instance-workspace-availability",
),
path(
"workspaces/",
InstanceWorkSpaceEndpoint.as_view(),
name="instance-workspace",
),
path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"),
]
+6
View File
@@ -10,9 +10,15 @@ from plane.space.views import (
ProjectStatesEndpoint,
ProjectLabelsEndpoint,
ProjectMembersEndpoint,
ProjectMetaDataEndpoint,
)
urlpatterns = [
path(
"anchor/<str:anchor>/meta/",
ProjectMetaDataEndpoint.as_view(),
name="project-meta",
),
path(
"anchor/<str:anchor>/settings/",
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
+2
View File
@@ -25,3 +25,5 @@ from .state import ProjectStatesEndpoint
from .label import ProjectLabelsEndpoint
from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint
from .meta import ProjectMetaDataEndpoint
+34
View File
@@ -0,0 +1,34 @@
# third party
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework.response import Response
from plane.db.models import DeployBoard, Project
from .base import BaseAPIView
from plane.space.serializer.project import ProjectLiteSerializer
class ProjectMetaDataEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request, anchor):
try:
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).first()
except DeployBoard.DoesNotExist:
return Response(
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
)
try:
project_id = deploy_board.entity_identifier
project = Project.objects.get(id=project_id)
except Project.DoesNotExist:
return Response(
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
)
serializer = ProjectLiteSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.16
Django==4.2.17
# rest framework
djangorestframework==3.15.2
# postgres
+7 -7
View File
@@ -6,11 +6,11 @@
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/start.js\"",
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js",
"lint": "eslint . --ext .ts,.tsx",
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
"start": "node dist/start.js",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"keywords": [],
"author": "",
@@ -30,7 +30,7 @@
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.20.0",
"express": "^4.21.2",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "^5.4.1",
@@ -52,13 +52,13 @@
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.21",
"@types/express-ws": "^3.0.4",
"@types/express-ws": "^3.0.5",
"@types/node": "^20.14.9",
"babel-plugin-module-resolver": "^5.0.2",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"reflect-metadata": "^0.2.2",
"ts-node": "^10.9.2",
"tsup": "^7.2.0",
"typescript": "5.3.3"
}
}
@@ -0,0 +1,21 @@
import { Router } from "express";
import { WebSocket } from "ws";
import { manualLogger } from "../helpers/logger.js";
import type { Hocuspocus as HocusPocusServer } from "@hocuspocus/server";
export class CollaborationController {
constructor(private hocusPocusServer: HocusPocusServer) {
this.hocusPocusServer = hocusPocusServer;
}
registerRoutes(router: Router) {
router.ws("/collaboration", (ws: WebSocket, req) => {
try {
this.hocusPocusServer.handleConnection(ws, req);
} catch (err) {
manualLogger.error("WebSocket connection error:", err);
ws.close();
}
});
}
}
@@ -0,0 +1,16 @@
import { Router, Request, Response } from "express";
import type { Hocuspocus as HocusPocusServer } from "@hocuspocus/server";
export class HealthController {
constructor(private hocusPocusServer: HocusPocusServer) {
this.hocusPocusServer = hocusPocusServer;
}
registerRoutes(router: Router) {
router.get("/health", this.healthCheck);
}
private healthCheck(_req: Request, res: Response) {
res.status(200).json({ status: "OK" });
}
}
+28
View File
@@ -0,0 +1,28 @@
import { RequestHandler, Router } from "express";
type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "options" | "head";
export function registerControllers(router: Router, Controller: any) {
const instance = new Controller();
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
if (methodName === "constructor") return; // Skip the constructor
const method = Reflect.getMetadata("method", instance, methodName) as HttpMethod;
const route = Reflect.getMetadata("route", instance, methodName) as string;
const middlewares = (Reflect.getMetadata("middlewares", instance, methodName) as RequestHandler[]) || [];
if (method && route) {
const handler = instance[methodName as keyof typeof instance] as unknown;
if (typeof handler === "function") {
(router[method] as (path: string, ...handlers: RequestHandler[]) => void)(
`${baseRoute}${route}`,
...middlewares,
handler.bind(instance)
);
}
}
});
}
+89
View File
@@ -0,0 +1,89 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-wrapper-object-types */
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import "reflect-metadata";
import { RequestHandler } from "express";
/**
* Controller decorator
* @param baseRoute
* @returns
*/
export function Controller(baseRoute: string = ""): ClassDecorator {
return function (target: Function) {
Reflect.defineMetadata("baseRoute", baseRoute, target);
};
}
/**
* Controller GET method decorator
* @param baseRoute
* @returns
*/
export function Get(route: string): any {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
Reflect.defineMetadata("method", "get", target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);
};
}
/**
* Controller POST method decorator
* @param baseRoute
* @returns
*/
export function Post(route: string): any {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
Reflect.defineMetadata("method", "post", target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);
};
}
/**
* Controller PATCH method decorator
* @param baseRoute
* @returns
*/
export function Patch(route: string): any {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
Reflect.defineMetadata("method", "patch", target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);
};
}
/**
* Controller PUT method decorator
* @param baseRoute
* @returns
*/
export function Put(route: string): any {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
Reflect.defineMetadata("method", "put", target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);
};
}
/**
* Controller DELETE method decorator
* @param baseRoute
* @returns
*/
export function Delete(route: string): any {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
Reflect.defineMetadata("method", "delete", target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);
};
}
/**
* Middle method decorator
* @param baseRoute
* @returns
*/
export function Middleware(middleware: RequestHandler): any {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const middlewares = Reflect.getMetadata("middlewares", target, propertyKey) || [];
middlewares.push(middleware);
Reflect.defineMetadata("middlewares", middlewares, target, propertyKey);
};
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./controller";
export * from "./decorators";
+143 -95
View File
@@ -1,118 +1,166 @@
import "@/core/config/sentry-config.js";
import express from "express";
import expressWs from "express-ws";
import express, { Express } from "express";
import expressWs, { Application as WsApplication } from "express-ws";
import * as Sentry from "@sentry/node";
import compression from "compression";
import helmet from "helmet";
// cors
import cors from "cors";
import { Server as HTTPServer } from "http";
// core hocuspocus server
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// helpers
import { logger, manualLogger } from "@/core/helpers/logger.js";
import { errorHandler } from "@/core/helpers/error-handler.js";
import { HealthController } from "@/core/controllers/health.controller.js";
import { CollaborationController } from "@/core/controllers/collaboration.controller.js";
import type { Hocuspocus as HocusPocusServer } from "@hocuspocus/server";
const app = express();
expressWs(app);
// Types
type WebSocketServerType = Express & WsApplication;
app.set("port", process.env.PORT || 3000);
const Controllers = [HealthController, CollaborationController];
export class Server {
private app: WebSocketServerType;
private port: number;
private hocusPocusServer: HocusPocusServer | null;
private httpServer: HTTPServer | null;
// Security middleware
app.use(helmet());
constructor() {
const expressApp = express();
const wsInstance = expressWs(expressApp);
this.app = wsInstance.app as WebSocketServerType;
this.port = Number(process.env.PORT || 3000);
this.hocusPocusServer = null;
this.httpServer = null;
// Middleware for response compression
app.use(
compression({
level: 6,
threshold: 5 * 1000,
}),
);
// Logging middleware
app.use(logger);
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// cors middleware
app.use(cors());
const router = express.Router();
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
manualLogger.error("Failed to initialize HocusPocusServer:", err);
process.exit(1);
});
router.get("/health", (_req, res) => {
res.status(200).json({ status: "OK" });
});
router.ws("/collaboration", (ws, req) => {
try {
HocusPocusServer.handleConnection(ws, req);
} catch (err) {
manualLogger.error("WebSocket connection error:", err);
ws.close();
this.setupMiddleware();
}
});
app.use(process.env.LIVE_BASE_PATH || "/live", router);
private setupMiddleware(): void {
// Security middleware
this.app.use(helmet());
app.use((_req, res) => {
res.status(404).send("Not Found");
});
Sentry.setupExpressErrorHandler(app);
app.use(errorHandler);
const liveServer = app.listen(app.get("port"), () => {
manualLogger.info(`Plane Live server has started at port ${app.get("port")}`);
});
const gracefulShutdown = async () => {
manualLogger.info("Starting graceful shutdown...");
try {
// Close the HocusPocus server WebSocket connections
await HocusPocusServer.destroy();
manualLogger.info(
"HocusPocus server WebSocket connections closed gracefully.",
// Compression middleware
this.app.use(
compression({
level: 6,
threshold: 5 * 1000,
}),
);
// Close the Express server
liveServer.close(() => {
manualLogger.info("Express server closed gracefully.");
process.exit(1);
});
} catch (err) {
manualLogger.error("Error during shutdown:", err);
process.exit(1);
// Logging middleware
this.app.use(logger);
// Body parsing middleware
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: true }));
// CORS middleware
this.app.use(cors());
}
// Forcefully shut down after 10 seconds if not closed
setTimeout(() => {
manualLogger.error("Forcing shutdown...");
process.exit(1);
}, 10000);
};
private async setupWebSocketServer(): Promise<void> {
try {
this.hocusPocusServer = await getHocusPocusServer();
} catch (err) {
manualLogger.error("Failed to initialize HocusPocusServer:", err);
process.exit(1);
}
}
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", (err: any) => {
manualLogger.info("Unhandled Rejection: ", err);
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
gracefulShutdown();
});
private setupControllers(): void {
if (!this.hocusPocusServer) {
throw new Error("HocusPocus server not initialized");
}
// Graceful shutdown on uncaught exception
process.on("uncaughtException", (err: any) => {
manualLogger.info("Uncaught Exception: ", err);
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
gracefulShutdown();
});
const router = express.Router();
Controllers.forEach((Controller) => {
const instance = new Controller(this.hocusPocusServer);
instance.registerRoutes(router);
});
this.app.use(process.env.LIVE_BASE_PATH || "/live", router);
// 404 handler
this.app.use((_req: express.Request, res: express.Response) => {
res.status(404).send("Not Found");
});
}
private setupErrorHandling(): void {
Sentry.setupExpressErrorHandler(this.app);
this.app.use(errorHandler);
}
private setupShutdownHandlers(): void {
const handleShutdown = async (signal: string) => {
manualLogger.info(`${signal} received. Starting graceful shutdown...`);
await this.gracefulShutdown();
};
process.on("SIGTERM", () => handleShutdown("SIGTERM"));
process.on("SIGINT", () => handleShutdown("SIGINT"));
process.on("unhandledRejection", (err: Error | null) => {
manualLogger.info("Unhandled Rejection: ", err);
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
this.gracefulShutdown();
});
process.on("uncaughtException", (err: Error) => {
manualLogger.info("Uncaught Exception: ", err);
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
this.gracefulShutdown();
});
}
public async gracefulShutdown(): Promise<void> {
manualLogger.info("Starting graceful shutdown...");
try {
if (this.hocusPocusServer) {
await this.hocusPocusServer.destroy();
manualLogger.info(
"HocusPocus server WebSocket connections closed gracefully.",
);
}
if (this.httpServer) {
await new Promise<void>((resolve) => {
this.httpServer?.close(() => {
manualLogger.info("Express server closed gracefully.");
resolve();
});
});
}
} catch (err) {
manualLogger.error("Error during shutdown:", err);
} finally {
process.exit(1);
}
}
public async start(): Promise<void> {
try {
// Initialize WebSocket server first
await this.setupWebSocketServer();
// Then setup controllers with the initialized hocusPocusServer
this.setupControllers();
// Setup error handling
this.setupErrorHandling();
// Start the server
this.httpServer = this.app.listen(this.port, () => {
manualLogger.info(`Plane Live server has started at port ${this.port}`);
});
// Setup graceful shutdown handlers
this.setupShutdownHandlers();
} catch (error) {
manualLogger.error("Failed to start server:", error);
process.exit(1);
}
}
}
+6
View File
@@ -0,0 +1,6 @@
import { Server } from "./server.js";
// Start the worker for taking over the migration jobs
const server = new Server();
server.start();
+19 -5
View File
@@ -3,16 +3,23 @@
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2015"],
"lib": [
"ES2015"
],
"outDir": "./dist",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/plane-live/*": ["./src/ce/*"]
"@/*": [
"./src/*"
],
"@/plane-live/*": [
"./src/ce/*"
]
},
"removeComments": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"sourceMap": true,
"inlineSources": true,
@@ -21,6 +28,13 @@
// This improves issue grouping in Sentry.
"sourceRoot": "/"
},
"include": ["src/**/*.ts", "tsup.config.ts"],
"exclude": ["./dist", "./build", "./node_modules"]
"include": [
"src/**/*.ts",
"tsup.config.ts"
],
"exclude": [
"./dist",
"./build",
"./node_modules"
]
}
-11
View File
@@ -1,11 +0,0 @@
import { defineConfig, Options } from "tsup";
export default defineConfig((options: Options) => ({
entry: ["src/server.ts"],
format: ["cjs", "esm"],
dts: true,
clean: false,
external: ["react"],
injectStyle: true,
...options,
}));
+3
View File
@@ -13,3 +13,6 @@ export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}/`);
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}/`);
// plane website url
export const WEBSITE_URL =
process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
+2 -1
View File
@@ -27,6 +27,7 @@
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"peerDependencies": {
@@ -36,8 +37,8 @@
"dependencies": {
"@floating-ui/react": "^0.26.4",
"@hocuspocus/provider": "^2.13.5",
"@plane/helpers": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-character-count": "^2.6.5",
@@ -1,5 +1,5 @@
// plane helpers
import { convertHexEmojiToDecimal } from "@plane/helpers";
import { convertHexEmojiToDecimal } from "@plane/utils";
// plane ui
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
// helpers
@@ -1,5 +1,5 @@
// plane helpers
import { sanitizeHTML } from "@plane/helpers";
import { sanitizeHTML } from "@plane/utils";
// plane ui
import { TEmojiLogoProps } from "@plane/ui";
// types
@@ -43,6 +43,16 @@ export const CoreEditorExtensionsWithoutProps = [
codeBlock: false,
horizontalRule: false,
blockquote: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
},
},
heading: {
HTMLAttributes: {
class: "editor-heading-block",
},
},
dropcursor: false,
}),
CustomQuoteExtension,
@@ -73,6 +73,16 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
codeBlock: false,
horizontalRule: false,
blockquote: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
},
},
heading: {
HTMLAttributes: {
class: "editor-heading-block",
},
},
dropcursor: {
class: "text-custom-text-300",
},
@@ -65,6 +65,16 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
codeBlock: false,
horizontalRule: false,
blockquote: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
},
},
heading: {
HTMLAttributes: {
class: "editor-heading-block",
},
},
dropcursor: false,
gapcursor: false,
}),
+1 -1
View File
@@ -125,7 +125,7 @@ export interface IEditorProps {
onEnterKeyPress?: (e?: any) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
value?: string | null;
value?: string | null;
}
export interface ILiteTextEditor extends IEditorProps {
extensions?: any[];
+20 -20
View File
@@ -133,7 +133,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"][checked]:hover {
}
/* the p tag just after the ul tag */
ul[data-type="taskList"] + p {
ul[data-type="taskList"] + p.editor-paragraph-block {
margin-top: 0.4rem !important;
}
@@ -180,7 +180,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
}
ul[data-type="taskList"] li > div {
& > p {
& > p.editor-paragraph-block {
margin-top: 10px;
transition: color 0.2s ease;
}
@@ -191,7 +191,7 @@ ul[data-type="taskList"] li > div {
}
ul[data-type="taskList"] li[data-checked="true"] {
& > div > p {
& > div > p.editor-paragraph-block {
color: rgb(var(--color-text-400));
}
@@ -322,18 +322,18 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
}
/* end numbered, bulleted and to-do lists spacing */
h1,
h2,
h3,
h4,
h5,
h6,
p {
h1.editor-heading-block,
h2.editor-heading-block,
h3.editor-heading-block,
h4.editor-heading-block,
h5.editor-heading-block,
h6.editor-heading-block,
p.editor-paragraph-block {
margin: 0 !important;
}
/* tailwind typography */
.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h1.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 28px;
}
@@ -344,7 +344,7 @@ p {
font-weight: 600;
}
.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h2.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 28px;
}
@@ -355,7 +355,7 @@ p {
font-weight: 600;
}
.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h3.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 28px;
}
@@ -366,7 +366,7 @@ p {
font-weight: 600;
}
.prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h4.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 28px;
}
@@ -377,7 +377,7 @@ p {
font-weight: 600;
}
.prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h5.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 20px;
}
@@ -388,7 +388,7 @@ p {
font-weight: 600;
}
.prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h6.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 20px;
}
@@ -399,7 +399,7 @@ p {
font-weight: 600;
}
.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(p.editor-paragraph-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:first-child {
padding-top: 0;
}
@@ -420,12 +420,12 @@ p {
line-height: var(--line-height-regular);
}
p + p {
p.editor-paragraph-block + p.editor-paragraph-block {
padding-top: 8px !important;
}
.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p,
.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p {
.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p.editor-paragraph-block,
.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p.editor-paragraph-block {
font-size: var(--font-size-list);
line-height: var(--line-height-list);
}
+1 -1
View File
@@ -10,7 +10,7 @@
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.6.0",
"@typescript-eslint/parser": "^8.6.0",
"eslint": "8",
"eslint": "8.57.1",
"eslint-config-next": "^14.1.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^1.12.4",
-22
View File
@@ -1,22 +0,0 @@
export const convertHexEmojiToDecimal = (emojiUnified: string): string => {
if (!emojiUnified) return "";
return emojiUnified
.toString()
.split("-")
.map((e) => parseInt(e, 16))
.join("-");
};
export const emojiCodeToUnicode = (emoji: string) => {
if (!emoji) return "";
// convert emoji code to unicode
const uniCodeEmoji = emoji
.toString()
.split("-")
.map((emoji) => parseInt(emoji, 10).toString(16))
.join("-");
return uniCodeEmoji;
};
-2
View File
@@ -1,2 +0,0 @@
export * from "./emoji.helper"
export * from "./string.helper"
-2
View File
@@ -1,2 +0,0 @@
export * from "./helpers";
export * from "./hooks";
+3
View File
@@ -0,0 +1,3 @@
build/*
dist/*
out/*
+9
View File
@@ -0,0 +1,9 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};
+4
View File
@@ -0,0 +1,4 @@
.turbo
out/
dist/
build/
+5
View File
@@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@plane/hooks",
"version": "0.24.0",
"description": "React hooks that are shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist/**"
],
"scripts": {
"build": "tsup ./src/index.ts --format esm,cjs --dts --external react --minify",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"dependencies": {
"react": "^18.3.1"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@types/node": "^22.5.4",
"@types/react": "^18.3.11",
"tsup": "^7.2.0",
"typescript": "^5.3.3"
}
}
@@ -4,6 +4,6 @@
"jsx": "react",
"lib": ["esnext", "dom"]
},
"include": ["."],
"include": ["./src"],
"exclude": ["dist", "build", "node_modules"]
}
+9 -4
View File
@@ -18,7 +18,8 @@
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"postcss": "postcss styles/globals.css -o styles/output.css --watch",
"lint": "eslint src --ext .ts,.tsx"
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"peerDependencies": {
"react": "^18.3.1",
@@ -30,8 +31,9 @@
"@blueprintjs/core": "^4.16.3",
"@blueprintjs/popover2": "^1.13.3",
"@headlessui/react": "^1.7.3",
"@plane/hooks": "*",
"@plane/utils": "*",
"@popperjs/core": "^2.11.8",
"@plane/helpers": "*",
"clsx": "^2.0.0",
"emoji-picker-react": "^4.5.16",
"lodash": "^4.17.21",
@@ -44,6 +46,8 @@
},
"devDependencies": {
"@chromatic-com/storybook": "^1.4.0",
"@plane/eslint-config": "*",
"@plane/typescript-config": "*",
"@storybook/addon-essentials": "^8.1.1",
"@storybook/addon-interactions": "^8.1.1",
"@storybook/addon-links": "^8.1.1",
@@ -61,14 +65,15 @@
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.19",
"classnames": "^2.3.2",
"@plane/eslint-config": "*",
"postcss-cli": "^11.0.0",
"postcss-nested": "^6.0.1",
"storybook": "^8.1.1",
"tailwind-config-custom": "*",
"tailwindcss": "^3.4.3",
"@plane/typescript-config": "*",
"tsup": "^7.2.0",
"typescript": "5.3.3"
},
"resolutions": {
"@types/react": "^18.0.0"
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { Combobox } from "@headlessui/react";
// popper-js
import { usePopper } from "react-popper";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
import { useOutsideClickDetector } from "@plane/hooks";
// components
import { DropdownButton } from "./common";
import { DropdownOptions } from "./common/options";
+1 -1
View File
@@ -5,7 +5,7 @@ import { Combobox } from "@headlessui/react";
// popper-js
import { usePopper } from "react-popper";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
import { useOutsideClickDetector } from "@plane/hooks";
// components
import { DropdownButton } from "./common";
import { DropdownOptions } from "./common/options";
@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
import { useOutsideClickDetector } from "@plane/hooks";
// components
import { ContextMenuItem } from "./item";
// helpers
+9 -2
View File
@@ -4,7 +4,7 @@ import { Menu } from "@headlessui/react";
import { usePopper } from "react-popper";
import { ChevronDown, MoreHorizontal } from "lucide-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
import { useOutsideClickDetector } from "@plane/hooks";
// hooks
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
// helpers
@@ -93,7 +93,14 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
useOutsideClickDetector(dropdownRef, closeDropdown, useCaptureForOutsideClick);
let menuItems = (
<Menu.Items data-prevent-outside-click={!!portalElement} className={cn("fixed z-10", menuItemsClassName)} static>
<Menu.Items
data-prevent-outside-click={!!portalElement}
className={cn(
"fixed z-10 translate-y-0",
menuItemsClassName
)} /** translate-y-0 is a hack to create new stacking context. Required for safari */
static
>
<div
className={cn(
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap",
@@ -4,7 +4,7 @@ import { Combobox } from "@headlessui/react";
import { Check, ChevronDown, Info, Search } from "lucide-react";
import { createPortal } from "react-dom";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
import { useOutsideClickDetector } from "@plane/hooks";
// hooks
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
// helpers
@@ -97,10 +97,11 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
<button
ref={setReferenceElement}
type="button"
className={`flex w-full items-center justify-between gap-1 text-xs ${disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${customButtonClassName}`}
className={`flex w-full items-center justify-between gap-1 text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${customButtonClassName}`}
onClick={toggleDropdown}
>
{customButton}
@@ -186,15 +187,13 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
{option.tooltip && (
<>
{
typeof option.tooltip === "string" ? (
<Tooltip tooltipContent={option.tooltip}>
<Info className="h-3.5 w-3.5 flex-shrink-0 cursor-pointer text-custom-text-200" />
</Tooltip>
) : (
option.tooltip
)
}
{typeof option.tooltip === "string" ? (
<Tooltip tooltipContent={option.tooltip}>
<Info className="h-3.5 w-3.5 flex-shrink-0 cursor-pointer text-custom-text-200" />
</Tooltip>
) : (
option.tooltip
)}
</>
)}
</>
+1 -1
View File
@@ -3,7 +3,7 @@ import { usePopper } from "react-popper";
import { Listbox } from "@headlessui/react";
import { Check, ChevronDown } from "lucide-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
import { useOutsideClickDetector } from "@plane/hooks";
// hooks
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
// helpers
@@ -3,7 +3,7 @@ import { usePopper } from "react-popper";
import { Popover, Tab } from "@headlessui/react";
import EmojiPicker from "emoji-picker-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
import { useOutsideClickDetector } from "@plane/hooks";
// helpers
import { cn } from "../../helpers";
// hooks
+1 -1
View File
@@ -3,7 +3,7 @@ import { usePopper } from "react-popper";
import EmojiPicker from "emoji-picker-react";
import { Popover, Tab } from "@headlessui/react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
import { useOutsideClickDetector } from "@plane/hooks";
// components
import { IconsList } from "./icons-list";
// helpers
+1 -1
View File
@@ -2,7 +2,7 @@ import React, { FC, Fragment } from "react";
import { Tab } from "@headlessui/react";
import { LucideProps } from "lucide-react";
// helpers
import { useLocalStorage } from "@plane/helpers";
import { useLocalStorage } from "@plane/hooks";
import { cn } from "../../helpers";
type TabItem = {
+19 -13
View File
@@ -23,6 +23,7 @@ export type TPosition =
interface ITooltipProps {
tooltipHeading?: string;
tooltipContent: string | React.ReactNode;
jsxContent?: string | React.ReactNode;
position?: TPosition;
children: JSX.Element;
disabled?: boolean;
@@ -38,13 +39,14 @@ export const Tooltip: React.FC<ITooltipProps> = ({
tooltipContent,
position = "top",
children,
jsxContent,
disabled = false,
className = "",
openDelay = 200,
closeDelay,
isMobile = false,
renderByDefault = true, //FIXME: tooltip should always render on hover and not by default, this is a temporary fix
}) => {
}: ITooltipProps) => {
const toolTipRef = useRef<HTMLDivElement | null>(null);
const [shouldRender, setShouldRender] = useState(renderByDefault);
@@ -79,18 +81,22 @@ export const Tooltip: React.FC<ITooltipProps> = ({
hoverOpenDelay={openDelay}
hoverCloseDelay={closeDelay}
content={
<div
className={cn(
"relative block z-50 max-w-xs gap-1 overflow-hidden break-words rounded-md bg-custom-background-100 p-2 text-xs text-custom-text-200 shadow-md",
{
hidden: isMobile,
},
className
)}
>
{tooltipHeading && <h5 className="font-medium text-custom-text-100">{tooltipHeading}</h5>}
{tooltipContent}
</div>
jsxContent ? (
<>{jsxContent}</>
) : (
<div
className={cn(
"relative block z-50 max-w-xs gap-1 overflow-hidden break-words rounded-md bg-custom-background-100 p-2 text-xs text-custom-text-200 shadow-md",
{
hidden: isMobile,
},
className
)}
>
{tooltipHeading && <h5 className="font-medium text-custom-text-100">{tooltipHeading}</h5>}
{tooltipContent}
</div>
)
}
position={position}
renderTarget={({
+3
View File
@@ -0,0 +1,3 @@
build/*
dist/*
out/*
+9
View File
@@ -0,0 +1,9 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};
+5
View File
@@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}
@@ -1,5 +1,5 @@
{
"name": "@plane/helpers",
"name": "@plane/utils",
"version": "0.24.0",
"description": "Helper functions shared across multiple apps internally",
"private": true,
@@ -10,16 +10,19 @@
"dist/**"
],
"scripts": {
"build": "tsup ./index.ts --format esm,cjs --dts --external react --minify"
},
"devDependencies": {
"@types/node": "^22.5.4",
"@types/react": "^18.3.11",
"tsup": "^7.2.0",
"typescript": "^5.6.2"
"build": "tsup ./src/index.ts --format esm,cjs --dts --external react --minify",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"dependencies": {
"isomorphic-dompurify": "^2.16.0",
"react": "^18.3.1"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@types/node": "^22.5.4",
"@types/react": "^18.3.11",
"tsup": "^7.2.0",
"typescript": "^5.3.3"
}
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Represents an RGB color with numeric values for red, green, and blue components
* @typedef {Object} RGB
* @property {number} r - Red component (0-255)
* @property {number} g - Green component (0-255)
* @property {number} b - Blue component (0-255)
*/
export type RGB = { r: number; g: number; b: number };
/**
* Validates and clamps color values to RGB range (0-255)
* @param {number} value - The color value to validate
* @returns {number} Clamped and floored value between 0-255
*/
export const validateColor = (value: number) => {
if (value < 0) return 0;
if (value > 255) return 255;
return Math.floor(value);
};
/**
* Converts a decimal color value to two-character hex
* @param {number} value - Decimal color value (0-255)
* @returns {string} Two-character hex value with leading zero if needed
*/
export const toHex = (value: number) => validateColor(value).toString(16).padStart(2, "0");
/**
* Converts a hexadecimal color code to RGB values
* @param {string} hex - The hexadecimal color code (e.g., "#ff0000" for red)
* @returns {RGB} An object containing the RGB values
* @example
* hexToRgb("#ff0000") // returns { r: 255, g: 0, b: 0 }
* hexToRgb("#00ff00") // returns { r: 0, g: 255, b: 0 }
* hexToRgb("#0000ff") // returns { r: 0, g: 0, b: 255 }
*/
export const hexToRgb = (hex: string): RGB => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex.trim());
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: { r: 0, g: 0, b: 0 };
};
/**
* Converts RGB values to a hexadecimal color code
* @param {RGB} rgb - An object containing RGB values
* @param {number} rgb.r - Red component (0-255)
* @param {number} rgb.g - Green component (0-255)
* @param {number} rgb.b - Blue component (0-255)
* @returns {string} The hexadecimal color code (e.g., "#ff0000" for red)
* @example
* rgbToHex({ r: 255, g: 0, b: 0 }) // returns "#ff0000"
* rgbToHex({ r: 0, g: 255, b: 0 }) // returns "#00ff00"
* rgbToHex({ r: 0, g: 0, b: 255 }) // returns "#0000ff"
*/
export const rgbToHex = ({ r, g, b }: RGB): string => `#${toHex(r)}${toHex(g)}${toHex(b)}`;
+40
View File
@@ -0,0 +1,40 @@
/**
* Converts a hyphen-separated hexadecimal emoji code to its decimal representation
* @param {string} emojiUnified - The unified emoji code in hexadecimal format (e.g., "1f600" or "1f1e6-1f1e8")
* @returns {string} The decimal representation of the emoji code (e.g., "128512" or "127462-127464")
* @example
* convertHexEmojiToDecimal("1f600") // returns "128512"
* convertHexEmojiToDecimal("1f1e6-1f1e8") // returns "127462-127464"
* convertHexEmojiToDecimal("") // returns ""
*/
export const convertHexEmojiToDecimal = (emojiUnified: string): string => {
if (!emojiUnified) return "";
return emojiUnified
.toString()
.split("-")
.map((e) => parseInt(e, 16))
.join("-");
};
/**
* Converts a hyphen-separated decimal emoji code back to its hexadecimal representation
* @param {string} emoji - The emoji code in decimal format (e.g., "128512" or "127462-127464")
* @returns {string} The hexadecimal representation of the emoji code (e.g., "1f600" or "1f1e6-1f1e8")
* @example
* emojiCodeToUnicode("128512") // returns "1f600"
* emojiCodeToUnicode("127462-127464") // returns "1f1e6-1f1e8"
* emojiCodeToUnicode("") // returns ""
*/
export const emojiCodeToUnicode = (emoji: string): string => {
if (!emoji) return "";
// convert emoji code to unicode
const uniCodeEmoji = emoji
.toString()
.split("-")
.map((emoji) => parseInt(emoji, 10).toString(16))
.join("-");
return uniCodeEmoji;
};
+3
View File
@@ -0,0 +1,3 @@
export * from "./color";
export * from "./emoji";
export * from "./string";
@@ -12,4 +12,4 @@ import DOMPurify from "isomorphic-dompurify";
export const sanitizeHTML = (htmlString: string) => {
const sanitizedText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: [] }); // sanitize the string to remove all HTML tags
return sanitizedText.trim(); // trim the string to remove leading and trailing whitespaces
};
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@plane/typescript-config/react-library.json",
"compilerOptions": {
"jsx": "react",
"lib": ["esnext", "dom"]
},
"include": ["./src"],
"exclude": ["dist", "build", "node_modules"]
}
@@ -0,0 +1,57 @@
"use client";
import { observer } from "mobx-react";
import useSWR from "swr";
// components
import { LogoSpinner, PoweredBy } from "@/components/common";
import { IssuesNavbarRoot } from "@/components/issues";
import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error";
// hooks
import { useIssueFilter, usePublish, usePublishList } from "@/hooks/store";
type Props = {
children: React.ReactNode;
anchor: string;
};
export const IssuesClientLayout = observer((props: Props) => {
const { children, anchor } = props;
// store hooks
const { fetchPublishSettings } = usePublishList();
const publishSettings = usePublish(anchor);
const { updateLayoutOptions } = useIssueFilter();
// fetch publish settings
const { error } = useSWR(
anchor ? `PUBLISH_SETTINGS_${anchor}` : null,
anchor
? async () => {
const response = await fetchPublishSettings(anchor);
if (response.view_props) {
updateLayoutOptions({
list: !!response.view_props.list,
kanban: !!response.view_props.kanban,
calendar: !!response.view_props.calendar,
gantt: !!response.view_props.gantt,
spreadsheet: !!response.view_props.spreadsheet,
});
}
}
: null
);
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">
<div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100">
<IssuesNavbarRoot publishSettings={publishSettings} />
</div>
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">{children}</div>
</div>
<PoweredBy />
</>
);
});
+40 -64
View File
@@ -1,16 +1,6 @@
"use client";
"use server";
import { observer } from "mobx-react";
import Image from "next/image";
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
import planeLogo from "@/public/plane-logo.svg";
import { IssuesClientLayout } from "./client-layout";
type Props = {
children: React.ReactNode;
@@ -19,58 +9,44 @@ type Props = {
};
};
const IssuesLayout = observer((props: Props) => {
const { children, params } = props;
// params
export async function generateMetadata({ params }: Props) {
const { anchor } = params;
// store hooks
const { fetchPublishSettings } = usePublishList();
const publishSettings = usePublish(anchor);
const { updateLayoutOptions } = useIssueFilter();
// fetch publish settings
const { error } = useSWR(
anchor ? `PUBLISH_SETTINGS_${anchor}` : null,
anchor
? async () => {
const response = await fetchPublishSettings(anchor);
if (response.view_props) {
updateLayoutOptions({
list: !!response.view_props.list,
kanban: !!response.view_props.kanban,
calendar: !!response.view_props.calendar,
gantt: !!response.view_props.gantt,
spreadsheet: !!response.view_props.spreadsheet,
});
}
}
: null
);
const DEFAULT_TITLE = "Plane";
const DEFAULT_DESCRIPTION = "Made with Plane, an AI-powered work management platform with publishing capabilities.";
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/public/anchor/${anchor}/meta/`);
const data = await response.json();
return {
title: data?.name || DEFAULT_TITLE,
description: data?.description || DEFAULT_DESCRIPTION,
openGraph: {
title: data?.name || DEFAULT_TITLE,
description: data?.description || DEFAULT_DESCRIPTION,
type: "website",
images: [
{
url: data?.cover_image,
width: 800,
height: 600,
alt: data?.name || DEFAULT_TITLE,
},
],
},
twitter: {
card: "summary_large_image",
title: data?.name || DEFAULT_TITLE,
description: data?.description || DEFAULT_DESCRIPTION,
images: [data?.cover_image],
},
};
} catch {
return { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION };
}
}
if (!publishSettings && !error) return <LogoSpinner />;
export default async function IssuesLayout(props: Props) {
const { children, params } = props;
const { anchor } = params;
if (error) return <SomethingWentWrongError />;
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">
<IssuesNavbarRoot 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;
return <IssuesClientLayout anchor={anchor}>{children}</IssuesClientLayout>;
}
+9 -15
View File
@@ -1,12 +1,10 @@
import { Metadata } from "next";
// helpers
import { ASSET_PREFIX } from "@/helpers/common.helper";
// components
import { InstanceProvider } from "@/lib/instance-provider";
import { StoreProvider } from "@/lib/store-provider";
import { SPACE_BASE_PATH } from "@plane/constants";
// styles
import "@/styles/globals.css";
import { ToastProvider } from "@/lib/toast-provider";
// components
import { AppProvider } from "./provider";
export const metadata: Metadata = {
title: "Plane Publish | Make your Plane boards public with one-click",
@@ -27,18 +25,14 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="en">
<head>
<link rel="apple-touch-icon" sizes="180x180" href={`${ASSET_PREFIX}/favicon/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${ASSET_PREFIX}/favicon/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${ASSET_PREFIX}/favicon/favicon-16x16.png`} />
<link rel="manifest" href={`${ASSET_PREFIX}/site.webmanifest.json`} />
<link rel="shortcut icon" href={`${ASSET_PREFIX}/favicon/favicon.ico`} />
<link rel="apple-touch-icon" sizes="180x180" href={`${SPACE_BASE_PATH}/favicon/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${SPACE_BASE_PATH}/favicon/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${SPACE_BASE_PATH}/favicon/favicon-16x16.png`} />
<link rel="manifest" href={`${SPACE_BASE_PATH}/site.webmanifest.json`} />
<link rel="shortcut icon" href={`${SPACE_BASE_PATH}/favicon/favicon.ico`} />
</head>
<body>
<StoreProvider>
<ToastProvider>
<InstanceProvider>{children}</InstanceProvider>
</ToastProvider>
</StoreProvider>
<AppProvider>{children}</AppProvider>
</body>
</html>
);
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { FC, ReactNode } from "react";
// components
import { InstanceProvider } from "@/lib/instance-provider";
import { StoreProvider } from "@/lib/store-provider";
import { ToastProvider } from "@/lib/toast-provider";
interface IAppProvider {
children: ReactNode;
}
export const AppProvider: FC<IAppProvider> = (props) => {
const { children } = props;
return (
<StoreProvider>
<ToastProvider>
<InstanceProvider>{children}</InstanceProvider>
</ToastProvider>
</StoreProvider>
);
};
+2 -17
View File
@@ -1,18 +1,15 @@
"use client";
import { observer } from "mobx-react";
import Image from "next/image";
import useSWR from "swr";
// components
import { LogoSpinner } from "@/components/common";
import { LogoSpinner, PoweredBy } 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;
@@ -53,19 +50,7 @@ const IssuesLayout = observer((props: Props) => {
<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>
<PoweredBy />
</div>
);
});
+8 -1
View File
@@ -2,6 +2,8 @@
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// components
import { PoweredBy } from "@/components/common";
// hooks
import { usePublish } from "@/hooks/store";
// plane-web
@@ -24,7 +26,12 @@ const IssuesPage = observer((props: Props) => {
if (!publishSettings) return null;
return <ViewLayoutsRoot peekId={peekId} publishSettings={publishSettings} />;
return (
<>
<ViewLayoutsRoot peekId={peekId} publishSettings={publishSettings} />
<PoweredBy />
</>
);
});
export default IssuesPage;
@@ -3,11 +3,11 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
import { Eye, EyeOff, XCircle } from "lucide-react";
import { API_BASE_URL } from "@plane/constants";
import { Button, Input, Spinner } from "@plane/ui";
// components
import { PasswordStrengthMeter } from "@/components/account";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper";
// services
import { AuthService } from "@/services/auth.service";
@@ -2,9 +2,8 @@
import React, { useEffect, useState } from "react";
import { CircleCheck, XCircle } from "lucide-react";
import { API_BASE_URL } from "@plane/constants";
import { Button, Input, Spinner } from "@plane/ui";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// hooks
import useTimer from "@/hooks/use-timer";
// services
@@ -2,8 +2,7 @@ import { FC } from "react";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import { useTheme } from "next-themes";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
import { API_BASE_URL } from "@plane/constants";
// images
import githubLightModeImage from "/public/logos/github-black.png";
import githubDarkModeImage from "/public/logos/github-dark.svg";
@@ -2,8 +2,7 @@ import { FC } from "react";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import { useTheme } from "next-themes";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
import { API_BASE_URL } from "@plane/constants";
// images
import GitlabLogo from "/public/logos/gitlab-logo.svg";
@@ -2,8 +2,7 @@ import { FC } from "react";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import { useTheme } from "next-themes";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
import { API_BASE_URL } from "@plane/constants";
// images
import GoogleLogo from "/public/logos/google-logo.svg";
@@ -4,6 +4,7 @@ import { observer } from "mobx-react";
import Image from "next/image";
import { useTheme } from "next-themes";
// components
import { PoweredBy } from "@/components/common";
import { UserAvatar } from "@/components/issues";
// hooks
import { useUser } from "@/hooks/store";
@@ -45,6 +46,7 @@ export const UserLoggedIn = observer(() => {
</p>
</div>
</div>
<PoweredBy />
</div>
);
});
+1
View File
@@ -1,2 +1,3 @@
export * from "./project-logo";
export * from "./logo-spinner";
export * from "./powered-by";
@@ -0,0 +1,34 @@
"use client";
import { FC } from "react";
import Image from "next/image";
import { WEBSITE_URL } from "@plane/constants";
// assets
import planeLogo from "@/public/plane-logo.svg";
type TPoweredBy = {
disabled?: boolean;
};
export const PoweredBy: FC<TPoweredBy> = (props) => {
// props
const { disabled = false } = props;
if (disabled || !WEBSITE_URL) return null;
return (
<a
href={WEBSITE_URL}
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>
);
};
@@ -7,9 +7,9 @@ import { usePathname, useSearchParams } from "next/navigation";
import { usePopper } from "react-popper";
import { LogOut } from "lucide-react";
import { Popover, Transition } from "@headlessui/react";
import { API_BASE_URL } from "@plane/constants";
import { Avatar, Button } from "@plane/ui";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
import { getFileURL } from "@/helpers/file.helper";
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
+5 -4
View File
@@ -4,15 +4,15 @@ import { observer } from "mobx-react";
import Image from "next/image";
import Link from "next/link";
import { useTheme } from "next-themes";
import { SPACE_BASE_PATH } from "@plane/constants";
// components
import { AuthRoot } from "@/components/account";
// helpers
import { SPACE_BASE_PATH } from "@/helpers/common.helper";
import { PoweredBy } from "@/components/common";
// images
import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg";
import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg";
import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png";
import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.png";
import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png";
import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png";
export const AuthView = observer(() => {
// hooks
@@ -41,6 +41,7 @@ export const AuthView = observer(() => {
<AuthRoot />
</div>
</div>
<PoweredBy />
</div>
);
});
+6 -7
View File
@@ -6,18 +6,17 @@ import Image from "next/image";
import Link from "next/link";
import { useTheme } from "next-themes";
import useSWR from "swr";
import { SPACE_BASE_PATH } from "@plane/constants";
// components
import { LogoSpinner } from "@/components/common";
import { InstanceFailureView } from "@/components/instance";
// helpers
import { SPACE_BASE_PATH } from "@/helpers/common.helper";
// hooks
import { useInstance, useUser } from "@/hooks/store";
// assets
import PlaneBackgroundPatternDark from "public/auth/background-pattern-dark.svg";
import PlaneBackgroundPattern from "public/auth/background-pattern.svg";
import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png";
import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.png";
import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg";
import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg";
import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png";
import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png";
export const InstanceProvider = observer(({ children }: { children: ReactNode }) => {
const { fetchInstanceInfo, instance, error } = useInstance();
@@ -69,5 +68,5 @@ export const InstanceProvider = observer(({ children }: { children: ReactNode })
);
}
return <>{children}</>;
return children;
});
+9 -8
View File
@@ -9,13 +9,8 @@ let rootStore = new RootStore();
export const StoreContext = createContext(rootStore);
function initializeStore(initialData = {}) {
function initializeStore() {
const singletonRootStore = rootStore ?? new RootStore();
// If your page has Next.js data fetching methods that use a Mobx store, it will
// get hydrated here, check `pages/ssg.js` and `pages/ssr.js` for more details
if (initialData) {
singletonRootStore.hydrate(initialData);
}
// For SSG and SSR always create a new store
if (typeof window === "undefined") return singletonRootStore;
// Create the store once in the client
@@ -29,8 +24,14 @@ export type StoreProviderProps = {
initialState?: any;
};
export const StoreProvider = ({ children, initialState = {} }: StoreProviderProps) => {
const store = initializeStore(initialState);
export const StoreProvider = ({ children, initialState = undefined }: StoreProviderProps) => {
const store = initializeStore();
// If your page has Next.js data fetching methods that use a Mobx store, it will
// get hydrated here, check `pages/ssg.js` and `pages/ssr.js` for more details
if (initialState) {
store.hydrate(initialState);
}
return (
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
<StoreContext.Provider value={store}>{children}</StoreContext.Provider>

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