Compare commits

..
6 Commits
346 changed files with 2807 additions and 72742 deletions
-20
View File
@@ -1,20 +0,0 @@
### 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 == 'Release' }}
name: Attach Assets to Release
if: ${{ needs.branch_build_setup.outputs.build_type == 'Build' }}
name: Attach Assets to Build
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/hooks";
import { useOutsideClickDetector } from "@plane/helpers";
// components
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
// hooks
+3 -4
View File
@@ -1,6 +1,6 @@
{
"name": "admin",
"version": "0.24.1",
"version": "0.24.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -14,10 +14,9 @@
"dependencies": {
"@headlessui/react": "^1.7.19",
"@plane/constants": "*",
"@plane/hooks": "*",
"@plane/helpers": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@sentry/nextjs": "^8.32.0",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
@@ -27,7 +26,7 @@
"lucide-react": "^0.356.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.20",
"next": "^14.2.12",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
+1 -1
View File
@@ -4,7 +4,7 @@ FROM python:3.12.5-alpine AS backend
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
WORKDIR /code
+1 -1
View File
@@ -4,7 +4,7 @@ FROM python:3.12.5-alpine AS backend
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
RUN apk --no-cache add \
"bash~=5.2" \
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.24.1"
"version": "0.24.0"
}
+3 -1
View File
@@ -68,7 +68,9 @@ 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(
+8 -4
View File
@@ -15,6 +15,8 @@ from django.db.models import (
UUIDField,
Value,
Subquery,
Case,
When,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
@@ -443,10 +445,12 @@ class IssueViewSet(BaseViewSet):
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
:1
]
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(
+31 -9
View File
@@ -16,7 +16,12 @@ 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
@@ -78,7 +83,10 @@ 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"
@@ -86,7 +94,10 @@ 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"
@@ -124,7 +135,8 @@ 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(
@@ -133,7 +145,9 @@ 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
@@ -224,7 +238,9 @@ 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,
)
@@ -264,7 +280,9 @@ 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,
)
@@ -285,7 +303,10 @@ 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
):
@@ -323,6 +344,7 @@ 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)
+2 -3
View File
@@ -41,7 +41,6 @@ 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
@@ -82,12 +81,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"),
}
},
]
)
+33 -8
View File
@@ -1,12 +1,22 @@
# 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 (
@@ -16,7 +26,12 @@ 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
@@ -104,7 +119,9 @@ 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,
)
@@ -131,7 +148,9 @@ 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
@@ -145,7 +164,9 @@ 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"
)
@@ -192,7 +213,9 @@ 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
@@ -256,7 +279,9 @@ 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,9 +60,6 @@ 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,7 +60,6 @@ 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)
+23 -91
View File
@@ -3,8 +3,7 @@ from django.utils import timezone
from django.apps import apps
from django.conf import settings
from django.db import models
from django.db.models.fields.related import OneToOneRel
from django.core.exceptions import ObjectDoesNotExist
# Third party imports
from celery import shared_task
@@ -12,98 +11,31 @@ 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)
# 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
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:
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
# 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 []
)
# 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()
for obj in related_objects:
if obj:
obj.deleted_at = timezone.now()
obj.save(using=using)
except ObjectDoesNotExist:
pass
# @shared_task
+13 -5
View File
@@ -162,7 +162,8 @@ 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 ""
),
(
@@ -196,7 +197,8 @@ 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": (
@@ -206,11 +208,17 @@ 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"]),
+1 -3
View File
@@ -257,9 +257,7 @@ 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,14 +13,28 @@ 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(
"--role", type=int, nargs="?", help="Role of the user in the project"
"--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",
)
def handle(self, *args: Any, **options: Any):
@@ -53,7 +67,9 @@ 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()
)
@@ -63,15 +79,22 @@ 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
@@ -79,7 +102,9 @@ 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,6 +53,7 @@ from .project import (
ProjectMemberInvite,
ProjectPublicMember,
)
from .deploy_board import DeployBoard
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State
@@ -68,14 +69,23 @@ 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
+26 -6
View File
@@ -44,25 +44,45 @@ 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)
+31 -8
View File
@@ -661,7 +661,9 @@ 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"),
@@ -686,7 +688,9 @@ 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)
@@ -696,10 +700,25 @@ 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)
@@ -722,7 +741,9 @@ 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,
@@ -750,7 +771,9 @@ 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
+3 -1
View File
@@ -29,7 +29,9 @@ 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
+25 -7
View File
@@ -102,7 +102,12 @@ 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):
@@ -131,7 +136,9 @@ 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"""
@@ -160,7 +167,10 @@ 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:
@@ -174,7 +184,9 @@ 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,
@@ -209,7 +221,9 @@ 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)
@@ -269,7 +283,9 @@ 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)
@@ -304,7 +320,9 @@ 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,8 +1,6 @@
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,8 +13,6 @@ from .admin import (
InstanceAdminUserSessionEndpoint,
)
from .changelog import ChangeLogEndpoint
from .workspace import (
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
)
from .workspace import InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint
@@ -0,0 +1,33 @@
# 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,14 +66,16 @@ 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)
+7 -1
View File
@@ -11,12 +11,14 @@ 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(
@@ -60,5 +62,9 @@ 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,15 +10,9 @@ 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,5 +25,3 @@ from .state import ProjectStatesEndpoint
from .label import ProjectLabelsEndpoint
from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint
from .meta import ProjectMetaDataEndpoint
-34
View File
@@ -1,34 +0,0 @@
# 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.17
Django==4.2.16
# rest framework
djangorestframework==3.15.2
# postgres
+5 -5
View File
@@ -1,16 +1,16 @@
{
"name": "live",
"version": "0.24.1",
"version": "0.24.0",
"description": "",
"main": "./src/server.ts",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
"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"
},
"keywords": [],
"author": "",
@@ -30,7 +30,7 @@
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"express": "^4.20.0",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "^5.4.1",
-12
View File
@@ -4,10 +4,6 @@ import { v4 as uuidv4 } from "uuid";
import { handleAuthentication } from "@/core/lib/authentication.js";
// extensions
import { getExtensions } from "@/core/extensions/index.js";
import {
DocumentCollaborativeEvents,
TDocumentEventsServer,
} from "@plane/editor/lib";
// editor types
import { TUserDetails } from "@plane/editor";
// types
@@ -59,14 +55,6 @@ export const getHocusPocusServer = async () => {
throw Error("Authentication unsuccessful!");
}
},
async onStateless({ payload, document }) {
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
const response =
DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
},
extensions,
debounce: 10000,
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.24.1",
"version": "0.24.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.24.1",
"version": "0.24.0",
"private": true,
"main": "./src/index.ts"
}
-3
View File
@@ -13,6 +13,3 @@ 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 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.24.1",
"version": "0.24.0",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -27,7 +27,6 @@
"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": {
@@ -37,8 +36,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,6 +0,0 @@
export const DocumentCollaborativeEvents = {
lock: { client: "locked", server: "lock" },
unlock: { client: "unlocked", server: "unlock" },
archive: { client: "archived", server: "archive" },
unarchive: { client: "unarchived", server: "unarchive" },
} as const;
@@ -1,5 +1,5 @@
// plane helpers
import { convertHexEmojiToDecimal } from "@plane/utils";
import { convertHexEmojiToDecimal } from "@plane/helpers";
// plane ui
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
// helpers
@@ -1,5 +1,5 @@
// plane helpers
import { sanitizeHTML } from "@plane/utils";
import { sanitizeHTML } from "@plane/helpers";
// plane ui
import { TEmojiLogoProps } from "@plane/ui";
// types
@@ -43,16 +43,6 @@ export const CoreEditorExtensionsWithoutProps = [
codeBlock: false,
horizontalRule: false,
blockquote: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
},
},
heading: {
HTMLAttributes: {
class: "editor-heading-block",
},
},
dropcursor: false,
}),
CustomQuoteExtension,
@@ -118,6 +118,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatioCalculated,
};
setSize(initialComputedSize);
updateAttributesSafely(
initialComputedSize,
@@ -29,9 +29,12 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
useEffect(() => {
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
if (closestEditorContainer) {
setEditorContainer(closestEditorContainer as HTMLDivElement);
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
}
setEditorContainer(closestEditorContainer as HTMLDivElement);
}, []);
// the image is already uploaded if the image-component node has src attribute
@@ -52,7 +55,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
setResolvedSrc(url as string);
};
getImageSource();
}, [imgNodeSrc]);
}, [imageFromFileSystem, node.attrs.src]);
return (
<NodeViewWrapper>
@@ -73,16 +73,6 @@ 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,16 +65,6 @@ 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,
}),
@@ -41,7 +41,7 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
if (nextItem < 0) {
nextSection = currentSection - 1;
if (nextSection < 0) nextSection = sections.length - 1;
nextItem = sections[nextSection]?.items.length - 1;
nextItem = sections[nextSection].items.length - 1;
}
}
if (e.key === "ArrowDown") {
@@ -1,11 +0,0 @@
import { DocumentCollaborativeEvents } from "@/constants/document-collaborative-events";
import { TDocumentEventKey, TDocumentEventsClient, TDocumentEventsServer } from "@/types/document-collaborative-events";
export const getServerEventName = (clientEvent: TDocumentEventsClient): TDocumentEventsServer | undefined => {
for (const key in DocumentCollaborativeEvents) {
if (DocumentCollaborativeEvents[key as TDocumentEventKey].client === clientEvent) {
return DocumentCollaborativeEvents[key as TDocumentEventKey].server;
}
}
return undefined;
};
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import Collaboration from "@tiptap/extension-collaboration";
import { IndexeddbPersistence } from "y-indexeddb";
@@ -58,19 +58,21 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
[id, realtimeConfig, serverHandler, user]
);
const localProvider = useMemo(
() => (id ? new IndexeddbPersistence(id, provider.document) : undefined),
[id, provider]
);
// destroy and disconnect all providers connection on unmount
// destroy and disconnect connection on unmount
useEffect(
() => () => {
provider?.destroy();
localProvider?.destroy();
provider.destroy();
provider.disconnect();
},
[provider, localProvider]
[provider]
);
// indexed db integration for offline support
useLayoutEffect(() => {
const localProvider = new IndexeddbPersistence(id, provider.document);
return () => {
localProvider?.destroy();
};
}, [provider, id]);
const editor = useEditor({
disabledExtensions,
+4 -7
View File
@@ -16,14 +16,13 @@ import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helper
// props
import { CoreEditorProps } from "@/props";
// types
import type {
TDocumentEventsServer,
import {
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
TEditorCommands,
TFileHandler,
TExtensions,
TFileHandler,
} from "@/types";
export interface CustomEditorProps {
@@ -68,9 +67,9 @@ export const useEditor = (props: CustomEditorProps) => {
onChange,
onTransaction,
placeholder,
provider,
tabIndex,
value,
provider,
autofocus = false,
} = props;
// states
@@ -258,7 +257,7 @@ export const useEditor = (props: CustomEditorProps) => {
if (empty) return null;
const nodesArray: string[] = [];
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
state.doc.nodesBetween(from, to, (node, pos, parent) => {
if (parent === state.doc && editorRef.current) {
const serializer = DOMSerializer.fromSchema(editorRef.current?.schema);
const dom = serializer.serializeNode(node);
@@ -299,8 +298,6 @@ export const useEditor = (props: CustomEditorProps) => {
if (!document) return;
Y.applyUpdate(document, value);
},
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
}),
[editorRef, savedSelection]
);
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import Collaboration from "@tiptap/extension-collaboration";
import { IndexeddbPersistence } from "y-indexeddb";
@@ -31,8 +31,8 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
const provider = useMemo(
() =>
new HocuspocusProvider({
name: id,
url: realtimeConfig.url,
name: id,
token: JSON.stringify(user),
parameters: realtimeConfig.queryParams,
onAuthenticationFailed: () => {
@@ -48,23 +48,23 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
},
onSynced: () => setHasServerSynced(true),
}),
[id, realtimeConfig, serverHandler, user]
[id, realtimeConfig, user]
);
// indexed db integration for offline support
const localProvider = useMemo(
() => (id ? new IndexeddbPersistence(id, provider.document) : undefined),
[id, provider]
);
// destroy and disconnect connection on unmount
useEffect(
() => () => {
provider.destroy();
localProvider?.destroy();
provider.disconnect();
},
[provider, localProvider]
[provider]
);
// indexed db integration for offline support
useLayoutEffect(() => {
const localProvider = new IndexeddbPersistence(id, provider.document);
return () => {
localProvider?.destroy();
};
}, [provider, id]);
const editor = useReadOnlyEditor({
disabledExtensions,
@@ -11,13 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// props
import { CoreReadOnlyEditorProps } from "@/props";
// types
import type {
EditorReadOnlyRefApi,
IMentionHighlight,
TExtensions,
TDocumentEventsServer,
TFileHandler,
} from "@/types";
import { EditorReadOnlyRefApi, IMentionHighlight, TExtensions, TFileHandler } from "@/types";
interface CustomReadOnlyEditorProps {
disabledExtensions: TExtensions[];
@@ -126,8 +120,6 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
editorRef.current?.off("update");
};
},
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
getHeadings: () => editorRef?.current?.storage.headingList.headings,
}));
@@ -1,10 +0,0 @@
import { DocumentCollaborativeEvents } from "@/constants/document-collaborative-events";
export type TDocumentEventKey = keyof typeof DocumentCollaborativeEvents;
export type TDocumentEventsClient = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["client"];
export type TDocumentEventsServer = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["server"];
export type TDocumentEventEmitter = {
on: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void;
off: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void;
};
+1 -5
View File
@@ -8,8 +8,6 @@ import {
IMentionSuggestion,
TAIHandler,
TDisplayConfig,
TDocumentEventEmitter,
TDocumentEventsServer,
TEmbedConfig,
TExtensions,
TFileHandler,
@@ -85,8 +83,6 @@ export type EditorReadOnlyRefApi = {
};
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
getHeadings: () => IMarking[];
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
};
export interface EditorRefApi extends EditorReadOnlyRefApi {
@@ -125,7 +121,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[];
-1
View File
@@ -8,4 +8,3 @@ export * from "./image";
export * from "./mention-suggestion";
export * from "./slash-commands-suggestion";
export * from "@/plane-editor/types";
export * from "./document-collaborative-events";
-3
View File
@@ -1,4 +1 @@
export * from "@/extensions/core-without-props";
export * from "@/constants/document-collaborative-events";
export * from "@/helpers/get-document-server-event";
export * from "@/types/document-collaborative-events";
+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.editor-paragraph-block {
ul[data-type="taskList"] + p {
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.editor-paragraph-block {
& > p {
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.editor-paragraph-block {
& > div > p {
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.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 {
h1,
h2,
h3,
h4,
h5,
h6,
p {
margin: 0 !important;
}
/* tailwind typography */
.prose :where(h1.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 28px;
}
@@ -344,7 +344,7 @@ p.editor-paragraph-block {
font-weight: 600;
}
.prose :where(h2.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 28px;
}
@@ -355,7 +355,7 @@ p.editor-paragraph-block {
font-weight: 600;
}
.prose :where(h3.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 28px;
}
@@ -366,7 +366,7 @@ p.editor-paragraph-block {
font-weight: 600;
}
.prose :where(h4.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 28px;
}
@@ -377,7 +377,7 @@ p.editor-paragraph-block {
font-weight: 600;
}
.prose :where(h5.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 20px;
}
@@ -388,7 +388,7 @@ p.editor-paragraph-block {
font-weight: 600;
}
.prose :where(h6.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 20px;
}
@@ -399,7 +399,7 @@ p.editor-paragraph-block {
font-weight: 600;
}
.prose :where(p.editor-paragraph-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:first-child {
padding-top: 0;
}
@@ -420,12 +420,12 @@ p.editor-paragraph-block {
line-height: var(--line-height-regular);
}
p.editor-paragraph-block + p.editor-paragraph-block {
p + p {
padding-top: 8px !important;
}
.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 {
.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 {
font-size: var(--font-size-list);
line-height: var(--line-height-list);
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@plane/eslint-config",
"private": true,
"version": "0.24.1",
"version": "0.24.0",
"files": [
"library.js",
"next.js",
@@ -10,7 +10,7 @@
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.6.0",
"@typescript-eslint/parser": "^8.6.0",
"eslint": "8.57.1",
"eslint": "8",
"eslint-config-next": "^14.1.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^1.12.4",
+22
View File
@@ -0,0 +1,22 @@
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
@@ -0,0 +1,2 @@
export * from "./emoji.helper"
export * from "./string.helper"
@@ -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
};
};
+2
View File
@@ -0,0 +1,2 @@
export * from "./helpers";
export * from "./hooks";
@@ -1,6 +1,6 @@
{
"name": "@plane/utils",
"version": "0.24.1",
"name": "@plane/helpers",
"version": "0.24.0",
"description": "Helper functions shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",
@@ -10,19 +10,16 @@
"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"
"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"
},
"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"
}
}
@@ -4,6 +4,6 @@
"jsx": "react",
"lib": ["esnext", "dom"]
},
"include": ["./src"],
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}
-3
View File
@@ -1,3 +0,0 @@
build/*
dist/*
out/*
-9
View File
@@ -1,9 +0,0 @@
/** @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
@@ -1,4 +0,0 @@
.turbo
out/
dist/
build/
-5
View File
@@ -1,5 +0,0 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}
-27
View File
@@ -1,27 +0,0 @@
{
"name": "@plane/hooks",
"version": "0.24.1",
"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"
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "@plane/typescript-config/react-library.json",
"compilerOptions": {
"jsx": "react",
"lib": ["esnext", "dom"]
},
"include": ["./src"],
"exclude": ["dist", "build", "node_modules"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tailwind-config-custom",
"version": "0.24.1",
"version": "0.24.0",
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "0.24.1",
"version": "0.24.0",
"private": true,
"types": "./src/index.d.ts",
"main": "./src/index.d.ts"
-1
View File
@@ -57,7 +57,6 @@ export interface IInstanceConfig {
// intercom
is_intercom_enabled: boolean;
intercom_app_id: string | undefined;
instance_changelog_url?: string;
}
export interface IInstanceAdmin {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
"version": "0.24.1",
"version": "0.24.0",
"private": true,
"files": [
"base.json",
-25
View File
@@ -1,25 +0,0 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { join, dirname } from "path";
/**
* This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/
function getAbsolutePath(value: string): any {
return dirname(require.resolve(join(value, "package.json")));
}
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-essentials"),
getAbsolutePath("@chromatic-com/storybook"),
getAbsolutePath("@storybook/addon-interactions"),
],
framework: {
name: getAbsolutePath("@storybook/react-vite"),
options: {},
},
};
export default config;
@@ -1 +0,0 @@
<div id="portal-root"></div>
-15
View File
@@ -1,15 +0,0 @@
import type { Preview } from "@storybook/react";
import "../styles/output.css";
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;
-94
View File
@@ -1,94 +0,0 @@
export const fruits = [
{
id: 1,
name: "Apple",
emoji: "🍎",
description:
"A sweet and crisp fruit, often red or green, great for snacking and baking.",
// disabled: true,
},
{
id: 2,
name: "Banana",
emoji: "🍌",
description:
"A long and curved fruit with a soft, creamy flesh, known for its energy-boosting properties.",
},
{
id: 3,
name: "Cherry",
emoji: "🍒",
description:
"Small, round, and juicy fruits with a sweet or tart flavor, often used in desserts.",
// disabled: true,
},
{
id: 4,
name: "Grapes",
emoji: "🍇",
description:
"Small, juicy fruits that grow in clusters, available in various colors like green, red, and purple.",
},
{
id: 5,
name: "Orange",
emoji: "🍊",
description:
"A citrus fruit known for its tangy and refreshing taste, rich in vitamin C.",
},
{
id: 6,
name: "Strawberry",
emoji: "🍓",
description:
"A red, heart-shaped fruit with a sweet flavor and tiny seeds on its surface.",
},
{
id: 7,
name: "Watermelon",
emoji: "🍉",
description:
"A large, juicy fruit with green rind and red flesh, perfect for summertime snacks.",
},
{
id: 8,
name: "Peach",
emoji: "🍑",
description:
"A soft, fuzzy fruit with a sweet and slightly tangy taste, often enjoyed fresh or in desserts.",
},
{
id: 9,
name: "Pineapple",
emoji: "🍍",
description:
"A tropical fruit with spiky skin and sweet, tangy yellow flesh.",
},
{
id: 10,
name: "Lemon",
emoji: "🍋",
description:
"A bright yellow citrus fruit with a tart flavor, commonly used in drinks and cooking.",
},
{
id: 11,
name: "Mango",
emoji: "🥭",
},
{
id: 12,
name: "Gooseberry",
emoji: "🍇",
},
{
id: 13,
name: "Grapefruit",
emoji: "🍊",
},
{
id: 14,
name: "Guava",
emoji: "🍈",
},
];
-73
View File
@@ -1,73 +0,0 @@
export const vegetables = [
// Add 10 vegetables
{
id: 12,
name: "Carrot",
emoji: "🥕",
description:
"A crunchy orange root vegetable, rich in beta-carotene and vitamin A.",
},
{
id: 13,
name: "Broccoli",
emoji: "🥦",
description:
"A green vegetable with dense, nutritious florets, high in fiber and vitamins.",
},
{
id: 14,
name: "Tomato",
emoji: "🍅",
description:
"Technically a fruit, but commonly used as a vegetable in cooking.",
},
{
id: 15,
name: "Eggplant",
emoji: "🍆",
description:
"A purple vegetable with a meaty texture, popular in Mediterranean cuisine.",
},
{
id: 16,
name: "Corn",
emoji: "🌽",
description:
"Sweet yellow kernels on a cob, enjoyed grilled, boiled, or popped.",
},
{
id: 17,
name: "Bell Pepper",
emoji: "🫑",
description:
"A crisp, colorful vegetable that can be sweet or slightly bitter.",
},
{
id: 18,
name: "Cucumber",
emoji: "🥒",
description:
"A refreshing green vegetable with high water content, often used in salads.",
},
{
id: 19,
name: "Potato",
emoji: "🥔",
description:
"A starchy root vegetable that can be prepared in countless ways.",
},
{
id: 20,
name: "Mushroom",
emoji: "🍄",
description:
"Technically a fungus, but commonly used as a vegetable in cooking.",
},
{
id: 21,
name: "Onion",
emoji: "🧅",
description:
"A pungent bulb vegetable used as a base in many cuisines worldwide.",
},
];
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
testEnvironment: "jsdom",
moduleNameMapper: {
".(css|less|scss)$": "identity-obj-proxy",
},
};
-54
View File
@@ -1,54 +0,0 @@
{
"name": "@plane/ui-v2",
"version": "1.0.0",
"description": "Revamped UI components built using Radix, shared across multiple apps internally",
"author": "Plane",
"license": "MIT",
"private": true,
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm --dts --external react --minify",
"dev": "tsup src/index.ts --format esm --watch --dts --external react",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist",
"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",
"tcss": "tailwindcss -i ./styles/global.css -o ./styles/output.css --watch",
"start": "yarn dev & yarn storybook & yarn tcss",
"test": "vitest",
"test:ui": "vitest --ui"
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.2",
"@storybook/addon-essentials": "^8.4.5",
"@storybook/addon-interactions": "^8.4.5",
"@storybook/addon-onboarding": "^8.4.5",
"@storybook/blocks": "^8.4.5",
"@storybook/react": "^8.4.5",
"@storybook/react-vite": "^8.4.5",
"@storybook/test": "^8.4.5",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.14",
"@vitest/ui": "2.1.8",
"autoprefixer": "^10.4.20",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jsdom": "^25.0.1",
"storybook": "^8.4.5",
"tailwindcss": "^3.4.15",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vite": "^6.0.1",
"vitest": "^2.1.8"
},
"dependencies": {
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-scroll-area": "^1.2.1",
"@radix-ui/react-select": "^2.1.2"
}
}
-9
View File
@@ -1,9 +0,0 @@
## Philosophy
Components are our core building blocks adhering to our design system. None of these components should ever need any styling in the application.
Only styles you will need in the application are for layouts.
## Composition vs Render props
For any data that needs to be dynamic / comes from the server, we use render props.
@@ -1,135 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import React, { useState } from "react";
import { DropdownMenu } from "./DropdownMenu";
import { DropdownButton } from "./components/DropdownButton";
import { DropdownContent } from "./components/DropdownContent";
import { DropdownItem } from "./components/DropdownItem";
import { fruits } from "../../../data/fruits";
import { vegetables } from "../../../data/vegetables";
// import { SelectDropdown } from "./DropdownMenu-copy";
const fruitsAndVegetables = {
fruits,
vegetables,
};
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = {
title: "Example/DropdownMenu",
component: DropdownMenu,
parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
layout: "centered",
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ["autodocs"],
// More on argTypes: https://storybook.js.org/docs/api/argtypes
argTypes: {
items: [1, 2, 3, 4],
},
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
args: {},
} satisfies Meta<typeof DropdownMenu>;
export default meta;
type Story = StoryObj<typeof meta>;
export const DefaultDropdown = () => {
return (
<DropdownMenu defaultOpen={true}>
<DropdownButton>
<button>Click me!!</button>
</DropdownButton>
<DropdownContent>
<div>
<h1>Hello</h1>
<p>How are you today?</p>
<DropdownItem onSelect={(e) => console.log(e)}>
Click me again
</DropdownItem>
<DropdownItem onSelect={(e) => e.preventDefault()}>
Click me, I won't close
</DropdownItem>
</div>
</DropdownContent>
</DropdownMenu>
);
};
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const MultiSelect = () => {
const [value, setValue] = useState([fruits[6]]);
const [items, setItems] = useState([
...fruitsAndVegetables.fruits,
...fruitsAndVegetables.vegetables,
]);
const handleSearch = async (query: String) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
// Filters items on the name
const filteredItems = fruitsAndVegetables.fruits.filter((item) =>
item.name.toLowerCase().includes(query.toLowerCase())
);
setItems(filteredItems);
};
return (
<DropdownMenu
items={items}
onSelect={(e, value) => {
e.preventDefault();
console.log(e, value);
}}
renderItem={(item) => <Fruit fruit={item} />}
defaultOpen={true}
onSearch={handleSearch}
// isItemDisabled={(item) => item.id % 2 === 0}
>
<DropdownButton showIcon>
<div className="flex items-center gap-2 justify-between">
<Fruit fruit={fruits[1]} />({value.length})
</div>
</DropdownButton>
{items.length === 0 && (
<DropdownContent>
<div>No items found</div>
</DropdownContent>
)}
</DropdownMenu>
);
};
export const NestedDropdown = () => {
const items = [
{
name: "Vegetables",
children: fruitsAndVegetables.vegetables,
emoji: "🥦",
},
{
name: "Fruits",
children: fruitsAndVegetables.fruits,
emoji: "🍎",
},
];
return (
<div>
<DropdownMenu
defaultOpen={true}
items={items}
renderItem={(item) => <Fruit fruit={item} />}
>
<DropdownButton>
<button className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
Select
</button>
</DropdownButton>
</DropdownMenu>
</div>
);
};
const Fruit = ({ fruit }) => {
return <div>{`${fruit.emoji} ${fruit.name}`}</div>;
};
@@ -1,81 +0,0 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { DropdownButton } from "./components/DropdownButton";
import { DropdownMenu } from "./DropdownMenu";
describe("DropdownMenu", () => {
it("should render the dropdown menu", () => {
render(
<DropdownMenu>
<DropdownButton>Click me</DropdownButton>
</DropdownMenu>
);
expect(screen.getByText("Click me")).toHaveTextContent("Click me");
});
it("should render the dropdown arrow", () => {
render(
<DropdownMenu>
<DropdownButton showIcon>Click me</DropdownButton>
</DropdownMenu>
);
expect(screen.getByTestId("dropdown-arrow")).toBeInTheDocument();
});
it("Should not render the dropdown arrow if showIcon is false", () => {
render(
<DropdownMenu>
<DropdownButton>Click me</DropdownButton>
</DropdownMenu>
);
expect(screen.queryByTestId("dropdown-arrow")).not.toBeInTheDocument();
});
it("should render items using render prop", async () => {
type Item = { id: number; label: string; disabled?: boolean };
const items: Item[] = [
{ id: 1, label: "Item 1" },
{ id: 2, label: "Item 2", disabled: true },
];
const onSelectMock = vi.fn();
render(
<DropdownMenu
items={items}
onSelect={onSelectMock}
renderItem={(item) => <div>{item.label}</div>}
defaultOpen
>
<DropdownButton>Click me</DropdownButton>
</DropdownMenu>
);
// Check if items are rendered
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.getByText("Item 2")).toBeInTheDocument();
// Check if disabled item has correct attributes
const disabledItem = screen.getByText("Item 2");
expect(disabledItem.parentElement).toHaveAttribute("data-disabled");
// Check if the item is clickable and triggers onSelect
const activeItem = screen.getByText("Item 1");
act(() => {
fireEvent(activeItem, new MouseEvent("click", { bubbles: true }));
});
expect(onSelectMock).toHaveBeenCalled();
await waitFor(() => {
expect(screen.queryByText("Item 1")).not.toBeInTheDocument();
});
});
});
@@ -1,62 +0,0 @@
import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu";
import React, { createContext, useCallback, useState } from "react";
import { DropdownContent } from "./components/DropdownContent";
// Types
type DropdownMenuProps<T> = {
children: React.ReactNode;
items?: T[];
onSelect?: (e: React.MouseEvent, value: T) => void;
renderItem?: (item: T) => React.ReactNode;
defaultOpen?: boolean;
onSearch?: (value: string) => void;
isItemDisabled?: (item: T) => boolean;
onOpenChange?: (open: boolean) => void;
};
type DropdownMenuContextType<T> = {
items?: T[];
onSelect?: (e: React.MouseEvent, value: T) => void;
renderItem?: (item: T) => React.ReactNode;
setOpen?: (open: boolean) => void;
onSearch?: (value: string) => void;
isItemDisabled?: (item: T) => boolean;
};
//@todo: Is it possible to not use any here?
export const DropdownMenuContext = createContext<DropdownMenuContextType<any>>({
items: [],
onSelect: (e, value) => {},
renderItem: (item) => <></>,
});
export const DropdownMenu = <T,>({
children,
items,
onSelect,
renderItem,
defaultOpen = false,
onSearch,
isItemDisabled,
onOpenChange,
}: DropdownMenuProps<T>) => {
const [open, setOpen] = useState(defaultOpen);
const handleOpenChange = useCallback((open: boolean) => {
if (onOpenChange) onOpenChange(open);
setTimeout(() => {
setOpen(open);
}, 16);
}, []);
return (
<DropdownMenuContext.Provider
value={{ items, onSelect, renderItem, setOpen, onSearch, isItemDisabled }}
>
<RadixDropdownMenu.Root open={open} onOpenChange={handleOpenChange}>
{children}
{items && <DropdownContent />}
</RadixDropdownMenu.Root>
</DropdownMenuContext.Provider>
);
};
@@ -1,32 +0,0 @@
import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu";
import { ChevronDownIcon } from "@radix-ui/react-icons";
import React, { useContext } from "react";
import { DropdownMenuContext } from "../DropdownMenu";
type DropdownButtonProps = {
children: React.ReactNode;
showIcon?: boolean;
label: string;
};
export const DropdownButton = ({
children,
showIcon = false,
label,
}: DropdownButtonProps) => {
const { setOpen } = useContext(DropdownMenuContext);
return (
<RadixDropdownMenu.Trigger
className="inline-flex h-[35px]
items-center justify-between gap-[5px] rounded bg-white px-[15px] text-[13px] leading-none"
aria-label={label}
asChild
>
<div>
{children}
{showIcon && <ChevronDownIcon data-testid="dropdown-arrow" />}
</div>
</RadixDropdownMenu.Trigger>
);
};
@@ -1,52 +0,0 @@
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import React from "react";
import { DropdownMenu } from "../DropdownMenu";
import { DropdownContent } from "./DropdownContent";
import { DropdownButton } from "./DropdownButton";
let portalRoot = document.getElementById("portal");
if (!portalRoot) {
portalRoot = document.createElement("div");
portalRoot.setAttribute("id", "portal");
document.body.appendChild(portalRoot);
console.log("Added");
}
const Item = ({ item }: { item: any }) => {
return <div>{item.label}</div>;
};
describe("DropdownContent", () => {
it("should render the dropdown content", () => {
render(
<DropdownMenu defaultOpen>
<DropdownButton>Click me</DropdownButton>
<DropdownContent
container={document.getElementById("portal") || undefined}
>
Content.........
</DropdownContent>
</DropdownMenu>
);
expect(screen.getByRole("menu")).toHaveTextContent("Content");
});
it("should render dropdown items", () => {
const items = [
{ label: "Item 1", value: "item-1" },
{ label: "Item 2", value: "item-2" },
];
render(
<DropdownMenu
defaultOpen
items={items}
renderItem={(item) => <Item item={item} />}
>
<DropdownButton>Click me</DropdownButton>
<DropdownContent></DropdownContent>
</DropdownMenu>
);
console.log(screen.debug());
});
});
@@ -1,128 +0,0 @@
import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import React, { useCallback, useContext, useEffect, useState } from "react";
import { DropdownMenuContext } from "../DropdownMenu";
import { DropdownItems } from "./DropdownItems";
export const DropdownContent = ({
children,
container,
}: {
children?: React.ReactNode;
container?: HTMLElement;
}) => {
const { items, renderItem, onSelect, renderGroup, onSearch, isItemDisabled } =
useContext(DropdownMenuContext);
const [groupedItems, setGroupedItems] = useState<{
[key: string]: any[];
}>({});
const [showSearchLoading, setShowSearchLoading] = useState(false);
useEffect(() => {
if (!items) return;
let groupedItems: { [key: string]: any[] } = {};
if (Array.isArray(items)) {
groupedItems.default = items;
} else {
groupedItems = items;
}
setGroupedItems(groupedItems);
}, [open, items]);
const handleSearch = async (query: string) => {
if (!onSearch) return;
try {
setShowSearchLoading(true);
await onSearch(query);
} catch (error) {
console.error(error);
} finally {
setShowSearchLoading(false);
}
};
const handleSelect = useCallback(
(e: any, item: any) => {
onSelect && onSelect(e, item);
},
[onSelect]
);
return (
<RadixDropdownMenu.Portal container={container}>
<RadixDropdownMenu.Content className="p-3 rounded-md bg-white border border-neutral">
{onSearch && (
<div className="relative">
<input
type="text"
placeholder="Search"
className="w-full p-1 border border-neutral rounded-md mb-3 sticky top-0 bg-white z-10"
onChange={(e) => handleSearch(e.target.value)}
/>
{showSearchLoading && (
<div role="status" className="absolute z-20 top-2 right-2">
<svg
aria-hidden="true"
className="w-4 h-4 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
)}
</div>
)}
<ScrollArea.Root className=" ">
<ScrollArea.Viewport className="h-full w-full max-h-[80vh]">
{children}
{Object.keys(groupedItems).map((group) => (
<RadixDropdownMenu.Group key={group}>
{group !== "default" && (
<RadixDropdownMenu.Label>
{renderGroup ? renderGroup(group) : group}
</RadixDropdownMenu.Label>
)}
{renderItem && (
<DropdownItems
items={groupedItems[group]}
onSelect={handleSelect}
renderItem={renderItem}
isItemDisabled={isItemDisabled}
/>
)}
</RadixDropdownMenu.Group>
))}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex touch-none select-none bg-blackA3 p-0.5 transition-colors duration-[160ms] ease-out hover:bg-blackA5 data-[orientation=horizontal]:h-2.5 data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:flex-col"
orientation="vertical"
>
<ScrollArea.Thumb className="relative flex-1 rounded-[10px] bg-mauve10 before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-11 before:min-w-11 before:-translate-x-1/2 before:-translate-y-1/2" />
</ScrollArea.Scrollbar>
<ScrollArea.Scrollbar
className="flex touch-none select-none bg-blackA3 p-0.5 transition-colors duration-[160ms] ease-out hover:bg-blackA5 data-[orientation=horizontal]:h-2.5 data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:flex-col"
orientation="horizontal"
>
<ScrollArea.Thumb className="relative flex-1 rounded-[10px] bg-mauve10 before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-[44px] before:min-w-[44px] before:-translate-x-1/2 before:-translate-y-1/2" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-blackA5" />
</ScrollArea.Root>
</RadixDropdownMenu.Content>
</RadixDropdownMenu.Portal>
);
};
@@ -1,42 +0,0 @@
import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu";
import React, { useCallback } from "react";
const DropdownItem_ = ({
children,
onSelect,
disabled,
item,
}: {
children: React.ReactNode;
onSelect: (e: any, item: any) => void;
disabled?: boolean;
item?: any;
}) => {
const handleSelect = useCallback(
(e: any) => {
onSelect(e, item);
},
[onSelect]
);
return (
<RadixDropdownMenu.Item
onSelect={handleSelect}
className="p-1 first:pt-0 last:border-b-0 last:pb-0
rounded
hover:bg-bg-neutral-subtle
focus:bg-bg-neutral-subtle
focus:outline-none
cursor-pointer
data-[disabled]:pointer-events-none
data-[disabled]:opacity-50
data-[disabled]:cursor-not-allowed
"
disabled={disabled}
>
{children}
</RadixDropdownMenu.Item>
);
};
export const DropdownItem = React.memo(DropdownItem_);
@@ -1,86 +0,0 @@
import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu";
import React, { useCallback } from "react";
import { DropdownItem } from "./DropdownItem";
import * as ScrollArea from "@radix-ui/react-scroll-area";
type Item<T> = T & {
children?: Item<T>[];
disabled?: boolean;
};
type DropdownItemsProps<T> = {
items: Item<T>[];
onSelect: (e: any, item: Item<T>) => void;
renderItem: (item: Item<T>) => React.ReactNode;
isItemDisabled?: (item: Item<T>) => boolean;
};
const DropdownItems_ = <T,>({
items,
onSelect,
renderItem,
isItemDisabled,
}: DropdownItemsProps<T>) => {
const handleSelect = useCallback(
(e: any, item: Item<T>) => {
onSelect(e, item);
},
[onSelect]
);
return (
<>
{items.map((item, index) => {
if (item.children) {
return (
<RadixDropdownMenu.Sub key={index}>
<RadixDropdownMenu.SubTrigger
className="group relative flex h-[25px] select-none items-center rounded-[3px] pr-[5px] text-[13px]
leading-none text-violet11 outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-violet9
data-[highlighted]:data-[state=open]:bg-violet9 data-[state=open]:bg-violet4 data-[disabled]:text-mauve8 data-[highlighted]:data-[state=open]:text-violet1 data-[highlighted]:text-violet1 data-[state=open]:text-violet11"
>
{renderItem(item)}
</RadixDropdownMenu.SubTrigger>
<RadixDropdownMenu.Portal>
<RadixDropdownMenu.SubContent
className="min-w-[220px] rounded-md bg-white p-[5px]
shadow-[0px_10px_38px_-10px_rgba(22,_23,_24,_0.35),_0px_10px_20px_-15px_rgba(22,_23,_24,_0.2)]
will-change-[opacity,transform] data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade
data-[side=right]:animate-slideLeftAndFade data-[side=top]:animate-slideDownAndFade
"
sideOffset={2}
alignOffset={-5}
>
<ScrollArea.Root className=" ">
<ScrollArea.Viewport className="h-full w-full max-h-[80vh]">
<DropdownItems
items={item.children}
onSelect={handleSelect}
renderItem={renderItem}
isItemDisabled={isItemDisabled}
/>
</ScrollArea.Viewport>
</ScrollArea.Root>
</RadixDropdownMenu.SubContent>
</RadixDropdownMenu.Portal>
</RadixDropdownMenu.Sub>
);
}
return (
<DropdownItem
key={index}
onSelect={handleSelect}
disabled={isItemDisabled ? isItemDisabled(item) : item?.disabled}
item={item}
>
{renderItem(item)}
</DropdownItem>
);
})}
</>
);
};
const DropdownItems = React.memo(DropdownItems_);
export { DropdownItems };
@@ -1,62 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import React, { useState } from "react";
import { Select } from "./Select";
import { fruits } from "../../../data/fruits";
import { SelectButton } from "./SelectButton";
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = {
title: "Example/SelectMenu",
component: Select,
parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
layout: "centered",
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ["autodocs"],
// More on argTypes: https://storybook.js.org/docs/api/argtypes
argTypes: {
items: [1, 2, 3, 4],
},
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
args: { onChange: fn() },
} satisfies Meta<typeof Select>;
export default meta;
type Story = StoryObj<typeof meta>;
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const SingleSelect = () => {
const [items, setItems] = useState(fruits);
const handleSearch = async (query: String) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
// Filters items on the name
const filteredItems = fruits.filter((item) =>
item.name.toLowerCase().includes(query.toLowerCase())
);
setItems(filteredItems);
};
return (
<Select
items={fruits}
renderItem={Fruit}
onChange={function (value: any): void {
console.log("Selected items", value);
}}
value={[3, 7, 9]}
renderGroup={function (group: string): React.ReactNode {
throw new Error("Function not implemented.");
}}
onSearch={handleSearch}
multiple
defaultOpen
>
<SelectButton>Click Me!!</SelectButton>
</Select>
);
};
const Fruit = ({ item: fruit }) => {
return <div>{`${fruit.emoji} ${fruit.name}`}</div>;
};
@@ -1,97 +0,0 @@
import React, { useCallback } from "react";
import { DropdownMenu } from "../DropdownMenu/DropdownMenu";
import { SelectItem } from "./SelectItem";
type SelectProps = {
items: any[];
onChange: (value: any) => void;
value: any;
renderItem: (item: any) => React.ReactNode;
renderGroup: (group: string) => React.ReactNode;
multiple?: boolean;
keyExtractor?: (item: any) => string;
showSelectedAtTop?: boolean;
defaultOpen?: boolean;
children: React.ReactNode;
onSearch?: (query: String) => void;
};
export const Select = (props: SelectProps) => {
const {
items,
onChange,
renderItem: Item,
renderGroup,
multiple,
value: initialValue = [],
keyExtractor = (item) => item.id,
showSelectedAtTop = true,
defaultOpen = false,
children,
onSearch,
} = props;
const [value, setValue] = React.useState(initialValue);
const [open, setOpen] = React.useState(defaultOpen);
const handleSelect = useCallback(
(e: React.MouseEvent, item: any) => {
const key = keyExtractor(item);
if (multiple) {
e.preventDefault();
e.stopPropagation();
// If values includes item, remove it
if (value.includes(key)) {
setValue(value.filter((v) => v !== key));
} else {
setValue([...value, key]);
}
onChange([...value, key]);
} else {
setValue([key]);
onChange([key]);
}
},
[value]
);
const renderItem_ = useCallback(
(item: any) => {
const key = keyExtractor(item);
const selected = value.includes(key);
return (
<SelectItem item={item} selected={selected} render={Item}></SelectItem>
);
},
[value, keyExtractor]
);
const orderedItems = React.useMemo(() => {
if (showSelectedAtTop && open) {
// Move selected items to the top
return [
...items.filter((item) => value.includes(keyExtractor(item))),
...items.filter((item) => !value.includes(keyExtractor(item))),
];
}
return items;
}, [initialValue, open]);
const handleOpenChange = useCallback((open_: boolean) => {
setOpen(open_);
}, []);
return (
<DropdownMenu
defaultOpen={defaultOpen}
items={orderedItems}
renderItem={renderItem_}
onSelect={handleSelect}
onOpenChange={handleOpenChange}
onSearch={onSearch}
>
{children}
</DropdownMenu>
);
};
@@ -1,3 +0,0 @@
import { DropdownButton } from "../DropdownMenu/components/DropdownButton";
export const SelectButton = DropdownButton;
@@ -1,31 +0,0 @@
/**
* SelectItem
* @description Takes render function, item, and if its selected as a prop
* @param {Function} render
* @param {Object} item
* @param {Boolean} selected
*/
import { CheckIcon } from "@radix-ui/react-icons";
import React from "react";
const EmptyIcon = () => (
<div style={{ width: "15px", height: "15px" }}>&nbsp;</div>
);
type Props = {
item: any;
selected: boolean;
render: any;
};
const SelectItem_ = ({ render: Item, item, selected }: Props) => {
return (
<div className="flex items-center gap-2 justify-between">
<Item item={item} />
{/* Added empty icon to reserve space */}
{selected ? <CheckIcon /> : <EmptyIcon />}
</div>
);
};
export const SelectItem = React.memo(SelectItem_);
View File
File diff suppressed because one or more lines are too long

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