Compare commits
6
Commits
ui-v2
..
otel-setup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b793ba62a9 | ||
|
|
acedb24fb4 | ||
|
|
5411e96783 | ||
|
|
78efe18f25 | ||
|
|
3f3500e0f3 | ||
|
|
15ceb7c312 |
@@ -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 -->
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.24.1"
|
||||
"version": "0.24.0"
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -25,5 +25,3 @@ from .state import ProjectStatesEndpoint
|
||||
from .label import ProjectLabelsEndpoint
|
||||
|
||||
from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint
|
||||
|
||||
from .meta import ProjectMetaDataEndpoint
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.17
|
||||
Django==4.2.16
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
||||
+5
-5
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.24.1",
|
||||
"version": "0.24.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts"
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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[];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
build/*
|
||||
dist/*
|
||||
out/*
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
.turbo
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,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,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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/typescript-config",
|
||||
"version": "0.24.1",
|
||||
"version": "0.24.0",
|
||||
"private": true,
|
||||
"files": [
|
||||
"base.json",
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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: "🍈",
|
||||
},
|
||||
];
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
testEnvironment: "jsdom",
|
||||
moduleNameMapper: {
|
||||
".(css|less|scss)$": "identity-obj-proxy",
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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" }}> </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_);
|
||||
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
Reference in New Issue
Block a user