Compare commits

..

5 Commits

Author SHA1 Message Date
Palanikannan M 6c71d7c87b wip: optimization of pages binary data 2024-12-10 18:05:04 +05:30
Palanikannan M d6f27f7019 Merge branch 'preview' into fix/pages-loading-optimization 2024-12-09 20:34:10 +05:30
Palanikannan M 97ff3832fd Merge branch 'chore/page-description' into fix/pages-loading-optimization 2024-12-09 20:32:30 +05:30
Palanikannan M d29ab80762 fix: removing readonly editor 2024-12-09 20:30:44 +05:30
NarayanBavisetti e5ebee664b chore: removed binary decoding 2024-11-27 00:41:34 +05:30
96 changed files with 1033 additions and 1770 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "admin",
"version": "0.24.1",
"version": "0.24.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
+1 -1
View File
@@ -4,7 +4,7 @@ FROM python:3.12.5-alpine AS backend
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
WORKDIR /code
+1 -1
View File
@@ -4,7 +4,7 @@ FROM python:3.12.5-alpine AS backend
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
RUN apk --no-cache add \
"bash~=5.2" \
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.24.1"
"version": "0.24.0"
}
+30 -24
View File
@@ -39,6 +39,7 @@ from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
from plane.bgtasks.page_version_task import page_version
from plane.bgtasks.recent_visited_task import recent_visited_task
from rest_framework.parsers import MultiPartParser, FormParser
def unarchive_archive_page_and_descendants(page_id, archived_at):
@@ -470,6 +471,8 @@ class SubPagesEndpoint(BaseAPIView):
class PagesDescriptionViewSet(BaseViewSet):
parser_classes = [MultiPartParser]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve(self, request, slug, project_id, pk):
page = (
@@ -526,30 +529,33 @@ class PagesDescriptionViewSet(BaseViewSet):
existing_instance = json.dumps(
{"description_html": page.description_html}, cls=DjangoJSONEncoder
)
print("before the variables")
# Get the base64 data from the request
base64_data = request.data.get("description_binary")
# If base64 data is provided
if base64_data:
# Decode the base64 data to bytes
new_binary_data = base64.b64decode(base64_data)
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data, old_value=existing_instance, page_id=pk
)
# Store the updated binary data
page.description_binary = new_binary_data
page.description_html = request.data.get("description_html")
page.description = request.data.get("description")
page.save()
# Return a success response
page_version.delay(
page_id=page.id,
existing_instance=existing_instance,
user_id=request.user.id,
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data, old_value=existing_instance, page_id=pk
)
return Response({"message": "Updated successfully"})
else:
if not request.data.get("description_binary"):
return Response({"error": "No binary data provided"})
# Store the updated binary data
page.description_html = request.data.get(
"description_html", page.description_html
)
page.description = request.data.get("description", page.description)
page.description_binary = request.POST.get("description_binary")
page.description_binary = base64.b64decode(
request.POST.get("description_binary")
)
print("before the ssssave")
page.save()
# Return a success response
page_version.delay(
page_id=page.id,
existing_instance=existing_instance,
user_id=request.user.id,
)
return Response({"message": "Updated successfully"})
@@ -1,120 +0,0 @@
# Python imports
from typing import Optional
# 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:
print(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:
print(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=batch_size, countdown=countdown)
@@ -1,85 +0,0 @@
from celery import shared_task
from django.db import transaction
from django.utils import timezone
from typing import Optional, Dict, Any
import json
from plane.db.models import Issue, IssueDescriptionVersion
from plane.utils.logging 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, description_data: Dict[str, Any]
) -> None:
version.description_json = description_data.get("description")
version.description_html = description_data.get("description_html")
version.description_binary = description_data.get("description_binary")
version.description_stripped = description_data.get("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
def issue_description_version_task(
updated_issue: Optional[str], issue_id: str, user_id: str
) -> 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:
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(latest_version, user_id):
update_existing_version(latest_version, current_issue)
else:
IssueDescriptionVersion.log_issue_description_version(
current_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,255 +0,0 @@
# Python imports
import json
from typing import Optional, List, Dict
from uuid import UUID
from itertools import groupby
# 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:
print(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:
print(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,
point=issue.point,
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
print(f"Offset: {offset}")
print(f"Total Issues: {total_issues_count}")
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,
)
print(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=batch_size, countdown=countdown)
@@ -1,90 +0,0 @@
# Python imports
import json
from typing import Dict, Any, Optional, List
# 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
from plane.utils.exception_logger import log_exception
def get_changed_fields(current_issue: Dict[str, Any], issue: Issue) -> Dict[str, Any]:
return {
key: value
for key, value in current_issue.items()
if getattr(issue, key) != value
}
def should_update_existing_version(
version: Optional[IssueVersion], user_id: str, max_time_difference: int = 600
) -> bool:
if not version:
return False
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_version_fields(
version: IssueVersion, changed_fields: Dict[str, Any]
) -> List[str]:
for key, value in changed_fields.items():
setattr(version, key, value)
version.last_saved_at = timezone.now()
update_fields = list(changed_fields.keys()) + ["last_saved_at"]
return update_fields
@shared_task
def issue_version_task(
updated_issue: Optional[str], issue_id: str, user_id: str
) -> Optional[bool]:
try:
# Parse updated issue data
current_issue: Dict = json.loads(updated_issue) if updated_issue else {}
with transaction.atomic():
# Get current issue
issue = Issue.objects.get(id=issue_id)
# Get changed fields
changed_fields = get_changed_fields(current_issue, issue)
if not changed_fields:
return True
# Get latest version
latest_version = (
IssueVersion.objects.filter(issue_id=issue_id)
.order_by("-last_saved_at")
.first()
)
# Update existing or create new version
if should_update_existing_version(latest_version, user_id):
update_fields = update_version_fields(latest_version, changed_fields)
latest_version.save(update_fields=update_fields)
else:
IssueVersion.log_issue_version(issue, user_id)
return True
except Issue.DoesNotExist:
return None
except json.JSONDecodeError as e:
log_exception(f"Invalid JSON for updated_issue: {e}")
return False
except Exception as e:
log_exception(f"Error processing issue version: {e}")
return False
@@ -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=int(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=int(batch_size), countdown=int(batch_countdown)
)
self.stdout.write(self.style.SUCCESS("Successfully created issue version task"))
@@ -1,90 +0,0 @@
# Generated by Django 4.2.16 on 2024-12-09 10:03
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='issueversion',
name='point',
field=models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(12)]),
),
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.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='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',
},
),
]
-2
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
+26 -87
View File
@@ -660,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"),
@@ -667,17 +670,14 @@ class IssueVersion(ProjectBaseModel):
("low", "Low"),
("none", "None"),
)
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="versions"
)
parent = models.UUIDField(blank=True, null=True)
state = models.UUIDField(blank=True, null=True)
point = models.IntegerField(
validators=[MinValueValidator(0), MaxValueValidator(12)], null=True, blank=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,
@@ -686,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)
@@ -696,22 +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)
activity = models.ForeignKey(
"db.IssueActivity",
on_delete=models.SET_NULL,
null=True,
related_name="versions",
)
properties = models.JSONField(default=dict) # issue properties
meta = models.JSONField(default=dict) # issue meta
last_saved_at = models.DateTimeField(default=timezone.now)
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"
@@ -731,87 +721,36 @@ 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,
parent=issue.parent,
state=issue.state,
point=issue.point,
estimate_point=issue.estimate_point_id,
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(),
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(
issue=issue,
description_binary=issue.description_binary,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_json=issue.description,
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
-12
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,10 +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)
class Meta:
verbose_name = "Profile"
-3
View File
@@ -262,9 +262,6 @@ CELERY_IMPORTS = (
"plane.license.bgtasks.tracer",
# management tasks
"plane.bgtasks.dummy_data_task",
# issue sync tasks
"plane.bgtasks.issue_version_sync",
"plane.bgtasks.issue_description_version_sync",
)
# Sentry Settings
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "0.24.1",
"version": "0.24.0",
"description": "",
"main": "./src/server.ts",
"private": true,
+24 -13
View File
@@ -1,9 +1,15 @@
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs"
import {
prosemirrorJSONToYDoc,
yXmlFragmentToProseMirrorRootNode,
} from "y-prosemirror";
import * as Y from "yjs";
// plane editor
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
import {
CoreEditorExtensionsWithoutProps,
DocumentEditorExtensionsWithoutProps,
} from "@plane/editor/lib";
const DOCUMENT_EDITOR_EXTENSIONS = [
...CoreEditorExtensionsWithoutProps,
@@ -11,7 +17,9 @@ const DOCUMENT_EDITOR_EXTENSIONS = [
];
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
export const getAllDocumentFormatsFromBinaryData = (
description: Uint8Array,
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
@@ -24,7 +32,7 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(
type,
documentEditorSchema
documentEditorSchema,
).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
@@ -34,26 +42,29 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
contentJSON,
contentHTML,
};
}
};
export const getBinaryDataFromHTMLString = (descriptionHTML: string): {
contentBinary: Uint8Array
export const getBinaryDataFromHTMLString = (
descriptionHTML: string,
): {
contentBinary: Uint8Array;
} => {
// convert HTML to JSON
const contentJSON = generateJSON(
descriptionHTML ?? "<p></p>",
DOCUMENT_EDITOR_EXTENSIONS
DOCUMENT_EDITOR_EXTENSIONS,
);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(
documentEditorSchema,
contentJSON,
"default"
"default",
);
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return {
contentBinary: encodedData
}
}
contentBinary: encodedData,
};
};
+33 -6
View File
@@ -26,18 +26,41 @@ export const updatePageDescription = async (
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromBinaryData(updatedDescription);
try {
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
// Generate a unique boundary
const boundary = `----FormBoundary${Date.now().toString()}`;
// Construct the multipart form data manually
let formData = "";
// Add binary content
formData += `--${boundary}\r\n`;
formData += 'Content-Disposition: form-data; name="description_binary"\r\n';
formData += "Content-Type: application/octet-stream\r\n\r\n";
formData += updatedDescription + "\r\n";
// Add HTML content
formData += `--${boundary}\r\n`;
formData += 'Content-Disposition: form-data; name="description_html"\r\n';
formData += "Content-Type: text/html\r\n\r\n";
formData += contentHTML + "\r\n";
// Add JSON content
formData += `--${boundary}\r\n`;
formData += 'Content-Disposition: form-data; name="description"\r\n';
formData += "Content-Type: application/json\r\n\r\n";
formData += JSON.stringify(contentJSON) + "\r\n";
// End boundary
formData += `--${boundary}--\r\n`;
await pageService.updateDescription(
workspaceSlug,
projectId,
pageId,
payload,
formData,
boundary,
cookie,
);
} catch (error) {
@@ -46,6 +69,8 @@ export const updatePageDescription = async (
}
};
// Update the service method
const fetchDescriptionHTMLAndTransform = async (
workspaceSlug: string,
projectId: string,
@@ -90,7 +115,9 @@ export const fetchPageDescriptionBinary = async (
pageId,
cookie,
);
console.log("response", response);
const binaryData = new Uint8Array(response);
console.log("binaryData", binaryData);
if (binaryData.byteLength === 0) {
const binary = await fetchDescriptionHTMLAndTransform(
+10 -12
View File
@@ -12,7 +12,7 @@ export class PageService extends APIService {
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
cookie: string,
): Promise<TPage> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`,
@@ -20,7 +20,7 @@ export class PageService extends APIService {
headers: {
Cookie: cookie,
},
}
},
)
.then((response) => response?.data)
.catch((error) => {
@@ -32,7 +32,7 @@ export class PageService extends APIService {
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
cookie: string,
): Promise<any> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
@@ -42,7 +42,7 @@ export class PageService extends APIService {
Cookie: cookie,
},
responseType: "arraybuffer",
}
},
)
.then((response) => response?.data)
.catch((error) => {
@@ -54,21 +54,19 @@ export class PageService extends APIService {
workspaceSlug: string,
projectId: string,
pageId: string,
data: {
description_binary: string;
description_html: string;
description: object;
},
cookie: string
formData: string,
boundary: string,
cookie: string,
): Promise<any> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
data,
formData,
{
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
Cookie: cookie,
},
}
},
)
.then((response) => response?.data)
.catch((error) => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.24.1",
"version": "0.24.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.24.1",
"version": "0.24.0",
"private": true,
"main": "./src/index.ts"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.24.1",
"version": "0.24.0",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect, useState } from "react";
// components
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
// constants
@@ -19,6 +19,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
embedHandler,
fileHandler,
@@ -43,23 +44,25 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
}
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
onTransaction,
disabledExtensions,
editorClassName,
embedHandler,
extensions,
fileHandler,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
user,
});
const { editor, hasServerConnectionFailed, hasServerSynced, localProvider, hasIndexedDbSynced } =
useCollaborativeEditor({
disabledExtensions,
editable,
editorClassName,
embedHandler,
extensions,
fileHandler,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
onTransaction,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
user,
});
const editorContainerClassNames = getEditorClassNames({
noBorder: true,
@@ -67,9 +70,30 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
containerClassName,
});
const [hasIndexedDbEntry, setHasIndexedDbEntry] = useState(null);
useEffect(() => {
async function documentIndexedDbEntry(dbName: string) {
try {
const databases = await indexedDB.databases();
const hasEntry = databases.some((db) => db.name === dbName);
setHasIndexedDbEntry(hasEntry);
} catch (error) {
console.error("Error checking database existence:", error);
return false;
}
}
documentIndexedDbEntry(id);
}, [id, localProvider]);
if (!editor) return null;
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader />;
// Wait until we know about IndexedDB status
if (hasIndexedDbEntry === null) return null;
if (hasServerConnectionFailed || (!hasIndexedDbEntry && !hasServerSynced) || !hasIndexedDbSynced) {
return <DocumentContentLoader />;
}
return (
<PageRenderer
@@ -129,6 +129,7 @@ export const PageRenderer = (props: IPageRenderer) => {
[editor, cleanup]
);
console.log("rendered");
return (
<>
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
@@ -139,12 +140,12 @@ export const PageRenderer = (props: IPageRenderer) => {
id={id}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && (
<>
<BlockMenu editor={editor} />
<AIFeaturesMenu menu={aiHandler?.menu} />
</>
)}
{/* {editor.isEditable && ( */}
{/* <> */}
{/* <BlockMenu editor={editor} /> */}
{/* <AIFeaturesMenu menu={aiHandler?.menu} /> */}
{/* </> */}
{/* )} */}
</EditorContainer>
</div>
{isOpen && linkViewProps && coordinates && (
@@ -21,6 +21,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
extensions,
id,
@@ -38,6 +39,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
} = props;
const editor = useEditor({
editable,
disabledExtensions,
editorClassName,
enableHistory: true,
@@ -127,24 +127,30 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
return "Uploading...";
}
if (draggedInside) {
if (draggedInside && editor.isEditable) {
return "Drop image here";
}
return "Add an image";
if (!editor.isEditable) {
return "Viewing Mode: Image Upload Disabled";
} else {
return "Add an image";
}
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
return (
<div
className={cn(
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 transition-all duration-200 ease-in-out cursor-default",
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 bg-custom-background-90 border border-dashed border-custom-border-300 transition-all duration-200 ease-in-out cursor-default",
{
"hover:text-custom-text-200 cursor-pointer": editor.isEditable,
"bg-custom-background-80 text-custom-text-200": draggedInside,
"text-custom-primary-200 bg-custom-primary-100/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200 border-custom-primary-200/10":
selected,
"text-red-500 cursor-default hover:text-red-500": failedToLoadImage,
"bg-red-500/10 hover:bg-red-500/10": failedToLoadImage && selected,
"hover:text-custom-text-200 hover:bg-custom-background-80 cursor-pointer": editor.isEditable,
"bg-custom-background-80 text-custom-text-200": draggedInside && editor.isEditable,
"text-custom-primary-200 bg-custom-primary-100/10 border-custom-primary-200/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200":
selected && editor.isEditable,
"text-red-500 cursor-default": failedToLoadImage,
"hover:text-red-500": failedToLoadImage && editor.isEditable,
"bg-red-500/10": failedToLoadImage && selected,
"hover:bg-red-500/10": failedToLoadImage && selected && editor.isEditable,
}
)}
onDrop={onDrop}
+54 -45
View File
@@ -2,58 +2,67 @@ import { Extension, Editor } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
export const DropHandlerExtension = () =>
Extension.create({
name: "dropHandler",
priority: 1000,
export const DropHandlerExtension = Extension.create({
name: "dropHandler",
priority: 1000,
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
key: new PluginKey("drop-handler-plugin"),
props: {
handlePaste: (view: EditorView, event: ClipboardEvent) => {
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files.length > 0) {
event.preventDefault();
const files = Array.from(event.clipboardData.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
key: new PluginKey("drop-handler-plugin"),
props: {
handlePaste: (view: EditorView, event: ClipboardEvent) => {
if (
editor.isEditable &&
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files.length > 0
) {
event.preventDefault();
const files = Array.from(event.clipboardData.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
if (imageFiles.length > 0) {
const pos = view.state.selection.from;
if (imageFiles.length > 0) {
const pos = view.state.selection.from;
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
return false;
},
handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => {
if (
editor.isEditable &&
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files.length > 0
) {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
if (imageFiles.length > 0) {
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (coordinates) {
const pos = coordinates.pos;
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
return false;
},
handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => {
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
if (imageFiles.length > 0) {
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (coordinates) {
const pos = coordinates.pos;
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
}
return false;
},
}
return false;
},
}),
];
},
});
},
}),
];
},
});
export const insertImagesSafely = async ({
editor,
files,
@@ -47,10 +47,11 @@ type TArguments = {
};
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
editable?: boolean;
};
export const CoreEditorExtensions = (args: TArguments): Extensions => {
const { disabledExtensions, enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args;
const { disabledExtensions, enableHistory, fileHandler, mentionConfig, placeholder, tabIndex, editable } = args;
return [
StarterKit.configure({
@@ -89,7 +90,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
...(enableHistory ? {} : { history: false }),
}),
CustomQuoteExtension,
DropHandlerExtension(),
DropHandlerExtension,
CustomHorizontalRule.configure({
HTMLAttributes: {
class: "py-4 border-custom-border-400",
@@ -145,9 +146,9 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
TableCell,
TableRow,
CustomMention({
mentionSuggestions: mentionConfig.mentionSuggestions,
mentionSuggestions: editable ? mentionConfig.mentionSuggestions : undefined,
mentionHighlights: mentionConfig.mentionHighlights,
readonly: false,
readonly: !editable,
}),
Placeholder.configure({
placeholder: ({ editor, node }) => {
@@ -15,6 +15,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
const {
onTransaction,
disabledExtensions,
editable,
editorClassName,
editorProps = {},
embedHandler,
@@ -33,6 +34,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
// states
const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false);
const [hasServerSynced, setHasServerSynced] = useState(false);
const [hasIndexedDbSynced, setHasIndexedDbSynced] = useState(false);
// initialize Hocuspocus provider
const provider = useMemo(
() =>
@@ -53,7 +55,10 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
setHasServerConnectionFailed(true);
}
},
onSynced: () => setHasServerSynced(true),
onSynced: () => {
serverHandler?.onServerSync?.();
setHasServerSynced(true);
},
}),
[id, realtimeConfig, serverHandler, user]
);
@@ -63,6 +68,10 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
[id, provider]
);
localProvider?.on("synced", () => {
setHasIndexedDbSynced(true);
});
// destroy and disconnect all providers connection on unmount
useEffect(
() => () => {
@@ -75,7 +84,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
const editor = useEditor({
disabledExtensions,
id,
onTransaction,
editable,
editorProps,
editorClassName,
enableHistory: false,
@@ -97,9 +106,10 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
}),
],
fileHandler,
handleEditorReady,
forwardedRef,
handleEditorReady,
mentionHandler,
onTransaction,
placeholder,
provider,
tabIndex,
@@ -109,5 +119,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
editor,
hasServerConnectionFailed,
hasServerSynced,
hasIndexedDbSynced,
localProvider,
};
};
+37 -31
View File
@@ -27,6 +27,7 @@ import type {
} from "@/types";
export interface CustomEditorProps {
editable: boolean;
editorClassName: string;
editorProps?: EditorProps;
enableHistory: boolean;
@@ -55,6 +56,7 @@ export interface CustomEditorProps {
export const useEditor = (props: CustomEditorProps) => {
const {
disabledExtensions,
editable,
editorClassName,
editorProps = {},
enableHistory,
@@ -74,42 +76,46 @@ export const useEditor = (props: CustomEditorProps) => {
autofocus = false,
} = props;
// states
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
// refs
const editorRef: MutableRefObject<Editor | null> = useRef(null);
const savedSelectionRef = useRef(savedSelection);
const editor = useTiptapEditor({
autofocus,
editorProps: {
...CoreEditorProps({
editorClassName,
}),
...editorProps,
const editor = useTiptapEditor(
{
editable,
autofocus,
editorProps: {
...CoreEditorProps({
editorClassName,
}),
...editorProps,
},
extensions: [
...CoreEditorExtensions({
editable,
disabledExtensions,
enableHistory,
fileHandler,
mentionConfig: {
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
mentionHighlights: mentionHandler.highlights,
},
placeholder,
tabIndex,
}),
...extensions,
],
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
onCreate: () => handleEditorReady?.(true),
onTransaction: ({ editor }) => {
setSavedSelection(editor.state.selection);
onTransaction?.();
},
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
onDestroy: () => handleEditorReady?.(false),
},
extensions: [
...CoreEditorExtensions({
disabledExtensions,
enableHistory,
fileHandler,
mentionConfig: {
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
mentionHighlights: mentionHandler.highlights,
},
placeholder,
tabIndex,
}),
...extensions,
],
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
onCreate: () => handleEditorReady?.(true),
onTransaction: ({ editor }) => {
setSavedSelection(editor.state.selection);
onTransaction?.();
},
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
onDestroy: () => handleEditorReady?.(false),
});
[editable]
);
// Update the ref whenever savedSelection changes
useEffect(() => {
@@ -105,7 +105,7 @@ export const useDropZone = (args: TDropzoneArgs) => {
async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDraggedInside(false);
if (e.dataTransfer.files.length === 0) {
if (e.dataTransfer.files.length === 0 || !editor.isEditable) {
return;
}
const filesList = e.dataTransfer.files;
@@ -17,10 +17,12 @@ import {
export type TServerHandler = {
onConnect?: () => void;
onServerError?: () => void;
onServerSync?: () => void;
};
type TCollaborativeEditorHookProps = {
disabledExtensions: TExtensions[];
editable?: boolean;
editorClassName: string;
editorProps?: EditorProps;
extensions?: Extensions;
+1
View File
@@ -106,6 +106,7 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
// editor props
export interface IEditorProps {
editable: boolean;
containerClassName?: string;
displayConfig?: TDisplayConfig;
disabledExtensions: TExtensions[];
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@plane/eslint-config",
"private": true,
"version": "0.24.1",
"version": "0.24.0",
"files": [
"library.js",
"next.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/hooks",
"version": "0.24.1",
"version": "0.24.0",
"description": "React hooks that are shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tailwind-config-custom",
"version": "0.24.1",
"version": "0.24.0",
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "0.24.1",
"version": "0.24.0",
"private": true,
"types": "./src/index.d.ts",
"main": "./src/index.d.ts"
-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;
};
-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";
+1 -1
View File
@@ -216,7 +216,7 @@ export type GroupByColumnTypes =
export interface IGroupByColumn {
id: string;
name: string;
icon?: ReactElement | undefined;
icon: ReactElement | undefined;
payload: Partial<TIssue>;
isDropDisabled?: boolean;
dropErrorMessage?: string;
+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;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
"version": "0.24.1",
"version": "0.24.0",
"private": true,
"files": [
"base.json",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "0.24.1",
"version": "0.24.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/utils",
"version": "0.24.1",
"version": "0.24.0",
"description": "Helper functions shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "0.24.1",
"version": "0.24.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -1,7 +1,7 @@
"use client";
// components
import { NotificationsSidebarRoot } from "@/components/workspace-notifications";
import { NotificationsSidebarRoot } from "@/plane-web/components/workspace-notifications";
export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) {
return (
@@ -1,3 +0,0 @@
export * from "./workspace-level";
export * from "./project-level";
export * from "./issue-level";
@@ -1,73 +0,0 @@
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import useSWR from "swr";
// components
import { BulkDeleteIssuesModal } from "@/components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// constants
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
// hooks
import { useCommandPalette, useUser } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
// services
import { IssueService } from "@/services/issue";
// services
const issueService = new IssueService();
export const IssueLevelModals = observer(() => {
// router
const pathname = usePathname();
const { workspaceSlug, projectId, issueId, cycleId, moduleId } = useParams();
const router = useAppRouter();
// store hooks
const { data: currentUser } = useUser();
const {
issues: { removeIssue },
} = useIssuesStore();
const {
isCreateIssueModalOpen,
toggleCreateIssueModal,
isDeleteIssueModalOpen,
toggleDeleteIssueModal,
isBulkDeleteIssueModalOpen,
toggleBulkDeleteIssueModal,
} = useCommandPalette();
// derived values
const isDraftIssue = pathname?.includes("draft-issues") || false;
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId
? () => issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
: null
);
return (
<>
<CreateUpdateIssueModal
isOpen={isCreateIssueModalOpen}
onClose={() => toggleCreateIssueModal(false)}
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined}
isDraft={isDraftIssue}
/>
{workspaceSlug && projectId && issueId && issueDetails && (
<DeleteIssueModal
handleClose={() => toggleDeleteIssueModal(false)}
isOpen={isDeleteIssueModalOpen}
data={issueDetails}
onSubmit={async () => {
await removeIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString());
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
}}
/>
)}
<BulkDeleteIssuesModal
isOpen={isBulkDeleteIssueModalOpen}
onClose={() => toggleBulkDeleteIssueModal(false)}
user={currentUser}
/>
</>
);
});
@@ -1,59 +0,0 @@
import { observer } from "mobx-react";
// components
import { CycleCreateUpdateModal } from "@/components/cycles";
import { CreateUpdateModuleModal } from "@/components/modules";
import { CreatePageModal } from "@/components/pages";
import { CreateUpdateProjectViewModal } from "@/components/views";
// hooks
import { useCommandPalette } from "@/hooks/store";
export type TProjectLevelModalsProps = {
workspaceSlug: string;
projectId: string;
};
export const ProjectLevelModals = observer((props: TProjectLevelModalsProps) => {
const { workspaceSlug, projectId } = props;
// store hooks
const {
isCreateCycleModalOpen,
toggleCreateCycleModal,
isCreateModuleModalOpen,
toggleCreateModuleModal,
isCreateViewModalOpen,
toggleCreateViewModal,
createPageModal,
toggleCreatePageModal,
} = useCommandPalette();
return (
<>
<CycleCreateUpdateModal
isOpen={isCreateCycleModalOpen}
handleClose={() => toggleCreateCycleModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
<CreateUpdateModuleModal
isOpen={isCreateModuleModalOpen}
onClose={() => toggleCreateModuleModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
<CreateUpdateProjectViewModal
isOpen={isCreateViewModalOpen}
onClose={() => toggleCreateViewModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
<CreatePageModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isModalOpen={createPageModal.isOpen}
pageAccess={createPageModal.pageAccess}
handleModalClose={() => toggleCreatePageModal({ isOpen: false })}
redirectionEnabled
/>
</>
);
});
@@ -1,25 +0,0 @@
import { observer } from "mobx-react";
// components
import { CreateProjectModal } from "@/components/project";
// hooks
import { useCommandPalette } from "@/hooks/store";
export type TWorkspaceLevelModalsProps = {
workspaceSlug: string;
};
export const WorkspaceLevelModals = observer((props: TWorkspaceLevelModalsProps) => {
const { workspaceSlug } = props;
// store hooks
const { isCreateProjectModalOpen, toggleCreateProjectModal } = useCommandPalette();
return (
<>
<CreateProjectModal
isOpen={isCreateProjectModalOpen}
onClose={() => toggleCreateProjectModal(false)}
workspaceSlug={workspaceSlug.toString()}
/>
</>
);
});
@@ -1 +1 @@
export * from "./notification-card/root";
export * from './root'
@@ -11,6 +11,7 @@ import {
NotificationEmptyState,
NotificationSidebarHeader,
AppliedFilters,
NotificationCardListRoot,
} from "@/components/workspace-notifications";
// constants
import { NOTIFICATION_TABS, TNotificationTab } from "@/constants/notification";
@@ -20,8 +21,6 @@ import { getNumberCount } from "@/helpers/string.helper";
// hooks
import { useWorkspace, useWorkspaceNotifications } from "@/hooks/store";
import { NotificationCardListRoot } from "@/plane-web/components/workspace-notifications";
export const NotificationsSidebarRoot: FC = observer(() => {
const { workspaceSlug } = useParams();
// hooks
-95
View File
@@ -1,95 +0,0 @@
// types
import { TCommandPaletteActionList, TCommandPaletteShortcut, TCommandPaletteShortcutList } from "@plane/types";
// store
import { store } from "@/lib/store-context";
export const getGlobalShortcutsList: () => TCommandPaletteActionList = () => {
const { toggleCreateIssueModal } = store.commandPalette;
return {
c: {
title: "Create a new issue",
description: "Create a new issue in the current project",
action: () => toggleCreateIssueModal(true),
},
};
};
export const getWorkspaceShortcutsList: () => TCommandPaletteActionList = () => {
const { toggleCreateProjectModal } = store.commandPalette;
return {
p: {
title: "Create a new project",
description: "Create a new project in the current workspace",
action: () => toggleCreateProjectModal(true),
},
};
};
export const getProjectShortcutsList: () => TCommandPaletteActionList = () => {
const {
toggleCreatePageModal,
toggleCreateModuleModal,
toggleCreateCycleModal,
toggleCreateViewModal,
toggleBulkDeleteIssueModal,
} = store.commandPalette;
return {
d: {
title: "Create a new page",
description: "Create a new page in the current project",
action: () => toggleCreatePageModal({ isOpen: true }),
},
m: {
title: "Create a new module",
description: "Create a new module in the current project",
action: () => toggleCreateModuleModal(true),
},
q: {
title: "Create a new cycle",
description: "Create a new cycle in the current project",
action: () => toggleCreateCycleModal(true),
},
v: {
title: "Create a new view",
description: "Create a new view in the current project",
action: () => toggleCreateViewModal(true),
},
backspace: {
title: "Bulk delete issues",
description: "Bulk delete issues in the current project",
action: () => toggleBulkDeleteIssueModal(true),
},
delete: {
title: "Bulk delete issues",
description: "Bulk delete issues in the current project",
action: () => toggleBulkDeleteIssueModal(true),
},
};
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const handleAdditionalKeyDownEvents = (e: KeyboardEvent) => null;
export const getNavigationShortcutsList = (): TCommandPaletteShortcut[] => [
{ keys: "Ctrl,K", description: "Open command menu" },
];
export const getCommonShortcutsList = (platform: string): TCommandPaletteShortcut[] => [
{ keys: "P", description: "Create project" },
{ keys: "C", description: "Create issue" },
{ keys: "Q", description: "Create cycle" },
{ keys: "M", description: "Create module" },
{ keys: "V", description: "Create view" },
{ keys: "D", description: "Create page" },
{ keys: "Delete", description: "Bulk delete issues" },
{ keys: "Shift,/", description: "Open shortcuts guide" },
{
keys: platform === "MacOS" ? "Ctrl,control,C" : "Ctrl,Alt,C",
description: "Copy issue URL from the issue details page",
},
];
export const getAdditionalShortcutsList = (): TCommandPaletteShortcutList[] => [];
-12
View File
@@ -1,12 +0,0 @@
import { makeObservable } from "mobx";
// types / constants
import { BaseCommandPaletteStore, IBaseCommandPaletteStore } from "@/store/base-command-palette.store";
export type ICommandPaletteStore = IBaseCommandPaletteStore;
export class CommandPaletteStore extends BaseCommandPaletteStore implements ICommandPaletteStore {
constructor() {
super();
makeObservable(this, {});
}
}
@@ -2,43 +2,87 @@
import React, { useCallback, useEffect, FC, useMemo } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useParams, usePathname } from "next/navigation";
import useSWR from "swr";
// ui
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { CommandModal, ShortcutsModal } from "@/components/command-palette";
import { BulkDeleteIssuesModal } from "@/components/core";
import { CycleCreateUpdateModal } from "@/components/cycles";
import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
import { CreateUpdateModuleModal } from "@/components/modules";
import { CreatePageModal } from "@/components/pages";
import { CreateProjectModal } from "@/components/project";
import { CreateUpdateProjectViewModal } from "@/components/views";
// constants
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
// helpers
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useUser, useAppTheme, useCommandPalette, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import {
IssueLevelModals,
ProjectLevelModals,
WorkspaceLevelModals,
} from "@/plane-web/components/command-palette/modals";
// plane web constants
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
// plane web helpers
import {
getGlobalShortcutsList,
getProjectShortcutsList,
getWorkspaceShortcutsList,
handleAdditionalKeyDownEvents,
} from "@/plane-web/helpers/command-palette";
// services
import { IssueService } from "@/services/issue";
// services
const issueService = new IssueService();
export const CommandPalette: FC = observer(() => {
// router
const router = useAppRouter();
// router params
const { workspaceSlug, projectId, issueId } = useParams();
const { workspaceSlug, projectId, issueId, cycleId, moduleId } = useParams();
// pathname
const pathname = usePathname();
// store hooks
const { toggleSidebar } = useAppTheme();
const { setTrackElement } = useEventTracker();
const { platform } = usePlatformOS();
const { data: currentUser, canPerformAnyCreateAction } = useUser();
const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen } = useCommandPalette();
const {
data: currentUser,
// canPerformProjectMemberActions,
// canPerformWorkspaceMemberActions,
canPerformAnyCreateAction,
// canPerformProjectAdminActions,
} = useUser();
const {
issues: { removeIssue },
} = useIssuesStore();
const {
toggleCommandPaletteModal,
isCreateIssueModalOpen,
toggleCreateIssueModal,
isCreateCycleModalOpen,
toggleCreateCycleModal,
createPageModal,
toggleCreatePageModal,
isCreateProjectModalOpen,
toggleCreateProjectModal,
isCreateModuleModalOpen,
toggleCreateModuleModal,
isCreateViewModalOpen,
toggleCreateViewModal,
isShortcutModalOpen,
toggleShortcutModal,
isBulkDeleteIssueModalOpen,
toggleBulkDeleteIssueModal,
isDeleteIssueModalOpen,
toggleDeleteIssueModal,
isAnyModalOpen,
} = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId
? () => issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
: null
);
// derived values
const canPerformWorkspaceMemberActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
@@ -126,11 +170,62 @@ export const CommandPalette: FC = observer(() => {
project: Record<string, { title: string; description: string; action: () => void }>;
} = useMemo(
() => ({
global: getGlobalShortcutsList(),
workspace: getWorkspaceShortcutsList(),
project: getProjectShortcutsList(),
global: {
c: {
title: "Create a new issue",
description: "Create a new issue in the current project",
action: () => toggleCreateIssueModal(true),
},
},
workspace: {
p: {
title: "Create a new project",
description: "Create a new project in the current workspace",
action: () => toggleCreateProjectModal(true),
},
},
project: {
d: {
title: "Create a new page",
description: "Create a new page in the current project",
action: () => toggleCreatePageModal({ isOpen: true }),
},
m: {
title: "Create a new module",
description: "Create a new module in the current project",
action: () => toggleCreateModuleModal(true),
},
q: {
title: "Create a new cycle",
description: "Create a new cycle in the current project",
action: () => toggleCreateCycleModal(true),
},
v: {
title: "Create a new view",
description: "Create a new view in the current project",
action: () => toggleCreateViewModal(true),
},
backspace: {
title: "Bulk delete issues",
description: "Bulk delete issues in the current project",
action: () => toggleBulkDeleteIssueModal(true),
},
delete: {
title: "Bulk delete issues",
description: "Bulk delete issues in the current project",
action: () => toggleBulkDeleteIssueModal(true),
},
},
}),
[]
[
toggleBulkDeleteIssueModal,
toggleCreateCycleModal,
toggleCreateIssueModal,
toggleCreateModuleModal,
toggleCreatePageModal,
toggleCreateProjectModal,
toggleCreateViewModal,
]
);
const handleKeyDown = useCallback(
@@ -201,8 +296,6 @@ export const CommandPalette: FC = observer(() => {
shortcutsList.project[keyPressed].action();
}
}
// Additional keydown events
handleAdditionalKeyDownEvents(e);
},
[
copyIssueUrlToClipboard,
@@ -227,16 +320,75 @@ export const CommandPalette: FC = observer(() => {
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
const isDraftIssue = pathname?.includes("draft-issues") || false;
if (!currentUser) return null;
return (
<>
<ShortcutsModal isOpen={isShortcutModalOpen} onClose={() => toggleShortcutModal(false)} />
{workspaceSlug && <WorkspaceLevelModals workspaceSlug={workspaceSlug.toString()} />}
{workspaceSlug && projectId && (
<ProjectLevelModals workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
{workspaceSlug && (
<CreateProjectModal
isOpen={isCreateProjectModalOpen}
onClose={() => toggleCreateProjectModal(false)}
workspaceSlug={workspaceSlug.toString()}
/>
)}
<IssueLevelModals />
{workspaceSlug && projectId && (
<>
<CycleCreateUpdateModal
isOpen={isCreateCycleModalOpen}
handleClose={() => toggleCreateCycleModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
<CreateUpdateModuleModal
isOpen={isCreateModuleModalOpen}
onClose={() => toggleCreateModuleModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
<CreateUpdateProjectViewModal
isOpen={isCreateViewModalOpen}
onClose={() => toggleCreateViewModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
<CreatePageModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isModalOpen={createPageModal.isOpen}
pageAccess={createPageModal.pageAccess}
handleModalClose={() => toggleCreatePageModal({ isOpen: false })}
redirectionEnabled
/>
</>
)}
<CreateUpdateIssueModal
isOpen={isCreateIssueModalOpen}
onClose={() => toggleCreateIssueModal(false)}
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined}
isDraft={isDraftIssue}
/>
{workspaceSlug && projectId && issueId && issueDetails && (
<DeleteIssueModal
handleClose={() => toggleDeleteIssueModal(false)}
isOpen={isDeleteIssueModalOpen}
data={issueDetails}
onSubmit={async () => {
await removeIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString());
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
}}
/>
)}
<BulkDeleteIssuesModal
isOpen={isBulkDeleteIssueModalOpen}
onClose={() => toggleBulkDeleteIssueModal(false)}
user={currentUser}
/>
<CommandModal />
</>
);
@@ -3,12 +3,6 @@ import { Command } from "lucide-react";
import { substringMatch } from "@/helpers/string.helper";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web helpers
import {
getAdditionalShortcutsList,
getCommonShortcutsList,
getNavigationShortcutsList,
} from "@/plane-web/helpers/command-palette";
type Props = {
searchQuery: string;
@@ -22,14 +16,26 @@ export const ShortcutCommandsList: React.FC<Props> = (props) => {
{
key: "navigation",
title: "Navigation",
shortcuts: getNavigationShortcutsList(),
shortcuts: [{ keys: "Ctrl,K", description: "Open command menu" }],
},
{
key: "common",
title: "Common",
shortcuts: getCommonShortcutsList(platform),
shortcuts: [
{ keys: "P", description: "Create project" },
{ keys: "C", description: "Create issue" },
{ keys: "Q", description: "Create cycle" },
{ keys: "M", description: "Create module" },
{ keys: "V", description: "Create view" },
{ keys: "D", description: "Create page" },
{ keys: "Delete", description: "Bulk delete issues" },
{ keys: "Shift,/", description: "Open shortcuts guide" },
{
keys: platform === "MacOS" ? "Ctrl,control,C" : "Ctrl,Alt,C",
description: "Copy issue URL from the issue details page",
},
],
},
...getAdditionalShortcutsList(),
];
const filteredShortcuts = KEYBOARD_SHORTCUTS.map((category) => {
@@ -63,11 +69,7 @@ export const ShortcutCommandsList: React.FC<Props> = (props) => {
<div key={key} className="flex items-center gap-1">
{key === "Ctrl" ? (
<div className="grid h-6 min-w-[1.5rem] place-items-center rounded-sm border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 text-[10px] text-custom-text-200">
{platform === "MacOS" ? (
<Command className="h-2.5 w-2.5 text-custom-text-200" />
) : (
"Ctrl"
)}
{ platform === "MacOS" ? <Command className="h-2.5 w-2.5 text-custom-text-200" /> : 'Ctrl'}
</div>
) : (
<kbd className="grid h-6 min-w-[1.5rem] place-items-center rounded-sm border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 text-[10px] text-custom-text-200">
@@ -1,6 +1,6 @@
import { useRef, useState } from "react";
// Plane
import { setToast, TOAST_TYPE } from "@plane/ui";
import { setToast } from "@plane/ui";
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
@@ -103,7 +103,7 @@ export const useGanttResizable = (
const deltaWidth = Math.round((width - (block.position?.width ?? 0)) / dayWidth) * dayWidth;
// call update blockPosition
if (deltaWidth || deltaLeft) updateBlockPosition(block.id, deltaLeft, deltaWidth);
if (deltaWidth || deltaLeft) updateBlockPosition(block.id, deltaLeft, deltaWidth, dragDirection !== "move");
};
// remove event listeners and call updateBlockDates
@@ -119,14 +119,10 @@ export const useGanttResizable = (
(dragDirection === "left" && !block.start_date) || (dragDirection === "right" && !block.target_date);
try {
const blockUpdates = getUpdatedPositionAfterDrag(block.id, shouldUpdateHalfBlock);
if (updateBlockDates) updateBlockDates(blockUpdates);
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error",
message: "Something went wrong while updating block dates",
});
const blockUpdates = getUpdatedPositionAfterDrag(block.id, shouldUpdateHalfBlock, dragDirection !== "move");
updateBlockDates && updateBlockDates(blockUpdates);
} catch (e) {
setToast;
}
setIsDragging(false);
@@ -0,0 +1,18 @@
import { RefreshCcw } from "lucide-react";
import { Tooltip } from "@plane/ui";
export const SyncingComponent = (props: { toolTipContent?: string }) => {
const { toolTipContent } = props;
const lockedComponent = (
<div className="flex-shrink-0 flex h-7 items-center gap-2 rounded-full bg-custom-background-80 px-3 py-0.5 text-xs font-medium text-custom-text-300">
<RefreshCcw className="h-3 w-3" />
<span>Syncing</span>
</div>
);
return (
<>
{toolTipContent ? <Tooltip tooltipContent={toolTipContent}>{lockedComponent}</Tooltip> : <>{lockedComponent}</>}
</>
);
};
@@ -82,7 +82,6 @@ export const IssueActivityItem: FC<TIssueActivityItem> = observer((props) => {
return <IssueAttachmentActivity {...componentDefaultProps} showIssue={false} />;
case "archived_at":
return <IssueArchivedAtActivity {...componentDefaultProps} />;
case "intake":
case "inbox":
return <IssueInboxActivity {...componentDefaultProps} />;
case "type":
@@ -18,7 +18,7 @@ import { ContentWrapper } from "@plane/ui";
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
import { KanbanColumnLoader } from "@/components/ui";
// hooks
import { useKanbanView } from "@/hooks/store";
import { useCycle, useKanbanView, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
// types
// parent components
@@ -87,16 +87,30 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
dropErrorMessage,
subGroupIndex = 0,
} = props;
// store hooks
const storeType = useIssueStoreType();
const member = useMember();
const project = useProject();
const label = useLabel();
const cycle = useCycle();
const moduleInfo = useModule();
const projectState = useProjectState();
const issueKanBanView = useKanbanView();
// derived values
const isDragDisabled = !issueKanBanView?.getCanUserDragDrop(group_by, sub_group_by);
const list = getGroupByColumns({
groupBy: group_by as GroupByColumnTypes,
includeNone: true,
isWorkspaceLevel: isWorkspaceLevel(storeType),
});
const list = getGroupByColumns(
group_by as GroupByColumnTypes,
project,
cycle,
moduleInfo,
label,
projectState,
member,
true,
isWorkspaceLevel(storeType)
);
if (!list) return null;
@@ -15,6 +15,7 @@ import {
// UI
import { Row } from "@plane/ui";
// hooks
import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
// components
import { TRenderQuickActions } from "../list/list-view-types";
@@ -261,19 +262,38 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
quickAddCallback,
scrollableContainerRef,
} = props;
// store hooks
const storeType = useIssueStoreType();
// derived values
const groupByList = getGroupByColumns({
groupBy: group_by as GroupByColumnTypes,
includeNone: true,
isWorkspaceLevel: isWorkspaceLevel(storeType),
});
const subGroupByList = getGroupByColumns({
groupBy: sub_group_by as GroupByColumnTypes,
includeNone: true,
isWorkspaceLevel: isWorkspaceLevel(storeType),
});
const member = useMember();
const project = useProject();
const label = useLabel();
const cycle = useCycle();
const projectModule = useModule();
const projectState = useProjectState();
const groupByList = getGroupByColumns(
group_by as GroupByColumnTypes,
project,
cycle,
projectModule,
label,
projectState,
member,
true,
isWorkspaceLevel(storeType)
);
const subGroupByList = getGroupByColumns(
sub_group_by as GroupByColumnTypes,
project,
cycle,
projectModule,
label,
projectState,
member,
true,
isWorkspaceLevel(storeType)
);
if (!groupByList || !subGroupByList) return null;
@@ -18,7 +18,9 @@ import {
} from "@plane/types";
// components
import { MultipleSelectGroup } from "@/components/core";
// hooks
import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
// plane web components
import { IssueBulkOperationsRoot } from "@/plane-web/components/issues";
@@ -73,16 +75,29 @@ export const List: React.FC<IList> = observer((props) => {
} = props;
const storeType = useIssueStoreType();
// store hooks
const member = useMember();
const project = useProject();
const label = useLabel();
const projectState = useProjectState();
const cycle = useCycle();
const projectModule = useModule();
// plane web hooks
const isBulkOperationsEnabled = useBulkOperationStatus();
const containerRef = useRef<HTMLDivElement | null>(null);
const groups = getGroupByColumns({
groupBy: group_by as GroupByColumnTypes,
includeNone: true,
isWorkspaceLevel: isWorkspaceLevel(storeType),
});
const groups = getGroupByColumns(
group_by as GroupByColumnTypes,
project,
cycle,
projectModule,
label,
projectState,
member,
true,
isWorkspaceLevel(storeType)
);
// Enable Auto Scroll for Main Kanban
useEffect(() => {
+117 -111
View File
@@ -36,8 +36,13 @@ import { STATE_GROUPS } from "@/constants/state";
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
// store
import { store } from "@/lib/store-context";
import { ICycleStore } from "@/store/cycle.store";
import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/base-issues.store";
import { ILabelStore } from "@/store/label.store";
import { IMemberRootStore } from "@/store/member";
import { IModuleStore } from "@/store/module.store";
import { IProjectStore } from "@/store/project/project.store";
import { IStateStore } from "@/store/state.store";
export const HIGHLIGHT_CLASS = "highlight";
export const HIGHLIGHT_WITH_LINE = "highlight-with-line";
@@ -60,61 +65,51 @@ export type IssueUpdates = {
export const isWorkspaceLevel = (type: EIssuesStoreType) =>
[EIssuesStoreType.PROFILE, EIssuesStoreType.GLOBAL].includes(type) ? true : false;
type TGetGroupByColumns = {
groupBy: GroupByColumnTypes | null;
includeNone: boolean;
isWorkspaceLevel: boolean;
};
// NOTE: Type of groupBy is different compared to what's being passed from the components.
// We are using `as` to typecast it to the expected type.
// It can break the includeNone logic if not handled properly.
export const getGroupByColumns = ({
groupBy,
includeNone,
isWorkspaceLevel,
}: TGetGroupByColumns): IGroupByColumn[] | undefined => {
// If no groupBy is specified and includeNone is true, return "All Issues" group
if (!groupBy && includeNone) {
return [
{
id: "All Issues",
name: "All Issues",
payload: {},
icon: undefined,
},
];
export const getGroupByColumns = (
groupBy: GroupByColumnTypes | null,
project: IProjectStore,
cycle: ICycleStore,
module: IModuleStore,
label: ILabelStore,
projectState: IStateStore,
member: IMemberRootStore,
includeNone?: boolean,
isWorkspaceLevel?: boolean
): IGroupByColumn[] | undefined => {
switch (groupBy) {
case "project":
return getProjectColumns(project);
case "cycle":
return getCycleColumns(project, cycle);
case "module":
return getModuleColumns(project, module);
case "state":
return getStateColumns(projectState);
case "state_detail.group":
return getStateGroupColumns();
case "priority":
return getPriorityColumns();
case "labels":
return getLabelsColumns(label, isWorkspaceLevel) as any;
case "assignees":
return getAssigneeColumns(member) as any;
case "created_by":
return getCreatedByColumns(member) as any;
default:
if (includeNone) return [{ id: `All Issues`, name: `All Issues`, payload: {}, icon: undefined }];
}
// Return undefined if no valid groupBy
if (!groupBy) return undefined;
// Map of group by options to their corresponding column getter functions
const groupByColumnMap: Record<GroupByColumnTypes, () => IGroupByColumn[] | undefined> = {
project: getProjectColumns,
cycle: getCycleColumns,
module: getModuleColumns,
state: getStateColumns,
"state_detail.group": getStateGroupColumns,
priority: getPriorityColumns,
labels: () => getLabelsColumns(isWorkspaceLevel),
assignees: getAssigneeColumns,
created_by: getCreatedByColumns,
};
// Get and return the columns for the specified group by option
return groupByColumnMap[groupBy]?.();
};
const getProjectColumns = (): IGroupByColumn[] | undefined => {
const { joinedProjectIds: projectIds, projectMap } = store.projectRoot.project;
// Return undefined if no project ids
const getProjectColumns = (project: IProjectStore): IGroupByColumn[] | undefined => {
const { workspaceProjectIds: projectIds, projectMap } = project;
if (!projectIds) return;
// Map project ids to project columns
return projectIds
.map((projectId: string) => {
.filter((projectId) => !!projectMap[projectId])
.map((projectId) => {
const project = projectMap[projectId];
if (!project) return;
return {
id: project.id,
name: project.name,
@@ -125,71 +120,78 @@ const getProjectColumns = (): IGroupByColumn[] | undefined => {
),
payload: { project_id: project.id },
};
})
.filter((column) => column !== undefined) as IGroupByColumn[];
}) as any;
};
const getCycleColumns = (): IGroupByColumn[] | undefined => {
const { currentProjectDetails } = store.projectRoot.project;
// Check for the current project details
const getCycleColumns = (projectStore: IProjectStore, cycleStore: ICycleStore): IGroupByColumn[] | undefined => {
const { currentProjectDetails } = projectStore;
const { getProjectCycleIds, getCycleById } = cycleStore;
if (!currentProjectDetails || !currentProjectDetails?.id) return;
const { getProjectCycleDetails } = store.cycle;
// Get the cycle details for the current project
const cycleDetails = currentProjectDetails?.id ? getProjectCycleDetails(currentProjectDetails?.id) : undefined;
// Map the cycle details to the group by columns
const cycles: IGroupByColumn[] = [];
cycleDetails?.map((cycle) => {
const cycleStatus = cycle.status ? (cycle.status.toLocaleLowerCase() as TCycleGroups) : "draft";
const isDropDisabled = cycleStatus === "completed";
cycles.push({
id: cycle.id,
name: cycle.name,
icon: <CycleGroupIcon cycleGroup={cycleStatus as TCycleGroups} className="h-3.5 w-3.5" />,
payload: { cycle_id: cycle.id },
isDropDisabled,
dropErrorMessage: isDropDisabled ? "Issue cannot be moved to completed cycles" : undefined,
});
const cycleIds = currentProjectDetails?.id ? getProjectCycleIds(currentProjectDetails?.id) : undefined;
if (!cycleIds) return;
const cycles = [];
cycleIds.map((cycleId) => {
const cycle = getCycleById(cycleId);
if (cycle) {
const cycleStatus = cycle.status ? (cycle.status.toLocaleLowerCase() as TCycleGroups) : "draft";
const isDropDisabled = cycleStatus === "completed";
cycles.push({
id: cycle.id,
name: cycle.name,
icon: <CycleGroupIcon cycleGroup={cycleStatus as TCycleGroups} className="h-3.5 w-3.5" />,
payload: { cycle_id: cycle.id },
isDropDisabled,
dropErrorMessage: isDropDisabled ? "Issue cannot be moved to completed cycles" : undefined,
});
}
});
cycles.push({
id: "None",
name: "None",
icon: <ContrastIcon className="h-3.5 w-3.5" />,
payload: {},
});
return cycles;
return cycles as any;
};
const getModuleColumns = (): IGroupByColumn[] | undefined => {
// get current project details
const { currentProjectDetails } = store.projectRoot.project;
const getModuleColumns = (projectStore: IProjectStore, moduleStore: IModuleStore): IGroupByColumn[] | undefined => {
const { currentProjectDetails } = projectStore;
const { getProjectModuleIds, getModuleById } = moduleStore;
if (!currentProjectDetails || !currentProjectDetails?.id) return;
// get project module ids and module details
const { getProjectModuleDetails } = store.module;
// get module details
const moduleDetails = currentProjectDetails?.id ? getProjectModuleDetails(currentProjectDetails?.id) : undefined;
// map module details to group by columns
const modules: IGroupByColumn[] = [];
moduleDetails?.map((module) => {
modules.push({
id: module.id,
name: module.name,
icon: <DiceIcon className="h-3.5 w-3.5" />,
payload: { module_ids: [module.id] },
});
});
const moduleIds = currentProjectDetails?.id ? getProjectModuleIds(currentProjectDetails?.id) : undefined;
if (!moduleIds) return;
const modules = [];
moduleIds.map((moduleId) => {
const moduleInfo = getModuleById(moduleId);
if (moduleInfo)
modules.push({
id: moduleInfo.id,
name: moduleInfo.name,
icon: <DiceIcon className="h-3.5 w-3.5" />,
payload: { module_ids: [moduleInfo.id] },
});
}) as any;
modules.push({
id: "None",
name: "None",
icon: <DiceIcon className="h-3.5 w-3.5" />,
payload: {},
});
return modules;
return modules as any;
};
const getStateColumns = (): IGroupByColumn[] | undefined => {
const { projectStates } = store.state;
const getStateColumns = (projectState: IStateStore): IGroupByColumn[] | undefined => {
const { projectStates } = projectState;
if (!projectStates) return;
// map project states to group by columns
return projectStates.map((state) => ({
id: state.id,
name: state.name,
@@ -199,12 +201,12 @@ const getStateColumns = (): IGroupByColumn[] | undefined => {
</div>
),
payload: { state_id: state.id },
}));
})) as any;
};
const getStateGroupColumns = (): IGroupByColumn[] => {
const getStateGroupColumns = () => {
const stateGroups = STATE_GROUPS;
// map state groups to group by columns
return Object.values(stateGroups).map((stateGroup) => ({
id: stateGroup.key,
name: stateGroup.label,
@@ -217,9 +219,9 @@ const getStateGroupColumns = (): IGroupByColumn[] => {
}));
};
const getPriorityColumns = (): IGroupByColumn[] => {
const getPriorityColumns = () => {
const priorities = ISSUE_PRIORITIES;
// map priorities to group by columns
return priorities.map((priority) => ({
id: priority.key,
name: priority.title,
@@ -228,14 +230,14 @@ const getPriorityColumns = (): IGroupByColumn[] => {
}));
};
const getLabelsColumns = (isWorkspaceLevel: boolean = false): IGroupByColumn[] => {
const { workspaceLabels, projectLabels } = store.label;
// map labels to group by columns
const getLabelsColumns = (label: ILabelStore, isWorkspaceLevel: boolean = false) => {
const { workspaceLabels, projectLabels } = label;
const labels = [
...(isWorkspaceLevel ? workspaceLabels || [] : projectLabels || []),
{ id: "None", name: "None", color: "#666" },
];
// map labels to group by columns
return labels.map((label) => ({
id: label.id,
name: label.name,
@@ -246,14 +248,15 @@ const getLabelsColumns = (isWorkspaceLevel: boolean = false): IGroupByColumn[] =
}));
};
const getAssigneeColumns = (): IGroupByColumn[] | undefined => {
const getAssigneeColumns = (member: IMemberRootStore) => {
const {
project: { projectMemberIds },
getUserDetails,
} = store.memberRoot;
} = member;
if (!projectMemberIds) return;
// Map project member ids to group by assignee columns
const assigneeColumns: IGroupByColumn[] = projectMemberIds.map((memberId) => {
const assigneeColumns: any = projectMemberIds.map((memberId) => {
const member = getUserDetails(memberId);
return {
id: memberId,
@@ -262,17 +265,20 @@ const getAssigneeColumns = (): IGroupByColumn[] | undefined => {
payload: { assignee_ids: [memberId] },
};
});
assigneeColumns.push({ id: "None", name: "None", icon: <Avatar size="md" />, payload: {} });
return assigneeColumns;
};
const getCreatedByColumns = (): IGroupByColumn[] | undefined => {
const getCreatedByColumns = (member: IMemberRootStore) => {
const {
project: { projectMemberIds },
getUserDetails,
} = store.memberRoot;
} = member;
if (!projectMemberIds) return;
// Map project member ids to group by created by columns
return projectMemberIds.map((memberId) => {
const member = getUserDetails(memberId);
return {
@@ -1,11 +1,9 @@
import { useCallback, useMemo } from "react";
import { Dispatch, SetStateAction, useCallback, useMemo } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// document-editor
import {
CollaborativeDocumentEditorWithRef,
CollaborativeDocumentReadOnlyEditorWithRef,
EditorReadOnlyRefApi,
EditorRefApi,
TAIMenuProps,
TDisplayConfig,
@@ -20,7 +18,7 @@ import { Row } from "@plane/ui";
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
// helpers
import { cn, LIVE_BASE_PATH, LIVE_BASE_URL } from "@/helpers/common.helper";
import { getEditorFileHandlers, getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
import { getEditorFileHandlers } from "@/helpers/editor.helper";
import { generateRandomColor } from "@/helpers/string.helper";
// hooks
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
@@ -42,24 +40,15 @@ const fileService = new FileService();
type Props = {
editorRef: React.RefObject<EditorRefApi>;
editorReady: boolean;
handleConnectionStatus: (status: boolean) => void;
handleEditorReady: (value: boolean) => void;
handleReadOnlyEditorReady: (value: boolean) => void;
handleConnectionStatus: Dispatch<SetStateAction<boolean>>;
handleEditorReady: Dispatch<SetStateAction<boolean>>;
page: IPage;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
sidePeekVisible: boolean;
setSyncing: (value: boolean) => void;
};
export const PageEditorBody: React.FC<Props> = observer((props) => {
const {
editorRef,
handleConnectionStatus,
handleEditorReady,
handleReadOnlyEditorReady,
page,
readOnlyEditorRef,
sidePeekVisible,
} = props;
const { editorRef, handleConnectionStatus, handleEditorReady, page, sidePeekVisible, setSyncing } = props;
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
@@ -118,12 +107,17 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
handleConnectionStatus(true);
}, []);
const handleServerSynced = useCallback(() => {
setSyncing(true);
}, []);
const serverHandler: TServerHandler = useMemo(
() => ({
onConnect: handleServerConnect,
onServerError: handleServerError,
onServerSync: handleServerSynced,
}),
[handleServerConnect, handleServerError]
[handleServerConnect, handleServerError, handleServerSynced]
);
const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => {
@@ -169,9 +163,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
"w-[5%]": isFullWidth,
})}
>
{!isFullWidth && (
<PageContentBrowser editorRef={(isContentEditable ? editorRef : readOnlyEditorRef)?.current} />
)}
{!isFullWidth && <PageContentBrowser editorRef={editorRef.current} />}
</Row>
<div
className={cn("h-full w-full pt-5 duration-200", {
@@ -188,72 +180,48 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
readOnly={!isContentEditable}
/>
</div>
{isContentEditable ? (
<CollaborativeDocumentEditorWithRef
id={pageId}
fileHandler={getEditorFileHandlers({
maxFileSize,
projectId: projectId?.toString() ?? "",
uploadFile: async (file) => {
const { asset_id } = await fileService.uploadProjectAsset(
workspaceSlug?.toString() ?? "",
projectId?.toString() ?? "",
{
entity_identifier: pageId,
entity_type: EFileAssetType.PAGE_DESCRIPTION,
},
file
);
return asset_id;
},
workspaceId,
workspaceSlug: workspaceSlug?.toString() ?? "",
})}
handleEditorReady={handleEditorReady}
ref={editorRef}
containerClassName="h-full p-0 pb-64"
displayConfig={displayConfig}
editorClassName="pl-10"
mentionHandler={{
highlights: mentionHighlights,
suggestions: mentionSuggestions,
}}
embedHandler={{
issue: issueEmbedProps,
}}
realtimeConfig={realtimeConfig}
serverHandler={serverHandler}
user={userConfig}
disabledExtensions={disabledExtensions}
aiHandler={{
menu: getAIMenu,
}}
/>
) : (
<CollaborativeDocumentReadOnlyEditorWithRef
id={pageId}
ref={readOnlyEditorRef}
disabledExtensions={disabledExtensions}
fileHandler={getReadOnlyEditorFileHandlers({
projectId: projectId?.toString() ?? "",
workspaceSlug: workspaceSlug?.toString() ?? "",
})}
handleEditorReady={handleReadOnlyEditorReady}
containerClassName="p-0 pb-64 border-none"
displayConfig={displayConfig}
editorClassName="pl-10"
mentionHandler={{
highlights: mentionHighlights,
}}
embedHandler={{
issue: {
widgetCallback: issueEmbedProps.widgetCallback,
},
}}
realtimeConfig={realtimeConfig}
user={userConfig}
/>
)}
<CollaborativeDocumentEditorWithRef
editable={isContentEditable}
id={pageId}
fileHandler={getEditorFileHandlers({
maxFileSize,
projectId: projectId?.toString() ?? "",
uploadFile: async (file) => {
const { asset_id } = await fileService.uploadProjectAsset(
workspaceSlug?.toString() ?? "",
projectId?.toString() ?? "",
{
entity_identifier: pageId,
entity_type: EFileAssetType.PAGE_DESCRIPTION,
},
file
);
return asset_id;
},
workspaceId,
workspaceSlug: workspaceSlug?.toString() ?? "",
})}
handleEditorReady={handleEditorReady}
ref={editorRef}
containerClassName="h-full p-0 pb-64"
displayConfig={displayConfig}
editorClassName="pl-10"
mentionHandler={{
highlights: mentionHighlights,
suggestions: mentionSuggestions,
}}
embedHandler={{
issue: issueEmbedProps,
}}
realtimeConfig={realtimeConfig}
serverHandler={serverHandler}
user={userConfig}
disabledExtensions={disabledExtensions}
aiHandler={{
menu: getAIMenu,
}}
/>
)
</div>
</div>
<div
@@ -2,11 +2,12 @@
import { observer } from "mobx-react";
// editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { EditorRefApi } from "@plane/editor";
// ui
import { ArchiveIcon, FavoriteStar, setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
// components
import { LockedComponent } from "@/components/icons/locked-component";
import { SyncingComponent } from "@/components/icons/syncing-component";
import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
@@ -19,11 +20,11 @@ type Props = {
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
page: IPage;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
syncState: boolean | null;
};
export const PageExtraOptions: React.FC<Props> = observer((props) => {
const { editorRef, handleDuplicatePage, page, readOnlyEditorRef } = props;
const { editorRef, syncState, handleDuplicatePage, page } = props;
// derived values
const {
archived_at,
@@ -60,6 +61,7 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
return (
<div className="flex items-center justify-end gap-3">
{is_locked && <LockedComponent />}
{!syncState && <SyncingComponent />}
{archived_at && (
<div className="flex-shrink-0 flex h-7 items-center gap-2 rounded-full bg-blue-500/20 px-3 py-0.5 text-xs font-medium text-blue-500">
<ArchiveIcon className="flex-shrink-0 size-3" />
@@ -85,12 +87,8 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
iconClassName="text-custom-text-100"
/>
)}
<PageInfoPopover editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current} />
<PageOptionsDropdown
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
handleDuplicatePage={handleDuplicatePage}
page={page}
/>
<PageInfoPopover editorRef={editorRef.current} />
<PageOptionsDropdown editorRef={editorRef.current} handleDuplicatePage={handleDuplicatePage} page={page} />
</div>
);
});
@@ -2,12 +2,12 @@ import { useState } from "react";
import { usePopper } from "react-popper";
import { Info } from "lucide-react";
// plane editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { EditorRefApi } from "@plane/editor";
// helpers
import { getReadTimeFromWordsCount } from "@/helpers/date-time.helper";
type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
editorRef: EditorRefApi | null;
};
export const PageInfoPopover: React.FC<Props> = (props) => {
@@ -13,52 +13,34 @@ type Props = {
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
page: IPage;
readOnlyEditorReady: boolean;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
setSidePeekVisible: (sidePeekState: boolean) => void;
sidePeekVisible: boolean;
};
export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
const {
editorReady,
editorRef,
handleDuplicatePage,
page,
readOnlyEditorReady,
readOnlyEditorRef,
setSidePeekVisible,
sidePeekVisible,
} = props;
const { editorReady, editorRef, handleDuplicatePage, page, setSidePeekVisible, sidePeekVisible } = props;
// derived values
const { isContentEditable } = page;
// page filters
const { isFullWidth } = usePageFilters();
if (!editorRef.current && !readOnlyEditorRef.current) return null;
if (!editorRef.current) return null;
return (
<>
<Header variant={EHeaderVariant.SECONDARY}>
<div className="flex-shrink-0 my-auto">
<PageSummaryPopover
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
editorRef={editorRef.current}
isFullWidth={isFullWidth}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
/>
</div>
<PageExtraOptions
editorRef={editorRef}
handleDuplicatePage={handleDuplicatePage}
page={page}
readOnlyEditorRef={readOnlyEditorRef}
/>
<PageExtraOptions editorRef={editorRef} handleDuplicatePage={handleDuplicatePage} page={page} />
</Header>
<Header variant={EHeaderVariant.TERNARY}>
{(editorReady || readOnlyEditorReady) && isContentEditable && editorRef.current && (
<PageToolbar editorRef={editorRef?.current} />
)}
{editorReady && isContentEditable && editorRef.current && <PageToolbar editorRef={editorRef?.current} />}
</Header>
</>
);
@@ -15,7 +15,7 @@ import {
LucideIcon,
} from "lucide-react";
// document editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { EditorRefApi } from "@plane/editor";
// ui
import { ArchiveIcon, CustomMenu, type ISvgIcons, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components
@@ -30,7 +30,7 @@ import { useQueryParams } from "@/hooks/use-query-params";
import { IPage } from "@/store/pages/page";
type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
editorRef: EditorRefApi | null;
handleDuplicatePage: () => void;
page: IPage;
};
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { EditorRefApi } from "@plane/editor";
// components
import { Header, EHeaderVariant } from "@plane/ui";
import { PageEditorMobileHeaderRoot, PageExtraOptions, PageSummaryPopover, PageToolbar } from "@/components/pages";
@@ -15,35 +15,25 @@ type Props = {
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
page: IPage;
readOnlyEditorReady: boolean;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
setSidePeekVisible: (sidePeekState: boolean) => void;
sidePeekVisible: boolean;
syncState: boolean | null;
};
export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
const {
editorReady,
editorRef,
handleDuplicatePage,
page,
readOnlyEditorReady,
readOnlyEditorRef,
setSidePeekVisible,
sidePeekVisible,
} = props;
const { editorReady, editorRef, setSidePeekVisible, sidePeekVisible, handleDuplicatePage, page, syncState } = props;
// derived values
const { isContentEditable } = page;
// page filters
const { isFullWidth } = usePageFilters();
if (!editorRef.current && !readOnlyEditorRef.current) return null;
if (!editorRef.current) return null;
return (
<>
<Header variant={EHeaderVariant.SECONDARY} showOnMobile={false}>
<Header.LeftItem className="gap-0 w-full">
{(editorReady || readOnlyEditorReady) && (
{editorReady && (
<div
className={cn("flex-shrink-0 my-auto", {
"w-40 lg:w-56": !isFullWidth,
@@ -51,30 +41,26 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
})}
>
<PageSummaryPopover
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
editorRef={editorRef.current}
isFullWidth={isFullWidth}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
/>
</div>
)}
{(editorReady || readOnlyEditorReady) && isContentEditable && editorRef.current && (
<PageToolbar editorRef={editorRef?.current} />
)}
{editorReady && isContentEditable && editorRef.current && <PageToolbar editorRef={editorRef?.current} />}
</Header.LeftItem>
<PageExtraOptions
editorRef={editorRef}
handleDuplicatePage={handleDuplicatePage}
page={page}
readOnlyEditorRef={readOnlyEditorRef}
syncState={syncState}
/>
</Header>
<div className="md:hidden">
<PageEditorMobileHeaderRoot
editorRef={editorRef}
readOnlyEditorRef={readOnlyEditorRef}
editorReady={editorReady}
readOnlyEditorReady={readOnlyEditorReady}
handleDuplicatePage={handleDuplicatePage}
page={page}
sidePeekVisible={sidePeekVisible}
+7 -12
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { EditorRefApi } from "@plane/editor";
// types
import { TPage } from "@plane/types";
// ui
@@ -32,12 +32,11 @@ export const PageRoot = observer((props: TPageRootProps) => {
// states
const [editorReady, setEditorReady] = useState(false);
const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
const [readOnlyEditorReady, setReadOnlyEditorReady] = useState(false);
const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768);
const [syncState, setSyncing] = useState<boolean | null>(null);
const [isVersionsOverlayOpen, setIsVersionsOverlayOpen] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
const readOnlyEditorRef = useRef<EditorReadOnlyRefApi>(null);
// router
const router = useAppRouter();
// search params
@@ -99,9 +98,7 @@ export const PageRoot = observer((props: TPageRootProps) => {
editorRef.current?.clearEditor();
editorRef.current?.setEditorValue(descriptionHTML);
};
const currentVersionDescription = isContentEditable
? editorRef.current?.getDocument().html
: readOnlyEditorRef.current?.getDocument().html;
const currentVersionDescription = editorRef.current?.getDocument().html;
return (
<>
@@ -137,20 +134,18 @@ export const PageRoot = observer((props: TPageRootProps) => {
editorRef={editorRef}
handleDuplicatePage={handleDuplicatePage}
page={page}
readOnlyEditorReady={readOnlyEditorReady}
readOnlyEditorRef={readOnlyEditorRef}
setSidePeekVisible={(state) => setSidePeekVisible(state)}
sidePeekVisible={sidePeekVisible}
syncState={syncState}
/>
<PageEditorBody
editorReady={editorReady}
editorRef={editorRef}
handleConnectionStatus={(status) => setHasConnectionFailed(status)}
handleEditorReady={(val) => setEditorReady(val)}
handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)}
handleConnectionStatus={setHasConnectionFailed}
handleEditorReady={setEditorReady}
page={page}
readOnlyEditorRef={readOnlyEditorRef}
sidePeekVisible={sidePeekVisible}
setSyncing={setSyncing}
/>
</>
);
@@ -1,11 +1,11 @@
import { useState, useEffect } from "react";
// plane editor
import { EditorReadOnlyRefApi, EditorRefApi, IMarking } from "@plane/editor";
import { EditorRefApi, IMarking } from "@plane/editor";
// components
import { OutlineHeading1, OutlineHeading2, OutlineHeading3 } from "./heading-components";
type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
editorRef: EditorRefApi | null;
setSidePeekVisible?: (sidePeekState: boolean) => void;
};
@@ -2,14 +2,14 @@ import { useState } from "react";
import { usePopper } from "react-popper";
import { List } from "lucide-react";
// document editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { EditorRefApi } from "@plane/editor";
// helpers
import { cn } from "@/helpers/common.helper";
// components
import { PageContentBrowser } from "./content-browser";
type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
editorRef: EditorRefApi | null;
isFullWidth: boolean;
sidePeekVisible: boolean;
setSidePeekVisible: (sidePeekState: boolean) => void;
@@ -4,7 +4,7 @@ import { useState } from "react";
import { PageProps, pdf } from "@react-pdf/renderer";
import { Controller, useForm } from "react-hook-form";
// plane editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { EditorRefApi } from "@plane/editor";
// plane ui
import { Button, CustomSelect, EModalPosition, EModalWidth, ModalCore, setToast, TOAST_TYPE } from "@plane/ui";
// components
@@ -16,7 +16,7 @@ import {
} from "@/helpers/editor.helper";
type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
editorRef: EditorRefApi | null;
isOpen: boolean;
onClose: () => void;
pageTitle: string;
@@ -4,7 +4,7 @@ import React, { FC, useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { IUserEmailNotificationSettings } from "@plane/types";
// ui
import { ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
import { Button, Checkbox, TOAST_TYPE, setToast } from "@plane/ui";
// services
import { UserService } from "@/services/user.service";
// types
@@ -20,32 +20,35 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
const { data } = props;
// form data
const {
handleSubmit,
control,
reset,
formState: { isSubmitting, dirtyFields },
} = useForm<IUserEmailNotificationSettings>({
defaultValues: {
...data,
},
});
const handleSettingChange = async (key: keyof IUserEmailNotificationSettings, value: boolean) => {
try {
await userService.updateCurrentUserEmailNotificationSettings({
[key]: value,
});
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Email notification setting updated successfully",
});
} catch (err) {
console.error(err);
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Failed to update email notification setting",
});
}
const onSubmit = async (formData: IUserEmailNotificationSettings) => {
// Get the dirty fields from the form data and create a payload
let payload = {};
Object.keys(dirtyFields).forEach((key) => {
payload = {
...payload,
[key]: formData[key as keyof IUserEmailNotificationSettings],
};
});
await userService
.updateCurrentUserEmailNotificationSettings(payload)
.then(() =>
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Email Notification Settings updated successfully",
})
)
.catch((err) => console.error(err));
};
useEffect(() => {
@@ -61,7 +64,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">Property changes</div>
<div className="text-sm font-normal text-custom-text-300">
Notify me when issue's properties like assignees, priority, estimates or anything else changes.
Notify me when issues properties like assignees, priority, estimates or anything else changes.
</div>
</div>
<div className="shrink-0">
@@ -69,14 +72,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
control={control}
name="property_change"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("property_change", newValue);
}}
size="sm"
/>
<Checkbox checked={value} onChange={() => onChange(!value)} containerClassName="mx-2" />
)}
/>
</div>
@@ -93,13 +89,12 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
control={control}
name="state_change"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("state_change", newValue);
<Checkbox
checked={value}
onChange={() => {
onChange(!value);
}}
size="sm"
containerClassName="mx-2"
/>
)}
/>
@@ -115,14 +110,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
control={control}
name="issue_completed"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("issue_completed", newValue);
}}
size="sm"
/>
<Checkbox checked={value} onChange={() => onChange(!value)} containerClassName="mx-2" />
)}
/>
</div>
@@ -139,14 +127,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
control={control}
name="comment"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("comment", newValue);
}}
size="sm"
/>
<Checkbox checked={value} onChange={() => onChange(!value)} containerClassName="mx-2" />
)}
/>
</div>
@@ -163,19 +144,17 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
control={control}
name="mention"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("mention", newValue);
}}
size="sm"
/>
<Checkbox checked={value} onChange={() => onChange(!value)} containerClassName="mx-2" />
)}
/>
</div>
</div>
</div>
<div className="flex items-center py-12">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
</div>
</>
);
};
@@ -1,3 +1,2 @@
export * from "./notification-app-sidebar-option";
export * from "./sidebar";
export * from "./root";
@@ -1,2 +1,3 @@
export * from "./root";
export * from "./item";
export * from "./options";
@@ -27,7 +27,6 @@ import { CustomMenu, Tooltip, DropIndicator, FavoriteFolderIcon, DragHandle } fr
import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme } from "@/hooks/store";
import { useFavorite } from "@/hooks/store/use-favorite";
import { usePlatformOS } from "@/hooks/use-platform-os";
// constants
import { FavoriteRoot } from "./favorite-items";
@@ -46,7 +45,7 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
const { favorite, handleRemoveFromFavorites, isLastChild, handleDrop } = props;
// store hooks
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
const { getGroupedFavorites } = useFavorite();
const { isMobile } = usePlatformOS();
const { workspaceSlug } = useParams();
// states
@@ -59,12 +58,6 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const elementRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (favorite.children === undefined && workspaceSlug) {
getGroupedFavorites(workspaceSlug.toString(), favorite.id);
}
}, [favorite.id, favorite.children, workspaceSlug, getGroupedFavorites]);
useEffect(() => {
const element = elementRef.current;
@@ -130,7 +123,7 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDragging, favorite.id, isLastChild, favorite.id]);
}, [isDragging, favorite.id]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
@@ -131,7 +131,7 @@ export const FavoriteRoot: FC<Props> = observer((props) => {
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementRef?.current, isDragging, isLastChild, favorite.id]);
}, [elementRef?.current, isDragging]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
@@ -87,27 +87,33 @@ export const SidebarFavoritesMenu = observer(() => {
const sourceData = source.data as TargetData;
if (!sourceData.id) return;
if (isFolder) {
// handle move to a new parent folder if dropped on a folder
if (parentId && parentId !== sourceData.parentId) {
handleMoveToFolder(sourceData.id, parentId); /**parent id */
handleMoveToFolder(sourceData.id, parentId);
}
//handle remove from folder if dropped outside of the folder
if (parentId && parentId !== sourceData.parentId && sourceData.isChild) {
handleRemoveFromFavoritesFolder(sourceData.id);
}
// handle reordering at root level
if (droppedFavId) {
if (instruction != "make-child") {
handleReorder(sourceData.id, droppedFavId, instruction); /** sequence */
handleReorder(sourceData.id, droppedFavId, instruction);
}
}
} else {
//handling reordering for favorites
if (droppedFavId) {
handleReorder(sourceData.id, droppedFavId, instruction); /** sequence */
handleReorder(sourceData.id, droppedFavId, instruction);
}
}
/**remove if dropped outside and source is a child */
if (!parentId && sourceData.isChild) {
handleRemoveFromFavoritesFolder(sourceData.id); /**parent null */
// handle removal from folder if dropped outside a folder
if (!parentId && sourceData.isChild) {
handleRemoveFromFavoritesFolder(sourceData.id);
}
}
};
+1 -1
View File
@@ -2,7 +2,7 @@ import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import { ICommandPaletteStore } from "@/plane-web/store/command-palette.store";
import { ICommandPaletteStore } from "@/store/command-palette.store";
export const useCommandPalette = (): ICommandPaletteStore => {
const context = useContext(StoreContext);
@@ -50,6 +50,10 @@ export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorRead
try {
await actionDetails.execute(isPerformedByCurrentUser);
if (isPerformedByCurrentUser) {
const serverEventName = getServerEventName(clientAction);
if (serverEventName) {
editorRef?.emitRealTimeUpdate(serverEventName);
}
setCurrentActionBeingProcessed(clientAction);
}
} catch {
@@ -60,18 +64,9 @@ export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorRead
});
}
},
[actionHandlerMap]
[actionHandlerMap, editorRef]
);
useEffect(() => {
if (currentActionBeingProcessed) {
const serverEventName = getServerEventName(currentActionBeingProcessed);
if (serverEventName) {
editorRef?.emitRealTimeUpdate(serverEventName);
}
}
}, [currentActionBeingProcessed, editorRef]);
useEffect(() => {
const realTimeStatelessMessageListener = editorRef?.listenToRealTimeUpdate();
@@ -95,6 +90,5 @@ export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorRead
return {
executeCollaborativeAction,
EVENT_ACTION_DETAILS_MAP: actionHandlerMap,
};
};
@@ -9,8 +9,9 @@ export interface ModalData {
viewId: string;
}
export interface IBaseCommandPaletteStore {
export interface ICommandPaletteStore {
// observables
isCommandPaletteOpen: boolean;
isShortcutModalOpen: boolean;
isCreateProjectModalOpen: boolean;
@@ -21,7 +22,6 @@ export interface IBaseCommandPaletteStore {
isCreateIssueModalOpen: boolean;
isDeleteIssueModalOpen: boolean;
isBulkDeleteIssueModalOpen: boolean;
createIssueStoreType: TCreateModalStoreTypes;
// computed
isAnyModalOpen: boolean;
// toggle actions
@@ -35,9 +35,11 @@ export interface IBaseCommandPaletteStore {
toggleCreateModuleModal: (value?: boolean) => void;
toggleDeleteIssueModal: (value?: boolean) => void;
toggleBulkDeleteIssueModal: (value?: boolean) => void;
createIssueStoreType: TCreateModalStoreTypes;
}
export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStore {
export class CommandPaletteStore implements ICommandPaletteStore {
// observables
isCommandPaletteOpen: boolean = false;
isShortcutModalOpen: boolean = false;
@@ -49,6 +51,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
isDeleteIssueModalOpen: boolean = false;
isBulkDeleteIssueModalOpen: boolean = false;
createPageModal: TCreatePageModal = DEFAULT_CREATE_PAGE_MODAL_DATA;
createIssueStoreType: TCreateModalStoreTypes = EIssuesStoreType.PROJECT;
constructor() {
@@ -64,7 +67,6 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
isDeleteIssueModalOpen: observable.ref,
isBulkDeleteIssueModalOpen: observable.ref,
createPageModal: observable,
createIssueStoreType: observable,
// computed
isAnyModalOpen: computed,
// projectPages: computed,
@@ -83,20 +85,20 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
}
/**
* Checks whether any modal is open or not in the base command palette.
* Checks whether any modal is open or not.
* @returns boolean
*/
get isAnyModalOpen() {
return Boolean(
this.isCreateIssueModalOpen ||
this.isCreateCycleModalOpen ||
this.isCreateProjectModalOpen ||
this.isCreateModuleModalOpen ||
this.isCreateViewModalOpen ||
this.isShortcutModalOpen ||
this.isBulkDeleteIssueModalOpen ||
this.isDeleteIssueModalOpen ||
this.createPageModal.isOpen
this.isCreateCycleModalOpen ||
this.isCreateProjectModalOpen ||
this.isCreateModuleModalOpen ||
this.isCreateViewModalOpen ||
this.isShortcutModalOpen ||
this.isBulkDeleteIssueModalOpen ||
this.isDeleteIssueModalOpen ||
this.createPageModal.isOpen
);
}
+77 -19
View File
@@ -1,4 +1,4 @@
import { isPast, isToday } from "date-fns";
import { isFuture, isPast, isToday } from "date-fns";
import isEmpty from "lodash/isEmpty";
import set from "lodash/set";
import sortBy from "lodash/sortBy";
@@ -7,14 +7,16 @@ import { computedFn } from "mobx-utils";
// types
import {
ICycle,
CycleDateCheckData,
TCyclePlotType,
TProgressSnapshot,
TCycleEstimateDistribution,
TCycleDistribution,
TCycleEstimateType,
TCycleProgress,
} from "@plane/types";
// helpers
import { orderCycles, shouldFilterCycle } from "@/helpers/cycle.helper";
import { orderCycles, shouldFilterCycle, formatActiveCycle } from "@/helpers/cycle.helper";
import { getDate } from "@/helpers/date-time.helper";
import { DistributionUpdates, updateDistribution } from "@/helpers/distribution-update.helper";
// services
@@ -40,18 +42,21 @@ export interface ICycleStore {
// computed
currentProjectCycleIds: string[] | null;
currentProjectCompletedCycleIds: string[] | null;
currentProjectUpcomingCycleIds: string[] | null;
currentProjectIncompleteCycleIds: string[] | null;
currentProjectDraftCycleIds: string[] | null;
currentProjectActiveCycleId: string | null;
currentProjectArchivedCycleIds: string[] | null;
currentProjectActiveCycle: ICycle | null;
// computed actions
getActiveCycleProgress: (cycleId?: string) => { cycle: ICycle; isBurnDown: boolean; isTypeIssue: boolean } | null;
getFilteredCycleIds: (projectId: string, sortByManual: boolean) => string[] | null;
getFilteredCompletedCycleIds: (projectId: string) => string[] | null;
getFilteredArchivedCycleIds: (projectId: string) => string[] | null;
getCycleById: (cycleId: string) => ICycle | null;
getCycleNameById: (cycleId: string) => string | undefined;
getProjectCycleDetails: (projectId: string) => ICycle[] | null;
getActiveCycleById: (cycleId: string) => ICycle | null;
getProjectCycleIds: (projectId: string) => string[] | null;
getPlotTypeByCycleId: (cycleId: string) => TCyclePlotType;
getEstimateTypeByCycleId: (cycleId: string) => TCycleEstimateType;
@@ -59,6 +64,7 @@ export interface ICycleStore {
// actions
updateCycleDistribution: (distributionUpdates: DistributionUpdates, cycleId: string) => void;
validateDate: (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) => Promise<any>;
setPlotType: (cycleId: string, plotType: TCyclePlotType) => void;
setEstimateType: (cycleId: string, estimateType: TCycleEstimateType) => void;
// fetch
@@ -124,12 +130,15 @@ export class CycleStore implements ICycleStore {
// computed
currentProjectCycleIds: computed,
currentProjectCompletedCycleIds: computed,
currentProjectUpcomingCycleIds: computed,
currentProjectIncompleteCycleIds: computed,
currentProjectDraftCycleIds: computed,
currentProjectActiveCycleId: computed,
currentProjectArchivedCycleIds: computed,
currentProjectActiveCycle: computed,
// actions
setPlotType: action,
setEstimateType: action,
fetchWorkspaceCycles: action,
fetchAllCycles: action,
@@ -186,6 +195,22 @@ export class CycleStore implements ICycleStore {
return completedCycleIds;
}
/**
* returns all upcoming cycle ids for a project
*/
get currentProjectUpcomingCycleIds() {
const projectId = this.rootStore.router.projectId;
if (!projectId || !this.fetchedMap[projectId]) return null;
let upcomingCycles = Object.values(this.cycleMap ?? {}).filter((c) => {
const startDate = getDate(c.start_date);
const isStartDateUpcoming = startDate && isFuture(startDate);
return c.project_id === projectId && isStartDateUpcoming && !c?.archived_at;
});
upcomingCycles = sortBy(upcomingCycles, [(c) => c.sort_order]);
const upcomingCycleIds = upcomingCycles.map((c) => c.id);
return upcomingCycleIds;
}
/**
* returns all incomplete cycle ids for a project
*/
@@ -202,6 +227,20 @@ export class CycleStore implements ICycleStore {
return incompleteCycleIds;
}
/**
* returns all draft cycle ids for a project
*/
get currentProjectDraftCycleIds() {
const projectId = this.rootStore.router.projectId;
if (!projectId || !this.fetchedMap[projectId]) return null;
let draftCycles = Object.values(this.cycleMap ?? {}).filter(
(c) => c.project_id === projectId && !c.start_date && !c.end_date && !c?.archived_at
);
draftCycles = sortBy(draftCycles, [(c) => c.sort_order]);
const draftCycleIds = draftCycles.map((c) => c.id);
return draftCycleIds;
}
/**
* returns active cycle id for a project
*/
@@ -246,6 +285,19 @@ export class CycleStore implements ICycleStore {
} else return false;
});
/**
* returns active cycle progress for a project
*/
getActiveCycleProgress = computedFn((cycleId?: string) => {
const cycle = cycleId ? this.cycleMap[cycleId] : this.currentProjectActiveCycle;
if (!cycle) return null;
const isTypeIssue = this.getEstimateTypeByCycleId(cycle.id) === "issues";
const isBurnDown = this.getPlotTypeByCycleId(cycle.id) === "burndown";
return { cycle, isTypeIssue, isBurnDown };
});
/**
* @description returns filtered cycle ids based on display filters and filters
* @param {TCycleDisplayFilters} displayFilters
@@ -327,28 +379,37 @@ export class CycleStore implements ICycleStore {
getCycleNameById = computedFn((cycleId: string): string => this.cycleMap?.[cycleId]?.name);
/**
* @description returns list of cycle details of the project id passed as argument
* @param projectId
* @description returns active cycle details by cycle id
* @param cycleId
* @returns
*/
getProjectCycleDetails = computedFn((projectId: string): ICycle[] | null => {
if (!this.fetchedMap[projectId]) return null;
let cycles = Object.values(this.cycleMap ?? {}).filter((c) => c.project_id === projectId && !c?.archived_at);
cycles = sortBy(cycles, [(c) => c.sort_order]);
return cycles || null;
});
getActiveCycleById = computedFn((cycleId: string): ICycle | null =>
this.activeCycleIdMap?.[cycleId] && this.cycleMap?.[cycleId] ? this.cycleMap?.[cycleId] : null
);
/**
* @description returns list of cycle ids of the project id passed as argument
* @param projectId
*/
getProjectCycleIds = computedFn((projectId: string): string[] | null => {
const cycles = this.getProjectCycleDetails(projectId);
if (!cycles) return null;
if (!this.fetchedMap[projectId]) return null;
let cycles = Object.values(this.cycleMap ?? {}).filter((c) => c.project_id === projectId && !c?.archived_at);
cycles = sortBy(cycles, [(c) => c.sort_order]);
const cycleIds = cycles.map((c) => c.id);
return cycleIds || null;
});
/**
* @description validates cycle dates
* @param workspaceSlug
* @param projectId
* @param payload
* @returns
*/
validateDate = async (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) =>
await this.cycleService.cycleDateCheck(workspaceSlug, projectId, payload);
/**
* @description gets the plot type for the cycle store
* @param {TCyclePlotType} plotType
@@ -412,16 +473,14 @@ export class CycleStore implements ICycleStore {
runInAction(() => {
response.forEach((cycle) => {
set(this.cycleMap, [cycle.id], cycle);
if (cycle.status?.toLowerCase() === "current") {
set(this.activeCycleIdMap, [cycle.id], true);
}
cycle.status?.toLowerCase() === "current" && set(this.activeCycleIdMap, [cycle.id], true);
});
set(this.fetchedMap, projectId, true);
this.loader = false;
});
return response;
});
} catch {
} catch (error) {
this.loader = false;
return undefined;
}
@@ -494,7 +553,6 @@ export class CycleStore implements ICycleStore {
* @param cycleId
* @returns
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
fetchActiveCycleProgressPro = action(async (workspaceSlug: string, projectId: string, cycleId: string) => {});
/**
+26 -8
View File
@@ -174,14 +174,23 @@ export class FavoriteStore implements IFavoriteStore {
* @returns Promise<void>
*/
moveFavoriteToFolder = async (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => {
const oldParent = this.favoriteMap[favoriteId].parent;
try {
await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, data);
runInAction(() => {
// add parent of the favorite
set(this.favoriteMap, [favoriteId, "parent"], data.parent);
});
await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, data);
} catch (error) {
console.error("Failed to move favorite to folder", error);
console.error("Failed to move favorite from favorite store");
// revert the changes
runInAction(() => {
if (!data.parent) return;
// revert the parent
set(this.favoriteMap, [favoriteId, "parent"], oldParent);
});
throw error;
}
};
@@ -192,6 +201,7 @@ export class FavoriteStore implements IFavoriteStore {
destinationId: string,
edge: string | undefined
) => {
const initialSequence = this.favoriteMap[favoriteId].sequence;
try {
let resultSequence = 10000;
if (edge) {
@@ -211,27 +221,35 @@ export class FavoriteStore implements IFavoriteStore {
}
}
}
await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, { sequence: resultSequence });
runInAction(() => {
set(this.favoriteMap, [favoriteId, "sequence"], resultSequence);
});
await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, { sequence: resultSequence });
} catch (error) {
console.error("Failed to move favorite folder");
throw error;
runInAction(() => {
set(this.favoriteMap, [favoriteId, "sequence"], initialSequence);
throw error;
});
}
};
removeFromFavoriteFolder = async (workspaceSlug: string, favoriteId: string) => {
const parent = this.favoriteMap[favoriteId].parent;
try {
await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, { parent: null });
runInAction(() => {
//remove parent
set(this.favoriteMap, [favoriteId, "parent"], null);
});
await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, { parent: null });
} catch (error) {
console.error("Failed to move favorite");
runInAction(() => {
set(this.favoriteMap, [favoriteId, "parent"], parent);
throw error;
});
throw error;
}
};
@@ -366,7 +384,7 @@ export class FavoriteStore implements IFavoriteStore {
set(this.favoriteMap, [favorite.id], favorite);
this.favoriteIds.push(favorite.id);
this.favoriteIds = uniqBy(this.favoriteIds, (id) => id);
if (favorite.entity_identifier) set(this.entityMap, [favorite.entity_identifier], favorite);
favorite.entity_identifier && set(this.entityMap, [favorite.entity_identifier], favorite);
});
});
+1 -1
View File
@@ -98,7 +98,7 @@ export class InboxIssueStore implements IInboxIssueStore {
// If issue accepted sync issue to local db
if (status === EInboxIssueStatus.ACCEPTED) {
addIssueToPersistanceLayer({ ...this.issue, ...inboxIssue.issue });
addIssueToPersistanceLayer(inboxIssue.issue);
}
} catch {
runInAction(() => set(this, "status", previousData.status));
@@ -352,7 +352,7 @@ export class WorkspaceDraftIssues implements IWorkspaceDraftIssues {
}
// sync issue to local db
addIssueToPersistanceLayer({ ...payload, ...response });
addIssueToPersistanceLayer(response);
// Update draft issue count in workspaceUserInfo
this.updateWorkspaceUserDraftIssueCount(workspaceSlug, -1);
+6 -16
View File
@@ -32,7 +32,6 @@ export interface IModuleStore {
getFilteredArchivedModuleIds: (projectId: string) => string[] | null;
getModuleById: (moduleId: string) => IModule | null;
getModuleNameById: (moduleId: string) => string;
getProjectModuleDetails: (projectId: string) => IModule[] | null;
getProjectModuleIds: (projectId: string) => string[] | null;
getPlotTypeByModuleId: (moduleId: string) => TModulePlotType;
// actions
@@ -211,24 +210,15 @@ export class ModulesStore implements IModuleStore {
*/
getModuleNameById = computedFn((moduleId: string) => this.moduleMap?.[moduleId]?.name);
/**
* @description returns list of module details of the project id passed as argument
* @param projectId
*/
getProjectModuleDetails = computedFn((projectId: string) => {
if (!this.fetchedMap[projectId]) return null;
let projectModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId && !m.archived_at);
projectModules = sortBy(projectModules, [(m) => m.sort_order]);
return projectModules;
});
/**
* @description returns list of module ids of the project id passed as argument
* @param projectId
*/
getProjectModuleIds = computedFn((projectId: string) => {
const projectModules = this.getProjectModuleDetails(projectId);
if (!projectModules) return null;
if (!this.fetchedMap[projectId]) return null;
let projectModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId && !m.archived_at);
projectModules = sortBy(projectModules, [(m) => m.sort_order]);
const projectModuleIds = projectModules.map((m) => m.id);
return projectModuleIds;
});
@@ -292,7 +282,7 @@ export class ModulesStore implements IModuleStore {
});
return response;
});
} catch {
} catch (error) {
this.loader = false;
return undefined;
}
@@ -318,7 +308,7 @@ export class ModulesStore implements IModuleStore {
});
return projectModules;
});
} catch {
} catch (error) {
this.loader = false;
return undefined;
}
+16 -2
View File
@@ -26,7 +26,7 @@ export interface INotification extends TNotification {
export class Notification implements INotification {
// observables
id: string;
id: string | undefined = undefined;
title: string | undefined = undefined;
data: TNotificationData | undefined = undefined;
entity_identifier: string | undefined = undefined;
@@ -54,7 +54,6 @@ export class Notification implements INotification {
private store: CoreRootStore,
private notification: TNotification
) {
this.id = this.notification.id;
makeObservable(this, {
// observables
id: observable.ref,
@@ -91,6 +90,7 @@ export class Notification implements INotification {
snoozeNotification: action,
unSnoozeNotification: action,
});
this.id = this.notification.id;
this.title = this.notification.title;
this.data = this.notification.data;
this.entity_identifier = this.notification.entity_identifier;
@@ -169,6 +169,8 @@ export class Notification implements INotification {
workspaceSlug: string,
payload: Partial<TNotification>
): Promise<TNotification | undefined> => {
if (!this.id) return undefined;
try {
const notification = await workspaceNotificationService.updateNotificationById(workspaceSlug, this.id, payload);
if (notification) {
@@ -186,6 +188,8 @@ export class Notification implements INotification {
* @returns { TNotification | undefined }
*/
markNotificationAsRead = async (workspaceSlug: string): Promise<TNotification | undefined> => {
if (!this.id) return undefined;
const currentNotificationReadAt = this.read_at;
try {
const payload: Partial<TNotification> = {
@@ -211,6 +215,8 @@ export class Notification implements INotification {
* @returns { TNotification | undefined }
*/
markNotificationAsUnRead = async (workspaceSlug: string): Promise<TNotification | undefined> => {
if (!this.id) return undefined;
const currentNotificationReadAt = this.read_at;
try {
const payload: Partial<TNotification> = {
@@ -236,6 +242,8 @@ export class Notification implements INotification {
* @returns { TNotification | undefined }
*/
archiveNotification = async (workspaceSlug: string): Promise<TNotification | undefined> => {
if (!this.id) return undefined;
const currentNotificationArchivedAt = this.archived_at;
try {
const payload: Partial<TNotification> = {
@@ -259,6 +267,8 @@ export class Notification implements INotification {
* @returns { TNotification | undefined }
*/
unArchiveNotification = async (workspaceSlug: string): Promise<TNotification | undefined> => {
if (!this.id) return undefined;
const currentNotificationArchivedAt = this.archived_at;
try {
const payload: Partial<TNotification> = {
@@ -283,6 +293,8 @@ export class Notification implements INotification {
* @returns { TNotification | undefined }
*/
snoozeNotification = async (workspaceSlug: string, snoozeTill: Date): Promise<TNotification | undefined> => {
if (!this.id) return undefined;
const currentNotificationSnoozeTill = this.snoozed_till;
try {
const payload: Partial<TNotification> = {
@@ -303,6 +315,8 @@ export class Notification implements INotification {
* @returns { TNotification | undefined }
*/
unSnoozeNotification = async (workspaceSlug: string): Promise<TNotification | undefined> => {
if (!this.id) return undefined;
const currentNotificationSnoozeTill = this.snoozed_till;
try {
const payload: Partial<TNotification> = {
+1 -1
View File
@@ -1,9 +1,9 @@
import { enableStaticRendering } from "mobx-react";
// plane web store
import { CommandPaletteStore, ICommandPaletteStore } from "@/plane-web/store/command-palette.store";
import { RootStore } from "@/plane-web/store/root.store";
import { IStateStore, StateStore } from "@/plane-web/store/state.store";
// stores
import { CommandPaletteStore, ICommandPaletteStore } from "./command-palette.store";
import { CycleStore, ICycleStore } from "./cycle.store";
import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store";
import { DashboardStore, IDashboardStore } from "./dashboard.store";
@@ -1 +0,0 @@
export * from "ce/components/command-palette/modals";
-1
View File
@@ -1 +0,0 @@
export * from "ce/helpers/command-palette";
-1
View File
@@ -1 +0,0 @@
export * from "ce/store/command-palette.store";
-1
View File
@@ -1 +0,0 @@
export * from "ce/helpers/command-palette";
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.24.1",
"version": "0.24.0",
"private": true,
"scripts": {
"dev": "turbo run develop",