Compare 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
Satish Gandham 678e06eb62 wip 2024-12-09 12:03:31 +05:30
Satish Gandham 4a7e67dc3d wip 2024-12-06 16:51:09 +05:30
Satish Gandham 526b7061ef Setup unit testing 2024-12-05 18:03:27 +05:30
Satish Gandham 12a499a95a - Add dropdown component
- Setup some tailwind styles
2024-12-04 11:12:51 +05:30
Satish Gandham 171b2c5022 New component library setup with storybook 2024-11-29 16:18:18 +05:30
319 changed files with 72658 additions and 5937 deletions
+3 -3
View File
@@ -44,7 +44,7 @@ const InstanceGithubAuthenticationPage = observer(() => {
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `GitHub authentication is now ${value ? "active" : "disabled"}.`,
message: () => `Github authentication is now ${value ? "active" : "disabled"}.`,
},
error: {
title: "Error",
@@ -67,8 +67,8 @@ const InstanceGithubAuthenticationPage = observer(() => {
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<AuthenticationMethodCard
name="GitHub"
description="Allow members to login or sign up to plane with their GitHub accounts."
name="Github"
description="Allow members to login or sign up to plane with their Github accounts."
icon={
<Image
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
+1 -1
View File
@@ -30,7 +30,7 @@ export const InstanceHeader: FC = observer(() => {
case "google":
return "Google";
case "github":
return "GitHub";
return "Github";
case "gitlab":
return "GitLab";
case "workspace":
+1 -1
View File
@@ -1,3 +1,3 @@
web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
worker: celery -A plane worker -O fair --prefetch-multiplier=1 --max-tasks-per-child=100 --max-memory-per-child=150000 -l INFO
worker: celery -A plane worker -l info
beat: celery -A plane beat -l INFO
+1 -13
View File
@@ -4,7 +4,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.db.models import Cycle, CycleIssue
from plane.utils.timezone_converter import convert_to_utc
class CycleSerializer(BaseSerializer):
total_issues = serializers.IntegerField(read_only=True)
@@ -24,18 +24,6 @@ class CycleSerializer(BaseSerializer):
and data.get("start_date", None) > data.get("end_date", None)
):
raise serializers.ValidationError("Start date cannot exceed end date")
if (
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
):
project_id = self.initial_data.get("project_id") or self.instance.project_id
data["start_date"] = convert_to_utc(
str(data.get("start_date").date()), project_id, is_start_date=True
)
data["end_date"] = convert_to_utc(
str(data.get("end_date", None).date()), project_id
)
return data
class Meta:
-12
View File
@@ -5,7 +5,6 @@ from rest_framework import serializers
from .base import BaseSerializer
from .issue import IssueStateSerializer
from plane.db.models import Cycle, CycleIssue, CycleUserProperties
from plane.utils.timezone_converter import convert_to_utc
class CycleWriteSerializer(BaseSerializer):
@@ -16,17 +15,6 @@ class CycleWriteSerializer(BaseSerializer):
and data.get("start_date", None) > data.get("end_date", None)
):
raise serializers.ValidationError("Start date cannot exceed end date")
if (
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
):
project_id = self.initial_data.get("project_id") or self.instance.project_id
data["start_date"] = convert_to_utc(
str(data.get("start_date").date()), project_id, is_start_date=True
)
data["end_date"] = convert_to_utc(
str(data.get("end_date", None).date()), project_id
)
return data
class Meta:
+5 -49
View File
@@ -1,7 +1,5 @@
# Python imports
import json
import pytz
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
@@ -54,11 +52,6 @@ from plane.bgtasks.recent_visited_task import recent_visited_task
# Module imports
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.webhook_task import model_activity
from plane.utils.timezone_converter import (
convert_utc_to_project_timezone,
convert_to_utc,
user_timezone_converter,
)
class CycleViewSet(BaseViewSet):
@@ -74,19 +67,6 @@ class CycleViewSet(BaseViewSet):
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
project = Project.objects.get(id=self.kwargs.get("project_id"))
# Fetch project for the specific record or pass project_id dynamically
project_timezone = project.timezone
# Convert the current time (timezone.now()) to the project's timezone
local_tz = pytz.timezone(project_timezone)
current_time_in_project_tz = timezone.now().astimezone(local_tz)
# Convert project local time back to UTC for comparison (start_date is stored in UTC)
current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc)
return self.filter_queryset(
super()
.get_queryset()
@@ -139,15 +119,12 @@ class CycleViewSet(BaseViewSet):
.annotate(
status=Case(
When(
Q(start_date__lte=current_time_in_utc)
& Q(end_date__gte=current_time_in_utc),
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=current_time_in_utc,
then=Value("UPCOMING"),
),
When(end_date__lt=current_time_in_utc, then=Value("COMPLETED")),
When(start_date__gt=timezone.now(), then=Value("UPCOMING")),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
@@ -183,22 +160,10 @@ class CycleViewSet(BaseViewSet):
# Update the order by
queryset = queryset.order_by("-is_favorite", "-created_at")
project = Project.objects.get(id=self.kwargs.get("project_id"))
# Fetch project for the specific record or pass project_id dynamically
project_timezone = project.timezone
# Convert the current time (timezone.now()) to the project's timezone
local_tz = pytz.timezone(project_timezone)
current_time_in_project_tz = timezone.now().astimezone(local_tz)
# Convert project local time back to UTC for comparison (start_date is stored in UTC)
current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc)
# Current Cycle
if cycle_view == "current":
queryset = queryset.filter(
start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc
start_date__lte=timezone.now(), end_date__gte=timezone.now()
)
data = queryset.values(
@@ -226,8 +191,6 @@ class CycleViewSet(BaseViewSet):
"version",
"created_by",
)
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(data, datetime_fields, project_timezone)
if data:
return Response(data, status=status.HTTP_200_OK)
@@ -258,8 +221,6 @@ class CycleViewSet(BaseViewSet):
"version",
"created_by",
)
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@@ -456,8 +417,6 @@ class CycleViewSet(BaseViewSet):
)
queryset = queryset.first()
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
recent_visited_task.delay(
slug=slug,
@@ -533,9 +492,6 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
start_date = convert_to_utc(str(start_date), project_id, is_start_date=True)
end_date = convert_to_utc(str(end_date), project_id)
# Check if any cycle intersects in the given interval
cycles = Cycle.objects.filter(
Q(workspace__slug=slug)
+1 -15
View File
@@ -54,11 +54,10 @@ from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from .. import BaseAPIView, BaseViewSet
from plane.utils.timezone_converter import user_timezone_converter
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.global_paginator import paginate
from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.issue_description_version_task import issue_description_version_task
class IssueListEndpoint(BaseAPIView):
@@ -429,13 +428,6 @@ class IssueViewSet(BaseViewSet):
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=json.dumps(request.data, cls=DjangoJSONEncoder),
issue_id=str(serializer.data["id"]),
user_id=request.user.id,
is_creating=True,
)
return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -657,12 +649,6 @@ class IssueViewSet(BaseViewSet):
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=current_instance,
issue_id=str(serializer.data.get("id", None)),
user_id=request.user.id,
)
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+1 -1
View File
@@ -20,7 +20,7 @@ from plane.app.serializers import IssueSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.timezone_converter import user_timezone_converter
from plane.utils.user_timezone_converter import user_timezone_converter
from collections import defaultdict
+1 -1
View File
@@ -28,7 +28,7 @@ from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import ModuleDetailSerializer
from plane.db.models import Issue, Module, ModuleLink, UserFavorite, Project
from plane.utils.analytics_plot import burndown_plot
from plane.utils.timezone_converter import user_timezone_converter
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports
+1 -1
View File
@@ -56,7 +56,7 @@ from plane.db.models import (
Project,
)
from plane.utils.analytics_plot import burndown_plot
from plane.utils.timezone_converter import user_timezone_converter
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.bgtasks.webhook_task import model_activity
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.recent_visited_task import recent_visited_task
+1 -1
View File
@@ -10,7 +10,7 @@ from plane.app.views.base import BaseAPIView
from plane.db.models import Cycle
from plane.app.permissions import WorkspaceViewerPermission
from plane.app.serializers.cycle import CycleSerializer
from plane.utils.timezone_converter import user_timezone_converter
class WorkspaceCyclesEndpoint(BaseAPIView):
permission_classes = [WorkspaceViewerPermission]
+3 -11
View File
@@ -1,18 +1,10 @@
# Python imports
from datetime import timedelta
# Django imports
from django.utils import timezone
from django.conf import settings
# Third party imports
from datetime import timedelta
from plane.db.models import APIActivityLog
from celery import shared_task
# Module imports
from plane.db.models import APIActivityLog
@shared_task(queue=settings.TASK_SCHEDULER_QUEUE)
@shared_task
def delete_api_logs():
# Get the logs older than 30 days to delete
logs_to_delete = APIActivityLog.objects.filter(
+2 -2
View File
@@ -10,7 +10,7 @@ from django.db.models.fields.related import OneToOneRel
from celery import shared_task
@shared_task(queue=settings.TASK_DEFAULT_QUEUE)
@shared_task
def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
"""
Soft delete related objects for a given model instance
@@ -111,7 +111,7 @@ def restore_related_objects(app_label, model_name, instance_pk, using=None):
pass
@shared_task(queue=settings.TASK_SCHEDULER_QUEUE)
@shared_task
def hard_delete():
from plane.db.models import (
Workspace,
+1 -2
View File
@@ -5,7 +5,6 @@ from datetime import datetime, timedelta
# Django imports
from django.db.models import Max
from django.conf import settings
# Third party imports
from celery import shared_task
@@ -532,7 +531,7 @@ def create_module_issues(workspace, project, user_id, issue_count):
)
@shared_task(queue=settings.TASK_LOW_QUEUE)
@shared_task
def create_dummy_data(
slug,
email,
@@ -2,16 +2,16 @@ import logging
import re
from datetime import datetime
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.html import strip_tags
from django.conf import settings
from bs4 import BeautifulSoup
# Third party imports
from bs4 import BeautifulSoup
from celery import shared_task
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
# Django imports
from django.utils import timezone
from django.utils.html import strip_tags
# Module imports
from plane.db.models import EmailNotificationLog, Issue, User
@@ -168,7 +168,7 @@ def process_html_content(content):
return processed_content_list
@shared_task(queue=settings.TASK_NOTIFICATION_QUEUE)
@shared_task
def send_email_notification(
issue_id, notification_data, receiver_id, email_notification_ids
):
@@ -1,10 +1,6 @@
# Python imports
import os
import uuid
# Django imports
from django.conf import settings
# third party imports
from celery import shared_task
from posthog import Posthog
@@ -53,7 +49,7 @@ def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
return
@shared_task(queue=settings.TASK_LOW_QUEUE)
@shared_task
def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from):
try:
POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()
+3 -4
View File
@@ -7,13 +7,12 @@ import zipfile
import boto3
from botocore.client import Config
# Third party imports
from celery import shared_task
# Django imports
from django.conf import settings
from django.utils import timezone
# Third party imports
from celery import shared_task
from openpyxl import Workbook
# Module imports
@@ -300,7 +299,7 @@ def generate_xlsx(header, project_id, issues, files):
files.append((f"{project_id}.xlsx", xlsx_file))
@shared_task(queue=settings.TASK_LOW_QUEUE)
@shared_task
def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug):
try:
exporter_instance = ExporterHistory.objects.get(token=token_id)
@@ -15,7 +15,7 @@ from botocore.client import Config
from plane.db.models import ExporterHistory
@shared_task(queue=settings.TASK_SCHEDULER_QUEUE)
@shared_task
def delete_old_s3_link():
# Get a list of keys and IDs to process
expired_exporter_history = ExporterHistory.objects.filter(
+1 -2
View File
@@ -5,7 +5,6 @@ from datetime import timedelta
# Django imports
from django.utils import timezone
from django.db.models import Q
from django.conf import settings
# Third party imports
from celery import shared_task
@@ -14,7 +13,7 @@ from celery import shared_task
from plane.db.models import FileAsset
@shared_task(queue=settings.TASK_HIGH_QUEUE)
@shared_task
def delete_unuploaded_file_asset():
"""This task deletes unuploaded file assets older than a certain number of days."""
FileAsset.objects.filter(
@@ -1,22 +1,21 @@
# Python imports
import logging
# Third party imports
from celery import shared_task
# Django imports
# Third party imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
# Module imports
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@shared_task(queue=settings.TASK_HIGH_QUEUE)
@shared_task
def forgot_password(first_name, email, uidb64, token, current_site):
try:
relative_link = (
@@ -2,17 +2,17 @@
import json
# Django imports
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from django.conf import settings
# Third Party imports
from celery import shared_task
# Module imports
# Django imports
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
# Module imports
from plane.db.models import (
CommentReaction,
Cycle,
@@ -1548,7 +1548,7 @@ def create_intake_activity(
# Receive message from room group
@shared_task(queue=settings.TASK_HIGH_QUEUE)
@shared_task
def issue_activity(
type,
requested_data,
@@ -2,13 +2,12 @@
import json
from datetime import timedelta
# Django imports
from django.db.models import Q
from django.utils import timezone
from django.conf import settings
# Third party imports
from celery import shared_task
from django.db.models import Q
# Django imports
from django.utils import timezone
# Module imports
from plane.bgtasks.issue_activities_task import issue_activity
@@ -16,7 +15,7 @@ from plane.db.models import Issue, Project, State
from plane.utils.exception_logger import log_exception
@shared_task(queue=settings.TASK_SCHEDULER_QUEUE)
@shared_task
def archive_and_close_old_issues():
archive_old_issues()
close_old_issues()
@@ -1,125 +0,0 @@
# Python imports
from typing import Optional
import logging
# Django imports
from django.utils import timezone
from django.db import transaction
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import Issue, IssueDescriptionVersion, ProjectMember
from plane.utils.exception_logger import log_exception
def get_owner_id(issue: Issue) -> Optional[int]:
"""Get the owner ID of the issue"""
if issue.updated_by_id:
return issue.updated_by_id
if issue.created_by_id:
return issue.created_by_id
# Find project admin as fallback
project_member = ProjectMember.objects.filter(
project_id=issue.project_id,
role=20, # Admin role
).first()
return project_member.member_id if project_member else None
@shared_task
def sync_issue_description_version(batch_size=5000, offset=0, countdown=300):
"""Task to create IssueDescriptionVersion records for existing Issues in batches"""
try:
with transaction.atomic():
base_query = Issue.objects
total_issues_count = base_query.count()
if total_issues_count == 0:
return
# Calculate batch range
end_offset = min(offset + batch_size, total_issues_count)
# Fetch issues with related data
issues_batch = (
base_query.order_by("created_at")
.select_related("workspace", "project")
.only(
"id",
"workspace_id",
"project_id",
"created_by_id",
"updated_by_id",
"description_binary",
"description_html",
"description_stripped",
"description",
)[offset:end_offset]
)
if not issues_batch:
return
version_objects = []
for issue in issues_batch:
# Validate required fields
if not issue.workspace_id or not issue.project_id:
logging.warning(
f"Skipping {issue.id} - missing workspace_id or project_id"
)
continue
# Determine owned_by_id
owned_by_id = get_owner_id(issue)
if owned_by_id is None:
logging.warning(f"Skipping issue {issue.id} - missing owned_by")
continue
# Create version object
version_objects.append(
IssueDescriptionVersion(
workspace_id=issue.workspace_id,
project_id=issue.project_id,
created_by_id=issue.created_by_id,
updated_by_id=issue.updated_by_id,
owned_by_id=owned_by_id,
last_saved_at=timezone.now(),
issue_id=issue.id,
description_binary=issue.description_binary,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_json=issue.description,
)
)
# Bulk create version objects
if version_objects:
IssueDescriptionVersion.objects.bulk_create(version_objects)
# Schedule next batch if needed
if end_offset < total_issues_count:
sync_issue_description_version.apply_async(
kwargs={
"batch_size": batch_size,
"offset": end_offset,
"countdown": countdown,
},
countdown=countdown,
)
return
except Exception as e:
log_exception(e)
return
@shared_task
def schedule_issue_description_version(batch_size=5000, countdown=300):
sync_issue_description_version.delay(
batch_size=int(batch_size), countdown=countdown
)
@@ -1,91 +0,0 @@
# Python imports
from typing import Optional, Dict
import json
# Django imports
from django.db import transaction
from django.utils import timezone
from django.conf import settings
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import Issue, IssueDescriptionVersion
from plane.utils.exception_logger import log_exception
def should_update_existing_version(
version: IssueDescriptionVersion, user_id: str, max_time_difference: int = 600
) -> bool:
if not version:
return
time_difference = (timezone.now() - version.last_saved_at).total_seconds()
return (
str(version.owned_by_id) == str(user_id)
and time_difference <= max_time_difference
)
def update_existing_version(version: IssueDescriptionVersion, issue) -> None:
version.description_json = issue.description
version.description_html = issue.description_html
version.description_binary = issue.description_binary
version.description_stripped = issue.description_stripped
version.last_saved_at = timezone.now()
version.save(
update_fields=[
"description_json",
"description_html",
"description_binary",
"description_stripped",
"last_saved_at",
]
)
@shared_task(queue=settings.TASK_HIGH_QUEUE)
def issue_description_version_task(
updated_issue, issue_id, user_id, is_creating=False
) -> Optional[bool]:
try:
# Parse updated issue data
current_issue: Dict = json.loads(updated_issue) if updated_issue else {}
# Get current issue
issue = Issue.objects.get(id=issue_id)
# Check if description has changed
if (
current_issue.get("description_html") == issue.description_html
and not is_creating
):
return
with transaction.atomic():
# Get latest version
latest_version = (
IssueDescriptionVersion.objects.filter(issue_id=issue_id)
.order_by("-last_saved_at")
.first()
)
# Determine whether to update existing or create new version
if should_update_existing_version(version=latest_version, user_id=user_id):
update_existing_version(latest_version, issue)
else:
IssueDescriptionVersion.log_issue_description_version(issue, user_id)
return
except Issue.DoesNotExist:
# Issue no longer exists, skip processing
return
except json.JSONDecodeError as e:
log_exception(f"Invalid JSON for updated_issue: {e}")
return
except Exception as e:
log_exception(f"Error processing issue description version: {e}")
return
@@ -1,254 +0,0 @@
# Python imports
import json
from typing import Optional, List, Dict
from uuid import UUID
from itertools import groupby
import logging
# Django imports
from django.utils import timezone
from django.db import transaction
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import (
Issue,
IssueVersion,
ProjectMember,
CycleIssue,
ModuleIssue,
IssueActivity,
IssueAssignee,
IssueLabel,
)
from plane.utils.exception_logger import log_exception
@shared_task
def issue_task(updated_issue, issue_id, user_id):
try:
current_issue = json.loads(updated_issue) if updated_issue else {}
issue = Issue.objects.get(id=issue_id)
updated_current_issue = {}
for key, value in current_issue.items():
if getattr(issue, key) != value:
updated_current_issue[key] = value
if updated_current_issue:
issue_version = (
IssueVersion.objects.filter(issue_id=issue_id)
.order_by("-last_saved_at")
.first()
)
if (
issue_version
and str(issue_version.owned_by) == str(user_id)
and (timezone.now() - issue_version.last_saved_at).total_seconds()
<= 600
):
for key, value in updated_current_issue.items():
setattr(issue_version, key, value)
issue_version.last_saved_at = timezone.now()
issue_version.save(
update_fields=list(updated_current_issue.keys()) + ["last_saved_at"]
)
else:
IssueVersion.log_issue_version(issue, user_id)
return
except Issue.DoesNotExist:
return
except Exception as e:
log_exception(e)
return
def get_owner_id(issue: Issue) -> Optional[int]:
"""Get the owner ID of the issue"""
if issue.updated_by_id:
return issue.updated_by_id
if issue.created_by_id:
return issue.created_by_id
# Find project admin as fallback
project_member = ProjectMember.objects.filter(
project_id=issue.project_id,
role=20, # Admin role
).first()
return project_member.member_id if project_member else None
def get_related_data(issue_ids: List[UUID]) -> Dict:
"""Get related data for the given issue IDs"""
cycle_issues = {
ci.issue_id: ci.cycle_id
for ci in CycleIssue.objects.filter(issue_id__in=issue_ids)
}
# Get assignees with proper grouping
assignee_records = list(
IssueAssignee.objects.filter(issue_id__in=issue_ids)
.values_list("issue_id", "assignee_id")
.order_by("issue_id")
)
assignees = {}
for issue_id, group in groupby(assignee_records, key=lambda x: x[0]):
assignees[issue_id] = [str(g[1]) for g in group]
# Get labels with proper grouping
label_records = list(
IssueLabel.objects.filter(issue_id__in=issue_ids)
.values_list("issue_id", "label_id")
.order_by("issue_id")
)
labels = {}
for issue_id, group in groupby(label_records, key=lambda x: x[0]):
labels[issue_id] = [str(g[1]) for g in group]
# Get modules with proper grouping
module_records = list(
ModuleIssue.objects.filter(issue_id__in=issue_ids)
.values_list("issue_id", "module_id")
.order_by("issue_id")
)
modules = {}
for issue_id, group in groupby(module_records, key=lambda x: x[0]):
modules[issue_id] = [str(g[1]) for g in group]
# Get latest activities
latest_activities = {}
activities = IssueActivity.objects.filter(issue_id__in=issue_ids).order_by(
"issue_id", "-created_at"
)
for issue_id, activities_group in groupby(activities, key=lambda x: x.issue_id):
first_activity = next(activities_group, None)
if first_activity:
latest_activities[issue_id] = first_activity.id
return {
"cycle_issues": cycle_issues,
"assignees": assignees,
"labels": labels,
"modules": modules,
"activities": latest_activities,
}
def create_issue_version(issue: Issue, related_data: Dict) -> Optional[IssueVersion]:
"""Create IssueVersion object from the given issue and related data"""
try:
if not issue.workspace_id or not issue.project_id:
logging.warning(
f"Skipping issue {issue.id} - missing workspace_id or project_id"
)
return None
owned_by_id = get_owner_id(issue)
if owned_by_id is None:
logging.warning(f"Skipping issue {issue.id} - missing owned_by")
return None
return IssueVersion(
workspace_id=issue.workspace_id,
project_id=issue.project_id,
created_by_id=issue.created_by_id,
updated_by_id=issue.updated_by_id,
owned_by_id=owned_by_id,
last_saved_at=timezone.now(),
activity_id=related_data["activities"].get(issue.id),
properties=getattr(issue, "properties", {}),
meta=getattr(issue, "meta", {}),
issue_id=issue.id,
parent=issue.parent_id,
state=issue.state_id,
estimate_point=issue.estimate_point_id,
name=issue.name,
priority=issue.priority,
start_date=issue.start_date,
target_date=issue.target_date,
assignees=related_data["assignees"].get(issue.id, []),
sequence_id=issue.sequence_id,
labels=related_data["labels"].get(issue.id, []),
sort_order=issue.sort_order,
completed_at=issue.completed_at,
archived_at=issue.archived_at,
is_draft=issue.is_draft,
external_source=issue.external_source,
external_id=issue.external_id,
type=issue.type_id,
cycle=related_data["cycle_issues"].get(issue.id),
modules=related_data["modules"].get(issue.id, []),
)
except Exception as e:
log_exception(e)
return None
@shared_task
def sync_issue_version(batch_size=5000, offset=0, countdown=300):
"""Task to create IssueVersion records for existing Issues in batches"""
try:
with transaction.atomic():
base_query = Issue.objects
total_issues_count = base_query.count()
if total_issues_count == 0:
return
end_offset = min(offset + batch_size, total_issues_count)
# Get issues batch with optimized queries
issues_batch = list(
base_query.order_by("created_at")
.select_related("workspace", "project")
.all()[offset:end_offset]
)
if not issues_batch:
return
# Get all related data in bulk
issue_ids = [issue.id for issue in issues_batch]
related_data = get_related_data(issue_ids)
issue_versions = []
for issue in issues_batch:
version = create_issue_version(issue, related_data)
if version:
issue_versions.append(version)
# Bulk create versions
if issue_versions:
IssueVersion.objects.bulk_create(issue_versions, batch_size=1000)
# Schedule the next batch if there are more workspaces to process
if end_offset < total_issues_count:
sync_issue_version.apply_async(
kwargs={
"batch_size": batch_size,
"offset": end_offset,
"countdown": countdown,
},
countdown=countdown,
)
logging.info(f"Processed Issues: {end_offset}")
return
except Exception as e:
log_exception(e)
return
@shared_task
def schedule_issue_version(batch_size=5000, countdown=300):
sync_issue_version.delay(batch_size=int(batch_size), countdown=countdown)
@@ -1,21 +1,21 @@
# Python imports
import logging
# Third party imports
from celery import shared_task
# Django imports
# Third party imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
# Module imports
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@shared_task(queue=settings.TASK_NOTIFICATION_QUEUE)
@shared_task
def magic_link(email, key, token, current_site):
try:
(
+1 -4
View File
@@ -3,9 +3,6 @@ import json
import uuid
from uuid import UUID
# Django imports
from django.conf import settings
# Module imports
from plane.db.models import (
@@ -207,7 +204,7 @@ def create_mention_notification(
)
@shared_task(queue=settings.TASK_NOTIFICATION_QUEUE)
@shared_task
def notifications(
type,
issue_id,
@@ -3,14 +3,13 @@ import json
# Django imports
from django.utils import timezone
from django.conf import settings
# Third-party imports
from bs4 import BeautifulSoup
from celery import shared_task
# Module imports
from plane.db.models import Page, PageLog
from celery import shared_task
from plane.utils.exception_logger import log_exception
@@ -34,7 +33,7 @@ def extract_components(value, tag):
return []
@shared_task(queue=settings.TASK_LOW_QUEUE)
@shared_task
def page_transaction(new_value, old_value, page_id):
try:
page = Page.objects.get(pk=page_id)
+1 -4
View File
@@ -4,15 +4,12 @@ import json
# Third party imports
from celery import shared_task
# Django imports
from django.conf import settings
# Module imports
from plane.db.models import Page, PageVersion
from plane.utils.exception_logger import log_exception
@shared_task(queue=settings.TASK_DEFAULT_QUEUE)
@shared_task
def page_version(page_id, existing_instance, user_id):
try:
# Get the page
@@ -1,15 +1,13 @@
# Python imports
import logging
# Third party imports
from celery import shared_task
# Django imports
# Third party imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
# Module imports
@@ -19,7 +17,7 @@ from plane.db.models import ProjectMember
from plane.db.models import User
@shared_task(queue=settings.TASK_DEFAULT_QUEUE)
@shared_task
def project_add_user_email(current_site, project_member_id, invitor_id):
try:
# Get the invitor
@@ -1,15 +1,14 @@
# Python imports
import logging
# Third party imports
from celery import shared_task
# Django imports
# Third party imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import Project, ProjectMemberInvite, User
@@ -17,7 +16,7 @@ from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@shared_task(queue=settings.TASK_DEFAULT_QUEUE)
@shared_task
def project_invitation(email, project_id, token, current_site, invitor):
try:
user = User.objects.get(email=invitor)
+1 -13
View File
@@ -1,6 +1,5 @@
# Python imports
from django.utils import timezone
from django.conf import settings
# Third party imports
from celery import shared_task
@@ -8,18 +7,11 @@ from celery import shared_task
# Module imports
from plane.db.models import UserRecentVisit, Workspace
from plane.utils.exception_logger import log_exception
from plane.settings.redis import redis_instance
@shared_task(queue=settings.TASK_LOW_QUEUE)
@shared_task
def recent_visited_task(entity_name, entity_identifier, user_id, project_id, slug):
try:
ri = redis_instance()
# Check if the same entity is set in redis for the user
if ri.exists(f"recent_visited:{user_id}:{entity_name}:{entity_identifier}"):
return
# Check if the same entity is set in redis for the workspace
workspace = Workspace.objects.get(slug=slug)
recent_visited = UserRecentVisit.objects.filter(
entity_name=entity_name,
@@ -58,10 +50,6 @@ def recent_visited_task(entity_name, entity_identifier, user_id, project_id, slu
recent_activity.updated_by_id = user_id
recent_activity.save(update_fields=["created_by_id", "updated_by_id"])
# Set in redis
ri.set(
f"recent_visited:{user_id}:{entity_name}:{entity_identifier}", 1, ex=60 * 10
)
return
except Exception as e:
log_exception(e)
@@ -1,6 +1,3 @@
# Django imports
from django.conf import settings
# Third party imports
from celery import shared_task
@@ -10,7 +7,7 @@ from plane.settings.storage import S3Storage
from plane.utils.exception_logger import log_exception
@shared_task(queue=settings.TASK_DEFAULT_QUEUE)
@shared_task
def get_asset_object_metadata(asset_id):
try:
# Get the asset
@@ -5,7 +5,6 @@ import logging
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
@@ -16,7 +15,7 @@ from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@shared_task(queue=settings.TASK_HIGH_QUEUE)
@shared_task
def user_activation_email(current_site, user_id):
try:
# Send email to user when account is activated
@@ -5,7 +5,6 @@ import logging
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
@@ -16,7 +15,7 @@ from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@shared_task(queue=settings.TASK_DEFAULT_QUEUE)
@shared_task
def user_deactivation_email(current_site, user_id):
try:
# Send email to user when account is deactivated
-1
View File
@@ -86,7 +86,6 @@ def get_model_data(event, event_id, many=False):
retry_backoff=600,
max_retries=5,
retry_jitter=True,
queue=settings.TASK_NOTIFICATION_QUEUE,
)
def webhook_task(self, webhook, slug, event, event_data, action, current_site):
try:
@@ -8,7 +8,6 @@ from celery import shared_task
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Module imports
from plane.db.models import User, Workspace, WorkspaceMemberInvite
@@ -16,7 +15,7 @@ from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@shared_task(queue=settings.TASK_DEFAULT_QUEUE)
@shared_task
def workspace_invitation(email, workspace_id, token, current_site, invitor):
try:
user = User.objects.get(email=invitor)
-33
View File
@@ -14,39 +14,6 @@ app = Celery("plane")
# pickle the object when using Windows.
app.config_from_object("django.conf:settings", namespace="CELERY")
# Add additional configurations
CELERY_CONFIGURATIONS = {
"worker_prefetch_multiplier": int(os.environ.get("WORKER_PREFETCH_MULTIPLIER", 1)),
"worker_max_tasks_per_child": int(
os.environ.get("WORKER_MAX_TASKS_PER_CHILD", 100)
),
"worker_max_memory_per_child": int(
os.environ.get("WORKER_MAX_MEMORY_PER_CHILD", 150000)
),
"task_time_limit": int(os.environ.get("TASK_TIME_LIMIT", 3600)), # Hard time limit
"task_soft_time_limit": int(
os.environ.get("TASK_SOFT_TIME_LIMIT", 1800)
), # Soft time limit (30 minutes)
"worker_send_task_events": bool(
os.environ.get("WORKER_SEND_TASK_EVENTS", "0") == "1"
),
"task_ignore_result": bool(
os.environ.get("TASK_IGNORE_RESULT", "1") == "1"
), # Ignore results unless explicitly needed
"task_store_errors_even_if_ignored": bool(
os.environ.get("TASK_STORE_ERRORS_EVEN_IF_IGNORED", "1") == "1"
), # Store errors even if results are ignored
"task_acks_late": bool(
os.environ.get("TASK_ACKS_LATE", "1") == "1"
), # Acknowledge tasks after completion
"task_reject_on_worker_lost": bool(
os.environ.get("TASK_REJECT_ON_WORKER_LOST", "1") == "1"
), # Reject tasks if worker is lost
}
app.conf.update(**CELERY_CONFIGURATIONS)
app.conf.beat_schedule = {
# Executes every day at 12 AM
"check-every-day-to-archive-and-close": {
@@ -1,23 +0,0 @@
# Django imports
from django.core.management.base import BaseCommand
# Module imports
from plane.bgtasks.issue_description_version_sync import (
schedule_issue_description_version,
)
class Command(BaseCommand):
help = "Creates IssueDescriptionVersion records for existing Issues in batches"
def handle(self, *args, **options):
batch_size = input("Enter the batch size: ")
batch_countdown = input("Enter the batch countdown: ")
schedule_issue_description_version.delay(
batch_size=batch_size, countdown=int(batch_countdown)
)
self.stdout.write(
self.style.SUCCESS("Successfully created issue description version task")
)
@@ -1,19 +0,0 @@
# Django imports
from django.core.management.base import BaseCommand
# Module imports
from plane.bgtasks.issue_version_sync import schedule_issue_version
class Command(BaseCommand):
help = "Creates IssueVersion records for existing Issues in batches"
def handle(self, *args, **options):
batch_size = input("Enter the batch size: ")
batch_countdown = input("Enter the batch countdown: ")
schedule_issue_version.delay(
batch_size=batch_size, countdown=int(batch_countdown)
)
self.stdout.write(self.style.SUCCESS("Successfully created issue version task"))
@@ -1,117 +0,0 @@
# Generated by Django 4.2.17 on 2024-12-13 10:09
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import plane.db.models.user
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0086_issueversion_alter_teampage_unique_together_and_more'),
]
operations = [
migrations.RemoveField(
model_name='issueversion',
name='description',
),
migrations.RemoveField(
model_name='issueversion',
name='description_binary',
),
migrations.RemoveField(
model_name='issueversion',
name='description_html',
),
migrations.RemoveField(
model_name='issueversion',
name='description_stripped',
),
migrations.AddField(
model_name='issueversion',
name='activity',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='versions', to='db.issueactivity'),
),
migrations.AddField(
model_name='profile',
name='is_mobile_onboarded',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='profile',
name='mobile_onboarding_step',
field=models.JSONField(default=plane.db.models.user.get_mobile_default_onboarding),
),
migrations.AddField(
model_name='profile',
name='mobile_timezone_auto_set',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='profile',
name='language',
field=models.CharField(default='en', max_length=255),
),
migrations.AlterField(
model_name='issueversion',
name='owned_by',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_versions', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='Sticky',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.TextField()),
('description', models.JSONField(blank=True, default=dict)),
('description_html', models.TextField(blank=True, default='<p></p>')),
('description_stripped', models.TextField(blank=True, null=True)),
('description_binary', models.BinaryField(null=True)),
('logo_props', models.JSONField(default=dict)),
('color', models.CharField(blank=True, max_length=255, null=True)),
('background_color', models.CharField(blank=True, max_length=255, null=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stickies', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stickies', to='db.workspace')),
],
options={
'verbose_name': 'Sticky',
'verbose_name_plural': 'Stickies',
'db_table': 'stickies',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='IssueDescriptionVersion',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('description_binary', models.BinaryField(null=True)),
('description_html', models.TextField(blank=True, default='<p></p>')),
('description_stripped', models.TextField(blank=True, null=True)),
('description_json', models.JSONField(blank=True, default=dict)),
('last_saved_at', models.DateTimeField(default=django.utils.timezone.now)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='description_versions', to='db.issue')),
('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_description_versions', to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Issue Description Version',
'verbose_name_plural': 'Issue Description Versions',
'db_table': 'issue_description_versions',
},
),
]
+9 -4
View File
@@ -41,8 +41,6 @@ from .issue import (
IssueSequence,
IssueSubscriber,
IssueVote,
IssueVersion,
IssueDescriptionVersion,
)
from .module import Module, ModuleIssue, ModuleLink, ModuleMember, ModuleUserProperties
from .notification import EmailNotificationLog, Notification, UserNotificationPreference
@@ -70,6 +68,15 @@ from .workspace import (
WorkspaceUserProperties,
)
from .favorite import UserFavorite
from .issue_type import IssueType
@@ -79,5 +86,3 @@ from .recent_visit import UserRecentVisit
from .label import Label
from .device import Device, DeviceSession
from .sticky import Sticky
+27 -90
View File
@@ -15,7 +15,6 @@ from django import apps
from plane.utils.html_processor import strip_tags
from plane.db.mixins import SoftDeletionManager
from plane.utils.exception_logger import log_exception
from .base import BaseModel
from .project import ProjectBaseModel
@@ -661,6 +660,9 @@ class IssueVote(ProjectBaseModel):
class IssueVersion(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="versions"
)
PRIORITY_CHOICES = (
("urgent", "Urgent"),
("high", "High"),
@@ -668,11 +670,14 @@ class IssueVersion(ProjectBaseModel):
("low", "Low"),
("none", "None"),
)
parent = models.UUIDField(blank=True, null=True)
state = models.UUIDField(blank=True, null=True)
estimate_point = models.UUIDField(blank=True, null=True)
name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True)
priority = models.CharField(
max_length=30,
choices=PRIORITY_CHOICES,
@@ -681,9 +686,7 @@ class IssueVersion(ProjectBaseModel):
)
start_date = models.DateField(null=True, blank=True)
target_date = models.DateField(null=True, blank=True)
assignees = ArrayField(models.UUIDField(), blank=True, default=list)
sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID")
labels = ArrayField(models.UUIDField(), blank=True, default=list)
sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
archived_at = models.DateField(null=True)
@@ -691,26 +694,14 @@ class IssueVersion(ProjectBaseModel):
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
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)
properties = models.JSONField(default=dict) # issue properties
meta = models.JSONField(default=dict) # issue meta
last_saved_at = models.DateTimeField(default=timezone.now)
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="versions"
)
activity = models.ForeignKey(
"db.IssueActivity",
on_delete=models.SET_NULL,
null=True,
related_name="versions",
)
owned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_versions",
)
properties = models.JSONField(default=dict)
meta = models.JSONField(default=dict)
class Meta:
verbose_name = "Issue Version"
@@ -730,93 +721,39 @@ class IssueVersion(ProjectBaseModel):
Module = apps.get_model("db.Module")
CycleIssue = apps.get_model("db.CycleIssue")
IssueAssignee = apps.get_model("db.IssueAssignee")
IssueLabel = apps.get_model("db.IssueLabel")
cycle_issue = CycleIssue.objects.filter(issue=issue).first()
cls.objects.create(
issue=issue,
parent=issue.parent_id,
state=issue.state_id,
estimate_point=issue.estimate_point_id,
parent=issue.parent,
state=issue.state,
point=issue.point,
estimate_point=issue.estimate_point,
name=issue.name,
description=issue.description,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_binary=issue.description_binary,
priority=issue.priority,
start_date=issue.start_date,
target_date=issue.target_date,
assignees=list(
IssueAssignee.objects.filter(issue=issue).values_list(
"assignee_id", flat=True
)
),
sequence_id=issue.sequence_id,
labels=list(
IssueLabel.objects.filter(issue=issue).values_list(
"label_id", flat=True
)
),
sort_order=issue.sort_order,
completed_at=issue.completed_at,
archived_at=issue.archived_at,
is_draft=issue.is_draft,
external_source=issue.external_source,
external_id=issue.external_id,
type=issue.type_id,
cycle=cycle_issue.cycle_id if cycle_issue else None,
modules=list(
Module.objects.filter(issue=issue).values_list("id", flat=True)
),
properties={},
meta={},
last_saved_at=timezone.now(),
type=issue.type,
last_saved_at=issue.last_saved_at,
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),
owned_by=user,
)
return True
except Exception as e:
log_exception(e)
return False
class IssueDescriptionVersion(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="description_versions"
)
description_binary = models.BinaryField(null=True)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_json = models.JSONField(default=dict, blank=True)
last_saved_at = models.DateTimeField(default=timezone.now)
owned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_description_versions",
)
class Meta:
verbose_name = "Issue Description Version"
verbose_name_plural = "Issue Description Versions"
db_table = "issue_description_versions"
@classmethod
def log_issue_description_version(cls, issue, user):
try:
"""
Log the issue description version
"""
cls.objects.create(
workspace_id=issue.workspace_id,
project_id=issue.project_id,
created_by_id=issue.created_by_id,
updated_by_id=issue.updated_by_id,
owned_by_id=user,
last_saved_at=timezone.now(),
issue_id=issue.id,
description_binary=issue.description_binary,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_json=issue.description,
)
return True
except Exception as e:
log_exception(e)
return False
-32
View File
@@ -1,32 +0,0 @@
# Django imports
from django.conf import settings
from django.db import models
# Module imports
from .base import BaseModel
class Sticky(BaseModel):
name = models.TextField()
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True)
logo_props = models.JSONField(default=dict)
color = models.CharField(max_length=255, blank=True, null=True)
background_color = models.CharField(max_length=255, blank=True, null=True)
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="stickies"
)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="stickies"
)
class Meta:
verbose_name = "Sticky"
verbose_name_plural = "Stickies"
db_table = "stickies"
ordering = ("-created_at",)
-14
View File
@@ -26,14 +26,6 @@ def get_default_onboarding():
}
def get_mobile_default_onboarding():
return {
"profile_complete": False,
"workspace_create": False,
"workspace_join": False,
}
class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
@@ -186,12 +178,6 @@ class Profile(TimeAuditModel):
billing_address = models.JSONField(null=True)
has_billing_address = models.BooleanField(default=False)
company_name = models.CharField(max_length=255, blank=True)
# mobile
is_mobile_onboarded = models.BooleanField(default=False)
mobile_onboarding_step = models.JSONField(default=get_mobile_default_onboarding)
mobile_timezone_auto_set = models.BooleanField(default=False)
# language
language = models.CharField(max_length=255, default="en")
class Meta:
verbose_name = "Profile"
+1 -4
View File
@@ -1,6 +1,3 @@
# Django imports
from django.conf import settings
# Third party imports
from celery import shared_task
from opentelemetry import trace
@@ -22,7 +19,7 @@ from plane.db.models import (
from plane.utils.telemetry import init_tracer, shutdown_tracer
@shared_task(queue=settings.TASK_LOW_QUEUE)
@shared_task
def instance_traces():
try:
init_tracer()
+15 -78
View File
@@ -3,7 +3,7 @@
# Python imports
import os
from urllib.parse import urlparse
from kombu import Queue, Exchange
# Third party imports
import dj_database_url
@@ -146,20 +146,8 @@ else:
}
}
REDIS_HOST = os.environ.get("REDIS_HOST")
REDIS_PORT = os.environ.get("REDIS_PORT", "6379")
REDIS_USER = os.environ.get("REDIS_USER", "")
REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "")
# Redis Config
if os.environ.get("REDIS_URL"):
REDIS_URL = os.environ.get("REDIS_URL")
else:
if not REDIS_HOST:
raise Exception("REDIS_HOST is not set")
REDIS_URL = f"redis://{REDIS_USER}:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}" # noqa
# Check if redis url is ssl
REDIS_URL = os.environ.get("REDIS_URL")
REDIS_SSL = REDIS_URL and "rediss" in REDIS_URL
if REDIS_SSL:
@@ -245,79 +233,25 @@ if AWS_S3_ENDPOINT_URL and USE_MINIO:
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
# RabbitMQ connection settings
RABBITMQ_HOST = os.environ.get("RABBITMQ_HOST")
RABBITMQ_HOST = os.environ.get("RABBITMQ_HOST", "localhost")
RABBITMQ_PORT = os.environ.get("RABBITMQ_PORT", "5672")
RABBITMQ_USER = os.environ.get("RABBITMQ_USER", "guest")
RABBITMQ_PASSWORD = os.environ.get("RABBITMQ_PASSWORD", "guest")
RABBITMQ_VHOST = os.environ.get("RABBITMQ_VHOST", "/")
if os.environ.get("AMQP_URL"):
AMQP_URL = os.environ.get("AMQP_URL")
else:
if not RABBITMQ_HOST:
raise Exception("RABBITMQ_HOST is not set")
AMQP_URL = f"amqp://{RABBITMQ_USER}:{RABBITMQ_PASSWORD}@{RABBITMQ_HOST}:{RABBITMQ_PORT}/{RABBITMQ_VHOST}"
AMQP_URL = os.environ.get("AMQP_URL")
# Celery Configuration
CELERY_BROKER_URL = AMQP_URL
CELERY_RESULT_BACKEND = REDIS_URL
if AMQP_URL:
CELERY_BROKER_URL = AMQP_URL
else:
CELERY_BROKER_URL = f"amqp://{RABBITMQ_USER}:{RABBITMQ_PASSWORD}@{RABBITMQ_HOST}:{RABBITMQ_PORT}/{RABBITMQ_VHOST}"
CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_ACCEPT_CONTENT = ["application/json"]
CELERY_RESULT_EXPIRES = int(
os.environ.get("CELERY_RESULT_EXPIRES", 60 * 60 * 24)
) # Expire results after 24 hours
QUEUE_SUFFIX = os.environ.get("QUEUE_SUFFIX", "plane")
TASK_HIGH_QUEUE = f"{QUEUE_SUFFIX}_high"
TASK_DEFAULT_QUEUE = f"{QUEUE_SUFFIX}_default"
TASK_LOW_QUEUE = f"{QUEUE_SUFFIX}_low"
TASK_SCHEDULER_QUEUE = f"{QUEUE_SUFFIX}_schedule"
TASK_NOTIFICATION_QUEUE = f"{QUEUE_SUFFIX}_notification"
# Define queues with their exchanges
CELERY_TASK_QUEUES = (
Queue(
TASK_HIGH_QUEUE,
Exchange("high_exchange", type="direct"),
routing_key="high.#",
queue_arguments={"x-max-priority": 10, "x-queue-mode": "lazy"},
),
Queue(
TASK_DEFAULT_QUEUE,
Exchange("default_exchange", type="direct"),
routing_key="default.#",
queue_arguments={"x-max-priority": 5, "x-queue-mode": "lazy"},
),
Queue(
TASK_LOW_QUEUE,
Exchange("low_exchange", type="direct"),
routing_key="low.#",
queue_arguments={"x-max-priority": 1, "x-queue-mode": "lazy"},
),
Queue(
TASK_SCHEDULER_QUEUE,
Exchange("scheduled_exchange", type="direct"),
routing_key="scheduled.#",
queue_arguments={"x-max-priority": 3, "x-queue-mode": "lazy"},
),
Queue(
TASK_NOTIFICATION_QUEUE,
Exchange("notifications_exchange", type="direct"),
routing_key="notifications.#",
queue_arguments={"x-max-priority": 4, "x-queue-mode": "lazy"},
),
)
# Default queue settings
CELERY_TASK_DEFAULT_QUEUE = "default"
CELERY_TASK_DEFAULT_EXCHANGE = "default_exchange"
CELERY_TASK_DEFAULT_ROUTING_KEY = "default.#"
CELERY_IMPORTS = (
# scheduled tasks
"plane.bgtasks.issue_automation_task",
@@ -328,9 +262,6 @@ CELERY_IMPORTS = (
"plane.license.bgtasks.tracer",
# management tasks
"plane.bgtasks.dummy_data_task",
# issue version tasks
"plane.bgtasks.issue_version_sync",
"plane.bgtasks.issue_description_version_sync",
)
# Sentry Settings
@@ -363,10 +294,16 @@ GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
# Posthog settings
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False)
# instance key
INSTANCE_KEY = os.environ.get(
"INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3"
)
# Skip environment variable configuration
SKIP_ENV_VAR = os.environ.get("SKIP_ENV_VAR", "1") == "1"
-100
View File
@@ -1,100 +0,0 @@
import pytz
from plane.db.models import Project
from datetime import datetime, time
from datetime import timedelta
def user_timezone_converter(queryset, datetime_fields, user_timezone):
# Create a timezone object for the user's timezone
user_tz = pytz.timezone(user_timezone)
# Check if queryset is a dictionary (single item) or a list of dictionaries
if isinstance(queryset, dict):
queryset_values = [queryset]
else:
queryset_values = list(queryset)
# Iterate over the dictionaries in the list
for item in queryset_values:
# Iterate over the datetime fields
for field in datetime_fields:
# Convert the datetime field to the user's timezone
if field in item and item[field]:
item[field] = item[field].astimezone(user_tz)
# If queryset was a single item, return a single item
if isinstance(queryset, dict):
return queryset_values[0]
else:
return queryset_values
def convert_to_utc(date, project_id, is_start_date=False):
"""
Converts a start date string to the project's local timezone at 12:00 AM
and then converts it to UTC for storage.
Args:
date (str): The date string in "YYYY-MM-DD" format.
project_id (int): The project's ID to fetch the associated timezone.
Returns:
datetime: The UTC datetime.
"""
# Retrieve the project's timezone using the project ID
project = Project.objects.get(id=project_id)
project_timezone = project.timezone
if not date or not project_timezone:
raise ValueError("Both date and timezone must be provided.")
# Parse the string into a date object
start_date = datetime.strptime(date, "%Y-%m-%d").date()
# Get the project's timezone
local_tz = pytz.timezone(project_timezone)
# Combine the date with 12:00 AM time
local_datetime = datetime.combine(start_date, time.min)
# Localize the datetime to the project's timezone
localized_datetime = local_tz.localize(local_datetime)
# If it's an start date, add one minute
if is_start_date:
localized_datetime += timedelta(minutes=1)
# Convert the localized datetime to UTC
utc_datetime = localized_datetime.astimezone(pytz.utc)
# Return the UTC datetime for storage
return utc_datetime
def convert_utc_to_project_timezone(utc_datetime, project_id):
"""
Converts a UTC datetime (stored in the database) to the project's local timezone.
Args:
utc_datetime (datetime): The UTC datetime to be converted.
project_id (int): The project's ID to fetch the associated timezone.
Returns:
datetime: The datetime in the project's local timezone.
"""
# Retrieve the project's timezone using the project ID
project = Project.objects.get(id=project_id)
project_timezone = project.timezone
if not project_timezone:
raise ValueError("Project timezone must be provided.")
# Get the timezone object for the project's timezone
local_tz = pytz.timezone(project_timezone)
# Convert the UTC datetime to the project's local timezone
if utc_datetime.tzinfo is None:
# Localize UTC datetime if it's naive (i.e., without timezone info)
utc_datetime = pytz.utc.localize(utc_datetime)
# Convert to the project's local timezone
local_datetime = utc_datetime.astimezone(local_tz)
return local_datetime
@@ -0,0 +1,26 @@
import pytz
def user_timezone_converter(queryset, datetime_fields, user_timezone):
# Create a timezone object for the user's timezone
user_tz = pytz.timezone(user_timezone)
# Check if queryset is a dictionary (single item) or a list of dictionaries
if isinstance(queryset, dict):
queryset_values = [queryset]
else:
queryset_values = list(queryset)
# Iterate over the dictionaries in the list
for item in queryset_values:
# Iterate over the datetime fields
for field in datetime_fields:
# Convert the datetime field to the user's timezone
if field in item and item[field]:
item[field] = item[field].astimezone(user_tz)
# If queryset was a single item, return a single item
if isinstance(queryset, dict):
return queryset_values[0]
else:
return queryset_values
+1 -1
View File
@@ -70,7 +70,7 @@
"value": ""
},
"GITHUB_CLIENT_SECRET": {
"description": "GitHub Client Secret",
"description": "Github Client Secret",
"value": ""
},
"NEXT_PUBLIC_API_BASE_URL": {
+1 -1
View File
@@ -62,7 +62,7 @@ mkdir plane-selfhost
cd plane-selfhost
curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/setup.sh
curl -fsSL -o setup.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/install.sh
chmod +x setup.sh
```
+59 -73
View File
@@ -1,63 +1,54 @@
x-db-env: &db-env
PGHOST: ${PGHOST:-plane-db}
PGDATABASE: ${PGDATABASE:-plane}
POSTGRES_USER: ${POSTGRES_USER:-plane}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-plane}
POSTGRES_DB: ${POSTGRES_DB:-plane}
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
PGDATA: ${PGDATA:-/var/lib/postgresql/data}
x-redis-env: &redis-env
REDIS_HOST: ${REDIS_HOST:-plane-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
REDIS_URL: ${REDIS_URL:-redis://plane-redis:6379/}
x-minio-env: &minio-env
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-access-key}
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-secret-key}
x-aws-s3-env: &aws-s3-env
AWS_REGION: ${AWS_REGION:-}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-access-key}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-secret-key}
AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
x-proxy-env: &proxy-env
NGINX_PORT: ${NGINX_PORT:-80}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
x-mq-env: &mq-env
# RabbitMQ Settings
RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq}
RABBITMQ_PORT: ${RABBITMQ_PORT:-5672}
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-plane}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-plane}
RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-plane}
RABBITMQ_VHOST: ${RABBITMQ_VHOST:-plane}
x-live-env: &live-env
API_BASE_URL: ${API_BASE_URL:-http://api:8000}
x-app-env: &app-env
WEB_URL: ${WEB_URL:-http://localhost}
DEBUG: ${DEBUG:-0}
SENTRY_DSN: ${SENTRY_DSN}
SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
GUNICORN_WORKERS: 1
USE_MINIO: ${USE_MINIO:-1}
DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
ADMIN_BASE_URL: ${ADMIN_BASE_URL}
SPACE_BASE_URL: ${SPACE_BASE_URL}
APP_BASE_URL: ${APP_BASE_URL}
AMQP_URL: ${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
environment:
- NGINX_PORT=${NGINX_PORT:-80}
- WEB_URL=${WEB_URL:-http://localhost}
- DEBUG=${DEBUG:-0}
- SENTRY_DSN=${SENTRY_DSN:-""}
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-}
# Gunicorn Workers
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-1}
#DB SETTINGS
- PGHOST=${PGHOST:-plane-db}
- PGDATABASE=${PGDATABASE:-plane}
- POSTGRES_USER=${POSTGRES_USER:-plane}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane}
- POSTGRES_DB=${POSTGRES_DB:-plane}
- POSTGRES_PORT=${POSTGRES_PORT:-5432}
- PGDATA=${PGDATA:-/var/lib/postgresql/data}
- DATABASE_URL=${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
# REDIS SETTINGS
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://plane-redis:6379/}
# RabbitMQ Settings
- RABBITMQ_HOST=${RABBITMQ_HOST:-plane-mq}
- RABBITMQ_PORT=${RABBITMQ_PORT:-5672}
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER:-plane}
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD:-plane}
- RABBITMQ_DEFAULT_VHOST=${RABBITMQ_VHOST:-plane}
- RABBITMQ_VHOST=${RABBITMQ_VHOST:-plane}
- AMQP_URL=${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
# Application secret
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
# DATA STORE SETTINGS
- USE_MINIO=${USE_MINIO:-1}
- AWS_REGION=${AWS_REGION:-}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-"access-key"}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-"secret-key"}
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-"access-key"}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-"secret-key"}
- BUCKET_NAME=${BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
# Live server env
- API_BASE_URL=${API_BASE_URL:-http://api:8000}
services:
web:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -70,6 +61,7 @@ services:
- worker
space:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -83,6 +75,7 @@ services:
- web
admin:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -95,13 +88,12 @@ services:
- web
live:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-live:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
command: node live/dist/server.js live
environment:
<<: [ *live-env ]
deploy:
replicas: ${LIVE_REPLICAS:-1}
depends_on:
@@ -109,6 +101,7 @@ services:
- web
api:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -118,14 +111,14 @@ services:
replicas: ${API_REPLICAS:-1}
volumes:
- logs_api:/code/plane/logs
environment:
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
depends_on:
- plane-db
- plane-redis
- plane-mq
worker:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -133,8 +126,6 @@ services:
command: ./bin/docker-entrypoint-worker.sh
volumes:
- logs_worker:/code/plane/logs
environment:
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
depends_on:
- api
- plane-db
@@ -142,6 +133,7 @@ services:
- plane-mq
beat-worker:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -149,8 +141,6 @@ services:
command: ./bin/docker-entrypoint-beat.sh
volumes:
- logs_beat-worker:/code/plane/logs
environment:
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
depends_on:
- api
- plane-db
@@ -158,6 +148,7 @@ services:
- plane-mq
migrator:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -165,23 +156,21 @@ services:
command: ./bin/docker-entrypoint-migrator.sh
volumes:
- logs_migrator:/code/plane/logs
environment:
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
depends_on:
- plane-db
- plane-redis
plane-db:
<<: *app-env
image: postgres:15.7-alpine
pull_policy: if_not_present
restart: unless-stopped
command: postgres -c 'max_connections=1000'
environment:
<<: *db-env
volumes:
- pgdata:/var/lib/postgresql/data
plane-redis:
<<: *app-env
image: valkey/valkey:7.2.5-alpine
pull_policy: if_not_present
restart: unless-stopped
@@ -189,33 +178,30 @@ services:
- redisdata:/data
plane-mq:
<<: *app-env
image: rabbitmq:3.13.6-management-alpine
restart: always
environment:
<<: *mq-env
volumes:
- rabbitmq_data:/var/lib/rabbitmq
plane-minio:
<<: *app-env
image: minio/minio:latest
pull_policy: if_not_present
restart: unless-stopped
command: server /export --console-address ":9090"
environment:
<<: *minio-env
volumes:
- uploads:/export
# Comment this if you already have a reverse proxy running
proxy:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
ports:
- ${NGINX_PORT}:80
environment:
<<: *proxy-env
depends_on:
- web
- api
+13 -97
View File
@@ -4,12 +4,9 @@ BRANCH=${BRANCH:-master}
SCRIPT_DIR=$PWD
SERVICE_FOLDER=plane-app
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
export APP_RELEASE=stable
export APP_RELEASE="stable"
export DOCKERHUB_USER=makeplane
export PULL_POLICY=${PULL_POLICY:-if_not_present}
export GH_REPO=makeplane/plane
export RELEASE_DOWNLOAD_URL="https://github.com/$GH_REPO/releases/download"
export FALLBACK_DOWNLOAD_URL="https://raw.githubusercontent.com/$GH_REPO/$BRANCH/deploy/selfhost"
CPU_ARCH=$(uname -m)
OS_NAME=$(uname)
@@ -19,6 +16,13 @@ mkdir -p $PLANE_INSTALL_DIR/archive
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env
SED_PREFIX=()
if [ "$OS_NAME" == "Darwin" ]; then
SED_PREFIX=("-i" "")
else
SED_PREFIX=("-i")
fi
function print_header() {
clear
@@ -55,17 +59,6 @@ function spinner() {
printf " \b\b\b\b" >&2
}
function checkLatestRelease(){
echo "Checking for the latest release..." >&2
local latest_release=$(curl -s https://api.github.com/repos/$GH_REPO/releases/latest | grep -o '"tag_name": "[^"]*"' | sed 's/"tag_name": "//;s/"//g')
if [ -z "$latest_release" ]; then
echo "Failed to check for the latest release. Exiting..." >&2
exit 1
fi
echo $latest_release
}
function initialize(){
printf "Please wait while we check the availability of Docker images for the selected release ($APP_RELEASE) with ${UPPER_CPU_ARCH} support." >&2
@@ -137,12 +130,8 @@ function updateEnvFile() {
echo "$key=$value" >> "$file"
return
else
if [ "$OS_NAME" == "Darwin" ]; then
value=$(echo "$value" | sed 's/|/\\|/g')
sed -i '' "s|^$key=.*|$key=$value|g" "$file"
else
sed -i "s/^$key=.*/$key=$value/g" "$file"
fi
# if key exists, update the value
sed "${SED_PREFIX[@]}" "s/^$key=.*/$key=$value/g" "$file"
fi
else
echo "File not found: $file"
@@ -193,7 +182,7 @@ function buildYourOwnImage(){
local PLANE_TEMP_CODE_DIR=~/tmp/plane
rm -rf $PLANE_TEMP_CODE_DIR
mkdir -p $PLANE_TEMP_CODE_DIR
REPO=https://github.com/$GH_REPO.git
REPO=https://github.com/makeplane/plane.git
git clone "$REPO" "$PLANE_TEMP_CODE_DIR" --branch "$BRANCH" --single-branch --depth 1
cp "$PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml" "$PLANE_TEMP_CODE_DIR/build.yml"
@@ -215,10 +204,6 @@ function install() {
echo "Begin Installing Plane"
echo ""
if [ "$APP_RELEASE" == "stable" ]; then
export APP_RELEASE=$(checkLatestRelease)
fi
local build_image=$(initialize)
if [ "$build_image" == "build" ]; then
@@ -247,49 +232,8 @@ function download() {
mv $PLANE_INSTALL_DIR/docker-compose.yaml $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml
fi
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml?$(date +%s)")
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
if [ "$STATUS" -eq 200 ]; then
echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yaml
else
# Fallback to download from the raw github url
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/docker-compose.yml?$(date +%s)")
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
if [ "$STATUS" -eq 200 ]; then
echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yaml
else
echo "Failed to download docker-compose.yml. HTTP Status: $STATUS"
echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml"
mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml $PLANE_INSTALL_DIR/docker-compose.yaml
exit 1
fi
fi
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env?$(date +%s)")
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
if [ "$STATUS" -eq 200 ]; then
echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env
else
# Fallback to download from the raw github url
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/variables.env?$(date +%s)")
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
if [ "$STATUS" -eq 200 ]; then
echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env
else
echo "Failed to download variables.env. HTTP Status: $STATUS"
echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env"
mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml $PLANE_INSTALL_DIR/docker-compose.yaml
exit 1
fi
fi
curl -H 'Cache-Control: no-cache, no-store' -s -o $PLANE_INSTALL_DIR/docker-compose.yaml https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s)
curl -H 'Cache-Control: no-cache, no-store' -s -o $PLANE_INSTALL_DIR/variables-upgrade.env https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s)
if [ -f "$DOCKER_ENV_PATH" ];
then
@@ -391,34 +335,6 @@ function restartServices() {
startServices
}
function upgrade() {
local latest_release=$(checkLatestRelease)
echo ""
echo "Current release: $APP_RELEASE"
if [ "$latest_release" == "$APP_RELEASE" ]; then
echo ""
echo "You are already using the latest release"
exit 0
fi
echo "Latest release: $latest_release"
echo ""
# Check for confirmation to upgrade
echo "Do you want to upgrade to the latest release ($latest_release)?"
read -p "Continue? [y/N]: " confirm
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
echo "Exiting..."
exit 0
fi
export APP_RELEASE=$latest_release
echo "Upgrading Plane to the latest release..."
echo ""
echo "***** STOPPING SERVICES ****"
stopServices
+3
View File
@@ -47,6 +47,9 @@ AWS_ACCESS_KEY_ID=access-key
AWS_SECRET_ACCESS_KEY=secret-key
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
AWS_S3_BUCKET_NAME=uploads
MINIO_ROOT_USER=access-key
MINIO_ROOT_PASSWORD=secret-key
BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880
# Gunicorn Workers
-1
View File
@@ -11,7 +11,6 @@ export enum EIssueGroupByToServerOptions {
"target_date" = "target_date",
"project" = "project_id",
"created_by" = "created_by",
"team_project" = "project_id",
}
export enum EIssueGroupBYServerToProperty {
-3
View File
@@ -1,3 +0,0 @@
build/*
dist/*
out/*
-9
View File
@@ -1,9 +0,0 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};
-5
View File
@@ -1,5 +0,0 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}
-59
View File
@@ -1,59 +0,0 @@
# Logger Package
This package provides a logger and a request logger utility built using [Winston](https://github.com/winstonjs/winston). It offers customizable log levels using env and supports structured logging for general application logs and HTTP requests.
## Features.
- Dynamic log level configuration using env.
- Pre-configured winston logger for general usage (`logger`).
- Request logger middleware that logs incoming request
## Usage
### Adding as a package
Add this package as a dependency in package.json
```typescript
dependency: {
...
@plane/logger":"*",
...
}
```
### Importing the Logger
```typescript
import { logger, requestLogger } from '@plane/logger'
```
### Usage
### `logger`: General Logger
Use this for general application logs.
```typescript
logger.info("This is an info log");
logger.warn("This is a warning");
logger.error("This is an error");
```
### `requestLogger`: Request Logger Middleware
Use this as a middleware for incoming requests
```typescript
const app = express()
app.use(requestLogger)
```
## Available Log Levels
- `error`
- `warn`
- `info` (default)
- `http`
- `verbose`
- `debug`
- `silly`
## Log file
- Log files are stored in logs folder of current working directory. Error logs are stored in files with format `error-%DATE%.log` and combined logs are stored with format `combined-%DATE%.log`.
- Log files have a 7 day rotation period defined.
## Configuration
- By default, the log level is set to `info`.
- You can specify a log level by adding a LOG_LEVEL in .env.
-21
View File
@@ -1,21 +0,0 @@
{
"name": "@plane/logger",
"version": "0.24.1",
"description": "Logger shared across multiple apps internally",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"dependencies": {
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@types/node": "^22.5.4",
"typescript": "^5.3.3"
}
}
-66
View File
@@ -1,66 +0,0 @@
import winston from "winston";
import DailyRotateFile from "winston-daily-rotate-file";
import path from "path";
// Define log levels
const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4,
};
// Define colors for each level
const colors = {
error: "red",
warn: "yellow",
info: "green",
http: "magenta",
debug: "white",
};
// Tell winston about our colors
winston.addColors(colors);
// Custom format for logging
const format = winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss:ms" }),
winston.format.colorize({ all: true }),
winston.format.printf(
(info: winston.Logform.TransformableInfo) => `[${info?.timestamp}] ${info.level}: ${info.message}`
)
);
// Define which transports to use
const transports = [
// Console transport
new winston.transports.Console(),
// Rotating file transport for errors
new DailyRotateFile({
filename: path.join(process.cwd(), "logs", "error-%DATE%.log"),
datePattern: "YYYY-MM-DD",
zippedArchive: true,
maxSize: process.env.LOG_MAX_SIZE || "20m",
maxFiles: process.env.LOG_RETENTION || "7d",
level: "error",
}),
// Rotating file transport for all logs
new DailyRotateFile({
filename: path.join(process.cwd(), "logs", "combined-%DATE%.log"),
datePattern: "YYYY-MM-DD",
zippedArchive: true,
maxSize: process.env.LOG_MAX_SIZE || "20m",
maxFiles: process.env.LOG_RETENTION || "7d",
}),
];
// Create the logger
export const logger = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
levels,
format,
transports,
});
-2
View File
@@ -1,2 +0,0 @@
export * from "./config";
export * from "./middleware";
-23
View File
@@ -1,23 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { logger } from "./config";
export const requestLogger = (req: Request, res: Response, next: NextFunction) => {
// Log when the request starts
const startTime = Date.now();
// Log request details
logger.http(`Incoming ${req.method} request to ${req.url} from ${req.ip}`);
// Log request body if present
if (Object.keys(req.body).length > 0) {
logger.debug("Request body:", req.body);
}
// Capture response
res.on("finish", () => {
const duration = Date.now() - startTime;
logger.http(`Completed ${req.method} ${req.url} with status ${res.statusCode} in ${duration}ms`);
});
next();
};
-19
View File
@@ -1,19 +0,0 @@
{
"extends": "@plane/typescript-config/base.json",
"compilerOptions": {
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"experimentalDecorators": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
-15
View File
@@ -1,15 +0,0 @@
export type TCommandPaletteActionList = Record<
string,
{ title: string; description: string; action: () => void }
>;
export type TCommandPaletteShortcutList = {
key: string;
title: string;
shortcuts: TCommandPaletteShortcut[];
};
export type TCommandPaletteShortcut = {
keys: string; // comma separated keys
description: string;
};
-2
View File
@@ -22,5 +22,3 @@ export type TLogoProps = {
background_color?: string;
};
};
export type TNameDescriptionLoader = "submitting" | "submitted" | "saved";
-1
View File
@@ -59,5 +59,4 @@ export enum EFileAssetType {
USER_AVATAR = "USER_AVATAR",
USER_COVER = "USER_COVER",
WORKSPACE_LOGO = "WORKSPACE_LOGO",
TEAM_SPACE_DESCRIPTION = "TEAM_SPACE_DESCRIPTION",
}
-1
View File
@@ -32,4 +32,3 @@ export * from "./workspace-notifications";
export * from "./favorite";
export * from "./file";
export * from "./workspace-draft-issues/base";
export * from "./command-palette";
+2 -3
View File
@@ -211,13 +211,12 @@ export type GroupByColumnTypes =
| "priority"
| "labels"
| "assignees"
| "created_by"
| "team_project";
| "created_by";
export interface IGroupByColumn {
id: string;
name: string;
icon?: ReactElement | undefined;
icon: ReactElement | undefined;
payload: Partial<TIssue>;
isDropDisabled?: boolean;
dropErrorMessage?: string;
-1
View File
@@ -1,3 +1,2 @@
export * from "./project_filters";
export * from "./projects";
export * from "./project_link";
-22
View File
@@ -1,22 +0,0 @@
export type TProjectLinkEditableFields = {
title: string;
url: string;
};
export type TProjectLink = TProjectLinkEditableFields & {
created_by_id: string;
id: string;
metadata: any;
project_id: string;
//need
created_at: Date;
};
export type TProjectLinkMap = {
[project_id: string]: TProjectLink;
};
export type TProjectLinkIdMap = {
[project_id: string]: string[];
};
-3
View File
@@ -18,7 +18,6 @@ export type TIssueGroupByOptions =
| "cycle"
| "module"
| "target_date"
| "team_project"
| null;
export type TIssueOrderByOptions =
@@ -70,7 +69,6 @@ export type TIssueParams =
| "start_date"
| "target_date"
| "project"
| "team_project"
| "group_by"
| "sub_group_by"
| "order_by"
@@ -94,7 +92,6 @@ export interface IIssueFilterOptions {
cycle?: string[] | null;
module?: string[] | null;
project?: string[] | null;
team_project?: string[] | null;
start_date?: string[] | null;
state?: string[] | null;
state_group?: string[] | null;
+1 -1
View File
@@ -35,7 +35,7 @@ export type TNotificationData = {
};
export type TNotification = {
id: string;
id: string | undefined;
title: string | undefined;
data: TNotificationData | undefined;
entity_identifier: string | undefined;
+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};
@@ -0,0 +1 @@
var s=Object.create,a=Object.defineProperty,c=Object.getOwnPropertyDescriptor,o=Object.getOwnPropertyNames,u=Object.getPrototypeOf,O=Object.prototype.hasOwnProperty,l=(e,r)=>function(){return e&&(r=(0,e[o(e)[0]])(e=0)),r},v=(e,r)=>function(){return r||(0,e[o(e)[0]])((r={exports:{}}).exports,r),r.exports},b=(e,r)=>{for(var t in r)a(e,t,{get:r[t],enumerable:!0})},n=(e,r,t,p)=>{if(r&&typeof r=="object"||typeof r=="function")for(let _ of o(r))!O.call(e,_)&&_!==t&&a(e,_,{get:()=>r[_],enumerable:!(p=c(r,_))||p.enumerable});return e},P=(e,r,t)=>(t=e!=null?s(u(e)):{},n(!e||!e.__esModule?a(t,"default",{value:e,enumerable:!0}):t,e)),y=e=>n(a({},"__esModule",{value:!0}),e);export{b as _,P as a,v as b,l as c,y as d};
File diff suppressed because one or more lines are too long

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