Compare commits

..
54 Commits
Author SHA1 Message Date
Satish Gandham 039a8233a1 Optimize select component and cleanup story book 2024-12-30 14:52:11 +05:30
Satish Gandham c06e267206 - Add select component and some cleanup 2024-12-27 13:31:00 +05:30
Satish Gandham 36bf90ca2b Deleted default stories 2024-12-27 13:08:55 +05:30
Satish Gandham 296f579997 Some cleanup 2024-12-20 15:06:25 +05:30
Satish Gandham 2c890af224 Add more tests 2024-12-18 18:17:14 +05:30
Satish Gandham 8d5a549af7 Merge branch 'preview' of https://github.com/makeplane/plane into ui-v2 2024-12-11 13:19:51 +05:30
Satish Gandham c54294388d wip 2024-12-11 13:10:29 +05:30
sriram veeraghanta d0f3987aeb fix: instance changelog url updated 2024-12-10 21:03:44 +05:30
sriram veeraghanta f06b1b8c4a fix: updated package version 2024-12-10 21:02:29 +05:30
sriram veeraghanta 6e56ea4c60 fix: updated changelog url in apiserver 2024-12-10 20:28:51 +05:30
Anmol Singh BhatiaandGitHub 216a69f991 chore: workspace draft and inbox issue local db mutation (#6180) 2024-12-10 19:12:24 +05:30
Vihar KuramaandGitHub 205395e079 fix: changed checkboxes to toggles on notifications settings page (#6175) 2024-12-10 01:02:34 +05:30
Bavisetti NarayanandGitHub ff8bbed6f9 chore: changed the soft deletion logic (#6171) 2024-12-09 20:29:30 +05:30
Vamsi KrishnaandGitHub d04619477b [WEB-2382]chore: notifications code improvement (#6172)
* chore: adjusted  increment/decrement  for unread count

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

* fix: build error

* chore: code refactor

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

* notification snooze types fix

* handled promise

* modified notifications layout

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

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

* added comments for translation

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

* fix: site ssr implementation

* chore: fixed auto reload on file change in sites

* chore: updated constant imports and globalised powerBy component

* chore: resolved lint and updated the env

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-12-04 14:24:53 +05:30
rahulrameshaandGitHub 31b6d52417 fix root issue store to have updated url params at all times (#6147) 2024-12-04 13:57:33 +05:30
Vamsi KrishnaandGitHub a153de34d6 fixed piority icons shape (#6144) 2024-12-04 13:57:14 +05:30
Aaryan KhandelwalandGitHub 64a44f4fce style: add custom class to editor paragraph and heading blocks (#6143) 2024-12-04 13:43:52 +05:30
guru_sainathandGitHub bb8a156bdd fix: removed changelog endpoint (#6146) 2024-12-04 13:42:15 +05:30
Akshita GoyalandGitHub f02a2b04a5 fix: export btn overlap issue (#6149) 2024-12-04 13:41:48 +05:30
Satish Gandham 12a499a95a - Add dropdown component
- Setup some tailwind styles
2024-12-04 11:12:51 +05:30
Bavisetti NarayanandGitHub b6ab853c57 chore: filter out the removed cycle from issue detail (#6138) 2024-12-03 16:48:14 +05:30
Aaryan KhandelwalandGitHub fe43300aa7 fix: pages empty state authorization (#6141) 2024-12-03 14:53:02 +05:30
849d9891d2 chore: community edition product updates link (#6132)
* chore: community edition product updates link

* fix: iframe embed for changelog

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-12-03 13:28:28 +05:30
Vamsi KrishnaandGitHub 2768f560ad [WEB-2802]fix:filters drop down fix safari (#6133)
* filters drop down fix safari

* added comments for translation
2024-12-03 12:51:39 +05:30
Anmol Singh BhatiaandGitHub fe5999ceff fix: intake issue permission (#6136) 2024-12-02 19:49:09 +05:30
rahulrameshaandGitHub da0071256f fix half block dragging (#6135) 2024-12-02 19:30:58 +05:30
M. PalanikannanandGitHub 3c6006d04a [PE-31] feat: Add lock unlock archive restore realtime sync (#5629)
* fix: add lock unlock archive restore realtime sync

* fix: show only after editor loads

* fix: added strong types

* fix: live events fixed

* fix: remove unused vars and logs

* fix: converted objects to enum

* fix: error handling and removing the events in read only mode

* fix: added check to only update if the image aspect ratio is not present already

* fix: imports

* fix: props order

* revert: no need of these changes anymore

* fix: updated type names

* fix: order of things

* fix: fixed types and renamed variables

* fix: better typing for the real time updates

* fix: trying multiplexing our socket connection

* fix: multiplexing socket connection in read only editor as well

* fix: remove single socket logic

* fix: fixing the cleanup deps for the provider and localprovider

* fix: add a better data structure for managing events

* chore: refactored realtime events into hooks

* feat: fetch page meta while focusing tabs

* fix: cycling through items on slash command item in down arrow

* fix: better naming convention for realtime events

* fix: simplified localprovider initialization and cleaning

* fix: types from ui

* fix: abstracted away from exposing the provider directly

* fix: coderabbit suggestions

* regression: pass user in dependency array

* fix: removed page action api calls by the other users the document is synced with

* chore: removed unused imports
2024-12-02 14:26:36 +05:30
Satish Gandham 171b2c5022 New component library setup with storybook 2024-11-29 16:18:18 +05:30
346 changed files with 72747 additions and 2812 deletions
+20
View File
@@ -0,0 +1,20 @@
### Description
<!-- Provide a detailed description of the changes in this PR -->
### Type of Change
<!-- Put an 'x' in the boxes that apply -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Feature (non-breaking change which adds functionality)
- [ ] Improvement (change that would cause existing functionality to not work as expected)
- [ ] Code refactoring
- [ ] Performance improvements
- [ ] Documentation update
### Screenshots and Media (if applicable)
<!-- Add screenshots to help explain your changes, ideally showcasing before and after -->
### Test Scenarios
<!-- Please describe the tests that you ran to verify your changes -->
### References
<!-- Link related issues if there are any -->
+2 -2
View File
@@ -314,8 +314,8 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
attach_assets_to_build:
if: ${{ needs.branch_build_setup.outputs.build_type == 'Build' }}
name: Attach Assets to Build
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
name: Attach Assets to Release
runs-on: ubuntu-20.04
needs: [branch_build_setup]
steps:
+1 -1
View File
@@ -3,7 +3,7 @@
import { FC, useEffect, useRef } from "react";
import { observer } from "mobx-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
import { useOutsideClickDetector } from "@plane/hooks";
// components
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
// hooks
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "admin",
"version": "0.24.0",
"version": "0.24.1",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -14,9 +14,10 @@
"dependencies": {
"@headlessui/react": "^1.7.19",
"@plane/constants": "*",
"@plane/helpers": "*",
"@plane/hooks": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@sentry/nextjs": "^8.32.0",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
@@ -26,7 +27,7 @@
"lucide-react": "^0.356.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.12",
"next": "^14.2.20",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
+1 -1
View File
@@ -4,7 +4,7 @@ FROM python:3.12.5-alpine AS backend
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
WORKDIR /code
+1 -1
View File
@@ -4,7 +4,7 @@ FROM python:3.12.5-alpine AS backend
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
RUN apk --no-cache add \
"bash~=5.2" \
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.24.0"
"version": "0.24.1"
}
+1 -3
View File
@@ -68,9 +68,7 @@ urlpatterns = [
# user workspace invitations
path(
"users/me/workspaces/invitations/",
UserWorkspaceInvitationsViewSet.as_view(
{"get": "list", "post": "create"}
),
UserWorkspaceInvitationsViewSet.as_view({"get": "list", "post": "create"}),
name="user-workspace-invitations",
),
path(
+4 -8
View File
@@ -15,8 +15,6 @@ from django.db.models import (
UUIDField,
Value,
Subquery,
Case,
When,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
@@ -445,12 +443,10 @@ class IssueViewSet(BaseViewSet):
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
:1
]
)
)
.annotate(
+9 -31
View File
@@ -16,12 +16,7 @@ from plane.app.permissions import (
WorkspaceUserPermission,
)
from plane.db.models import (
Project,
ProjectMember,
IssueUserProperty,
WorkspaceMember,
)
from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
from plane.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host
from plane.app.permissions.base import allow_permission, ROLE
@@ -83,10 +78,7 @@ class ProjectMemberViewSet(BaseViewSet):
workspace_member_role = WorkspaceMember.objects.get(
workspace__slug=slug, member=member, is_active=True
).role
if workspace_member_role in [20] and member_roles.get(member) in [
5,
15,
]:
if workspace_member_role in [20] and member_roles.get(member) in [5, 15]:
return Response(
{
"error": "You cannot add a user with role lower than the workspace role"
@@ -94,10 +86,7 @@ class ProjectMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
if workspace_member_role in [5] and member_roles.get(member) in [
15,
20,
]:
if workspace_member_role in [5] and member_roles.get(member) in [15, 20]:
return Response(
{
"error": "You cannot add a user with role higher than the workspace role"
@@ -135,8 +124,7 @@ class ProjectMemberViewSet(BaseViewSet):
sort_order = [
project_member.get("sort_order")
for project_member in project_members
if str(project_member.get("member_id"))
== str(member.get("member_id"))
if str(project_member.get("member_id")) == str(member.get("member_id"))
]
# Create a new project member
bulk_project_members.append(
@@ -145,9 +133,7 @@ class ProjectMemberViewSet(BaseViewSet):
role=member.get("role", 5),
project_id=project_id,
workspace_id=project.workspace_id,
sort_order=(
sort_order[0] - 10000 if len(sort_order) else 65535
),
sort_order=(sort_order[0] - 10000 if len(sort_order) else 65535),
)
)
# Create a new issue property
@@ -238,9 +224,7 @@ class ProjectMemberViewSet(BaseViewSet):
> requested_project_member.role
):
return Response(
{
"error": "You cannot update a role that is higher than your own role"
},
{"error": "You cannot update a role that is higher than your own role"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -280,9 +264,7 @@ class ProjectMemberViewSet(BaseViewSet):
# User cannot deactivate higher role
if requesting_project_member.role < project_member.role:
return Response(
{
"error": "You cannot remove a user having role higher than you"
},
{"error": "You cannot remove a user having role higher than you"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -303,10 +285,7 @@ class ProjectMemberViewSet(BaseViewSet):
if (
project_member.role == 20
and not ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
role=20,
is_active=True,
workspace__slug=slug, project_id=project_id, role=20, is_active=True
).count()
> 1
):
@@ -344,7 +323,6 @@ class UserProjectRolesEndpoint(BaseAPIView):
).values("project_id", "role")
project_members = {
str(member["project_id"]): member["role"]
for member in project_members
str(member["project_id"]): member["role"] for member in project_members
}
return Response(project_members, status=status.HTTP_200_OK)
+3 -2
View File
@@ -41,6 +41,7 @@ from django.views.decorators.vary import vary_on_cookie
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.license.utils.instance_value import get_configuration_value
class WorkSpaceViewSet(BaseViewSet):
model = Workspace
serializer_class = WorkSpaceSerializer
@@ -81,12 +82,12 @@ class WorkSpaceViewSet(BaseViewSet):
def create(self, request):
try:
DISABLE_WORKSPACE_CREATION, = get_configuration_value(
(DISABLE_WORKSPACE_CREATION,) = get_configuration_value(
[
{
"key": "DISABLE_WORKSPACE_CREATION",
"default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
},
}
]
)
+8 -33
View File
@@ -1,22 +1,12 @@
# Django imports
from django.db.models import (
Count,
Q,
OuterRef,
Subquery,
IntegerField,
)
from django.db.models import Count, Q, OuterRef, Subquery, IntegerField
from django.db.models.functions import Coalesce
# Third party modules
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
WorkspaceEntityPermission,
allow_permission,
ROLE,
)
from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE
# Module imports
from plane.app.serializers import (
@@ -26,12 +16,7 @@ from plane.app.serializers import (
WorkSpaceMemberSerializer,
)
from plane.app.views.base import BaseAPIView
from plane.db.models import (
Project,
ProjectMember,
WorkspaceMember,
DraftIssue,
)
from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue
from plane.utils.cache import invalidate_cache
from .. import BaseViewSet
@@ -119,9 +104,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
if requesting_workspace_member.role < workspace_member.role:
return Response(
{
"error": "You cannot remove a user having role higher than you"
},
{"error": "You cannot remove a user having role higher than you"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -148,9 +131,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug,
member_id=workspace_member.member_id,
is_active=True,
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False)
workspace_member.is_active = False
@@ -164,9 +145,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
multiple=True,
)
@invalidate_cache(path="/api/users/me/settings/")
@invalidate_cache(
path="api/users/me/workspaces/", user=False, multiple=True
)
@invalidate_cache(path="api/users/me/workspaces/", user=False, multiple=True)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
@@ -213,9 +192,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# # Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug,
member_id=workspace_member.member_id,
is_active=True,
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False)
# # Deactivate the user
@@ -279,9 +256,7 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
project_members = ProjectMember.objects.filter(
workspace__slug=slug, project_id__in=project_ids, is_active=True
).select_related("project", "member", "workspace")
project_members = ProjectMemberRoleSerializer(
project_members, many=True
).data
project_members = ProjectMemberRoleSerializer(project_members, many=True).data
project_members_dict = dict()
@@ -60,6 +60,9 @@ class EmailCheckEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
# Lower the email
email = str(email).lower().strip()
# Validate email
try:
validate_email(email)
@@ -60,6 +60,7 @@ class EmailCheckSpaceEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
email = str(email).lower().strip()
# Validate email
try:
validate_email(email)
+92 -24
View File
@@ -3,7 +3,8 @@ from django.utils import timezone
from django.apps import apps
from django.conf import settings
from django.db import models
from django.core.exceptions import ObjectDoesNotExist
from django.db.models.fields.related import OneToOneRel
# Third party imports
from celery import shared_task
@@ -11,31 +12,98 @@ from celery import shared_task
@shared_task
def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
"""
Soft delete related objects for a given model instance
"""
# Get the model class using app registry
model_class = apps.get_model(app_label, model_name)
instance = model_class.all_objects.get(pk=instance_pk)
related_fields = instance._meta.get_fields()
for field in related_fields:
if field.one_to_many or field.one_to_one:
try:
# Check if the field has CASCADE on delete
if (
not hasattr(field.remote_field, "on_delete")
or field.remote_field.on_delete == models.CASCADE
):
if field.one_to_many:
related_objects = getattr(instance, field.name).all()
elif field.one_to_one:
related_object = getattr(instance, field.name)
related_objects = (
[related_object] if related_object is not None else []
)
for obj in related_objects:
if obj:
obj.deleted_at = timezone.now()
obj.save(using=using)
except ObjectDoesNotExist:
pass
# Get the instance using all_objects to ensure we can get even if it's already soft deleted
try:
instance = model_class.all_objects.get(pk=instance_pk)
except model_class.DoesNotExist:
return
# Get all related fields that are reverse relationships
all_related = [
f
for f in instance._meta.get_fields()
if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete
]
# Handle each related field
for relation in all_related:
related_name = relation.get_accessor_name()
# Skip if the relation doesn't exist
if not hasattr(instance, related_name):
continue
# Get the on_delete behavior name
on_delete_name = (
relation.on_delete.__name__
if hasattr(relation.on_delete, "__name__")
else ""
)
if on_delete_name == "DO_NOTHING":
continue
elif on_delete_name == "SET_NULL":
# Handle SET_NULL relationships
if isinstance(relation, OneToOneRel):
# For OneToOne relationships
related_obj = getattr(instance, related_name, None)
if related_obj and isinstance(related_obj, models.Model):
setattr(related_obj, relation.remote_field.name, None)
related_obj.save(update_fields=[relation.remote_field.name])
else:
# For other relationships
related_queryset = getattr(instance, related_name).all()
related_queryset.update(**{relation.remote_field.name: None})
else:
# Handle CASCADE and other delete behaviors
try:
if relation.one_to_one:
# Handle OneToOne relationships
related_obj = getattr(instance, related_name, None)
if related_obj:
if hasattr(related_obj, "deleted_at"):
if not related_obj.deleted_at:
related_obj.deleted_at = timezone.now()
related_obj.save()
# Recursively handle related objects
soft_delete_related_objects(
related_obj._meta.app_label,
related_obj._meta.model_name,
related_obj.pk,
using,
)
else:
# Handle other relationships
related_queryset = getattr(instance, related_name).all()
for related_obj in related_queryset:
if hasattr(related_obj, "deleted_at"):
if not related_obj.deleted_at:
related_obj.deleted_at = timezone.now()
related_obj.save()
# Recursively handle related objects
soft_delete_related_objects(
related_obj._meta.app_label,
related_obj._meta.model_name,
related_obj.pk,
using,
)
except Exception as e:
# Log the error or handle as needed
print(f"Error handling relation {related_name}: {str(e)}")
continue
# Finally, soft delete the instance itself if it hasn't been deleted yet
if hasattr(instance, "deleted_at") and not instance.deleted_at:
instance.deleted_at = timezone.now()
instance.save()
# @shared_task
+5 -13
View File
@@ -162,8 +162,7 @@ def generate_table_row(issue):
issue["priority"],
(
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"]
and issue["created_by__last_name"]
if issue["created_by__first_name"] and issue["created_by__last_name"]
else ""
),
(
@@ -197,8 +196,7 @@ def generate_json_row(issue):
"Priority": issue["priority"],
"Created By": (
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"]
and issue["created_by__last_name"]
if issue["created_by__first_name"] and issue["created_by__last_name"]
else ""
),
"Assignee": (
@@ -208,17 +206,11 @@ def generate_json_row(issue):
),
"Labels": issue["labels__name"] if issue["labels__name"] else "",
"Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(
issue["issue_cycle__cycle__start_date"]
),
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"],
"Module Start Date": dateConverter(
issue["issue_module__module__start_date"]
),
"Module Target Date": dateConverter(
issue["issue_module__module__target_date"]
),
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
"Created At": dateTimeConverter(issue["created_at"]),
"Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_at"]),
+3 -1
View File
@@ -257,7 +257,9 @@ def notifications(
)
new_mentions = [
str(mention) for mention in new_mentions if mention in set(project_members)
str(mention)
for mention in new_mentions
if mention in set(project_members)
]
removed_mention = get_removed_mentions(
requested_instance=requested_data, current_instance=current_instance
@@ -13,28 +13,14 @@ from plane.db.models import (
class Command(BaseCommand):
help = "Add a member to a project. If present in the workspace"
def add_arguments(self, parser):
# Positional argument
parser.add_argument("--project_id", type=str, nargs="?", help="Project ID")
parser.add_argument("--user_email", type=str, nargs="?", help="User Email")
parser.add_argument(
"--project_id",
type=str,
nargs="?",
help="Project ID",
)
parser.add_argument(
"--user_email",
type=str,
nargs="?",
help="User Email",
)
parser.add_argument(
"--role",
type=int,
nargs="?",
help="Role of the user in the project",
"--role", type=int, nargs="?", help="Role of the user in the project"
)
def handle(self, *args: Any, **options: Any):
@@ -67,9 +53,7 @@ class Command(BaseCommand):
# Get the smallest sort order
smallest_sort_order = (
ProjectMember.objects.filter(
workspace_id=project.workspace_id,
)
ProjectMember.objects.filter(workspace_id=project.workspace_id)
.order_by("sort_order")
.first()
)
@@ -79,22 +63,15 @@ class Command(BaseCommand):
else:
sort_order = 65535
if ProjectMember.objects.filter(
project=project,
member=user,
).exists():
if ProjectMember.objects.filter(project=project, member=user).exists():
# Update the project member
ProjectMember.objects.filter(
project=project,
member=user,
).update(is_active=True, sort_order=sort_order, role=role)
ProjectMember.objects.filter(project=project, member=user).update(
is_active=True, sort_order=sort_order, role=role
)
else:
# Create the project member
ProjectMember.objects.create(
project=project,
member=user,
role=role,
sort_order=sort_order,
project=project, member=user, role=role, sort_order=sort_order
)
# Issue Property
@@ -102,9 +79,7 @@ class Command(BaseCommand):
# Success message
self.stdout.write(
self.style.SUCCESS(
f"User {user_email} added to project {project_id}"
)
self.style.SUCCESS(f"User {user_email} added to project {project_id}")
)
return
except CommandError as e:
-10
View File
@@ -53,7 +53,6 @@ from .project import (
ProjectMemberInvite,
ProjectPublicMember,
)
from .deploy_board import DeployBoard
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State
@@ -69,23 +68,14 @@ from .workspace import (
WorkspaceUserProperties,
)
from .importer import Importer
from .page import Page, PageLog, PageLabel
from .estimate import Estimate, EstimatePoint
from .intake import Intake, IntakeIssue
from .analytic import AnalyticView
from .notification import Notification, UserNotificationPreference, EmailNotificationLog
from .exporter import ExporterHistory
from .webhook import Webhook, WebhookLog
from .dashboard import Dashboard, DashboardWidget, Widget
from .favorite import UserFavorite
+6 -26
View File
@@ -44,45 +44,25 @@ class FileAsset(BaseModel):
"db.User", on_delete=models.CASCADE, null=True, related_name="assets"
)
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
null=True,
related_name="assets",
"db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets"
)
draft_issue = models.ForeignKey(
"db.DraftIssue",
on_delete=models.CASCADE,
null=True,
related_name="assets",
"db.DraftIssue", on_delete=models.CASCADE, null=True, related_name="assets"
)
project = models.ForeignKey(
"db.Project",
on_delete=models.CASCADE,
null=True,
related_name="assets",
"db.Project", on_delete=models.CASCADE, null=True, related_name="assets"
)
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, null=True, related_name="assets"
)
comment = models.ForeignKey(
"db.IssueComment",
on_delete=models.CASCADE,
null=True,
related_name="assets",
"db.IssueComment", on_delete=models.CASCADE, null=True, related_name="assets"
)
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, null=True, related_name="assets"
)
entity_type = models.CharField(
max_length=255,
null=True,
blank=True,
)
entity_identifier = models.CharField(
max_length=255,
null=True,
blank=True,
)
entity_type = models.CharField(max_length=255, null=True, blank=True)
entity_identifier = models.CharField(max_length=255, null=True, blank=True)
is_deleted = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
external_id = models.CharField(max_length=255, null=True, blank=True)
+8 -31
View File
@@ -661,9 +661,7 @@ class IssueVote(ProjectBaseModel):
class IssueVersion(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue",
on_delete=models.CASCADE,
related_name="versions",
"db.Issue", on_delete=models.CASCADE, related_name="versions"
)
PRIORITY_CHOICES = (
("urgent", "Urgent"),
@@ -688,9 +686,7 @@ class IssueVersion(ProjectBaseModel):
)
start_date = models.DateField(null=True, blank=True)
target_date = models.DateField(null=True, blank=True)
sequence_id = models.IntegerField(
default=1, verbose_name="Issue Sequence ID"
)
sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID")
sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
archived_at = models.DateField(null=True)
@@ -700,25 +696,10 @@ class IssueVersion(ProjectBaseModel):
type = models.UUIDField(blank=True, null=True)
last_saved_at = models.DateTimeField(default=timezone.now)
owned_by = models.UUIDField()
assignees = ArrayField(
models.UUIDField(),
blank=True,
default=list,
)
labels = ArrayField(
models.UUIDField(),
blank=True,
default=list,
)
cycle = models.UUIDField(
null=True,
blank=True,
)
modules = ArrayField(
models.UUIDField(),
blank=True,
default=list,
)
assignees = ArrayField(models.UUIDField(), blank=True, default=list)
labels = ArrayField(models.UUIDField(), blank=True, default=list)
cycle = models.UUIDField(null=True, blank=True)
modules = ArrayField(models.UUIDField(), blank=True, default=list)
properties = models.JSONField(default=dict)
meta = models.JSONField(default=dict)
@@ -741,9 +722,7 @@ class IssueVersion(ProjectBaseModel):
Module = apps.get_model("db.Module")
CycleIssue = apps.get_model("db.CycleIssue")
cycle_issue = CycleIssue.objects.filter(
issue=issue,
).first()
cycle_issue = CycleIssue.objects.filter(issue=issue).first()
cls.objects.create(
issue=issue,
@@ -771,9 +750,7 @@ class IssueVersion(ProjectBaseModel):
assignees=issue.assignees,
labels=issue.labels,
cycle=cycle_issue.cycle if cycle_issue else None,
modules=Module.objects.filter(issue=issue).values_list(
"id", flat=True
),
modules=Module.objects.filter(issue=issue).values_list("id", flat=True),
owned_by=user,
)
return True
+1 -3
View File
@@ -29,9 +29,7 @@ def validate_domain(value):
class Webhook(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_webhooks",
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks"
)
url = models.URLField(
validators=[validate_schema, validate_domain], max_length=1024
+7 -25
View File
@@ -102,12 +102,7 @@ def get_default_display_properties():
def get_issue_props():
return {
"subscribed": True,
"assigned": True,
"created": True,
"all_issues": True,
}
return {"subscribed": True, "assigned": True, "created": True, "all_issues": True}
def slug_validator(value):
@@ -136,9 +131,7 @@ class Workspace(BaseModel):
max_length=48, db_index=True, unique=True, validators=[slug_validator]
)
organization_size = models.CharField(max_length=20, blank=True, null=True)
timezone = models.CharField(
max_length=255, default="UTC", choices=TIMEZONE_CHOICES
)
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
def __str__(self):
"""Return name of the Workspace"""
@@ -167,10 +160,7 @@ class WorkspaceBaseModel(BaseModel):
"db.Workspace", models.CASCADE, related_name="workspace_%(class)s"
)
project = models.ForeignKey(
"db.Project",
models.CASCADE,
related_name="project_%(class)s",
null=True,
"db.Project", models.CASCADE, related_name="project_%(class)s", null=True
)
class Meta:
@@ -184,9 +174,7 @@ class WorkspaceBaseModel(BaseModel):
class WorkspaceMember(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_member",
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member"
)
member = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -221,9 +209,7 @@ class WorkspaceMember(BaseModel):
class WorkspaceMemberInvite(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_member_invite",
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite"
)
email = models.CharField(max_length=255)
accepted = models.BooleanField(default=False)
@@ -283,9 +269,7 @@ class WorkspaceTheme(BaseModel):
)
name = models.CharField(max_length=300)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="themes",
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes"
)
colors = models.JSONField(default=dict)
@@ -320,9 +304,7 @@ class WorkspaceUserProperties(BaseModel):
)
filters = models.JSONField(default=get_default_filters)
display_filters = models.JSONField(default=get_default_display_filters)
display_properties = models.JSONField(
default=get_default_display_properties
)
display_properties = models.JSONField(default=get_default_display_properties)
class Meta:
unique_together = ["workspace", "user", "deleted_at"]
@@ -2,4 +2,4 @@ from .instance import InstanceSerializer
from .configuration import InstanceConfigurationSerializer
from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer
from .workspace import WorkspaceSerializer
from .workspace import WorkspaceSerializer
@@ -1,6 +1,8 @@
from .base import BaseSerializer
from plane.db.models import User
class UserLiteSerializer(BaseSerializer):
class Meta:
model = User
fields = ["id", "email", "first_name", "last_name",]
fields = ["id", "email", "first_name", "last_name"]
@@ -13,6 +13,8 @@ from .admin import (
InstanceAdminUserSessionEndpoint,
)
from .changelog import ChangeLogEndpoint
from .workspace import InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint
from .workspace import (
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
)
@@ -1,33 +0,0 @@
# Python imports
import requests
# Django imports
from django.conf import settings
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
# plane imports
from .base import BaseAPIView
class ChangeLogEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def fetch_change_logs(self):
response = requests.get(settings.INSTANCE_CHANGELOG_URL)
response.raise_for_status()
return response.json()
def get(self, request):
# Fetch the changelog
if settings.INSTANCE_CHANGELOG_URL:
data = self.fetch_change_logs()
return Response(data, status=status.HTTP_200_OK)
else:
return Response(
{"error": "could not fetch changelog please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -43,19 +43,19 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
member_count = (
WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False, is_active=True
).select_related("owner")
)
.select_related("owner")
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
workspaces = Workspace.objects.annotate(
total_projects=project_count,
total_members=member_count,
total_projects=project_count, total_members=member_count
)
# Add search functionality
@@ -66,16 +66,14 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=workspaces,
on_results=lambda results: WorkspaceSerializer(
results, many=True,
).data,
on_results=lambda results: WorkspaceSerializer(results, many=True).data,
max_per_page=10,
default_per_page=10,
)
def post(self, request):
try:
serializer = WorkspaceSerializer (data=request.data)
serializer = WorkspaceSerializer(data=request.data)
slug = request.data.get("slug", False)
name = request.data.get("name", False)
+1 -7
View File
@@ -11,14 +11,12 @@ from plane.license.api.views import (
InstanceAdminUserMeEndpoint,
InstanceAdminSignOutEndpoint,
InstanceAdminUserSessionEndpoint,
ChangeLogEndpoint,
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
)
urlpatterns = [
path("", InstanceEndpoint.as_view(), name="instance"),
path("changelog/", ChangeLogEndpoint.as_view(), name="instance-changelog"),
path("admins/", InstanceAdminEndpoint.as_view(), name="instance-admins"),
path("admins/me/", InstanceAdminUserMeEndpoint.as_view(), name="instance-admins"),
path(
@@ -62,9 +60,5 @@ urlpatterns = [
InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(),
name="instance-workspace-availability",
),
path(
"workspaces/",
InstanceWorkSpaceEndpoint.as_view(),
name="instance-workspace",
),
path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"),
]
+6
View File
@@ -10,9 +10,15 @@ from plane.space.views import (
ProjectStatesEndpoint,
ProjectLabelsEndpoint,
ProjectMembersEndpoint,
ProjectMetaDataEndpoint,
)
urlpatterns = [
path(
"anchor/<str:anchor>/meta/",
ProjectMetaDataEndpoint.as_view(),
name="project-meta",
),
path(
"anchor/<str:anchor>/settings/",
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
+2
View File
@@ -25,3 +25,5 @@ from .state import ProjectStatesEndpoint
from .label import ProjectLabelsEndpoint
from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint
from .meta import ProjectMetaDataEndpoint
+34
View File
@@ -0,0 +1,34 @@
# third party
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework.response import Response
from plane.db.models import DeployBoard, Project
from .base import BaseAPIView
from plane.space.serializer.project import ProjectLiteSerializer
class ProjectMetaDataEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request, anchor):
try:
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).first()
except DeployBoard.DoesNotExist:
return Response(
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
)
try:
project_id = deploy_board.entity_identifier
project = Project.objects.get(id=project_id)
except Project.DoesNotExist:
return Response(
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
)
serializer = ProjectLiteSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.16
Django==4.2.17
# rest framework
djangorestframework==3.15.2
# postgres
+5 -5
View File
@@ -1,16 +1,16 @@
{
"name": "live",
"version": "0.24.0",
"version": "0.24.1",
"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 . --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"
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"keywords": [],
"author": "",
@@ -30,7 +30,7 @@
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.20.0",
"express": "^4.21.2",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "^5.4.1",
+12
View File
@@ -4,6 +4,10 @@ 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
@@ -55,6 +59,14 @@ export const getHocusPocusServer = async () => {
throw Error("Authentication unsuccessful!");
}
},
async onStateless({ payload, document }) {
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
const response =
DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
},
extensions,
debounce: 10000,
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.24.0",
"version": "0.24.1",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.24.0",
"version": "0.24.1",
"private": true,
"main": "./src/index.ts"
}
+3
View File
@@ -13,3 +13,6 @@ export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}/`);
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}/`);
// plane website url
export const WEBSITE_URL =
process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.24.0",
"version": "0.24.1",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -27,6 +27,7 @@
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"peerDependencies": {
@@ -36,8 +37,8 @@
"dependencies": {
"@floating-ui/react": "^0.26.4",
"@hocuspocus/provider": "^2.13.5",
"@plane/helpers": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-character-count": "^2.6.5",
@@ -0,0 +1,6 @@
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/helpers";
import { convertHexEmojiToDecimal } from "@plane/utils";
// plane ui
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
// helpers
@@ -1,5 +1,5 @@
// plane helpers
import { sanitizeHTML } from "@plane/helpers";
import { sanitizeHTML } from "@plane/utils";
// plane ui
import { TEmojiLogoProps } from "@plane/ui";
// types
@@ -43,6 +43,16 @@ export const CoreEditorExtensionsWithoutProps = [
codeBlock: false,
horizontalRule: false,
blockquote: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
},
},
heading: {
HTMLAttributes: {
class: "editor-heading-block",
},
},
dropcursor: false,
}),
CustomQuoteExtension,
@@ -118,7 +118,6 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatioCalculated,
};
setSize(initialComputedSize);
updateAttributesSafely(
initialComputedSize,
@@ -29,12 +29,9 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
useEffect(() => {
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
if (closestEditorContainer) {
setEditorContainer(closestEditorContainer as HTMLDivElement);
}
setEditorContainer(closestEditorContainer as HTMLDivElement);
}, []);
// the image is already uploaded if the image-component node has src attribute
@@ -55,7 +52,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
setResolvedSrc(url as string);
};
getImageSource();
}, [imageFromFileSystem, node.attrs.src]);
}, [imgNodeSrc]);
return (
<NodeViewWrapper>
@@ -73,6 +73,16 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
codeBlock: false,
horizontalRule: false,
blockquote: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
},
},
heading: {
HTMLAttributes: {
class: "editor-heading-block",
},
},
dropcursor: {
class: "text-custom-text-300",
},
@@ -65,6 +65,16 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
codeBlock: false,
horizontalRule: false,
blockquote: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
},
},
heading: {
HTMLAttributes: {
class: "editor-heading-block",
},
},
dropcursor: false,
gapcursor: false,
}),
@@ -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") {
@@ -0,0 +1,11 @@
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, useLayoutEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import Collaboration from "@tiptap/extension-collaboration";
import { IndexeddbPersistence } from "y-indexeddb";
@@ -58,21 +58,19 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
[id, realtimeConfig, serverHandler, user]
);
// destroy and disconnect connection on unmount
const localProvider = useMemo(
() => (id ? new IndexeddbPersistence(id, provider.document) : undefined),
[id, provider]
);
// destroy and disconnect all providers connection on unmount
useEffect(
() => () => {
provider.destroy();
provider.disconnect();
},
[provider]
);
// indexed db integration for offline support
useLayoutEffect(() => {
const localProvider = new IndexeddbPersistence(id, provider.document);
return () => {
provider?.destroy();
localProvider?.destroy();
};
}, [provider, id]);
},
[provider, localProvider]
);
const editor = useEditor({
disabledExtensions,
+7 -4
View File
@@ -16,13 +16,14 @@ import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helper
// props
import { CoreEditorProps } from "@/props";
// types
import {
import type {
TDocumentEventsServer,
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
TEditorCommands,
TExtensions,
TFileHandler,
TExtensions,
} from "@/types";
export interface CustomEditorProps {
@@ -67,9 +68,9 @@ export const useEditor = (props: CustomEditorProps) => {
onChange,
onTransaction,
placeholder,
provider,
tabIndex,
value,
provider,
autofocus = false,
} = props;
// states
@@ -257,7 +258,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);
@@ -298,6 +299,8 @@ 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, useLayoutEffect, useMemo, useState } from "react";
import { useEffect, 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({
url: realtimeConfig.url,
name: id,
url: realtimeConfig.url,
token: JSON.stringify(user),
parameters: realtimeConfig.queryParams,
onAuthenticationFailed: () => {
@@ -48,23 +48,23 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
},
onSynced: () => setHasServerSynced(true),
}),
[id, realtimeConfig, user]
[id, realtimeConfig, serverHandler, 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();
provider.disconnect();
},
[provider]
);
// indexed db integration for offline support
useLayoutEffect(() => {
const localProvider = new IndexeddbPersistence(id, provider.document);
return () => {
localProvider?.destroy();
};
}, [provider, id]);
},
[provider, localProvider]
);
const editor = useReadOnlyEditor({
disabledExtensions,
@@ -11,7 +11,13 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// props
import { CoreReadOnlyEditorProps } from "@/props";
// types
import { EditorReadOnlyRefApi, IMentionHighlight, TExtensions, TFileHandler } from "@/types";
import type {
EditorReadOnlyRefApi,
IMentionHighlight,
TExtensions,
TDocumentEventsServer,
TFileHandler,
} from "@/types";
interface CustomReadOnlyEditorProps {
disabledExtensions: TExtensions[];
@@ -120,6 +126,8 @@ 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,
}));
@@ -0,0 +1,10 @@
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;
};
+5 -1
View File
@@ -8,6 +8,8 @@ import {
IMentionSuggestion,
TAIHandler,
TDisplayConfig,
TDocumentEventEmitter,
TDocumentEventsServer,
TEmbedConfig,
TExtensions,
TFileHandler,
@@ -83,6 +85,8 @@ export type EditorReadOnlyRefApi = {
};
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
getHeadings: () => IMarking[];
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
};
export interface EditorRefApi extends EditorReadOnlyRefApi {
@@ -121,7 +125,7 @@ export interface IEditorProps {
onEnterKeyPress?: (e?: any) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
value?: string | null;
value?: string | null;
}
export interface ILiteTextEditor extends IEditorProps {
extensions?: any[];
+1
View File
@@ -8,3 +8,4 @@ export * from "./image";
export * from "./mention-suggestion";
export * from "./slash-commands-suggestion";
export * from "@/plane-editor/types";
export * from "./document-collaborative-events";
+3
View File
@@ -1 +1,4 @@
export * from "@/extensions/core-without-props";
export * from "@/constants/document-collaborative-events";
export * from "@/helpers/get-document-server-event";
export * from "@/types/document-collaborative-events";
+20 -20
View File
@@ -133,7 +133,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"][checked]:hover {
}
/* the p tag just after the ul tag */
ul[data-type="taskList"] + p {
ul[data-type="taskList"] + p.editor-paragraph-block {
margin-top: 0.4rem !important;
}
@@ -180,7 +180,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
}
ul[data-type="taskList"] li > div {
& > p {
& > p.editor-paragraph-block {
margin-top: 10px;
transition: color 0.2s ease;
}
@@ -191,7 +191,7 @@ ul[data-type="taskList"] li > div {
}
ul[data-type="taskList"] li[data-checked="true"] {
& > div > p {
& > div > p.editor-paragraph-block {
color: rgb(var(--color-text-400));
}
@@ -322,18 +322,18 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
}
/* end numbered, bulleted and to-do lists spacing */
h1,
h2,
h3,
h4,
h5,
h6,
p {
h1.editor-heading-block,
h2.editor-heading-block,
h3.editor-heading-block,
h4.editor-heading-block,
h5.editor-heading-block,
h6.editor-heading-block,
p.editor-paragraph-block {
margin: 0 !important;
}
/* tailwind typography */
.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h1.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 28px;
}
@@ -344,7 +344,7 @@ p {
font-weight: 600;
}
.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h2.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 28px;
}
@@ -355,7 +355,7 @@ p {
font-weight: 600;
}
.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h3.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 28px;
}
@@ -366,7 +366,7 @@ p {
font-weight: 600;
}
.prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h4.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 28px;
}
@@ -377,7 +377,7 @@ p {
font-weight: 600;
}
.prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h5.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 20px;
}
@@ -388,7 +388,7 @@ p {
font-weight: 600;
}
.prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(h6.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 20px;
}
@@ -399,7 +399,7 @@ p {
font-weight: 600;
}
.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
.prose :where(p.editor-paragraph-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:first-child {
padding-top: 0;
}
@@ -420,12 +420,12 @@ p {
line-height: var(--line-height-regular);
}
p + p {
p.editor-paragraph-block + p.editor-paragraph-block {
padding-top: 8px !important;
}
.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p,
.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p {
.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p.editor-paragraph-block,
.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p.editor-paragraph-block {
font-size: var(--font-size-list);
line-height: var(--line-height-list);
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@plane/eslint-config",
"private": true,
"version": "0.24.0",
"version": "0.24.1",
"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",
"eslint": "8.57.1",
"eslint-config-next": "^14.1.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^1.12.4",
-22
View File
@@ -1,22 +0,0 @@
export const convertHexEmojiToDecimal = (emojiUnified: string): string => {
if (!emojiUnified) return "";
return emojiUnified
.toString()
.split("-")
.map((e) => parseInt(e, 16))
.join("-");
};
export const emojiCodeToUnicode = (emoji: string) => {
if (!emoji) return "";
// convert emoji code to unicode
const uniCodeEmoji = emoji
.toString()
.split("-")
.map((emoji) => parseInt(emoji, 10).toString(16))
.join("-");
return uniCodeEmoji;
};
-2
View File
@@ -1,2 +0,0 @@
export * from "./emoji.helper"
export * from "./string.helper"
-2
View File
@@ -1,2 +0,0 @@
export * from "./helpers";
export * from "./hooks";
+3
View File
@@ -0,0 +1,3 @@
build/*
dist/*
out/*
+9
View File
@@ -0,0 +1,9 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};
+4
View File
@@ -0,0 +1,4 @@
.turbo
out/
dist/
build/
+5
View File
@@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@plane/hooks",
"version": "0.24.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"
}
}
@@ -4,6 +4,6 @@
"jsx": "react",
"lib": ["esnext", "dom"]
},
"include": ["."],
"include": ["./src"],
"exclude": ["dist", "build", "node_modules"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tailwind-config-custom",
"version": "0.24.0",
"version": "0.24.1",
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "0.24.0",
"version": "0.24.1",
"private": true,
"types": "./src/index.d.ts",
"main": "./src/index.d.ts"
+1
View File
@@ -57,6 +57,7 @@ export interface IInstanceConfig {
// intercom
is_intercom_enabled: boolean;
intercom_app_id: string | undefined;
instance_changelog_url?: string;
}
export interface IInstanceAdmin {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
"version": "0.24.0",
"version": "0.24.1",
"private": true,
"files": [
"base.json",
+25
View File
@@ -0,0 +1,25 @@
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;
@@ -0,0 +1 @@
<div id="portal-root"></div>
+15
View File
@@ -0,0 +1,15 @@
import type { Preview } from "@storybook/react";
import "../styles/output.css";
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;
+94
View File
@@ -0,0 +1,94 @@
export const fruits = [
{
id: 1,
name: "Apple",
emoji: "🍎",
description:
"A sweet and crisp fruit, often red or green, great for snacking and baking.",
// disabled: true,
},
{
id: 2,
name: "Banana",
emoji: "🍌",
description:
"A long and curved fruit with a soft, creamy flesh, known for its energy-boosting properties.",
},
{
id: 3,
name: "Cherry",
emoji: "🍒",
description:
"Small, round, and juicy fruits with a sweet or tart flavor, often used in desserts.",
// disabled: true,
},
{
id: 4,
name: "Grapes",
emoji: "🍇",
description:
"Small, juicy fruits that grow in clusters, available in various colors like green, red, and purple.",
},
{
id: 5,
name: "Orange",
emoji: "🍊",
description:
"A citrus fruit known for its tangy and refreshing taste, rich in vitamin C.",
},
{
id: 6,
name: "Strawberry",
emoji: "🍓",
description:
"A red, heart-shaped fruit with a sweet flavor and tiny seeds on its surface.",
},
{
id: 7,
name: "Watermelon",
emoji: "🍉",
description:
"A large, juicy fruit with green rind and red flesh, perfect for summertime snacks.",
},
{
id: 8,
name: "Peach",
emoji: "🍑",
description:
"A soft, fuzzy fruit with a sweet and slightly tangy taste, often enjoyed fresh or in desserts.",
},
{
id: 9,
name: "Pineapple",
emoji: "🍍",
description:
"A tropical fruit with spiky skin and sweet, tangy yellow flesh.",
},
{
id: 10,
name: "Lemon",
emoji: "🍋",
description:
"A bright yellow citrus fruit with a tart flavor, commonly used in drinks and cooking.",
},
{
id: 11,
name: "Mango",
emoji: "🥭",
},
{
id: 12,
name: "Gooseberry",
emoji: "🍇",
},
{
id: 13,
name: "Grapefruit",
emoji: "🍊",
},
{
id: 14,
name: "Guava",
emoji: "🍈",
},
];
+73
View File
@@ -0,0 +1,73 @@
export const vegetables = [
// Add 10 vegetables
{
id: 12,
name: "Carrot",
emoji: "🥕",
description:
"A crunchy orange root vegetable, rich in beta-carotene and vitamin A.",
},
{
id: 13,
name: "Broccoli",
emoji: "🥦",
description:
"A green vegetable with dense, nutritious florets, high in fiber and vitamins.",
},
{
id: 14,
name: "Tomato",
emoji: "🍅",
description:
"Technically a fruit, but commonly used as a vegetable in cooking.",
},
{
id: 15,
name: "Eggplant",
emoji: "🍆",
description:
"A purple vegetable with a meaty texture, popular in Mediterranean cuisine.",
},
{
id: 16,
name: "Corn",
emoji: "🌽",
description:
"Sweet yellow kernels on a cob, enjoyed grilled, boiled, or popped.",
},
{
id: 17,
name: "Bell Pepper",
emoji: "🫑",
description:
"A crisp, colorful vegetable that can be sweet or slightly bitter.",
},
{
id: 18,
name: "Cucumber",
emoji: "🥒",
description:
"A refreshing green vegetable with high water content, often used in salads.",
},
{
id: 19,
name: "Potato",
emoji: "🥔",
description:
"A starchy root vegetable that can be prepared in countless ways.",
},
{
id: 20,
name: "Mushroom",
emoji: "🍄",
description:
"Technically a fungus, but commonly used as a vegetable in cooking.",
},
{
id: 21,
name: "Onion",
emoji: "🧅",
description:
"A pungent bulb vegetable used as a base in many cuisines worldwide.",
},
];
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
testEnvironment: "jsdom",
moduleNameMapper: {
".(css|less|scss)$": "identity-obj-proxy",
},
};
+54
View File
@@ -0,0 +1,54 @@
{
"name": "@plane/ui-v2",
"version": "1.0.0",
"description": "Revamped UI components built using Radix, shared across multiple apps internally",
"author": "Plane",
"license": "MIT",
"private": true,
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm --dts --external react --minify",
"dev": "tsup src/index.ts --format esm --watch --dts --external react",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"postcss": "postcss styles/globals.css -o styles/output.css --watch",
"lint": "eslint src --ext .ts,.tsx",
"tcss": "tailwindcss -i ./styles/global.css -o ./styles/output.css --watch",
"start": "yarn dev & yarn storybook & yarn tcss",
"test": "vitest",
"test:ui": "vitest --ui"
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.2",
"@storybook/addon-essentials": "^8.4.5",
"@storybook/addon-interactions": "^8.4.5",
"@storybook/addon-onboarding": "^8.4.5",
"@storybook/blocks": "^8.4.5",
"@storybook/react": "^8.4.5",
"@storybook/react-vite": "^8.4.5",
"@storybook/test": "^8.4.5",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.14",
"@vitest/ui": "2.1.8",
"autoprefixer": "^10.4.20",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jsdom": "^25.0.1",
"storybook": "^8.4.5",
"tailwindcss": "^3.4.15",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vite": "^6.0.1",
"vitest": "^2.1.8"
},
"dependencies": {
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-scroll-area": "^1.2.1",
"@radix-ui/react-select": "^2.1.2"
}
}
+9
View File
@@ -0,0 +1,9 @@
## 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.
@@ -0,0 +1,135 @@
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>;
};
@@ -0,0 +1,81 @@
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();
});
});
});
@@ -0,0 +1,62 @@
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>
);
};
@@ -0,0 +1,32 @@
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>
);
};
@@ -0,0 +1,52 @@
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());
});
});
@@ -0,0 +1,128 @@
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>
);
};
@@ -0,0 +1,42 @@
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_);
@@ -0,0 +1,86 @@
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 };
@@ -0,0 +1,62 @@
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>;
};
@@ -0,0 +1,97 @@
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>
);
};
@@ -0,0 +1,3 @@
import { DropdownButton } from "../DropdownMenu/components/DropdownButton";
export const SelectButton = DropdownButton;
@@ -0,0 +1,31 @@
/**
* SelectItem
* @description Takes render function, item, and if its selected as a prop
* @param {Function} render
* @param {Object} item
* @param {Boolean} selected
*/
import { CheckIcon } from "@radix-ui/react-icons";
import React from "react";
const EmptyIcon = () => (
<div style={{ width: "15px", height: "15px" }}>&nbsp;</div>
);
type Props = {
item: any;
selected: boolean;
render: any;
};
const SelectItem_ = ({ render: Item, item, selected }: Props) => {
return (
<div className="flex items-center gap-2 justify-between">
<Item item={item} />
{/* Added empty icon to reserve space */}
{selected ? <CheckIcon /> : <EmptyIcon />}
</div>
);
};
export const SelectItem = React.memo(SelectItem_);
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,63 @@
import{R as e,r as f}from"./index-Cqyox1Tj.js";import{D as i,a as d,b as C,c as b,f as m}from"./fruits-CZbL9byb.js";import"./index-DtqWk5t9.js";import"./index-DGXSSr1l.js";const x=[{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."}],s={fruits:m,vegetables:x},O={title:"Example/DropdownMenu",component:i,parameters:{layout:"centered"},tags:["autodocs"],argTypes:{items:[1,2,3,4]},args:{}},o=()=>e.createElement(i,{defaultOpen:!0},e.createElement(d,null,e.createElement("button",null,"Click me!!")),e.createElement(C,null,e.createElement("div",null,e.createElement("h1",null,"Hello"),e.createElement("p",null,"How are you today?"),e.createElement(b,{onSelect:t=>console.log(t)},"Click me again"),e.createElement(b,{onSelect:t=>t.preventDefault()},"Click me, I won't close")))),r=()=>{const[t,p]=f.useState([m[6]]),[g,k]=f.useState([...s.fruits,...s.vegetables]),A=async n=>{await new Promise(u=>setTimeout(u,1e3));const l=s.fruits.filter(u=>u.name.toLowerCase().includes(n.toLowerCase()));k(l)};return e.createElement(i,{items:g,onSelect:(n,l)=>{n.preventDefault(),console.log(n,l)},renderItem:n=>e.createElement(c,{fruit:n}),defaultOpen:!0,onSearch:A},e.createElement(d,{showIcon:!0},e.createElement("div",{className:"flex items-center gap-2 justify-between"},e.createElement(c,{fruit:m[1]}),"(",t.length,")")),g.length===0&&e.createElement(C,null,e.createElement("div",null,"No items found")))},a=()=>{const t=[{name:"Vegetables",children:s.vegetables,emoji:"🥦"},{name:"Fruits",children:s.fruits,emoji:"🍎"}];return e.createElement("div",null,e.createElement(i,{defaultOpen:!0,items:t,renderItem:p=>e.createElement(c,{fruit:p})},e.createElement(d,null,e.createElement("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"))))},c=({fruit:t})=>e.createElement("div",null,`${t.emoji} ${t.name}`);o.__docgenInfo={description:"",methods:[],displayName:"DefaultDropdown"};r.__docgenInfo={description:"",methods:[],displayName:"MultiSelect"};a.__docgenInfo={description:"",methods:[],displayName:"NestedDropdown"};var w,h,v;o.parameters={...o.parameters,docs:{...(w=o.parameters)==null?void 0:w.docs,source:{originalSource:`() => {
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>;
}`,...(v=(h=o.parameters)==null?void 0:h.docs)==null?void 0:v.source}}};var D,y,E;r.parameters={...r.parameters,docs:{...(D=r.parameters)==null?void 0:D.docs,source:{originalSource:`() => {
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>;
}`,...(E=(y=r.parameters)==null?void 0:y.docs)==null?void 0:E.source}}};var S,I,j;a.parameters={...a.parameters,docs:{...(S=a.parameters)==null?void 0:S.docs,source:{originalSource:`() => {
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>;
}`,...(j=(I=a.parameters)==null?void 0:I.docs)==null?void 0:j.source}}};const _=["DefaultDropdown","MultiSelect","NestedDropdown"];export{o as DefaultDropdown,r as MultiSelect,a as NestedDropdown,_ as __namedExportsOrder,O as default};
@@ -0,0 +1,9 @@
import{f as x}from"./index-B5ZI-g0m.js";import{R as t,r as y}from"./index-Cqyox1Tj.js";import{C as O,D as _,a as b,f as M}from"./fruits-CZbL9byb.js";import"./index-DtqWk5t9.js";import"./index-DGXSSr1l.js";const B=()=>t.createElement("div",{style:{width:"15px",height:"15px"}}," "),l=a=>{const{items:i,onChange:m,renderItem:R,renderGroup:G,multiple:w,value:d=[],keyExtractor:o=e=>e.id,showSelectedAtTop:E=!0,defaultOpen:p=!1,children:v}=a,[n,c]=t.useState(d),[g,C]=t.useState(p),T=(e,u)=>{const r=o(u);w?(e.preventDefault(),e.stopPropagation(),n.includes(r)?c(n.filter(k=>k!==r)):c([...n,r]),m([...n,r])):(c([r]),m([r]))},I=y.useCallback(e=>{const u=o(e),r=n.includes(u);return t.createElement("div",{className:"flex items-center gap-2 justify-between"},t.createElement(R,{fruit:e}),r?t.createElement(O,null):t.createElement(B,null))},[n,o]),N=t.useMemo(()=>E&&g?[...i.filter(e=>n.includes(o(e))),...i.filter(e=>!n.includes(o(e)))]:i,[d,g]),q=y.useCallback(e=>{C(e)},[]);return t.createElement(_,{defaultOpen:p,items:N,renderItem:I,onSelect:T,onOpenChange:q},v)};l.__docgenInfo={description:"",methods:[],displayName:"Select",props:{items:{required:!0,tsType:{name:"Array",elements:[{name:"any"}],raw:"any[]"},description:""},onChange:{required:!0,tsType:{name:"signature",type:"function",raw:"(value: any) => void",signature:{arguments:[{type:{name:"any"},name:"value"}],return:{name:"void"}}},description:""},value:{required:!0,tsType:{name:"any"},description:""},renderItem:{required:!0,tsType:{name:"signature",type:"function",raw:"(item: any) => React.ReactNode",signature:{arguments:[{type:{name:"any"},name:"item"}],return:{name:"ReactReactNode",raw:"React.ReactNode"}}},description:""},renderGroup:{required:!0,tsType:{name:"signature",type:"function",raw:"(group: string) => React.ReactNode",signature:{arguments:[{type:{name:"string"},name:"group"}],return:{name:"ReactReactNode",raw:"React.ReactNode"}}},description:""},multiple:{required:!1,tsType:{name:"boolean"},description:""},keyExtractor:{required:!1,tsType:{name:"signature",type:"function",raw:"(item: any) => string",signature:{arguments:[{type:{name:"any"},name:"item"}],return:{name:"string"}}},description:""},showSelectedAtTop:{required:!1,tsType:{name:"boolean"},description:""},defaultOpen:{required:!1,tsType:{name:"boolean"},description:""},children:{required:!0,tsType:{name:"ReactReactNode",raw:"React.ReactNode"},description:""}}};const D=b,z={title:"Example/SelectMenu",component:l,parameters:{layout:"centered"},tags:["autodocs"],argTypes:{items:[1,2,3,4]},args:{onChange:x()}},s=()=>t.createElement(l,{items:M,renderItem:F,onChange:function(a){console.log("Selected items",a)},value:[3,7,9],renderGroup:function(a){throw new Error("Function not implemented.")},multiple:!0,defaultOpen:!0},t.createElement(D,null,"Click Me!!")),F=({fruit:a})=>t.createElement("div",null,`${a.emoji} ${a.name}`);s.__docgenInfo={description:"",methods:[],displayName:"SingleSelect"};var f,S,h;s.parameters={...s.parameters,docs:{...(f=s.parameters)==null?void 0:f.docs,source:{originalSource:`() => {
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.");
}} multiple defaultOpen>
<SelectButton>Click Me!!</SelectButton>
</Select>;
}`,...(h=(S=s.parameters)==null?void 0:S.docs)==null?void 0:h.source}}};const H=["SingleSelect"];export{s as SingleSelect,H as __namedExportsOrder,z as default};

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