Compare commits

...
Author SHA1 Message Date
pablohashescobar d1d6282d23 feat: implement WorkItemNotificationHandler for issue notifications and update imports 2025-11-10 22:51:18 +05:30
pablohashescobar bec90c2eb9 Merge branch 'preview' of github.com:makeplane/plane into refactor-notifications 2025-11-10 12:02:36 +05:30
Prateek ShouryaandGitHub dfc49eea27 [WEB-5379] fix: optimize filter handling in work item components (#8079) 2025-11-07 20:28:10 +05:30
pablohashescobar 60cab0c964 refactor: streamline issue notification processing and remove unused imports in notification_task 2025-11-07 17:22:38 +05:30
Akshita GoyalandGitHub 1e5f583a24 [WEB-5367] fix: removed integrations and imports from the routes #8073 2025-11-07 16:12:12 +05:30
Aaryan KhandelwalandGitHub d05c222d0a regression: fallback base url for redirection (#8075) 2025-11-07 16:10:52 +05:30
Vamsi KrishnaandGitHub 8853637e98 [WEB-5064] fix: peek overview reloading when parent is added (#7950) 2025-11-06 19:48:35 +05:30
Aaryan KhandelwalandGitHub 12a050e7d3 [WIKI-538] chore: common description component (#7785)
* chore: common description input component

* chore: replace existing description input components

* fix: await for update calls

* refactor: handle fallback values for description states and form data

* fix: import statements

* chore: add workspaceDetails check
2025-11-06 19:47:38 +05:30
Vipin ChaudharyandGitHub fd542a85fa [WIKI-728] fix: update emoji insertion method #7962 2025-11-06 19:46:32 +05:30
Jayash TripathyandGitHub 0a738d419e [WEB-5306] refactor: base layouts (#8051) 2025-11-06 19:45:56 +05:30
Vipin ChaudharyandGitHub c719d67f71 [WIKI-714] fix: close drag handle dropdown when any key is pressed #8040 2025-11-06 18:58:23 +05:30
Vamsi KrishnaandGitHub 7178627f6a [WEB-5339] chore: work item sidebar refactor #8058 2025-11-06 18:57:50 +05:30
Aaryan KhandelwalandGitHub 10590ffa13 [WEB-5360] fix: modules list board layout #8074 2025-11-06 18:57:22 +05:30
Akshita GoyalandGitHub 37aabe7d1d [PAI-923] fix: implemented fetch key functions for auth layouts #8067 2025-11-06 18:50:45 +05:30
Anmol Singh BhatiaandGitHub fd38b9b6d8 [WEB-4320] refactor: migrate emoji reactions to propel (#8039) 2025-11-06 18:25:43 +05:30
M. PalanikannanandGitHub d709465a89 [WIKI-564] fix: editor deps #8069 2025-11-06 17:33:51 +05:30
Anmol Singh BhatiaandGitHub 9b3bd11c03 [WEB-5305] refactor: migrate emoji and logo components to propel and remove duplication (#8038) 2025-11-06 17:33:06 +05:30
Aaryan KhandelwalandGitHub 4927ef8c71 [WEB-5352] fix: layout selection icon #8070 2025-11-06 16:58:38 +05:30
dependabot[bot]andGitHub 0dcea8db70 chore(deps): bump django (#8068) 2025-11-06 16:21:43 +05:30
Prateek ShouryaandGitHub 0941856f67 [WEB-5356] feat: update public environment keys in Vite config for improved configuration management #8071 2025-11-06 15:41:01 +05:30
112 changed files with 2214 additions and 2513 deletions
+4 -2
View File
@@ -5,8 +5,10 @@
*/
export function ensureTrailingSlash(url: string): string {
try {
// Handle relative URLs by creating a URL object with a dummy base
const urlObj = new URL(url, "http://dummy.com");
const fallbackBaseUrl =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "http://dummy.com";
// Handle relative URLs by creating a URL object with a fallback base URL
const urlObj = new URL(url, fallbackBaseUrl);
// Don't modify root path
if (urlObj.pathname === "/") {
@@ -12,7 +12,6 @@ from django.utils import timezone
# Module imports
from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
from plane.db.models import (
CommentReaction,
Cycle,
@@ -32,7 +31,7 @@ from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
from plane.utils.issue_relation_mapper import get_inverse_relation
from plane.utils.uuid import is_valid_uuid
from plane.bgtasks.notification_task import process_issue_notifications
def extract_ids(data: dict | None, primary_key: str, fallback_key: str) -> set[str]:
if not data:
@@ -1580,18 +1579,18 @@ def issue_activity(
issue_activities_created = IssueActivity.objects.bulk_create(issue_activities)
if notification:
notifications.delay(
type=type,
process_issue_notifications(
issue_id=issue_id,
actor_id=actor_id,
project_id=project_id,
subscriber=subscriber,
issue_activities_created=json.dumps(
actor_id=actor_id,
activities_data=json.dumps(
IssueActivitySerializer(issue_activities_created, many=True).data,
cls=DjangoJSONEncoder,
),
requested_data=requested_data,
current_instance=current_instance,
subscriber=subscriber,
notification_type=type,
)
return
+45 -656
View File
@@ -1,670 +1,59 @@
# Python imports
import json
import uuid
from uuid import UUID
# Third Party imports
from celery import shared_task
# Module imports
from plane.db.models import (
IssueMention,
IssueSubscriber,
Project,
User,
IssueAssignee,
Issue,
State,
EmailNotificationLog,
Notification,
IssueComment,
IssueActivity,
UserNotificationPreference,
ProjectMember,
)
from django.db.models import Subquery
# Third Party imports
from celery import shared_task
from bs4 import BeautifulSoup
# =========== Issue Description Html Parsing and notification Functions ======================
def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
aggregated_issue_mentions = []
for mention_id in new_mentions:
aggregated_issue_mentions.append(
IssueMention(
mention_id=mention_id,
issue=issue,
project=project,
workspace_id=project.workspace_id,
)
)
IssueMention.objects.bulk_create(aggregated_issue_mentions, batch_size=100)
IssueMention.objects.filter(issue=issue, mention__in=removed_mention).delete()
def get_new_mentions(requested_instance, current_instance):
# requested_data is the newer instance of the current issue
# current_instance is the older instance of the current issue, saved in the database
# extract mentions from both the instance of data
mentions_older = extract_mentions(current_instance)
mentions_newer = extract_mentions(requested_instance)
# Getting Set Difference from mentions_newer
new_mentions = [mention for mention in mentions_newer if mention not in mentions_older]
return new_mentions
# Get Removed Mention
def get_removed_mentions(requested_instance, current_instance):
# requested_data is the newer instance of the current issue
# current_instance is the older instance of the current issue, saved in the database
# extract mentions from both the instance of data
mentions_older = extract_mentions(current_instance)
mentions_newer = extract_mentions(requested_instance)
# Getting Set Difference from mentions_newer
removed_mentions = [mention for mention in mentions_older if mention not in mentions_newer]
return removed_mentions
# Adds mentions as subscribers
def extract_mentions_as_subscribers(project_id, issue_id, mentions):
# mentions is an array of User IDs representing the FILTERED set of mentioned users
bulk_mention_subscribers = []
for mention_id in mentions:
# If the particular mention has not already been subscribed to the issue, he must be sent the mentioned notification # noqa: E501
if (
not IssueSubscriber.objects.filter(
issue_id=issue_id, subscriber_id=mention_id, project_id=project_id
).exists()
and not IssueAssignee.objects.filter(
project_id=project_id, issue_id=issue_id, assignee_id=mention_id
).exists()
and not Issue.objects.filter(project_id=project_id, pk=issue_id, created_by_id=mention_id).exists()
and ProjectMember.objects.filter(project_id=project_id, member_id=mention_id, is_active=True).exists()
):
project = Project.objects.get(pk=project_id)
bulk_mention_subscribers.append(
IssueSubscriber(
workspace_id=project.workspace_id,
project_id=project_id,
issue_id=issue_id,
subscriber_id=mention_id,
)
)
return bulk_mention_subscribers
# Parse Issue Description & extracts mentions
def extract_mentions(issue_instance):
try:
# issue_instance has to be a dictionary passed, containing the description_html and other set of activity data. # noqa: E501
mentions = []
# Convert string to dictionary
data = json.loads(issue_instance)
html = data.get("description_html")
soup = BeautifulSoup(html, "html.parser")
mention_tags = soup.find_all("mention-component", attrs={"entity_name": "user_mention"})
mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags]
return list(set(mentions))
except Exception:
return []
# =========== Comment Parsing and notification Functions ======================
def extract_comment_mentions(comment_value):
try:
mentions = []
soup = BeautifulSoup(comment_value, "html.parser")
mentions_tags = soup.find_all("mention-component", attrs={"entity_name": "user_mention"})
for mention_tag in mentions_tags:
mentions.append(mention_tag["entity_identifier"])
return list(set(mentions))
except Exception:
return []
def get_new_comment_mentions(new_value, old_value):
mentions_newer = extract_comment_mentions(new_value)
if old_value is None:
return mentions_newer
mentions_older = extract_comment_mentions(old_value)
# Getting Set Difference from mentions_newer
new_mentions = [mention for mention in mentions_newer if mention not in mentions_older]
return new_mentions
def create_mention_notification(project, notification_comment, issue, actor_id, mention_id, issue_id, activity):
return Notification(
workspace=project.workspace,
sender="in_app:issue_activities:mentioned",
triggered_by_id=actor_id,
receiver_id=mention_id,
entity_identifier=issue_id,
entity_name="issue",
project=project,
message=notification_comment,
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(activity.get("id")),
"verb": str(activity.get("verb")),
"field": str(activity.get("field")),
"actor": str(activity.get("actor_id")),
"new_value": str(activity.get("new_value")),
"old_value": str(activity.get("old_value")),
"old_identifier": (str(activity.get("old_identifier")) if activity.get("old_identifier") else None),
"new_identifier": (str(activity.get("new_identifier")) if activity.get("new_identifier") else None),
},
},
)
from plane.utils.notifications import IssueNotificationHandler, NotificationContext
from plane.utils.exception_logger import log_exception
@shared_task
def notifications(
type,
def process_issue_notifications(
issue_id,
project_id,
actor_id,
subscriber,
issue_activities_created,
requested_data,
current_instance,
activities_data,
requested_data=None,
current_instance=None,
subscriber=False,
notification_type=""
):
"""
Process notifications for issue activities.
"""
try:
issue_activities_created = (
json.loads(issue_activities_created) if issue_activities_created is not None else None
# Let the handler normalize and parse activities
activities = IssueNotificationHandler.parse_activities(activities_data)
project = Project.objects.get(pk=project_id)
workspace_id = project.workspace_id
# Create context
context = NotificationContext(
entity_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
actor_id=actor_id,
activities=activities,
requested_data=requested_data,
current_instance=current_instance,
subscriber=subscriber,
notification_type=notification_type
)
if type not in [
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
"issue_reaction.activity.created",
"issue_reaction.activity.deleted",
"comment_reaction.activity.created",
"comment_reaction.activity.deleted",
"issue_vote.activity.created",
"issue_vote.activity.deleted",
"issue_draft.activity.created",
"issue_draft.activity.updated",
"issue_draft.activity.deleted",
]:
# Create Notifications
bulk_notifications = []
bulk_email_logs = []
"""
Mention Tasks
1. Perform Diffing and Extract the mentions, that mention notification needs to be sent
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
"""
# get the list of active project members
project_members = ProjectMember.objects.filter(project_id=project_id, is_active=True).values_list(
"member_id", flat=True
)
# Get new mentions from the newer instance
new_mentions = get_new_mentions(requested_instance=requested_data, current_instance=current_instance)
new_mentions = list(set(new_mentions) & {str(member) for member in project_members})
removed_mention = get_removed_mentions(requested_instance=requested_data, current_instance=current_instance)
comment_mentions = []
all_comment_mentions = []
# Get New Subscribers from the mentions of the newer instance
requested_mentions = extract_mentions(issue_instance=requested_data)
mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id, issue_id=issue_id, mentions=requested_mentions
)
for issue_activity in issue_activities_created:
issue_comment = issue_activity.get("issue_comment")
issue_comment_new_value = issue_activity.get("new_value")
issue_comment_old_value = issue_activity.get("old_value")
if issue_comment is not None:
# TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well.
all_comment_mentions = all_comment_mentions + extract_comment_mentions(issue_comment_new_value)
new_comment_mentions = get_new_comment_mentions(
old_value=issue_comment_old_value,
new_value=issue_comment_new_value,
)
comment_mentions = comment_mentions + new_comment_mentions
comment_mentions = [
mention for mention in comment_mentions if UUID(mention) in set(project_members)
]
comment_mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions
)
"""
We will not send subscription activity notification to the below mentioned user sets
- Those who have been newly mentioned in the issue description, we will send mention notification to them.
- When the activity is a comment_created and there exist a mention in the comment,
then we have to send the "mention_in_comment" notification
- When the activity is a comment_updated and there exist a mention change,
then also we have to send the "mention_in_comment" notification
"""
# ---------------------------------------------------------------------------------------------------------
issue_subscribers = list(
IssueSubscriber.objects.filter(
project_id=project_id,
issue_id=issue_id,
subscriber__in=Subquery(project_members),
)
.exclude(subscriber_id__in=list(new_mentions + comment_mentions + [actor_id]))
.values_list("subscriber", flat=True)
)
issue = Issue.objects.filter(pk=issue_id).first()
if subscriber:
# add the user to issue subscriber
try:
_ = IssueSubscriber.objects.get_or_create(
project_id=project_id, issue_id=issue_id, subscriber_id=actor_id
)
except Exception:
pass
project = Project.objects.get(pk=project_id)
issue_assignees = IssueAssignee.objects.filter(
issue_id=issue_id,
project_id=project_id,
assignee__in=Subquery(project_members),
).values_list("assignee", flat=True)
issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)})
for subscriber in issue_subscribers:
if issue.created_by_id and issue.created_by_id == subscriber:
sender = "in_app:issue_activities:created"
elif subscriber in issue_assignees and issue.created_by_id not in issue_assignees:
sender = "in_app:issue_activities:assigned"
else:
sender = "in_app:issue_activities:subscribed"
preference = UserNotificationPreference.objects.get(user_id=subscriber)
for issue_activity in issue_activities_created:
# If activity done in blocking then blocked by email should not go
if issue_activity.get("issue_detail").get("id") != issue_id:
continue
# Do not send notification for description update
if issue_activity.get("field") == "description":
continue
# Check if the value should be sent or not
send_email = False
if issue_activity.get("field") == "state" and preference.state_change:
send_email = True
elif (
issue_activity.get("field") == "state"
and preference.issue_completed
and State.objects.filter(
project_id=project_id,
pk=issue_activity.get("new_identifier"),
group="completed",
).exists()
):
send_email = True
elif issue_activity.get("field") == "comment" and preference.comment:
send_email = True
elif preference.property_change:
send_email = True
else:
send_email = False
# If activity is of issue comment fetch the comment
issue_comment = (
IssueComment.objects.filter(
id=issue_activity.get("issue_comment"),
issue_id=issue_id,
project_id=project_id,
workspace_id=project.workspace_id,
).first()
if issue_activity.get("issue_comment")
else None
)
# Create in app notification
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender=sender,
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
project=project,
title=issue_activity.get("comment"),
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(issue_activity.get("id")),
"verb": str(issue_activity.get("verb")),
"field": str(issue_activity.get("field")),
"actor": str(issue_activity.get("actor_id")),
"new_value": str(issue_activity.get("new_value")),
"old_value": str(issue_activity.get("old_value")),
"issue_comment": str(
issue_comment.comment_stripped if issue_comment is not None else ""
),
"old_identifier": (
str(issue_activity.get("old_identifier"))
if issue_activity.get("old_identifier")
else None
),
"new_identifier": (
str(issue_activity.get("new_identifier"))
if issue_activity.get("new_identifier")
else None
),
},
},
)
)
# Create email notification
if send_email:
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(issue.project.identifier),
"project_id": str(issue.project.id),
"workspace_slug": str(issue.project.workspace.slug),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(issue_activity.get("id")),
"verb": str(issue_activity.get("verb")),
"field": str(issue_activity.get("field")),
"actor": str(issue_activity.get("actor_id")),
"new_value": str(issue_activity.get("new_value")),
"old_value": str(issue_activity.get("old_value")),
"issue_comment": str(
issue_comment.comment_stripped if issue_comment is not None else ""
),
"old_identifier": (
str(issue_activity.get("old_identifier"))
if issue_activity.get("old_identifier")
else None
),
"new_identifier": (
str(issue_activity.get("new_identifier"))
if issue_activity.get("new_identifier")
else None
),
"activity_time": issue_activity.get("created_at"),
},
},
)
)
# -------------------------------------------------------------------------------------------------------- #
# Add Mentioned as Issue Subscribers
IssueSubscriber.objects.bulk_create(
mention_subscribers + comment_mention_subscribers,
batch_size=100,
ignore_conflicts=True,
)
last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first()
actor = User.objects.get(pk=actor_id)
for mention_id in comment_mentions:
if mention_id != actor_id:
preference = UserNotificationPreference.objects.get(user_id=mention_id)
for issue_activity in issue_activities_created:
notification = create_mention_notification(
project=project,
issue=issue,
notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}", # noqa: E501
actor_id=actor_id,
mention_id=mention_id,
issue_id=issue_id,
activity=issue_activity,
)
# check for email notifications
if preference.mention:
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=mention_id,
entity_identifier=issue_id,
entity_name="issue",
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
"project_id": str(issue.project.id),
"workspace_slug": str(issue.project.workspace.slug),
},
"issue_activity": {
"id": str(issue_activity.get("id")),
"verb": str(issue_activity.get("verb")),
"field": str("mention"),
"actor": str(issue_activity.get("actor_id")),
"new_value": str(issue_activity.get("new_value")),
"old_value": str(issue_activity.get("old_value")),
"old_identifier": (
str(issue_activity.get("old_identifier"))
if issue_activity.get("old_identifier")
else None
),
"new_identifier": (
str(issue_activity.get("new_identifier"))
if issue_activity.get("new_identifier")
else None
),
"activity_time": issue_activity.get("created_at"),
},
},
)
)
bulk_notifications.append(notification)
for mention_id in new_mentions:
if mention_id != actor_id:
preference = UserNotificationPreference.objects.get(user_id=mention_id)
if (
last_activity is not None
and last_activity.field == "description"
and actor_id == str(last_activity.actor_id)
):
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender="in_app:issue_activities:mentioned",
triggered_by_id=actor_id,
receiver_id=mention_id,
entity_identifier=issue_id,
entity_name="issue",
project=project,
message=f"You have been mentioned in the issue {issue.name}",
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
"project_id": str(issue.project.id),
"workspace_slug": str(issue.project.workspace.slug),
},
"issue_activity": {
"id": str(last_activity.id),
"verb": str(last_activity.verb),
"field": str(last_activity.field),
"actor": str(last_activity.actor_id),
"new_value": str(last_activity.new_value),
"old_value": str(last_activity.old_value),
"old_identifier": (
str(issue_activity.get("old_identifier"))
if issue_activity.get("old_identifier")
else None
),
"new_identifier": (
str(issue_activity.get("new_identifier"))
if issue_activity.get("new_identifier")
else None
),
},
},
)
)
if preference.mention:
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(last_activity.id),
"verb": str(last_activity.verb),
"field": "mention",
"actor": str(last_activity.actor_id),
"new_value": str(last_activity.new_value),
"old_value": str(last_activity.old_value),
"old_identifier": (
str(issue_activity.get("old_identifier"))
if issue_activity.get("old_identifier")
else None
),
"new_identifier": (
str(issue_activity.get("new_identifier"))
if issue_activity.get("new_identifier")
else None
),
"activity_time": str(last_activity.created_at),
},
},
)
)
else:
for issue_activity in issue_activities_created:
notification = create_mention_notification(
project=project,
issue=issue,
notification_comment=f"You have been mentioned in the issue {issue.name}",
actor_id=actor_id,
mention_id=mention_id,
issue_id=issue_id,
activity=issue_activity,
)
if preference.mention:
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(issue_activity.get("id")),
"verb": str(issue_activity.get("verb")),
"field": str("mention"),
"actor": str(issue_activity.get("actor_id")),
"new_value": str(issue_activity.get("new_value")),
"old_value": str(issue_activity.get("old_value")),
"old_identifier": (
str(issue_activity.get("old_identifier"))
if issue_activity.get("old_identifier")
else None
),
"new_identifier": (
str(issue_activity.get("new_identifier"))
if issue_activity.get("new_identifier")
else None
),
"activity_time": issue_activity.get("created_at"),
},
},
)
)
bulk_notifications.append(notification)
# save new mentions for the particular issue and remove the mentions that has been deleted from the description # noqa: E501
update_mentions_for_issue(
issue=issue,
project=project,
new_mentions=new_mentions,
removed_mention=removed_mention,
)
# Bulk create notifications
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
EmailNotificationLog.objects.bulk_create(bulk_email_logs, batch_size=100, ignore_conflicts=True)
return
# Process notifications
handler = IssueNotificationHandler(context)
payload = handler.process()
return {
"success": True,
"in_app_count": len(payload.in_app_notifications),
"email_count": len(payload.email_logs),
}
except Exception as e:
print(e)
return
log_exception(e)
return {
"success": False,
"error": str(e)
}
@@ -0,0 +1,7 @@
from .base import NotificationContext
from .workitem import WorkItemNotificationHandler
__all__ = [
"NotificationContext",
"WorkItemNotificationHandler",
]
+542
View File
@@ -0,0 +1,542 @@
# Python imports
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional
from uuid import UUID
import json
# Module imports
from plane.db.models import Notification, EmailNotificationLog, User, UserNotificationPreference
@dataclass
class ActivityData:
"""Represents a single activity/event"""
id: str
verb: str
field: str
actor_id: str
new_value: Any
old_value: Any
new_identifier: Optional[str] = None
old_identifier: Optional[str] = None
created_at: Optional[str] = None
comment: Optional[str] = None
entity_comment: Optional[str] = None
entity_detail: Optional[Dict] = None
@classmethod
def from_dict(cls, data: Dict) -> "ActivityData":
"""Create ActivityData from dictionary"""
return cls(
id=data.get("id"),
verb=data.get("verb"),
field=data.get("field"),
actor_id=data.get("actor_id"),
new_value=data.get("new_value"),
old_value=data.get("old_value"),
new_identifier=data.get("new_identifier"),
old_identifier=data.get("old_identifier"),
created_at=data.get("created_at"),
comment=data.get("comment"),
entity_comment=data.get("entity_comment"),
entity_detail=data.get("entity_detail"),
)
@dataclass
class NotificationContext:
"""Context data for notification processing"""
entity_id: str
project_id: Optional[str]
workspace_id: str
actor_id: str
activities: List[ActivityData]
requested_data: Optional[str] = None
current_instance: Optional[str] = None
subscriber: bool = False
notification_type: str = ""
@dataclass
class NotificationPayload:
"""Container for processed notification data"""
in_app_notifications: List[Notification] = field(default_factory=list)
email_logs: List[EmailNotificationLog] = field(default_factory=list)
push_notifications: List[Dict[str, Any]] = field(default_factory=list)
entity_data: Dict[str, Any] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class MentionData:
"""Data related to mentions in entity"""
new_mentions: List[str] = field(default_factory=list)
removed_mentions: List[str] = field(default_factory=list)
@dataclass
class SubscriberData:
"""Data related to entity subscribers"""
subscribers: List[UUID] = field(default_factory=list)
class BaseNotificationHandler(ABC):
"""
Abstract base class for entity-specific notification handlers.
Each entity type (Issue) should implement this.
"""
# Entity-specific configurations (must be overridden)
ENTITY_NAME: str = "entity"
ENTITY_MODEL = None
SUBSCRIBER_MODEL = None
MENTION_MODEL = None
ACTIVITY_MODEL = None
@classmethod
def normalize_activity_data(cls, activity_dict: Dict) -> Dict:
"""
Normalize entity-specific activity keys to generic keys.
Subclasses should override this to map their specific keys.
Default implementation does nothing (assumes already normalized).
"""
return activity_dict
@classmethod
def parse_activities(cls, activities_data: str) -> List[ActivityData]:
"""
Parse and normalize activities from JSON string.
Uses the subclass's normalize_activity_data method.
"""
parsed_activities = json.loads(activities_data)
normalized = [cls.normalize_activity_data(a) for a in parsed_activities]
return [ActivityData.from_dict(a) for a in normalized]
# Notification filters (can be overridden)
EXCLUDED_ACTIVITY_TYPES: List[str] = []
def __init__(self, context: NotificationContext):
"""Initialize the notification handler"""
self.context = context
self.entity = None
self.project = None
self.workspace = None
self.active_members = []
self.actor = None
self.payload = NotificationPayload()
def process(self) -> NotificationPayload:
"""
Main entry point to process notifications for an entity.
Orchestrates the entire notification flow.
"""
# 1. Load entity and related data
self.load_entity()
self.load_project()
self.load_workspace()
self.load_actor()
self.active_members = self.get_active_members()
# 2. Filter activities that should trigger notifications
if self.should_skip_notification():
return self.payload
# 3. Process mentions - subclasses provide the specific field values
mention_results = self.process_entity_mentions()
# 4. Create subscribers from mentions
all_mention_subscribers = []
for mention_type, mention_data in mention_results.items():
subscribers = self.create_subscribers(mention_data.get("all_mentions", []))
all_mention_subscribers.extend(subscribers)
mention_data["subscribers"] = subscribers
# 5. Bulk create all mention subscribers
if all_mention_subscribers and self.SUBSCRIBER_MODEL:
self.SUBSCRIBER_MODEL.objects.bulk_create(all_mention_subscribers, batch_size=100, ignore_conflicts=True)
# 6. Update mention records (for description mentions, not comments)
self.update_all_mentions(mention_results)
# 7. Handle subscriber opt-in if requested
if self.context.subscriber:
self.add_actor_as_subscriber()
# 8. Get all subscribers (excluding mentioned users to avoid duplicate notifications)
all_new_mentions = []
for mention_data in mention_results.values():
all_new_mentions.extend(mention_data.get("new_mentions", []))
excluded_users = all_new_mentions + [self.context.actor_id]
subscriber_data = self.get_subscribers(excluded_users)
# 9. Create notifications for subscribers
self.create_subscriber_notifications(subscriber_data)
# 10. Create mention notifications
self.create_all_mention_notifications(mention_results)
# 11. Persist all notifications
self.save_notifications()
return self.payload
# ==================== Abstract Methods (Must Implement) ====================
@abstractmethod
def load_entity(self):
"""Load the main entity (issue, initiative, teamspace, etc.)"""
pass
@abstractmethod
def get_entity_display_name(self) -> str:
"""Get display name for the entity"""
pass
@abstractmethod
def get_active_members(self) -> List[UUID]:
"""Get list of active members who can receive notifications"""
pass
@abstractmethod
def get_subscribers(self, exclude_users: List[str]) -> SubscriberData:
"""Get subscribers for this entity"""
pass
@abstractmethod
def create_subscribers(self, mentions: List[str]) -> List:
"""Create new subscribers for mentions"""
pass
@abstractmethod
def process_entity_mentions(self) -> Dict[str, Dict[str, Any]]:
"""
Process mentions for entity-specific fields.
Subclasses call self.process_mentions() with appropriate field values.
Returns:
Dict mapping mention types to their data:
{
'description': {
'new_mentions': [...],
'removed_mentions': [...],
'all_mentions': [...]
},
'comment': {
'new_mentions': [...],
'all_mentions': [...]
}
}
"""
pass
@abstractmethod
def update_all_mentions(self, mention_results: Dict[str, Dict[str, Any]]):
"""Update mention records for this entity (usually only description mentions)"""
pass
@abstractmethod
def should_send_email(self, preference: UserNotificationPreference, activity: ActivityData) -> bool:
"""Determine if email should be sent for this activity"""
pass
# ==================== Entity Loading Methods ====================
def load_project(self):
"""Load project (can be overridden for project-less entities)"""
from plane.db.models import Project
if self.context.project_id:
self.project = Project.objects.get(pk=self.context.project_id)
def load_workspace(self):
"""Load workspace"""
from plane.db.models import Workspace
self.workspace = self.project.workspace if self.project else None
if not self.workspace:
from plane.db.models import Workspace
self.workspace = Workspace.objects.get(pk=self.context.workspace_id)
def load_actor(self):
"""Load the user who triggered the activity"""
self.actor = User.objects.get(pk=self.context.actor_id)
# ==================== Mention Processing Methods ====================
def extract_mentions(self, content: str) -> List[str]:
"""
Extract mentions from HTML content.
"""
try:
from bs4 import BeautifulSoup
# Extract mentions from HTML
soup = BeautifulSoup(content, "html.parser")
mention_tags = soup.find_all("mention-component", attrs={"entity_name": "user_mention"})
mentions = [
mention_tag["entity_identifier"] for mention_tag in mention_tags if mention_tag.get("entity_identifier")
]
return list(set(mentions))
except Exception:
return []
def process_mentions(
self, old_value: Optional[str] = None, new_value: Optional[str] = None, filter_to_active: bool = True
) -> MentionData:
"""
Generic mention processing that works for any field.
Subclasses call this method with their specific field values.
Args:
old_value: Old content to extract mentions from
new_value: New content to extract mentions from
filter_to_active: Whether to filter mentions to active members only
Returns:
MentionData with new and removed mentions
"""
if not old_value and not new_value:
return MentionData()
# Extract mentions from both versions
mentions_older = self.extract_mentions(old_value) if old_value else []
mentions_newer = self.extract_mentions(new_value) if new_value else []
# Calculate differences
new_mentions = [m for m in mentions_newer if m not in mentions_older]
removed_mentions = [m for m in mentions_older if m not in mentions_newer]
# Filter to active members if requested
if filter_to_active and self.active_members:
active_member_ids = {str(m) for m in self.active_members}
new_mentions = [m for m in new_mentions if m in active_member_ids]
return MentionData(
new_mentions=new_mentions,
removed_mentions=removed_mentions,
)
# ==================== Notification Creation Methods ====================
def create_subscriber_notifications(self, subscriber_data: SubscriberData):
"""Create notifications for all subscribers"""
for subscriber_id in subscriber_data.subscribers:
# Get user preferences
try:
preference = UserNotificationPreference.objects.get(user_id=subscriber_id)
except UserNotificationPreference.DoesNotExist:
continue
# Determine sender type based on relationship
sender = self.get_sender_type(subscriber_id, subscriber_data)
# Create notifications for each activity
for activity in self.context.activities:
# Skip if activity is for a different entity
if not self.is_activity_for_this_entity(activity):
continue
# Skip description updates
if activity.field == "description":
continue
# Create in-app notification
self.create_in_app_notification(
receiver_id=subscriber_id, activity=activity, sender=sender, message=activity.comment
)
# Create email notification if preference allows
if self.should_send_email(preference, activity):
self.create_email_notification(receiver_id=subscriber_id, activity=activity)
def create_all_mention_notifications(self, mention_results: Dict[str, Dict[str, Any]]):
"""Create notifications for all mention types"""
for mention_type, data in mention_results.items():
new_mentions = data.get("new_mentions", [])
notification_type = data.get("notification_type", mention_type)
for mention_id in new_mentions:
# Skip self-mentions
if mention_id == self.context.actor_id:
continue
# Get user preferences
try:
preference = UserNotificationPreference.objects.get(user_id=mention_id)
except UserNotificationPreference.DoesNotExist:
continue
# Create notification message
entity_name = self.get_entity_display_name()
if notification_type == "comment":
message = (
f"{self.actor.display_name} has mentioned you in a comment in {self.ENTITY_NAME} {entity_name}"
)
else:
message = f"You have been mentioned in the {self.ENTITY_NAME} {entity_name}"
# Create notifications for each activity
for activity in self.context.activities:
# Create in-app notification
self.create_in_app_notification(
receiver_id=mention_id,
activity=activity,
sender=f"in_app:{self.ENTITY_NAME}_activities:mentioned",
message=message,
)
# Create email notification if preference allows
if preference.mention:
self.create_email_notification(
receiver_id=mention_id, activity=activity, field_override="mention"
)
def create_in_app_notification(
self, receiver_id: str, activity: ActivityData, sender: str, message: Optional[str] = None
):
"""Create an in-app notification"""
notification = Notification(
workspace=self.workspace,
sender=sender,
triggered_by_id=self.context.actor_id,
receiver_id=receiver_id,
entity_identifier=self.context.entity_id,
entity_name=self.get_entity_type(),
project=self.project,
title=message or activity.comment,
data=self.build_notification_data(activity),
)
self.payload.in_app_notifications.append(notification)
def create_email_notification(self, receiver_id: str, activity: ActivityData, field_override: Optional[str] = None):
"""Create an email notification log"""
email_log = EmailNotificationLog(
triggered_by_id=self.context.actor_id,
receiver_id=receiver_id,
entity_identifier=self.context.entity_id,
entity_name=self.get_entity_type(),
data=self.build_email_data(activity, field_override),
)
self.payload.email_logs.append(email_log)
# ==================== Notification Data Builders ====================
def build_notification_data(self, activity: ActivityData) -> Dict[str, Any]:
"""Build notification data dictionary (can be overridden for entity-specific data)"""
return {
self.ENTITY_NAME: self.get_entity_data(),
f"{self.ENTITY_NAME}_activity": {
"id": str(activity.id),
"verb": str(activity.verb),
"field": str(activity.field),
"actor": str(activity.actor_id),
"new_value": str(activity.new_value),
"old_value": str(activity.old_value),
"old_identifier": str(activity.old_identifier) if activity.old_identifier else None,
"new_identifier": str(activity.new_identifier) if activity.new_identifier else None,
},
}
def build_email_data(self, activity: ActivityData, field_override: Optional[str] = None) -> Dict[str, Any]:
"""Build email notification data dictionary (can be overridden)"""
data = self.build_notification_data(activity)
# Add email-specific fields
activity_key = f"{self.ENTITY_NAME}_activity"
if activity_key in data:
data[activity_key]["activity_time"] = activity.created_at
# Override field if provided (e.g., for mention notifications)
if field_override:
data[activity_key]["field"] = field_override
return data
# ==================== Helper Methods ====================
def get_sender_type(self, subscriber_id: UUID, subscriber_data: SubscriberData) -> str:
"""Determine sender type based on subscriber relationship"""
# Check if subscriber is the creator
if self.entity and hasattr(self.entity, "created_by_id"):
if self.entity.created_by_id == subscriber_id:
return f"in_app:{self.ENTITY_NAME}_activities:created"
# Check if subscriber is an assignee (if entity supports assignees)
if self.is_assignee(subscriber_id):
# Only use "assigned" if creator is not also an assignee
if not (
self.entity and hasattr(self.entity, "created_by_id") and self.entity.created_by_id == subscriber_id
):
return f"in_app:{self.ENTITY_NAME}_activities:assigned"
return f"in_app:{self.ENTITY_NAME}_activities:subscribed"
def is_assignee(self, user_id: UUID) -> bool:
"""Check if user is an assignee (can be overridden for entities without assignees)"""
return False # Default: no assignees
def get_entity_type(self) -> str:
"""Get entity type string (can be overridden for special cases like epics)"""
return self.ENTITY_NAME
def should_skip_notification(self) -> bool:
"""Check if notification should be skipped for this activity type"""
return self.context.notification_type in self.EXCLUDED_ACTIVITY_TYPES
def should_send_push_notifications(self) -> bool:
"""Determine if push notifications should be sent (can be overridden)"""
return True
def is_activity_for_this_entity(self, activity: ActivityData) -> bool:
"""Check if activity is for this entity (for blocking/blocked_by scenarios)"""
if activity.entity_detail:
return activity.entity_detail.get("id") == self.context.entity_id
return True
def add_actor_as_subscriber(self):
"""Add the actor as a subscriber if requested"""
if not self.SUBSCRIBER_MODEL:
return
try:
subscriber_data = {"subscriber_id": self.context.actor_id}
if self.project:
subscriber_data["project_id"] = self.context.project_id
subscriber_data["workspace_id"] = self.context.workspace_id
subscriber_data[f"{self.ENTITY_NAME}_id"] = self.context.entity_id
self.SUBSCRIBER_MODEL.objects.get_or_create(**subscriber_data)
except Exception:
pass
# ==================== Persistence Methods ====================
def save_notifications(self):
"""Persist all notifications to the database"""
# Bulk create in-app notifications
if self.payload.in_app_notifications:
notifications = Notification.objects.bulk_create(self.payload.in_app_notifications, batch_size=100)
# Update payload with saved notifications (with IDs)
self.payload.in_app_notifications = notifications
# Bulk create email logs
if self.payload.email_logs:
EmailNotificationLog.objects.bulk_create(self.payload.email_logs, batch_size=100, ignore_conflicts=True)
# ==================== External Notification send methods ====================
def send_email_notification(self, template_name: str, context: Dict[str, Any], receiver_data: Dict[str, Any]):
"""Send an email notification"""
pass
@@ -0,0 +1,404 @@
# Python imports
from typing import List, Dict, Any, Optional
from uuid import UUID
import json
# Django imports
from django.db.models import Subquery
# Module imports
from plane.db.models import (
Issue,
IssueSubscriber,
IssueMention,
IssueAssignee,
IssueComment,
IssueActivity,
ProjectMember,
State,
UserNotificationPreference,
)
from plane.utils.notifications.base import (
BaseNotificationHandler,
SubscriberData,
ActivityData,
)
class WorkItemNotificationHandler(BaseNotificationHandler):
"""
Notification handler for Issues and Epics.
Handles all issue-related notifications including mentions, assignments, and property changes.
"""
# Entity configuration
ENTITY_NAME = "issue"
ENTITY_MODEL = Issue
SUBSCRIBER_MODEL = IssueSubscriber
MENTION_MODEL = IssueMention
ACTIVITY_MODEL = IssueActivity
# Activity types that should not trigger notifications
EXCLUDED_ACTIVITY_TYPES = [
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
"issue_reaction.activity.created",
"issue_reaction.activity.deleted",
"comment_reaction.activity.created",
"comment_reaction.activity.deleted",
"issue_vote.activity.created",
"issue_vote.activity.deleted",
"issue_draft.activity.created",
"issue_draft.activity.updated",
"issue_draft.activity.deleted",
]
# Cleaning methods
@classmethod
def normalize_activity_data(cls, activity_dict: Dict) -> Dict:
"""
Normalize issue-specific activity keys to generic keys.
Maps issue_comment -> entity_comment, issue_detail -> entity_detail
"""
normalized = activity_dict.copy()
# Map issue-specific keys to generic keys
if 'issue_comment' in normalized:
normalized['entity_comment'] = normalized.pop('issue_comment')
if 'issue_detail' in normalized:
normalized['entity_detail'] = normalized.pop('issue_detail')
return normalized
# ==================== Entity Loading ====================
def load_entity(self):
"""Load the issue"""
self.entity = Issue.objects.filter(pk=self.context.entity_id).first()
def get_entity_display_name(self) -> str:
"""Get display name for the issue"""
return self.entity.name if self.entity else ""
def get_entity_data(self) -> Dict[str, Any]:
"""Get issue data for notification payload"""
if not self.entity:
return {}
return {
"id": str(self.context.entity_id),
"name": str(self.entity.name),
"identifier": str(self.entity.project.identifier),
"sequence_id": self.entity.sequence_id,
"state_name": self.entity.state.name,
"state_group": self.entity.state.group,
"type_id": str(self.entity.type_id),
}
def get_entity_type(self) -> str:
"""Return 'epic' if issue is an epic, otherwise 'issue'"""
if self.entity and self.entity.type and self.entity.type.is_epic:
return "epic"
return "issue"
# ==================== Member & Subscriber Management ====================
def get_active_members(self) -> List[UUID]:
"""Get list of active project members"""
if not self.context.project_id:
return []
return list(
ProjectMember.objects.filter(
project_id=self.context.project_id,
is_active=True
).values_list("member_id", flat=True)
)
def get_subscribers(self, exclude_users: List[str]) -> SubscriberData:
"""Get issue subscribers, assignees, and creator"""
# Get subscribers (excluding mentioned users and actor)
subscribers = list(
IssueSubscriber.objects.filter(
project_id=self.context.project_id,
issue_id=self.context.entity_id,
subscriber__in=Subquery(
ProjectMember.objects.filter(
project_id=self.context.project_id,
is_active=True
).values("member_id")
)
)
.exclude(subscriber_id__in=exclude_users)
.values_list("subscriber", flat=True)
)
# Get assignees
assignees = list(
IssueAssignee.objects.filter(
issue_id=self.context.entity_id,
project_id=self.context.project_id,
assignee__in=Subquery(
ProjectMember.objects.filter(
project_id=self.context.project_id,
is_active=True
).values("member_id")
)
).values_list("assignee", flat=True)
)
subscribers.extend(assignees)
if self.entity and self.entity.created_by_id:
subscribers.append(self.entity.created_by_id)
# Remove duplicates and convert to UUIDs
subscribers = list(set(subscribers))
# Remove actor from the list
subscribers = [subscriber for subscriber in subscribers if subscriber != UUID(self.context.actor_id)]
return SubscriberData(
subscribers=subscribers,
)
def create_subscribers(self, mentions: List[str]) -> List[IssueSubscriber]:
"""
Create issue subscribers for mentioned users.
Only creates if they're not already a subscriber, assignee, or creator.
"""
if not mentions:
return []
bulk_subscribers = []
for mention_id in mentions:
# Only create subscriber if they're not already involved with the issue
if (
not IssueSubscriber.objects.filter(
issue_id=self.context.entity_id,
subscriber_id=mention_id,
project_id=self.context.project_id
).exists()
and not IssueAssignee.objects.filter(
project_id=self.context.project_id,
issue_id=self.context.entity_id,
assignee_id=mention_id
).exists()
and not Issue.objects.filter(
project_id=self.context.project_id,
pk=self.context.entity_id,
created_by_id=mention_id
).exists()
and ProjectMember.objects.filter(
project_id=self.context.project_id,
member_id=mention_id,
is_active=True
).exists()
):
bulk_subscribers.append(
IssueSubscriber(
workspace_id=self.project.workspace_id,
project_id=self.context.project_id,
issue_id=self.context.entity_id,
subscriber_id=mention_id,
)
)
return bulk_subscribers
# ==================== Mention Processing ====================
def process_entity_mentions(self) -> Dict[str, Dict[str, Any]]:
"""
Process mentions for issue-specific fields (description and comments).
Calls base class process_mentions() with field values.
"""
results = {}
# 1. Process description mentions (JSON format)
if self.context.current_instance and self.context.requested_data:
current_instance = json.loads(self.context.current_instance)
requested_data = json.loads(self.context.requested_data)
description_mentions = self.process_mentions(
old_value=current_instance.get("description_html"),
new_value=requested_data.get("description_html"),
filter_to_active=True
)
# Get all current mentions for subscriber creation
current_mentions = self.extract_mentions(current_instance.get("description_html"))
results['description'] = {
'new_mentions': description_mentions.new_mentions,
'removed_mentions': description_mentions.removed_mentions,
'all_mentions': current_mentions,
'notification_type': 'description'
}
# 2. Process comment mentions (HTML format)
comment_mentions_new = []
comment_mentions_all = []
for activity in self.context.activities:
# Check if this activity has a comment
if activity.entity_comment is not None:
# Process mentions for this specific comment
mention_data = self.process_mentions(
old_value=activity.old_value,
new_value=activity.new_value,
filter_to_active=True
)
# Collect new mentions from this comment
comment_mentions_new.extend(mention_data.new_mentions)
# Get all mentions from new value
if activity.new_value:
all_mentions = self.extract_mentions(activity.new_value)
comment_mentions_all.extend(all_mentions)
if comment_mentions_new or comment_mentions_all:
results['comment'] = {
'new_mentions': comment_mentions_new,
'all_mentions': comment_mentions_all,
'notification_type': 'comment'
}
return results
def update_all_mentions(self, mention_results: Dict[str, Dict[str, Any]]):
"""
Update issue mention records.
Only updates description mentions (comment mentions are not stored).
"""
# Only update description mentions
if 'description' not in mention_results:
return
data = mention_results['description']
# Create new mentions
bulk_mentions = []
for mention_id in data.get('new_mentions', []):
bulk_mentions.append(
IssueMention(
mention_id=mention_id,
issue_id=self.context.entity_id,
project=self.project,
workspace_id=self.project.workspace_id,
)
)
if bulk_mentions:
IssueMention.objects.bulk_create(bulk_mentions, batch_size=100)
# Delete removed mentions
removed_mentions = data.get('removed_mentions', [])
if removed_mentions:
IssueMention.objects.filter(
issue_id=self.context.entity_id,
mention__in=removed_mentions
).delete()
# ==================== Email Preferences ====================
def should_send_email(
self,
preference: UserNotificationPreference,
activity: ActivityData
) -> bool:
"""
Determine if email should be sent based on user preferences and activity type.
Implements issue-specific email preference logic.
"""
# State changes
if activity.field == "state":
# Check if state change emails are enabled
if preference.state_change:
return True
# Check if issue completion emails are enabled and issue was completed
if (
preference.issue_completed
and activity.new_identifier
and State.objects.filter(
project_id=self.context.project_id,
pk=activity.new_identifier,
group="completed"
).exists()
):
return True
return False
# Comments
elif activity.field == "comment":
return preference.comment
# Page links
elif activity.field == "page":
return True
# All other property changes
elif preference.property_change:
return True
return False
# ==================== Notification Data Building ====================
def build_notification_data(self, activity: ActivityData) -> Dict[str, Any]:
"""Build notification data with issue comment if available"""
data = super().build_notification_data(activity)
# Add comment content if available
if activity.entity_comment:
issue_comment = IssueComment.objects.filter(
id=activity.entity_comment,
issue_id=self.context.entity_id,
project_id=self.context.project_id,
workspace_id=self.workspace.id,
).first()
if issue_comment:
data["issue_activity"]["issue_comment"] = str(
issue_comment.comment_stripped
)
else:
data["issue_activity"]["issue_comment"] = ""
return data
def build_email_data(
self,
activity: ActivityData,
field_override: Optional[str] = None
) -> Dict[str, Any]:
"""Build email data with additional project and workspace info"""
data = super().build_email_data(activity, field_override)
# Add email-specific fields for issues
if "issue" in data and self.project:
data["issue"]["project_id"] = str(self.project.id)
data["issue"]["workspace_slug"] = str(self.project.workspace.slug)
# Add comment content if available
if activity.entity_comment:
issue_comment = IssueComment.objects.filter(
id=activity.entity_comment,
issue_id=self.context.entity_id,
project_id=self.context.project_id,
workspace_id=self.workspace.id,
).first()
if issue_comment:
data["issue_activity"]["issue_comment"] = str(
issue_comment.comment_stripped
)
else:
data["issue_activity"]["issue_comment"] = ""
return data
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.25
Django==4.2.26
# rest framework
djangorestframework==3.15.2
# postgres
+4 -2
View File
@@ -5,8 +5,10 @@
*/
export function ensureTrailingSlash(url: string): string {
try {
// Handle relative URLs by creating a URL object with a dummy base
const urlObj = new URL(url, "http://dummy.com");
const fallbackBaseUrl =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "http://dummy.com";
// Handle relative URLs by creating a URL object with a fallback base URL
const urlObj = new URL(url, fallbackBaseUrl);
// Don't modify root path
if (urlObj.pathname === "/") {
@@ -37,7 +37,7 @@ export const BlockReactions = observer((props: Props) => {
)}
{canReact && (
<div className="flex flex-wrap items-center gap-2">
<IssueEmojiReactions anchor={anchor.toString()} issueIdFromProps={issueId} size="sm" />
<IssueEmojiReactions anchor={anchor.toString()} issueIdFromProps={issueId} />
</div>
)}
</div>
@@ -1,15 +1,14 @@
"use client";
import React from "react";
import React, { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Tooltip } from "@plane/propel/tooltip";
// plane imports
import { cn } from "@plane/utils";
// ui
import { ReactionSelector } from "@/components/ui";
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
// helpers
import { groupReactions, renderEmoji } from "@/helpers/emoji.helper";
import { groupReactions } from "@/helpers/emoji.helper";
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueDetails } from "@/hooks/store/use-issue-details";
@@ -23,6 +22,8 @@ type Props = {
export const CommentReactions: React.FC<Props> = observer((props) => {
const { anchor, commentId } = props;
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
const router = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
@@ -37,8 +38,18 @@ export const CommentReactions: React.FC<Props> = observer((props) => {
const { data: user } = useUser();
const isInIframe = useIsInIframe();
const commentReactions = peekId ? details[peekId].comments.find((c) => c.id === commentId)?.comment_reactions : [];
const groupedReactions = peekId ? groupReactions(commentReactions ?? [], "reaction") : {};
const commentReactions = useMemo(() => {
if (!peekId) return [];
const peekDetails = details[peekId];
if (!peekDetails) return [];
const comment = peekDetails.comments?.find((c) => c.id === commentId);
return comment?.comment_reactions ?? [];
}, [peekId, details, commentId]);
const groupedReactions = useMemo(() => {
if (!peekId) return {};
return groupReactions(commentReactions ?? [], "reaction");
}, [peekId, commentReactions]);
const userReactions = commentReactions?.filter((r) => r?.actor_detail?.id === user?.id);
@@ -62,70 +73,68 @@ export const CommentReactions: React.FC<Props> = observer((props) => {
// derived values
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
// Transform reactions data to Propel EmojiReactionType format
const propelReactions: EmojiReactionType[] = useMemo(() => {
const REACTIONS_LIMIT = 1000;
return Object.keys(groupedReactions || {})
.filter((reaction) => groupedReactions?.[reaction]?.length > 0)
.map((reaction) => {
const reactionList = groupedReactions?.[reaction] ?? [];
const userNames = reactionList
.map((r) => r?.actor_detail?.display_name)
.filter((name): name is string => !!name)
.slice(0, REACTIONS_LIMIT);
return {
emoji: stringToEmoji(reaction),
count: reactionList.length,
reacted: commentReactions?.some((r) => r?.actor_detail?.id === user?.id && r.reaction === reaction) || false,
users: userNames,
};
});
}, [groupedReactions, commentReactions, user?.id]);
const handleEmojiClick = (emoji: string) => {
if (isInIframe) return;
if (!user) {
router.push(`/?next_path=${pathName}?${queryParam}`);
return;
}
// Convert emoji back to decimal string format for the API
const emojiCodePoints = Array.from(emoji)
.map((char) => char.codePointAt(0))
.filter((cp): cp is number => cp !== undefined);
const reactionString = emojiCodePoints.join("-");
handleReactionClick(reactionString);
};
const handleEmojiSelect = (emoji: string) => {
if (!user) {
router.push(`/?next_path=${pathName}?${queryParam}`);
return;
}
// emoji is already in decimal string format from EmojiReactionPicker
handleReactionClick(emoji);
};
return (
<div className="mt-2 flex items-center gap-1.5">
{!isInIframe && (
<ReactionSelector
onSelect={(value) => {
if (user) handleReactionClick(value);
else router.push(`/?next_path=${pathName}?${queryParam}`);
}}
position="top"
selected={userReactions?.map((r) => r.reaction)}
size="md"
/>
)}
{Object.keys(groupedReactions || {}).map((reaction) => {
const reactions = groupedReactions?.[reaction] ?? [];
const REACTIONS_LIMIT = 1000;
if (reactions.length > 0)
return (
<Tooltip
key={reaction}
tooltipContent={
<div>
{reactions
.map((r) => r?.actor_detail?.display_name)
.splice(0, REACTIONS_LIMIT)
.join(", ")}
{reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"}
</div>
}
>
<button
type="button"
onClick={() => {
if (isInIframe) return;
if (user) handleReactionClick(reaction);
else router.push(`/?next_path=${pathName}?${queryParam}`);
}}
className={cn(
`flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100 ${
commentReactions?.some((r) => r?.actor_detail?.id === user?.id && r.reaction === reaction)
? "bg-custom-primary-100/10"
: "bg-custom-background-80"
}`,
{
"cursor-default": isInIframe,
}
)}
>
<span>{renderEmoji(reaction)}</span>
<span
className={
commentReactions?.some((r) => r?.actor_detail?.id === user?.id && r.reaction === reaction)
? "text-custom-primary-100"
: ""
}
>
{groupedReactions?.[reaction].length}{" "}
</span>
</button>
</Tooltip>
);
})}
<div className="mt-2">
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={isInIframe}
label={
<EmojiReactionGroup
reactions={propelReactions}
onReactionClick={handleEmojiClick}
showAddButton={!isInIframe}
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
</div>
);
});
@@ -1,12 +1,14 @@
"use client";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
// lib
import { Tooltip } from "@plane/propel/tooltip";
import { ReactionSelector } from "@/components/ui";
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
// helpers
import { groupReactions, renderEmoji } from "@/helpers/emoji.helper";
import { groupReactions } from "@/helpers/emoji.helper";
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueDetails } from "@/hooks/store/use-issue-details";
@@ -15,11 +17,12 @@ import { useUser } from "@/hooks/store/use-user";
type IssueEmojiReactionsProps = {
anchor: string;
issueIdFromProps?: string;
size?: "md" | "sm";
};
export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer((props) => {
const { anchor, issueIdFromProps, size = "md" } = props;
const { anchor, issueIdFromProps } = props;
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
// router
const router = useRouter();
const pathName = usePathname();
@@ -58,62 +61,63 @@ export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer(
// derived values
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
const reactionDimensions = size === "sm" ? "h-6 px-2 py-1" : "h-full px-2 py-1";
// Transform reactions data to Propel EmojiReactionType format
const propelReactions: EmojiReactionType[] = useMemo(() => {
const REACTIONS_LIMIT = 1000;
return Object.keys(groupedReactions || {})
.filter((reaction) => groupedReactions?.[reaction]?.length > 0)
.map((reaction) => {
const reactionList = groupedReactions?.[reaction] ?? [];
const userNames = reactionList
.map((r) => r?.actor_details?.display_name)
.filter((name): name is string => !!name)
.slice(0, REACTIONS_LIMIT);
return {
emoji: stringToEmoji(reaction),
count: reactionList.length,
reacted: reactionList.some((r) => r?.actor_details?.id === user?.id && r.reaction === reaction),
users: userNames,
};
});
}, [groupedReactions, user?.id]);
const handleEmojiClick = (emoji: string) => {
if (!user) {
router.push(`/?next_path=${pathName}?${queryParam}`);
return;
}
// Convert emoji back to decimal string format for the API
const emojiCodePoints = Array.from(emoji).map((char) => char.codePointAt(0));
const reactionString = emojiCodePoints.join("-");
handleReactionClick(reactionString);
};
const handleEmojiSelect = (emoji: string) => {
if (!user) {
router.push(`/?next_path=${pathName}?${queryParam}`);
return;
}
// emoji is already in decimal string format from EmojiReactionPicker
handleReactionClick(emoji);
};
return (
<>
<ReactionSelector
onSelect={(value) => {
if (user) handleReactionClick(value);
else router.push(`/?next_path=${pathName}?${queryParam}`);
}}
selected={userReactions?.map((r) => r.reaction)}
size={size}
/>
{Object.keys(groupedReactions || {}).map((reaction) => {
const reactions = groupedReactions?.[reaction] ?? [];
const REACTIONS_LIMIT = 1000;
if (reactions.length > 0)
return (
<Tooltip
key={reaction}
tooltipContent={
<div>
{reactions
?.map((r) => r?.actor_details?.display_name)
?.splice(0, REACTIONS_LIMIT)
?.join(", ")}
{reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"}
</div>
}
>
<button
type="button"
onClick={() => {
if (user) handleReactionClick(reaction);
else router.push(`/?next_path=${pathName}?${queryParam}`);
}}
className={`flex items-center gap-1 rounded-md text-sm text-custom-text-100 ${
reactions.some((r) => r?.actor_details?.id === user?.id && r.reaction === reaction)
? "bg-custom-primary-100/10"
: "bg-custom-background-80"
} ${reactionDimensions}`}
>
<span>{renderEmoji(reaction)}</span>
<span
className={
reactions.some((r) => r?.actor_details?.id === user?.id && r.reaction === reaction)
? "text-custom-primary-100"
: ""
}
>
{groupedReactions?.[reaction].length}{" "}
</span>
</button>
</Tooltip>
);
})}
</>
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
label={
<EmojiReactionGroup
reactions={propelReactions}
onReactionClick={handleEmojiClick}
showAddButton
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
);
});
-1
View File
@@ -1,2 +1 @@
export * from "./icon";
export * from "./reaction-selector";
@@ -1,80 +0,0 @@
import { Fragment } from "react";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// helper
import { Icon } from "@/components/ui";
import { renderEmoji } from "@/helpers/emoji.helper";
// icons
const reactionEmojis = ["128077", "128078", "128516", "128165", "128533", "129505", "9992", "128064"];
interface Props {
onSelect: (emoji: string) => void;
position?: "top" | "bottom";
selected?: string[];
size?: "sm" | "md" | "lg";
}
export const ReactionSelector: React.FC<Props> = (props) => {
const { onSelect, position, selected = [], size } = props;
return (
<Popover className="relative">
{({ open, close: closePopover }) => (
<>
<Popover.Button
className={`${
open ? "" : "text-opacity-90"
} group inline-flex items-center rounded-md bg-custom-background-80 focus:outline-none`}
>
<span
className={`flex items-center justify-center rounded-md px-2 ${
size === "sm" ? "h-6 w-6" : size === "md" ? "h-7 w-7" : "h-8 w-8"
}`}
>
<Icon iconName="add_reaction" className="scale-125 text-custom-text-100" />
</span>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel
className={`absolute -left-2 z-10 bg-custom-sidebar-background-100 ${
position === "top" ? "-top-12" : "-bottom-12"
}`}
>
<div className="rounded-md border border-custom-border-200 bg-custom-sidebar-background-100 p-1 shadow-custom-shadow-sm">
<div className="flex gap-x-1">
{reactionEmojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
onSelect(emoji);
closePopover();
}}
className={`grid select-none place-items-center rounded-md p-1 text-sm ${
selected.includes(emoji) ? "bg-custom-primary-100/10" : "hover:bg-custom-sidebar-background-80"
}`}
>
{renderEmoji(emoji)}
</button>
))}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
};
@@ -1,18 +1,25 @@
import { useParams, usePathname } from "next/navigation";
import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react";
import type { LucideIcon } from "lucide-react";
// plane imports
import {
EUserPermissionsLevel,
EUserPermissions,
GROUPED_WORKSPACE_SETTINGS,
WORKSPACE_SETTINGS_CATEGORIES,
EUserPermissions,
WORKSPACE_SETTINGS_CATEGORY,
} from "@plane/constants";
import type { WORKSPACE_SETTINGS } from "@plane/constants";
import type { ISvgIcons } from "@plane/propel/icons";
import type { EUserWorkspaceRoles } from "@plane/types";
// components
import { SettingsSidebar } from "@/components/settings/sidebar";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
// plane web imports
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
export const WORKSPACE_SETTINGS_ICONS = {
export const WORKSPACE_SETTINGS_ICONS: Record<keyof typeof WORKSPACE_SETTINGS, LucideIcon | React.FC<ISvgIcons>> = {
general: Building,
members: Users,
export: ArrowUpToLine,
+4 -2
View File
@@ -5,8 +5,10 @@
*/
export function ensureTrailingSlash(url: string): string {
try {
// Handle relative URLs by creating a URL object with a dummy base
const urlObj = new URL(url, "http://dummy.com");
const fallbackBaseUrl =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "http://dummy.com";
// Handle relative URLs by creating a URL object with a fallback base URL
const urlObj = new URL(url, fallbackBaseUrl);
// Don't modify root path
if (urlObj.pathname === "/") {
-8
View File
@@ -278,14 +278,6 @@ export const coreRoutes: RouteConfigEntry[] = [
":workspaceSlug/settings/billing",
"./(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx"
),
route(
":workspaceSlug/settings/integrations",
"./(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx"
),
route(
":workspaceSlug/settings/imports",
"./(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx"
),
route(
":workspaceSlug/settings/exports",
"./(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx"
@@ -1,12 +1,11 @@
"use client";
import { observer } from "mobx-react";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ProjectIcon } from "@plane/propel/icons";
// plane imports
import type { ICustomSearchSelectOption } from "@plane/types";
import { BreadcrumbNavigationSearchDropdown, Breadcrumbs } from "@plane/ui";
// components
import { Logo } from "@/components/common/logo";
import { SwitcherLabel } from "@/components/common/switcher-label";
// hooks
import { useProject } from "@/hooks/store/use-project";
@@ -0,0 +1,3 @@
import type { TIssue } from "@plane/types";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const TransferHopInfo = ({ workItem }: { workItem: TIssue }) => <></>;
@@ -1,6 +1,7 @@
import { useCallback, useMemo } from "react";
import { AtSign, Briefcase, Calendar } from "lucide-react";
// plane imports
import { Logo } from "@plane/propel/emoji-icon-picker";
import {
CycleGroupIcon,
CycleIcon,
@@ -26,7 +27,7 @@ import type {
IProject,
TWorkItemFilterProperty,
} from "@plane/types";
import { Avatar, Logo } from "@plane/ui";
import { Avatar } from "@plane/ui";
import {
getAssigneeFilterConfig,
getCreatedAtFilterConfig,
@@ -1,7 +1,7 @@
import { ProjectIcon } from "@plane/propel/icons";
// plane package imports
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ProjectIcon } from "@plane/propel/icons";
import { cn } from "@plane/utils";
import { Logo } from "@/components/common/logo";
// plane web hooks
import { useProject } from "@/hooks/store/use-project";
@@ -1,11 +1,10 @@
"use client";
import { observer } from "mobx-react";
import { ProjectIcon } from "@plane/propel/icons";
// plane package imports
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ProjectIcon } from "@plane/propel/icons";
import { CustomSearchSelect } from "@plane/ui";
// components
import { Logo } from "@/components/common/logo";
// hooks
import { useProject } from "@/hooks/store/use-project";
@@ -5,13 +5,13 @@ import { useParams } from "next/navigation";
import useSWR from "swr";
import { UserRound } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ProjectIcon } from "@plane/propel/icons";
// plane package imports
import type { AnalyticsTableDataMap, WorkItemInsightColumns } from "@plane/types";
// plane web components
import { Avatar } from "@plane/ui";
import { getFileURL } from "@plane/utils";
import { Logo } from "@/components/common/logo";
// hooks
import { useAnalytics } from "@/hooks/store/use-analytics";
import { useProject } from "@/hooks/store/use-project";
@@ -20,15 +20,22 @@ export const BaseKanbanLayout = observer(<T extends IBaseLayoutsKanbanItem>(prop
showEmptyGroups = true,
enableDragDrop = false,
loadMoreItems,
collapsedGroups: externalCollapsedGroups = [],
onToggleGroup: externalOnToggleGroup = () => {},
collapsedGroups: externalCollapsedGroups,
onToggleGroup: externalOnToggleGroup,
} = props;
const { containerRef, collapsedGroups, onToggleGroup } = useLayoutState({
mode: "external",
externalCollapsedGroups,
externalOnToggleGroup,
});
const useExternalMode = externalCollapsedGroups !== undefined && externalOnToggleGroup !== undefined;
const { containerRef, collapsedGroups, onToggleGroup } = useLayoutState(
useExternalMode
? {
mode: "external",
externalCollapsedGroups,
externalOnToggleGroup,
}
: {
mode: "internal",
}
);
return (
<div ref={containerRef} className={cn("relative w-full flex gap-2 p-3 h-full overflow-x-auto", className)}>
@@ -9,7 +9,7 @@ import { BASE_LAYOUTS } from "./constants";
type Props = {
layouts?: TBaseLayoutType[];
onChange: (layout: TBaseLayoutType) => void;
selectedLayout: TBaseLayoutType | undefined;
selectedLayout: TBaseLayoutType;
};
export const LayoutSwitcher: React.FC<Props> = (props) => {
@@ -17,17 +17,24 @@ export const BaseListLayout = observer(<T extends IBaseLayoutsListItem>(props: I
onDrop,
canDrag,
showEmptyGroups = false,
collapsedGroups: externalCollapsedGroups = [],
onToggleGroup: externalOnToggleGroup = () => {},
collapsedGroups: externalCollapsedGroups,
onToggleGroup: externalOnToggleGroup,
loadMoreItems,
className,
} = props;
const { containerRef, collapsedGroups, onToggleGroup } = useLayoutState({
mode: "external",
externalCollapsedGroups,
externalOnToggleGroup,
});
const useExternalMode = externalCollapsedGroups !== undefined && externalOnToggleGroup !== undefined;
const { containerRef, collapsedGroups, onToggleGroup } = useLayoutState(
useExternalMode
? {
mode: "external",
externalCollapsedGroups,
externalOnToggleGroup,
}
: {
mode: "internal",
}
);
return (
<div ref={containerRef} className={cn("relative size-full overflow-auto bg-custom-background-90", className)}>
@@ -1,15 +1,16 @@
"use client";
import type { FC } from "react";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
import type { TCommentsOperations, TIssueComment } from "@plane/types";
import { cn } from "@plane/utils";
// helpers
import { renderEmoji } from "@/helpers/emoji.helper";
// local imports
import { ReactionSelector } from "../issues/issue-detail/reactions";
export type TProps = {
comment: TIssueComment;
@@ -19,49 +20,66 @@ export type TProps = {
export const CommentReactions: FC<TProps> = observer((props) => {
const { comment, activityOperations, disabled = false } = props;
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
const userReactions = activityOperations.userReactions(comment.id);
const reactionIds = activityOperations.reactionIds(comment.id);
if (!userReactions) return null;
return (
<div className="relative flex items-center gap-1.5">
{!disabled && (
<ReactionSelector
size="md"
position="top"
value={userReactions}
onSelect={(reactionEmoji) => activityOperations.react(comment.id, reactionEmoji, userReactions)}
/>
)}
// Transform reactions data to Propel EmojiReactionType format
const reactions: EmojiReactionType[] = useMemo(() => {
if (!reactionIds) return [];
{reactionIds &&
Object.keys(reactionIds || {}).map(
(reaction: string) =>
reactionIds[reaction]?.length > 0 && (
<>
<Tooltip tooltipContent={activityOperations.getReactionUsers(reaction, reactionIds)}>
<button
type="button"
onClick={() => !disabled && activityOperations.react(comment.id, reaction, userReactions)}
key={reaction}
className={cn(
"flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100",
userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80",
{
"cursor-not-allowed": disabled,
}
)}
>
<span>{renderEmoji(reaction)}</span>
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
{(reactionIds || {})[reaction].length}{" "}
</span>
</button>
</Tooltip>
</>
)
)}
return Object.keys(reactionIds)
.filter((reaction) => reactionIds[reaction]?.length > 0)
.map((reaction) => {
// Get user names for this reaction
const tooltipContent = activityOperations.getReactionUsers(reaction, reactionIds);
// Parse the tooltip content string to extract user names
const users = tooltipContent ? tooltipContent.split(", ") : [];
return {
emoji: stringToEmoji(reaction),
count: reactionIds[reaction].length,
reacted: userReactions?.includes(reaction) || false,
users: users,
};
});
}, [reactionIds, userReactions, activityOperations]);
const handleReactionClick = (emoji: string) => {
if (disabled || !userReactions) return;
// Convert emoji back to decimal string format for the API
const emojiCodePoints = Array.from(emoji).map((char) => char.codePointAt(0));
const reactionString = emojiCodePoints.join("-");
activityOperations.react(comment.id, reactionString, userReactions);
};
const handleEmojiSelect = (emoji: string) => {
if (!userReactions) return;
// emoji is already in decimal string format from EmojiReactionPicker
activityOperations.react(comment.id, emoji, userReactions);
};
if (!userReactions) return null;
return (
<div className="relative">
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={disabled}
label={
<EmojiReactionGroup
reactions={reactions}
onReactionClick={handleReactionClick}
showAddButton={!disabled}
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
</div>
);
});
@@ -1,8 +1,8 @@
import type { FC } from "react";
import { Logo } from "@plane/propel/emoji-icon-picker";
import type { ISvgIcons } from "@plane/propel/icons";
import type { TLogoProps } from "@plane/types";
import { getFileURL, truncateText } from "@plane/utils";
import { Logo } from "@/components/common/logo";
type TSwitcherIconProps = {
logo_props?: TLogoProps;
@@ -3,13 +3,12 @@
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ChevronRightIcon } from "@plane/propel/icons";
// icons
import { Row } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import { useProject } from "@/hooks/store/use-project";
type Props = {
@@ -6,11 +6,11 @@ import { Check, Search } from "lucide-react";
import { Combobox } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ProjectIcon, ChevronDownIcon } from "@plane/propel/icons";
import { ComboDropDown } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
// hooks
import { useDropdown } from "@/hooks/use-dropdown";
// plane web imports
@@ -0,0 +1 @@
export * from "./root";
@@ -0,0 +1,33 @@
// plane imports
import { Loader } from "@plane/ui";
import { cn } from "@plane/utils";
type Props = {
className?: string;
};
export const DescriptionInputLoader: React.FC<Props> = (props) => {
const { className } = props;
return (
<Loader className={cn("space-y-2", className)}>
<Loader.Item width="100%" height="26px" />
<div className="flex items-center gap-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="400px" height="26px" />
</div>
<div className="flex items-center gap-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="400px" height="26px" />
</div>
<Loader.Item width="80%" height="26px" />
<div className="flex items-center gap-2">
<Loader.Item width="50%" height="26px" />
</div>
<div className="border-0.5 absolute bottom-2 right-3.5 z-10 flex items-center gap-2">
<Loader.Item width="100px" height="26px" />
<Loader.Item width="50px" height="26px" />
</div>
</Loader>
);
};
@@ -0,0 +1,245 @@
"use client";
import { useCallback, useEffect, useState, useRef } from "react";
import { debounce } from "lodash-es";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// plane imports
import type { EditorRefApi, TExtensions } from "@plane/editor";
import { useTranslation } from "@plane/i18n";
import type { EFileAssetType, TNameDescriptionLoader } from "@plane/types";
import { getDescriptionPlaceholderI18n } from "@plane/utils";
// components
import { RichTextEditor } from "@/components/editor/rich-text";
// hooks
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
import { useWorkspace } from "@/hooks/store/use-workspace";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// local imports
import { DescriptionInputLoader } from "./loader";
// services init
const workspaceService = new WorkspaceService();
type TFormData = {
id: string;
description_html: string;
};
type Props = {
/**
* @description Container class name, this will be used to add custom styles to the editor container
*/
containerClassName?: string;
/**
* @description Disabled, this will be used to disable the editor
*/
disabled?: boolean;
/**
* @description Disabled extensions, this will be used to disable the extensions in the editor
*/
disabledExtensions?: TExtensions[];
/**
* @description Editor ref, this will be used to imperatively attach editor related helper functions
*/
editorRef?: React.RefObject<EditorRefApi>;
/**
* @description Entity ID, this will be used for file uploads and as the unique identifier for the entity
*/
entityId: string;
/**
* @description File asset type, this will be used to upload the file to the editor
*/
fileAssetType: EFileAssetType;
/**
* @description Initial value, pass the actual description to initialize the editor
*/
initialValue: string | undefined;
/**
* @description Submit handler, the actual function which will be called when the form is submitted
*/
onSubmit: (value: string) => Promise<void>;
/**
* @description Placeholder, if not provided, the placeholder will be the default placeholder
*/
placeholder?: string | ((isFocused: boolean, value: string) => string);
/**
* @description projectId, if not provided, the entity will be considered as a workspace entity
*/
projectId?: string;
/**
* @description Set is submitting, use it to set the loading state of the form
*/
setIsSubmitting: (initialValue: TNameDescriptionLoader) => void;
/**
* @description SWR description, use it only if you want to sync changes in realtime(pseudo realtime)
*/
swrDescription?: string | null | undefined;
/**
* @description Workspace slug, this will be used to get the workspace details
*/
workspaceSlug: string;
};
/**
* @description DescriptionInput component for rich text editor with autosave functionality using debounce
* The component also makes an API call to save the description on unmount
*/
export const DescriptionInput: React.FC<Props> = observer((props) => {
const {
containerClassName,
disabled,
disabledExtensions,
editorRef,
entityId,
fileAssetType,
initialValue,
onSubmit,
placeholder,
projectId,
setIsSubmitting,
swrDescription,
workspaceSlug,
} = props;
// states
const [localDescription, setLocalDescription] = useState<TFormData>({
id: entityId,
description_html: initialValue?.trim() ?? "",
});
// ref to track if there are unsaved changes
const hasUnsavedChanges = useRef(false);
// store hooks
const { getWorkspaceBySlug } = useWorkspace();
const { uploadEditorAsset } = useEditorAsset();
// derived values
const workspaceDetails = getWorkspaceBySlug(workspaceSlug);
// translation
const { t } = useTranslation();
// form info
const { handleSubmit, reset, control } = useForm<TFormData>({
defaultValues: {
id: entityId,
description_html: initialValue?.trim() ?? "",
},
});
// submit handler
const handleDescriptionFormSubmit = useCallback(
async (formData: TFormData) => {
await onSubmit(formData.description_html);
},
[onSubmit]
);
// reset form values
useEffect(() => {
if (!entityId) return;
reset({
id: entityId,
description_html: initialValue?.trim() === "" ? "<p></p>" : (initialValue ?? "<p></p>"),
});
setLocalDescription({
id: entityId,
description_html: initialValue?.trim() === "" ? "<p></p>" : (initialValue ?? "<p></p>"),
});
// Reset unsaved changes flag when form is reset
hasUnsavedChanges.current = false;
}, [entityId, initialValue, reset]);
// ADDING handleDescriptionFormSubmit TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS
// TODO: Verify the exhaustive-deps warning
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedFormSave = useCallback(
debounce(async () => {
handleSubmit(handleDescriptionFormSubmit)()
.catch((error) => console.error(`Failed to save description for ${entityId}:`, error))
.finally(() => {
setIsSubmitting("submitted");
hasUnsavedChanges.current = false;
});
}, 1500),
[entityId, handleSubmit]
);
// Save on unmount if there are unsaved changes
useEffect(
() => () => {
debouncedFormSave.cancel();
if (hasUnsavedChanges.current) {
handleSubmit(handleDescriptionFormSubmit)()
.catch((error) => {
console.error("Failed to save description on unmount:", error);
})
.finally(() => {
setIsSubmitting("submitted");
hasUnsavedChanges.current = false;
});
}
},
// since we don't want to save on unmount if there are no unsaved changes, no deps are needed
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
if (!workspaceDetails) return null;
return (
<>
{localDescription.description_html ? (
<Controller
name="description_html"
control={control}
render={({ field: { onChange } }) => (
<RichTextEditor
editable={!disabled}
ref={editorRef}
id={entityId}
disabledExtensions={disabledExtensions}
initialValue={localDescription.description_html ?? "<p></p>"}
value={swrDescription ?? null}
workspaceSlug={workspaceSlug}
workspaceId={workspaceDetails.id}
projectId={projectId}
dragDropEnabled
onChange={(_description, description_html) => {
setIsSubmitting("submitting");
onChange(description_html);
hasUnsavedChanges.current = true;
debouncedFormSave();
}}
placeholder={placeholder ?? ((isFocused, value) => t(getDescriptionPlaceholderI18n(isFocused, value)))}
searchMentionCallback={async (payload) =>
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId,
})
}
containerClassName={containerClassName}
uploadFile={async (blockId, file) => {
try {
const { asset_id } = await uploadEditorAsset({
blockId,
data: {
entity_identifier: entityId,
entity_type: fileAssetType,
},
file,
projectId,
workspaceSlug,
});
return asset_id;
} catch (error) {
console.log("Error in uploading asset:", error);
throw new Error("Asset upload failed. Please try again later.");
}
}}
/>
)}
/>
) : (
<DescriptionInputLoader />
)}
</>
);
});
@@ -1,11 +1,10 @@
import { useRouter } from "next/navigation";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { PageIcon } from "@plane/propel/icons";
// plane import
import type { TActivityEntityData, TPageEntityData } from "@plane/types";
import { Avatar } from "@plane/ui";
import { calculateTimeAgo, getFileURL, getPageName } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import { ListItem } from "@/components/core/list";
// hooks
import { useMember } from "@/hooks/store/use-member";
@@ -1,9 +1,9 @@
import { useRouter } from "next/navigation";
// plane types
import { Logo } from "@plane/propel/emoji-icon-picker";
import type { TActivityEntityData, TProjectEntityData } from "@plane/types";
import { calculateTimeAgo } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import { ListItem } from "@/components/core/list";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
// helpers
@@ -8,20 +8,20 @@ import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import type { EditorRefApi } from "@plane/editor";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue, TNameDescriptionLoader } from "@plane/types";
import { EInboxIssueSource } from "@plane/types";
import { Loader } from "@plane/ui";
import { EFileAssetType, EInboxIssueSource } from "@plane/types";
import { getTextContent } from "@plane/utils";
// components
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
import { DescriptionInput } from "@/components/editor/rich-text/description-input";
import { DescriptionInputLoader } from "@/components/editor/rich-text/description-input/loader";
import { IssueAttachmentRoot } from "@/components/issues/attachment";
import { IssueDescriptionInput } from "@/components/issues/description-input";
import type { TIssueOperations } from "@/components/issues/issue-detail";
import { IssueActivity } from "@/components/issues/issue-detail/issue-activity";
import { IssueReaction } from "@/components/issues/issue-detail/reactions";
import { IssueTitleInput } from "@/components/issues/title-input";
// helpers
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
@@ -152,7 +152,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
payload: { id: issueId },
});
} catch (error) {
console.log("Error in archiving issue:", error);
console.error("Error in archiving issue:", error);
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
payload: { id: issueId },
@@ -192,21 +192,25 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
/>
{loader === "issue-loading" ? (
<Loader className="min-h-[6rem] rounded-md border border-custom-border-200">
<Loader.Item width="100%" height="140px" />
</Loader>
<DescriptionInputLoader />
) : (
<IssueDescriptionInput
editorRef={editorRef}
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
swrIssueDescription={issue.description_html ?? "<p></p>"}
initialValue={issue.description_html ?? "<p></p>"}
disabled={!isEditable}
issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)}
<DescriptionInput
containerClassName="-ml-3 border-none"
disabled={!isEditable}
editorRef={editorRef}
entityId={issue.id}
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION}
initialValue={issue.description_html ?? "<p></p>"}
onSubmit={async (value) => {
if (!issue.id || !issue.project_id) return;
await issueOperations.update(workspaceSlug, issue.project_id, issue.id, {
description_html: value,
});
}}
projectId={issue.project_id}
setIsSubmitting={(value) => setIsSubmitting(value)}
swrDescription={issue.description_html ?? "<p></p>"}
workspaceSlug={workspaceSlug}
/>
)}
@@ -1,199 +0,0 @@
"use client";
import type { FC } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { debounce } from "lodash-es";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import { useTranslation } from "@plane/i18n";
import type { TIssue, TNameDescriptionLoader } from "@plane/types";
import { EFileAssetType } from "@plane/types";
import { Loader } from "@plane/ui";
// components
import { getDescriptionPlaceholderI18n } from "@plane/utils";
import { RichTextEditor } from "@/components/editor/rich-text";
import type { TIssueOperations } from "@/components/issues/issue-detail";
// helpers
// hooks
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
import { useWorkspace } from "@/hooks/store/use-workspace";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
const workspaceService = new WorkspaceService();
export type IssueDescriptionInputProps = {
containerClassName?: string;
editorRef?: React.RefObject<EditorRefApi>;
workspaceSlug: string;
projectId: string;
issueId: string;
initialValue: string | undefined;
disabled?: boolean;
issueOperations: TIssueOperations;
placeholder?: string | ((isFocused: boolean, value: string) => string);
setIsSubmitting: (initialValue: TNameDescriptionLoader) => void;
swrIssueDescription?: string | null | undefined;
};
export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((props) => {
const {
containerClassName,
editorRef,
workspaceSlug,
projectId,
issueId,
disabled,
swrIssueDescription,
initialValue,
issueOperations,
setIsSubmitting,
placeholder,
} = props;
// states
const [localIssueDescription, setLocalIssueDescription] = useState({
id: issueId,
description_html: initialValue,
});
// ref to track if there are unsaved changes
const hasUnsavedChanges = useRef(false);
// store hooks
const { uploadEditorAsset } = useEditorAsset();
const { getWorkspaceBySlug } = useWorkspace();
// derived values
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id?.toString();
// form info
const { handleSubmit, reset, control } = useForm<TIssue>({
defaultValues: {
description_html: initialValue,
},
});
// i18n
const { t } = useTranslation();
const handleDescriptionFormSubmit = useCallback(
async (formData: Partial<TIssue>) => {
await issueOperations.update(workspaceSlug, projectId, issueId, {
description_html: formData.description_html ?? "<p></p>",
});
},
[workspaceSlug, projectId, issueId, issueOperations]
);
// reset form values
useEffect(() => {
if (!issueId) return;
reset({
id: issueId,
description_html: initialValue === "" ? "<p></p>" : initialValue,
});
setLocalIssueDescription({
id: issueId,
description_html: initialValue === "" ? "<p></p>" : initialValue,
});
// Reset unsaved changes flag when form is reset
hasUnsavedChanges.current = false;
}, [initialValue, issueId, reset]);
// ADDING handleDescriptionFormSubmit TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS
// TODO: Verify the exhaustive-deps warning
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedFormSave = useCallback(
debounce(async () => {
handleSubmit(handleDescriptionFormSubmit)().finally(() => {
setIsSubmitting("submitted");
hasUnsavedChanges.current = false;
});
}, 1500),
[handleSubmit, issueId]
);
// Save on unmount if there are unsaved changes
useEffect(
() => () => {
debouncedFormSave.cancel();
if (hasUnsavedChanges.current) {
handleSubmit(handleDescriptionFormSubmit)()
.catch((error) => {
console.error("Failed to save description on unmount:", error);
})
.finally(() => {
setIsSubmitting("submitted");
hasUnsavedChanges.current = false;
});
}
},
// since we don't want to save on unmount if there are no unsaved changes, no deps are needed
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
if (!workspaceId) return null;
return (
<>
{localIssueDescription.description_html ? (
<Controller
name="description_html"
control={control}
render={({ field: { onChange } }) => (
<RichTextEditor
editable={!disabled}
id={issueId}
initialValue={localIssueDescription.description_html ?? "<p></p>"}
value={swrIssueDescription ?? null}
workspaceSlug={workspaceSlug}
workspaceId={workspaceId}
projectId={projectId}
dragDropEnabled
onChange={(_description: object, description_html: string) => {
setIsSubmitting("submitting");
onChange(description_html);
hasUnsavedChanges.current = true;
debouncedFormSave();
}}
placeholder={
placeholder
? placeholder
: (isFocused, value) => t(`${getDescriptionPlaceholderI18n(isFocused, value)}`)
}
searchMentionCallback={async (payload) =>
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId?.toString() ?? "",
issue_id: issueId?.toString(),
})
}
containerClassName={containerClassName}
uploadFile={async (blockId, file) => {
try {
const { asset_id } = await uploadEditorAsset({
blockId,
data: {
entity_identifier: issueId,
entity_type: EFileAssetType.ISSUE_DESCRIPTION,
},
file,
projectId,
workspaceSlug,
});
return asset_id;
} catch (error) {
console.log("Error in uploading work item asset:", error);
throw new Error("Asset upload failed. Please try again later.");
}
}}
ref={editorRef}
/>
)}
/>
) : (
<Loader>
<Loader.Item height="150px" />
</Loader>
)}
</>
);
});
@@ -5,10 +5,11 @@ import { observer } from "mobx-react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import type { TNameDescriptionLoader } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { EFileAssetType, EIssueServiceType } from "@plane/types";
import { getTextContent } from "@plane/utils";
// components
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
import { DescriptionInput } from "@/components/editor/rich-text/description-input";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
@@ -23,7 +24,6 @@ import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-dup
// services
import { WorkItemVersionService } from "@/services/issue";
// local imports
import { IssueDescriptionInput } from "../description-input";
import { IssueDetailWidgets } from "../issue-detail-widgets";
import { NameDescriptionUpdateStatus } from "../issue-update-status";
import { PeekOverviewProperties } from "../peek-overview/properties";
@@ -128,16 +128,22 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
containerClassName="-ml-3"
/>
<IssueDescriptionInput
editorRef={editorRef}
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
initialValue={issue.description_html}
disabled={isArchived || !isEditable}
issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)}
<DescriptionInput
containerClassName="-ml-3 border-none"
disabled={isArchived || !isEditable}
editorRef={editorRef}
entityId={issue.id}
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION}
initialValue={issue.description_html}
onSubmit={async (value) => {
if (!issue.id || !issue.project_id) return;
await issueOperations.update(workspaceSlug, issue.project_id, issue.id, {
description_html: value,
});
}}
projectId={issue.project_id}
setIsSubmitting={(value) => setIsSubmitting(value)}
workspaceSlug={workspaceSlug}
/>
<div className="flex items-center justify-between gap-2">
@@ -1,4 +1,2 @@
export * from "./reaction-selector";
export * from "./issue";
// export * from "./issue-comment";
export * from "./issue-comment";
@@ -1,20 +1,20 @@
"use client";
import type { FC } from "react";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { IUser } from "@plane/types";
// components
import { cn, formatTextList } from "@plane/utils";
// helper
import { renderEmoji } from "@/helpers/emoji.helper";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
// types
import { ReactionSelector } from "./reaction-selector";
export type TIssueCommentReaction = {
workspaceSlug: string;
@@ -26,7 +26,8 @@ export type TIssueCommentReaction = {
export const IssueCommentReaction: FC<TIssueCommentReaction> = observer((props) => {
const { workspaceSlug, projectId, commentId, currentUser, disabled = false } = props;
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
// hooks
const {
commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser, getCommentReactionById },
@@ -82,7 +83,7 @@ export const IssueCommentReaction: FC<TIssueCommentReaction> = observer((props)
[workspaceSlug, projectId, commentId, currentUser, createCommentReaction, removeCommentReaction, userReactions]
);
const getReactionUsers = (reaction: string): string => {
const getReactionUsers = (reaction: string): string[] => {
const reactionUsers = (reactionIds?.[reaction] || [])
.map((reactionId) => {
const reactionDetails = getCommentReactionById(reactionId);
@@ -91,48 +92,53 @@ export const IssueCommentReaction: FC<TIssueCommentReaction> = observer((props)
: null;
})
.filter((displayName): displayName is string => !!displayName);
const formattedUsers = formatTextList(reactionUsers);
return formattedUsers;
return reactionUsers;
};
// Transform reactions data to Propel EmojiReactionType format
const reactions: EmojiReactionType[] = useMemo(() => {
if (!reactionIds) return [];
return Object.keys(reactionIds)
.filter((reaction) => reactionIds[reaction]?.length > 0)
.map((reaction) => ({
emoji: stringToEmoji(reaction),
count: reactionIds[reaction].length,
reacted: userReactions.includes(reaction),
users: getReactionUsers(reaction),
}));
}, [reactionIds, userReactions]);
const handleReactionClick = (emoji: string) => {
if (disabled) return;
// Convert emoji back to decimal string format for the API
const emojiCodePoints = Array.from(emoji).map((char) => char.codePointAt(0));
const reactionString = emojiCodePoints.join("-");
issueCommentReactionOperations.react(reactionString);
};
const handleEmojiSelect = (emoji: string) => {
// emoji is already in decimal string format from EmojiReactionPicker
issueCommentReactionOperations.react(emoji);
};
return (
<div className="relative mt-4 flex items-center gap-1.5">
{!disabled && (
<ReactionSelector
size="md"
position="top"
value={userReactions}
onSelect={issueCommentReactionOperations.react}
/>
)}
{reactionIds &&
Object.keys(reactionIds || {}).map(
(reaction) =>
reactionIds[reaction]?.length > 0 && (
<>
<Tooltip tooltipContent={getReactionUsers(reaction)}>
<button
type="button"
onClick={() => !disabled && issueCommentReactionOperations.react(reaction)}
key={reaction}
className={cn(
"flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100",
userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80",
{
"cursor-not-allowed": disabled,
}
)}
>
<span>{renderEmoji(reaction)}</span>
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
{(reactionIds || {})[reaction].length}{" "}
</span>
</button>
</Tooltip>
</>
)
)}
<div className="relative mt-4">
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={disabled}
label={
<EmojiReactionGroup
reactions={reactions}
onReactionClick={handleReactionClick}
showAddButton={!disabled}
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
</div>
);
});
@@ -1,20 +1,20 @@
"use client";
import type { FC } from "react";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { IUser } from "@plane/types";
// hooks
// ui
import { cn, formatTextList } from "@plane/utils";
// helpers
import { renderEmoji } from "@/helpers/emoji.helper";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
// types
import { ReactionSelector } from "./reaction-selector";
export type TIssueReaction = {
workspaceSlug: string;
@@ -27,6 +27,8 @@ export type TIssueReaction = {
export const IssueReaction: FC<TIssueReaction> = observer((props) => {
const { workspaceSlug, projectId, issueId, currentUser, disabled = false, className = "" } = props;
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
// hooks
const {
reaction: { getReactionsByIssueId, reactionsByUser, getReactionById },
@@ -82,7 +84,7 @@ export const IssueReaction: FC<TIssueReaction> = observer((props) => {
[workspaceSlug, projectId, issueId, currentUser, createReaction, removeReaction, userReactions]
);
const getReactionUsers = (reaction: string): string => {
const getReactionUsers = (reaction: string): string[] => {
const reactionUsers = (reactionIds?.[reaction] || [])
.map((reactionId) => {
const reactionDetails = getReactionById(reactionId);
@@ -92,42 +94,53 @@ export const IssueReaction: FC<TIssueReaction> = observer((props) => {
})
.filter((displayName): displayName is string => !!displayName);
const formattedUsers = formatTextList(reactionUsers);
return formattedUsers;
return reactionUsers;
};
// Transform reactions data to Propel EmojiReactionType format
const reactions: EmojiReactionType[] = useMemo(() => {
if (!reactionIds) return [];
return Object.keys(reactionIds)
.filter((reaction) => reactionIds[reaction]?.length > 0)
.map((reaction) => ({
emoji: stringToEmoji(reaction),
count: reactionIds[reaction].length,
reacted: userReactions.includes(reaction),
users: getReactionUsers(reaction),
}));
}, [reactionIds, userReactions]);
const handleReactionClick = (emoji: string) => {
if (disabled) return;
// Convert emoji back to decimal string format for the API
const emojiCodePoints = Array.from(emoji).map((char) => char.codePointAt(0));
const reactionString = emojiCodePoints.join("-");
issueReactionOperations.react(reactionString);
};
const handleEmojiSelect = (emoji: string) => {
// emoji is already in decimal string format from EmojiReactionPicker
issueReactionOperations.react(emoji);
};
return (
<div className={cn("relative mt-4 flex items-center gap-1.5", className)}>
{!disabled && (
<ReactionSelector size="md" position="top" value={userReactions} onSelect={issueReactionOperations.react} />
)}
{reactionIds &&
Object.keys(reactionIds || {}).map(
(reaction) =>
reactionIds[reaction]?.length > 0 && (
<>
<Tooltip tooltipContent={getReactionUsers(reaction)}>
<button
type="button"
onClick={() => !disabled && issueReactionOperations.react(reaction)}
key={reaction}
className={cn(
"flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100",
userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80",
{
"cursor-not-allowed": disabled,
}
)}
>
<span>{renderEmoji(reaction)}</span>
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
{(reactionIds || {})[reaction].length}{" "}
</span>
</button>
</Tooltip>
</>
)
)}
<div className={cn("relative mt-4", className)}>
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={disabled}
label={
<EmojiReactionGroup
reactions={reactions}
onReactionClick={handleReactionClick}
showAddButton={!disabled}
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
</div>
);
});
@@ -1,74 +0,0 @@
import { Fragment } from "react";
import { SmilePlus } from "lucide-react";
import { Popover, Transition } from "@headlessui/react";
// helper
import { renderEmoji } from "@/helpers/emoji.helper";
// icons
const reactionEmojis = ["128077", "128078", "128516", "128165", "128533", "129505", "9992", "128064"];
interface Props {
size?: "sm" | "md" | "lg";
position?: "top" | "bottom";
value?: string | string[] | null;
onSelect: (emoji: string) => void;
}
export const ReactionSelector: React.FC<Props> = (props) => {
const { onSelect, position, size } = props;
return (
<Popover className="relative">
{({ open, close: closePopover }) => (
<>
<Popover.Button
className={`${
open ? "" : "text-opacity-90"
} group inline-flex items-center rounded-md bg-custom-background-80 focus:outline-none`}
>
<span
className={`flex items-center justify-center rounded-md px-2 ${
size === "sm" ? "h-6 w-6" : size === "md" ? "h-7 w-7" : "h-8 w-8"
}`}
>
<SmilePlus className="h-3.5 w-3.5 text-custom-text-100" />
</span>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel
className={`absolute z-10 bg-custom-sidebar-background-100 ${
position === "top" ? "-top-12" : "-bottom-12"
}`}
>
<div className="rounded-md border border-custom-border-200 bg-custom-sidebar-background-100 p-1">
<div className="flex gap-x-1">
{reactionEmojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
onSelect(emoji);
closePopover();
}}
className="flex select-none items-center justify-between rounded-md p-1 text-sm hover:bg-custom-sidebar-background-90"
>
{renderEmoji(emoji)}
</button>
))}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
};
@@ -36,6 +36,7 @@ import { useProjectState } from "@/hooks/store/use-project-state";
// components
import { WorkItemAdditionalSidebarProperties } from "@/plane-web/components/issues/issue-details/additional-properties";
import { IssueParentSelectRoot } from "@/plane-web/components/issues/issue-details/parent-select-root";
import { TransferHopInfo } from "@/plane-web/components/issues/issue-details/sidebar/transfer-hop-info";
import { DateAlert } from "@/plane-web/components/issues/issue-details/sidebar.tsx/date-alert";
import { IssueWorklogProperty } from "@/plane-web/components/issues/worklog/property";
import { IssueCycleSelect } from "./cycle-select";
@@ -261,6 +262,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<CycleIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("common.cycle")}</span>
<TransferHopInfo workItem={issue} />
</div>
<IssueCycleSelect
className="w-3/5 flex-grow"
@@ -1,7 +1,6 @@
import { observer } from "mobx-react";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { CloseIcon } from "@plane/propel/icons";
// components
import { Logo } from "@/components/common/logo";
// hooks
import { useProject } from "@/hooks/store/use-project";
@@ -4,9 +4,9 @@ import React, { useMemo, useState } from "react";
import { sortBy } from "lodash-es";
import { observer } from "mobx-react";
// ui
import { Logo } from "@plane/propel/emoji-icon-picker";
import { Loader } from "@plane/ui";
// components
import { Logo } from "@/components/common/logo";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// hooks
import { useProject } from "@/hooks/store/use-project";
@@ -23,17 +23,12 @@ export const MobileLayoutSelection = ({
className="flex flex-grow justify-center text-sm text-custom-text-200"
placement="bottom-start"
customButton={
activeLayout ? (
<Button variant="neutral-primary" size="sm" className="relative px-2">
<Button variant="neutral-primary" size="sm" className="relative px-2">
{activeLayout && (
<IssueLayoutIcon layout={activeLayout} size={14} strokeWidth={2} className={`h-3.5 w-3.5`} />
<ChevronDownIcon className="size-3 text-custom-text-200 my-auto" strokeWidth={2} />
</Button>
) : (
<div className="flex flex-start text-sm text-custom-text-200">
{t("common.layout")}
<ChevronDownIcon className="ml-2 h-4 w-4 text-custom-text-200 my-auto" strokeWidth={2} />
</div>
)
)}
<ChevronDownIcon className="size-3 text-custom-text-200 my-auto" strokeWidth={2} />
</Button>
}
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
closeOnSelect
@@ -6,6 +6,7 @@ import { clone, isNil, pull, uniq, concat } from "lodash-es";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
// plane types
import { EIconSize, ISSUE_PRIORITIES, STATE_GROUPS } from "@plane/constants";
import { Logo } from "@plane/propel/emoji-icon-picker";
import type { ISvgIcons } from "@plane/propel/icons";
import { CycleGroupIcon, CycleIcon, ModuleIcon, PriorityIcon, StateGroupIcon } from "@plane/propel/icons";
import type {
@@ -26,8 +27,6 @@ import { EIssuesStoreType } from "@plane/types";
// plane ui
import { Avatar } from "@plane/ui";
import { renderFormattedDate, getFileURL } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
// helpers
// store
import { store } from "@/lib/store-context";
@@ -1,13 +1,16 @@
"use-client";
import type { FC } from "react";
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import { EFileAssetType } from "@plane/types";
import type { TNameDescriptionLoader } from "@plane/types";
// components
import { getTextContent } from "@plane/utils";
// components
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
import { DescriptionInput } from "@/components/editor/rich-text/description-input";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
@@ -22,7 +25,6 @@ import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-dup
// services
import { WorkItemVersionService } from "@/services/issue";
// local components
import { IssueDescriptionInput } from "../description-input";
import type { TIssueOperations } from "../issue-detail";
import { IssueParentDetail } from "../issue-detail/parent";
import { IssueReaction } from "../issue-detail/reactions";
@@ -125,16 +127,22 @@ export const PeekOverviewIssueDetails: FC<Props> = observer((props) => {
containerClassName="-ml-3"
/>
<IssueDescriptionInput
editorRef={editorRef}
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
initialValue={issueDescription}
disabled={disabled || isArchived}
issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)}
<DescriptionInput
containerClassName="-ml-3 border-none"
disabled={disabled || isArchived}
editorRef={editorRef}
entityId={issue.id}
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION}
initialValue={issueDescription}
onSubmit={async (value) => {
if (!issue.id || !issue.project_id) return;
await issueOperations.update(workspaceSlug, issue.project_id, issue.id, {
description_html: value,
});
}}
setIsSubmitting={(value) => setIsSubmitting(value)}
projectId={issue.project_id}
workspaceSlug={workspaceSlug}
/>
<div className="flex items-center justify-between gap-2">
@@ -34,6 +34,7 @@ import { useProjectState } from "@/hooks/store/use-project-state";
// plane web components
import { WorkItemAdditionalSidebarProperties } from "@/plane-web/components/issues/issue-details/additional-properties";
import { IssueParentSelectRoot } from "@/plane-web/components/issues/issue-details/parent-select-root";
import { TransferHopInfo } from "@/plane-web/components/issues/issue-details/sidebar/transfer-hop-info";
import { DateAlert } from "@/plane-web/components/issues/issue-details/sidebar.tsx/date-alert";
import { IssueWorklogProperty } from "@/plane-web/components/issues/worklog/property";
import type { TIssueOperations } from "../issue-detail";
@@ -263,6 +264,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
<CycleIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("common.cycle")}</span>
<TransferHopInfo workItem={issue} />
</div>
<IssueCycleSelect
className="w-3/4 flex-grow"
@@ -1,10 +1,11 @@
"use client";
import type { FC } from "react";
import { useEffect, useState, useMemo, useCallback } from "react";
import { useState, useMemo, useCallback } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
// Plane imports
import useSWR from "swr";
import { EUserPermissions, EUserPermissionsLevel, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/propel/toast";
@@ -40,7 +41,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
const {
peekIssue,
setPeekIssue,
issue: { fetchIssue, getIsFetchingIssueDetails },
issue: { fetchIssue },
fetchActivities,
} = useIssueDetail();
const issueStoreType = useIssueStoreType();
@@ -283,11 +284,15 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
[fetchIssue, is_draft, issues, fetchActivities, pathname, removeRoutePeekId, restoreIssue]
);
useEffect(() => {
if (peekIssue) {
issueOperations.fetch(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId);
const { isLoading } = useSWR(
["peek-issue", peekIssue?.workspaceSlug, peekIssue?.projectId, peekIssue?.issueId],
() => peekIssue && issueOperations.fetch(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId),
{
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
}, [peekIssue, issueOperations]);
);
if (!peekIssue?.workspaceSlug || !peekIssue?.projectId || !peekIssue?.issueId) return <></>;
@@ -304,7 +309,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
workspaceSlug={peekIssue.workspaceSlug}
projectId={peekIssue.projectId}
issueId={peekIssue.issueId}
isLoading={getIsFetchingIssueDetails(peekIssue.issueId)}
isLoading={isLoading}
isError={error}
is_archived={!!peekIssue.isArchived}
disabled={!isEditable}
@@ -36,7 +36,6 @@ import { useModule } from "@/hooks/store/use-module";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web constants
type Props = {
moduleId: string;
@@ -55,10 +54,8 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
const { allowPermissions } = useUserPermissions();
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites, updateModuleDetails } = useModule();
const { getUserDetails } = useMember();
// local storage
const { setValue: toggleFavoriteMenu, storedValue } = useLocalStorage<boolean>(IS_FAVORITE_MENU_OPEN, false);
// derived values
const moduleDetails = getModuleById(moduleId);
const isEditingAllowed = allowPermissions(
@@ -190,7 +187,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
const moduleStatus = MODULE_STATUS.find((status) => status.value === moduleDetails.status);
const issueCount = module
const issueCount = moduleDetails
? !moduleTotalIssues || moduleTotalIssues === 0
? `0 work items`
: moduleTotalIssues === moduleCompletedIssues
@@ -1,10 +1,8 @@
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { EmojiIconPicker, EmojiIconPickerTypes } from "@plane/ui";
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
import { cn } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
// store
import type { TPageInstance } from "@/store/pages/base-page";
@@ -27,7 +25,7 @@ export const PageEditorHeaderLogoPicker: React.FC<Props> = observer((props) => {
"max-h-[56px] pointer-events-auto": isLogoSelected,
})}
>
<EmojiIconPicker
<EmojiPicker
isOpen={isLogoPickerOpen}
handleToggle={(val) => setIsLogoPickerOpen(val)}
className="flex items-center justify-center"
@@ -2,7 +2,7 @@ import { useState } from "react";
import { observer } from "mobx-react";
import { SmilePlus } from "lucide-react";
// plane imports
import { EmojiIconPicker, EmojiIconPickerTypes } from "@plane/ui";
import { EmojiPicker, EmojiIconPickerTypes } from "@plane/propel/emoji-icon-picker";
import { cn } from "@plane/utils";
// store
import type { TPageInstance } from "@/store/pages/base-page";
@@ -32,7 +32,7 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
"opacity-100": isTitleEmpty,
})}
>
<EmojiIconPicker
<EmojiPicker
isOpen={isLogoPickerOpen}
handleToggle={(val) => setIsLogoPickerOpen(val)}
className="flex items-center justify-center"
@@ -3,11 +3,11 @@
import type { FC } from "react";
import { useRef } from "react";
import { observer } from "mobx-react";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { PageIcon } from "@plane/propel/icons";
// plane imports
import { getPageName } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import { ListItem } from "@/components/core/list";
import { BlockItemAction } from "@/components/pages/list/block-item-action";
// hooks
@@ -8,13 +8,13 @@ import { Globe2, Lock } from "lucide-react";
import { ETabIndices, EPageAccess } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
import { PageIcon } from "@plane/propel/icons";
import type { TPage } from "@plane/types";
import { EmojiIconPicker, EmojiIconPickerTypes, Input } from "@plane/ui";
import { convertHexEmojiToDecimal, getTabIndex } from "@plane/utils";
import { Input } from "@plane/ui";
import { getTabIndex } from "@plane/utils";
// components
import { AccessField } from "@/components/common/access-field";
import { Logo } from "@/components/common/logo";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -65,7 +65,7 @@ export const PageForm: React.FC<Props> = (props) => {
<div className="space-y-5 p-5">
<h3 className="text-xl font-medium text-custom-text-200">Create page</h3>
<div className="flex items-start gap-2 h-9 w-full">
<EmojiIconPicker
<EmojiPicker
isOpen={isOpen}
handleToggle={(val: boolean) => setIsOpen(val)}
className="flex items-center justify-center flex-shrink0"
@@ -86,8 +86,8 @@ export const PageForm: React.FC<Props> = (props) => {
if (val?.type === "emoji")
logoValue = {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
value: val.value,
url: undefined,
};
else if (val?.type === "icon") logoValue = val.value;
@@ -2,7 +2,7 @@
import React from "react";
// components
import { Logo } from "@/components/common/logo";
import { Logo } from "@plane/propel/emoji-icon-picker";
// plane imports
import type { TPartialProject } from "@/plane-web/types";
// local imports
+1 -3
View File
@@ -13,15 +13,13 @@ import { Disclosure, Transition } from "@headlessui/react";
import { useOutsideClickDetector } from "@plane/hooks";
// types
import { useTranslation } from "@plane/i18n";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ChevronDownIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import type { IUserProfileProjectSegregation } from "@plane/types";
// plane ui
import { Loader } from "@plane/ui";
import { cn, renderFormattedDate, getFileURL } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
// helpers
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useProject } from "@/hooks/store/use-project";
+1 -1
View File
@@ -9,6 +9,7 @@ import { ArchiveRestoreIcon, Check, ExternalLink, LinkIcon, Lock, Settings, Tras
import { EUserPermissions, EUserPermissionsLevel, IS_FAVORITE_MENU_OPEN } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
import { Button } from "@plane/propel/button";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { setPromiseToast, setToast, TOAST_TYPE } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { IProject } from "@plane/types";
@@ -16,7 +17,6 @@ import type { TContextMenuItem } from "@plane/ui";
import { Avatar, AvatarGroup, ContextMenu, FavoriteStar } from "@plane/ui";
import { copyUrlToClipboard, cn, getFileURL, renderFormattedDate } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
@@ -3,14 +3,13 @@ import { Controller, useFormContext } from "react-hook-form";
// plane imports
import { ETabIndices } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmojiPicker, EmojiIconPickerTypes } from "@plane/propel/emoji-icon-picker";
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
import { CloseIcon } from "@plane/propel/icons";
// plane types
import type { IProject } from "@plane/types";
// plane ui
import { getFileURL, getTabIndex } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
// plane web imports
import { ProjectTemplateSelect } from "@/plane-web/components/projects/create/template-select";
+2 -4
View File
@@ -8,14 +8,12 @@ import { NETWORK_CHOICES, PROJECT_TRACKER_ELEMENTS, PROJECT_TRACKER_EVENTS } fro
import { useTranslation } from "@plane/i18n";
// plane imports
import { Button } from "@plane/propel/button";
import { EmojiPicker } from "@plane/propel/emoji-icon-picker";
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { IProject, IWorkspace } from "@plane/types";
import { CustomSelect, Input, TextArea, EmojiIconPickerTypes } from "@plane/ui";
import { CustomSelect, Input, TextArea } from "@plane/ui";
import { renderFormattedDate, getFileURL } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
import { TimezoneSelect } from "@/components/global";
// helpers
@@ -7,6 +7,7 @@ import { Combobox } from "@headlessui/react";
// plane ui
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { CloseIcon } from "@plane/propel/icons";
import { Checkbox, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
import { cn } from "@plane/utils";
@@ -14,7 +15,6 @@ import { cn } from "@plane/utils";
import darkProjectAsset from "@/app/assets/empty-state/search/project-dark.webp?url";
import lightProjectAsset from "@/app/assets/empty-state/search/project-light.webp?url";
// components
import { Logo } from "@/components/common/logo";
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
// hooks
import { useProject } from "@/hooks/store/use-project";
@@ -7,9 +7,9 @@ import Link from "next/link";
import { useTranslation } from "@plane/i18n";
// ui
import { Button, getButtonStyling } from "@plane/propel/button";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { Row } from "@plane/ui";
// components
import { Logo } from "@/components/common/logo";
// hooks
import { useProject } from "@/hooks/store/use-project";
// plane web imports
@@ -64,7 +64,7 @@ export const ProjectSettingsMemberDefaults: React.FC<TProjectSettingsMemberDefau
const { reset, control } = useForm<IProject>({ defaultValues });
// fetching user members
useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId) : null,
workspaceSlug && projectId ? PROJECT_MEMBERS(workspaceSlug, projectId) : null,
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null
);
@@ -2,9 +2,9 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { PROJECT_SETTINGS_CATEGORIES, PROJECT_SETTINGS_CATEGORY } from "@plane/constants";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { getUserRole } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
// hooks
import { useProject } from "@/hooks/store/use-project";
// local imports
+1 -2
View File
@@ -7,7 +7,7 @@ import { Controller, useForm } from "react-hook-form";
import { ETabIndices, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { EmojiPicker, EmojiIconPickerTypes } from "@plane/propel/emoji-icon-picker";
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
import { ViewsIcon } from "@plane/propel/icons";
import type {
IIssueDisplayFilterOptions,
@@ -20,7 +20,6 @@ import { EViewAccess, EIssuesStoreType } from "@plane/types";
import { Input, TextArea } from "@plane/ui";
import { getComputedDisplayFilters, getComputedDisplayProperties, getTabIndex } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
// hooks
@@ -4,11 +4,11 @@ import type { FC } from "react";
import { useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ViewsIcon } from "@plane/propel/icons";
// types
import type { IProjectView } from "@plane/types";
// components
import { Logo } from "@/components/common/logo";
import { ListItem } from "@/components/core/list";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -62,27 +62,28 @@ const WorkItemFilterRoot = observer((props: TWorkItemFilterProps) => {
[isTemporary, entityId]
);
// memoize initial values to prevent re-computations when reference changes
const initialUserFilters = useMemo(
() => initialWorkItemFilters.richFilters,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
); // Empty dependency array to capture only the initial value
const initialUserFilters = useMemo(() => initialWorkItemFilters.richFilters, [initialWorkItemFilters]);
const workItemFiltersConfig = useWorkItemFiltersConfig({
allowedFilters: filtersToShowByLayout ? filtersToShowByLayout : [],
...entityConfigProps,
});
// get or create filter instance
const workItemLayoutFilter = getOrCreateFilter({
entityType,
entityId: workItemEntityID,
initialExpression: initialUserFilters,
onExpressionChange: updateFilters,
expressionOptions: {
saveViewOptions,
updateViewOptions,
},
showOnMount,
});
const workItemLayoutFilter = useMemo(
() =>
getOrCreateFilter({
entityType,
entityId: workItemEntityID,
initialExpression: initialUserFilters,
onExpressionChange: updateFilters,
expressionOptions: {
saveViewOptions,
updateViewOptions,
},
showOnMount,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[entityType, workItemEntityID, saveViewOptions, updateViewOptions, updateFilters]
);
// delete filter instance when component unmounts
useEffect(
@@ -92,8 +93,14 @@ const WorkItemFilterRoot = observer((props: TWorkItemFilterProps) => {
[deleteFilter, entityType, workItemEntityID]
);
workItemLayoutFilter.configManager.setAreConfigsReady(workItemFiltersConfig.areAllConfigsInitialized);
workItemLayoutFilter.configManager.registerAll(workItemFiltersConfig.configs);
useEffect(() => {
workItemLayoutFilter.configManager.setAreConfigsReady(workItemFiltersConfig.areAllConfigsInitialized);
workItemLayoutFilter.configManager.registerAll(workItemFiltersConfig.configs);
}, [
workItemFiltersConfig.areAllConfigsInitialized,
workItemFiltersConfig.configs,
workItemLayoutFilter.configManager,
]);
return <>{typeof children === "function" ? children({ filter: workItemLayoutFilter }) : children}</>;
});
@@ -88,6 +88,17 @@ export const ProjectLevelWorkItemFiltersHOC = observer((props: TProjectLevelWork
isCurrentUserOwner,
]
);
const createViewLabel = useMemo(() => props.saveViewOptions?.label, [props.saveViewOptions?.label]);
const updateViewLabel = useMemo(() => props.updateViewOptions?.label, [props.updateViewOptions?.label]);
const hasAdditionalChanges = useMemo(
() =>
!isEqual(initialWorkItemFilters?.displayFilters, viewDetails?.display_filters) ||
!isEqual(
removeNillKeys(initialWorkItemFilters?.displayProperties),
removeNillKeys(viewDetails?.display_properties)
),
[initialWorkItemFilters, viewDetails]
);
const getDefaultViewDetailPayload: () => Partial<IProjectView> = useCallback(
() => ({
@@ -108,6 +119,17 @@ export const ProjectLevelWorkItemFiltersHOC = observer((props: TProjectLevelWork
[initialWorkItemFilters]
);
const handleViewSave = useCallback(
(expression: TWorkItemFilterExpression) => {
setCreateViewPayload({
...getDefaultViewDetailPayload(),
...getViewFilterPayload(expression),
});
setIsCreateViewModalOpen(true);
},
[getDefaultViewDetailPayload, getViewFilterPayload]
);
const handleViewUpdate = useCallback(
(filterExpression: TWorkItemFilterExpression) => {
if (!viewDetails) {
@@ -153,6 +175,25 @@ export const ProjectLevelWorkItemFiltersHOC = observer((props: TProjectLevelWork
[viewDetails, updateView, workspaceSlug, projectId, getViewFilterPayload]
);
const saveViewOptions = useMemo(
() => ({
label: createViewLabel,
isDisabled: !canCreateView,
onViewSave: handleViewSave,
}),
[createViewLabel, canCreateView, handleViewSave]
);
const updateViewOptions = useMemo(
() => ({
label: updateViewLabel,
isDisabled: !canUpdateView,
hasAdditionalChanges,
onViewUpdate: handleViewUpdate,
}),
[updateViewLabel, canUpdateView, hasAdditionalChanges, handleViewUpdate]
);
return (
<>
<CreateUpdateProjectViewModal
@@ -177,28 +218,8 @@ export const ProjectLevelWorkItemFiltersHOC = observer((props: TProjectLevelWork
memberIds={getProjectMemberIds(projectId, false) ?? undefined}
moduleIds={getProjectModuleIds(projectId) ?? undefined}
stateIds={getProjectStateIds(projectId)}
saveViewOptions={{
label: props.saveViewOptions?.label,
isDisabled: !canCreateView,
onViewSave: (expression) => {
setCreateViewPayload({
...getDefaultViewDetailPayload(),
...getViewFilterPayload(expression),
});
setIsCreateViewModalOpen(true);
},
}}
updateViewOptions={{
label: props.updateViewOptions?.label,
isDisabled: !canUpdateView,
hasAdditionalChanges:
!isEqual(initialWorkItemFilters?.displayFilters, viewDetails?.display_filters) ||
!isEqual(
removeNillKeys(initialWorkItemFilters?.displayProperties),
removeNillKeys(viewDetails?.display_properties)
),
onViewUpdate: handleViewUpdate,
}}
saveViewOptions={saveViewOptions}
updateViewOptions={updateViewOptions}
>
{children}
</WorkItemFiltersHOC>
@@ -70,6 +70,17 @@ export const WorkspaceLevelWorkItemFiltersHOC = observer((props: TWorkspaceLevel
isCurrentUserOwner,
]
);
const createViewLabel = useMemo(() => props.saveViewOptions?.label, [props.saveViewOptions?.label]);
const updateViewLabel = useMemo(() => props.updateViewOptions?.label, [props.updateViewOptions?.label]);
const hasAdditionalChanges = useMemo(
() =>
!isEqual(initialWorkItemFilters?.displayFilters, viewDetails?.display_filters) ||
!isEqual(
removeNillKeys(initialWorkItemFilters?.displayProperties),
removeNillKeys(viewDetails?.display_properties)
),
[initialWorkItemFilters, viewDetails]
);
const getDefaultViewDetailPayload: () => Partial<IWorkspaceView> = useCallback(
() => ({
@@ -89,6 +100,17 @@ export const WorkspaceLevelWorkItemFiltersHOC = observer((props: TWorkspaceLevel
[initialWorkItemFilters]
);
const handleViewSave = useCallback(
(expression: TWorkItemFilterExpression) => {
setCreateViewPayload({
...getDefaultViewDetailPayload(),
...getViewFilterPayload(expression),
});
setIsCreateViewModalOpen(true);
},
[getDefaultViewDetailPayload, getViewFilterPayload]
);
const handleViewUpdate = useCallback(
(filterExpression: TWorkItemFilterExpression) => {
if (!viewDetails) {
@@ -140,6 +162,25 @@ export const WorkspaceLevelWorkItemFiltersHOC = observer((props: TWorkspaceLevel
[viewDetails, updateGlobalView, workspaceSlug, getViewFilterPayload]
);
const saveViewOptions = useMemo(
() => ({
label: createViewLabel,
isDisabled: !canCreateView,
onViewSave: handleViewSave,
}),
[createViewLabel, canCreateView, handleViewSave]
);
const updateViewOptions = useMemo(
() => ({
label: updateViewLabel,
isDisabled: !canUpdateView,
hasAdditionalChanges,
onViewUpdate: handleViewUpdate,
}),
[updateViewLabel, canUpdateView, hasAdditionalChanges, handleViewUpdate]
);
return (
<>
<CreateUpdateWorkspaceViewModal
@@ -155,28 +196,8 @@ export const WorkspaceLevelWorkItemFiltersHOC = observer((props: TWorkspaceLevel
memberIds={getWorkspaceMemberIds(workspaceSlug)}
labelIds={getWorkspaceLabelIds(workspaceSlug)}
projectIds={joinedProjectIds}
saveViewOptions={{
label: props.saveViewOptions?.label,
isDisabled: !canCreateView,
onViewSave: (expression) => {
setCreateViewPayload({
...getDefaultViewDetailPayload(),
...getViewFilterPayload(expression),
});
setIsCreateViewModalOpen(true);
},
}}
updateViewOptions={{
label: props.updateViewOptions?.label,
isDisabled: !canUpdateView,
hasAdditionalChanges:
!isEqual(initialWorkItemFilters?.displayFilters, viewDetails?.display_filters) ||
!isEqual(
removeNillKeys(initialWorkItemFilters?.displayProperties),
removeNillKeys(viewDetails?.display_properties)
),
onViewUpdate: handleViewUpdate,
}}
saveViewOptions={saveViewOptions}
updateViewOptions={updateViewOptions}
>
{children}
</WorkItemFiltersHOC>
@@ -1,10 +1,10 @@
"use client";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { PageIcon } from "@plane/propel/icons";
// plane imports
import type { IFavorite, TLogoProps } from "@plane/types";
// components
import { Logo } from "@/components/common/logo";
// plane web constants
import { FAVORITE_ITEM_ICONS, FAVORITE_ITEM_LINKS } from "@/plane-web/constants/sidebar-favorites";
@@ -15,12 +15,12 @@ import { Disclosure, Transition } from "@headlessui/react";
import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ArchiveIcon, ChevronRightIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { CustomMenu, DropIndicator, DragHandle, ControlLink } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import { LeaveProjectModal } from "@/components/project/leave-project-modal";
import { PublishProjectModal } from "@/components/project/publish-project/modal";
// hooks
+52 -2
View File
@@ -49,13 +49,35 @@ const paramsToKey = (params: any) => {
export const USER_WORKSPACES_LIST = "USER_WORKSPACES_LIST";
export const WORKSPACE_PARTIAL_PROJECTS = (workspaceSlug: string) =>
`WORKSPACE_PARTIAL_PROJECTS_${workspaceSlug.toUpperCase()}`;
export const WORKSPACE_MEMBERS = (workspaceSlug: string) => `WORKSPACE_MEMBERS_${workspaceSlug.toUpperCase()}`;
export const WORKSPACE_MODULES = (workspaceSlug: string) => `WORKSPACE_MODULES_${workspaceSlug.toUpperCase()}`;
export const WORKSPACE_CYCLES = (workspaceSlug: string) => `WORKSPACE_CYCLES_${workspaceSlug.toUpperCase()}`;
export const WORKSPACE_LABELS = (workspaceSlug: string) => `WORKSPACE_LABELS_${workspaceSlug.toUpperCase()}`;
export const WORKSPACE_ESTIMATES = (workspaceSlug: string) => `WORKSPACE_ESTIMATES_${workspaceSlug.toUpperCase()}`;
export const WORKSPACE_INVITATION = (invitationId: string) => `WORKSPACE_INVITATION_${invitationId}`;
export const PROJECT_DETAILS = (projectId: string) => `PROJECT_DETAILS_${projectId.toUpperCase()}`;
export const WORKSPACE_MEMBER_ME_INFORMATION = (workspaceSlug: string) =>
`WORKSPACE_MEMBER_ME_INFORMATION_${workspaceSlug.toUpperCase()}`;
export const PROJECT_MEMBERS = (projectId: string) => `PROJECT_MEMBERS_${projectId.toUpperCase()}`;
export const WORKSPACE_PROJECTS_ROLES_INFORMATION = (workspaceSlug: string) =>
`WORKSPACE_PROJECTS_ROLES_INFORMATION_${workspaceSlug.toUpperCase()}`;
export const WORKSPACE_FAVORITE = (workspaceSlug: string) => `WORKSPACE_FAVORITE_${workspaceSlug.toUpperCase()}`;
export const WORKSPACE_STATES = (workspaceSlug: string) => `WORKSPACE_STATES_${workspaceSlug.toUpperCase()}`;
export const WORKSPACE_SIDEBAR_PREFERENCES = (workspaceSlug: string) =>
`WORKSPACE_SIDEBAR_PREFERENCES_${workspaceSlug.toUpperCase()}`;
export const WORKSPACE_DB = (workspaceSlug: string) => `WORKSPACE_DB_${workspaceSlug.toUpperCase()}`;
export const PROJECT_GITHUB_REPOSITORY = (projectId: string) => `PROJECT_GITHUB_REPOSITORY_${projectId.toUpperCase()}`;
@@ -115,3 +137,31 @@ export const USER_PROFILE_PROJECT_SEGREGATION = (workspaceSlug: string, userId:
// api-tokens
export const API_TOKENS_LIST = `API_TOKENS_LIST`;
// project level keys
export const PROJECT_DETAILS = (workspaceSlug: string, projectId: string) =>
`PROJECT_DETAILS_${projectId.toString().toUpperCase()}`;
export const PROJECT_ME_INFORMATION = (workspaceSlug: string, projectId: string) =>
`PROJECT_ME_INFORMATION_${projectId.toString().toUpperCase()}`;
export const PROJECT_LABELS = (workspaceSlug: string, projectId: string) =>
`PROJECT_LABELS_${projectId.toString().toUpperCase()}`;
export const PROJECT_MEMBERS = (workspaceSlug: string, projectId: string) =>
`PROJECT_MEMBERS_${projectId.toString().toUpperCase()}`;
export const PROJECT_STATES = (workspaceSlug: string, projectId: string) =>
`PROJECT_STATES_${projectId.toString().toUpperCase()}`;
export const PROJECT_ESTIMATES = (workspaceSlug: string, projectId: string) =>
`PROJECT_ESTIMATES_${projectId.toString().toUpperCase()}`;
export const PROJECT_ALL_CYCLES = (workspaceSlug: string, projectId: string) =>
`PROJECT_ALL_CYCLES_${projectId.toString().toUpperCase()}`;
export const PROJECT_MODULES = (workspaceSlug: string, projectId: string) =>
`PROJECT_MODULES_${projectId.toString().toUpperCase()}`;
export const PROJECT_VIEWS = (workspaceSlug: string, projectId: string) =>
`PROJECT_VIEWS_${projectId.toString().toUpperCase()}`;
@@ -1,5 +1,6 @@
import useSWR from "swr";
// plane web imports
import { WORKSPACE_ESTIMATES, WORKSPACE_CYCLES, WORKSPACE_LABELS, WORKSPACE_MODULES } from "@/constants/fetch-keys";
import { useWorkspaceIssuePropertiesExtended } from "@/plane-web/hooks/use-workspace-issue-properties-extended";
// plane imports
import { useProjectEstimates } from "./store/estimates";
@@ -18,28 +19,28 @@ export const useWorkspaceIssueProperties = (workspaceSlug: string | string[] | u
// fetch workspace Modules
useSWR(
workspaceSlug ? `WORKSPACE_MODULES_${workspaceSlug}` : null,
workspaceSlug ? WORKSPACE_MODULES(workspaceSlug.toString()) : null,
workspaceSlug ? () => fetchWorkspaceModules(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetch workspace Cycles
useSWR(
workspaceSlug ? `WORKSPACE_CYCLES_${workspaceSlug}` : null,
workspaceSlug ? WORKSPACE_CYCLES(workspaceSlug.toString()) : null,
workspaceSlug ? () => fetchWorkspaceCycles(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetch workspace labels
useSWR(
workspaceSlug ? `WORKSPACE_LABELS_${workspaceSlug}` : null,
workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null,
workspaceSlug ? () => fetchWorkspaceLabels(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetch workspace estimates
useSWR(
workspaceSlug ? `WORKSPACE_ESTIMATES_${workspaceSlug}` : null,
workspaceSlug ? WORKSPACE_ESTIMATES(workspaceSlug.toString()) : null,
workspaceSlug ? () => getWorkspaceEstimates(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
@@ -13,6 +13,17 @@ import { EProjectNetwork } from "@plane/types";
import { JoinProject } from "@/components/auth-screens/project/join-project";
import { LogoSpinner } from "@/components/common/logo-spinner";
import { ETimeLineTypeType } from "@/components/gantt-chart/contexts";
import {
PROJECT_DETAILS,
PROJECT_ME_INFORMATION,
PROJECT_LABELS,
PROJECT_MEMBERS,
PROJECT_STATES,
PROJECT_ESTIMATES,
PROJECT_ALL_CYCLES,
PROJECT_MODULES,
PROJECT_VIEWS,
} from "@/constants/fetch-keys";
import { captureClick } from "@/helpers/event-tracker.helper";
// hooks
import { useProjectEstimates } from "@/hooks/store/estimates";
@@ -29,7 +40,6 @@ import { useTimeLineChart } from "@/hooks/use-timeline-chart";
// local
import { persistence } from "@/local-db/storage.sqlite";
// plane web constants
interface IProjectAuthWrapper {
workspaceSlug: string;
projectId?: string;
@@ -94,48 +104,48 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
// fetching project details
useSWR(
workspaceSlug && projectId ? `PROJECT_DETAILS_${workspaceSlug.toString()}_${projectId.toString()}` : null,
workspaceSlug && projectId ? PROJECT_DETAILS(workspaceSlug.toString(), projectId.toString()) : null,
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()) : null
);
// fetching user project member information
useSWR(
workspaceSlug && projectId ? `PROJECT_ME_INFORMATION_${workspaceSlug}_${projectId}` : null,
workspaceSlug && projectId ? PROJECT_ME_INFORMATION(workspaceSlug.toString(), projectId.toString()) : null,
workspaceSlug && projectId ? () => fetchUserProjectInfo(workspaceSlug.toString(), projectId.toString()) : null
);
// fetching project labels
useSWR(
workspaceSlug && projectId ? `PROJECT_LABELS_${workspaceSlug}_${projectId}` : null,
workspaceSlug && projectId ? PROJECT_LABELS(workspaceSlug.toString(), projectId.toString()) : null,
workspaceSlug && projectId ? () => fetchProjectLabels(workspaceSlug.toString(), projectId.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project members
useSWR(
workspaceSlug && projectId ? `PROJECT_MEMBERS_${workspaceSlug}_${projectId}` : null,
workspaceSlug && projectId ? PROJECT_MEMBERS(workspaceSlug.toString(), projectId.toString()) : null,
workspaceSlug && projectId ? () => fetchProjectMembers(workspaceSlug.toString(), projectId.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project states
useSWR(
workspaceSlug && projectId ? `PROJECT_STATES_${workspaceSlug}_${projectId}` : null,
workspaceSlug && projectId ? PROJECT_STATES(workspaceSlug.toString(), projectId.toString()) : null,
workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug.toString(), projectId.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project estimates
useSWR(
workspaceSlug && projectId ? `PROJECT_ESTIMATES_${workspaceSlug}_${projectId}` : null,
workspaceSlug && projectId ? PROJECT_ESTIMATES(workspaceSlug.toString(), projectId.toString()) : null,
workspaceSlug && projectId ? () => getProjectEstimates(workspaceSlug.toString(), projectId.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project cycles
useSWR(
workspaceSlug && projectId ? `PROJECT_ALL_CYCLES_${workspaceSlug}_${projectId}` : null,
workspaceSlug && projectId ? PROJECT_ALL_CYCLES(workspaceSlug.toString(), projectId.toString()) : null,
workspaceSlug && projectId ? () => fetchAllCycles(workspaceSlug.toString(), projectId.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project modules
useSWR(
workspaceSlug && projectId ? `PROJECT_MODULES_${workspaceSlug}_${projectId}` : null,
workspaceSlug && projectId ? PROJECT_MODULES(workspaceSlug.toString(), projectId.toString()) : null,
workspaceSlug && projectId
? async () => {
await fetchModulesSlim(workspaceSlug.toString(), projectId.toString());
@@ -146,7 +156,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
);
// fetching project views
useSWR(
workspaceSlug && projectId ? `PROJECT_VIEWS_${workspaceSlug}_${projectId}` : null,
workspaceSlug && projectId ? PROJECT_VIEWS(workspaceSlug.toString(), projectId.toString()) : null,
workspaceSlug && projectId ? () => fetchViews(workspaceSlug.toString(), projectId.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
@@ -5,7 +5,6 @@ import { observer } from "mobx-react";
import Image from "next/image";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useTheme } from "next-themes";
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
// ui
@@ -18,12 +17,20 @@ import { Tooltip } from "@plane/propel/tooltip";
// components
import { cn } from "@plane/utils";
// assets
import PlaneBlackLogo from "@/app/assets/plane-logos/black-horizontal-with-blue-logo.png?url";
import PlaneWhiteLogo from "@/app/assets/plane-logos/white-horizontal-with-blue-logo.png?url";
import WorkSpaceNotAvailable from "@/app/assets/workspace/workspace-not-available.png?url";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
// hooks
import {
WORKSPACE_MEMBERS,
WORKSPACE_PARTIAL_PROJECTS,
WORKSPACE_MEMBER_ME_INFORMATION,
WORKSPACE_PROJECTS_ROLES_INFORMATION,
WORKSPACE_FAVORITE,
WORKSPACE_STATES,
WORKSPACE_SIDEBAR_PREFERENCES,
WORKSPACE_DB,
} from "@/constants/fetch-keys";
import { useFavorite } from "@/hooks/store/use-favorite";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
@@ -43,8 +50,6 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
const { children, isLoading: isParentLoading = false } = props;
// router params
const { workspaceSlug } = useParams();
// next themes
const { resolvedTheme } = useTheme();
// store hooks
const { signOut, data: currentUser } = useUser();
const { fetchPartialProjects } = useProject();
@@ -62,7 +67,6 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const planeLogo = resolvedTheme === "dark" ? PlaneWhiteLogo : PlaneBlackLogo;
const allWorkspaces = workspaces ? Object.values(workspaces) : undefined;
const currentWorkspace =
(allWorkspaces && allWorkspaces.find((workspace) => workspace?.slug === workspaceSlug)) || undefined;
@@ -70,32 +74,32 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
// fetching user workspace information
useSWR(
workspaceSlug && currentWorkspace ? `WORKSPACE_MEMBER_ME_INFORMATION_${workspaceSlug}` : null,
workspaceSlug && currentWorkspace ? WORKSPACE_MEMBER_ME_INFORMATION(workspaceSlug.toString()) : null,
workspaceSlug && currentWorkspace ? () => fetchUserWorkspaceInfo(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
useSWR(
workspaceSlug && currentWorkspace ? `WORKSPACE_PROJECTS_ROLES_INFORMATION_${workspaceSlug}` : null,
workspaceSlug && currentWorkspace ? WORKSPACE_PROJECTS_ROLES_INFORMATION(workspaceSlug.toString()) : null,
workspaceSlug && currentWorkspace ? () => fetchUserProjectPermissions(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching workspace projects
useSWR(
workspaceSlug && currentWorkspace ? `WORKSPACE_PARTIAL_PROJECTS_${workspaceSlug}` : null,
workspaceSlug && currentWorkspace ? WORKSPACE_PARTIAL_PROJECTS(workspaceSlug.toString()) : null,
workspaceSlug && currentWorkspace ? () => fetchPartialProjects(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetch workspace members
useSWR(
workspaceSlug && currentWorkspace ? `WORKSPACE_MEMBERS_${workspaceSlug}` : null,
workspaceSlug && currentWorkspace ? WORKSPACE_MEMBERS(workspaceSlug.toString()) : null,
workspaceSlug && currentWorkspace ? () => fetchWorkspaceMembers(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetch workspace favorite
useSWR(
workspaceSlug && currentWorkspace && canPerformWorkspaceMemberActions
? `WORKSPACE_FAVORITE_${workspaceSlug}`
? WORKSPACE_FAVORITE(workspaceSlug.toString())
: null,
workspaceSlug && currentWorkspace && canPerformWorkspaceMemberActions
? () => fetchFavorite(workspaceSlug.toString())
@@ -104,21 +108,21 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
);
// fetch workspace states
useSWR(
workspaceSlug ? `WORKSPACE_STATES_${workspaceSlug}` : null,
workspaceSlug ? WORKSPACE_STATES(workspaceSlug.toString()) : null,
workspaceSlug ? () => fetchWorkspaceStates(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetch workspace sidebar preferences
useSWR(
workspaceSlug ? `WORKSPACE_SIDEBAR_PREFERENCES_${workspaceSlug}` : null,
workspaceSlug ? WORKSPACE_SIDEBAR_PREFERENCES(workspaceSlug.toString()) : null,
workspaceSlug ? () => fetchSidebarNavigationPreferences(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// initialize the local database
const { isLoading: isDBInitializing } = useSWRImmutable(
workspaceSlug ? `WORKSPACE_DB_${workspaceSlug}` : null,
workspaceSlug ? WORKSPACE_DB(workspaceSlug.toString()) : null,
workspaceSlug
? async () => {
// persistence.reset();
+3 -4
View File
@@ -2,9 +2,8 @@ import { set } from "lodash-es";
import { action, computed, makeObservable, observable, reaction, runInAction } from "mobx";
// plane imports
import { EPageAccess } from "@plane/constants";
import type { TChangeHandlerProps } from "@plane/propel/emoji-icon-picker";
import type { TDocumentPayload, TLogoProps, TNameDescriptionLoader, TPage } from "@plane/types";
import type { TChangeHandlerProps } from "@plane/ui";
import { convertHexEmojiToDecimal } from "@plane/utils";
// plane web store
import { ExtendedBasePage } from "@/plane-web/store/pages/extended-base-page";
import type { RootStore } from "@/plane-web/store/root.store";
@@ -448,8 +447,8 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
let logoValue = {};
if (value?.type === "emoji")
logoValue = {
value: convertHexEmojiToDecimal(value.value.unified),
url: value.value.imageUrl,
value: value.value,
url: undefined,
};
else if (value?.type === "icon") logoValue = value.value;
+19 -14
View File
@@ -4,23 +4,28 @@ import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
const PUBLIC_ENV_KEYS = [
"NEXT_PUBLIC_API_BASE_URL",
"NEXT_PUBLIC_API_BASE_PATH",
"NEXT_PUBLIC_ADMIN_BASE_URL",
"ENABLE_EXPERIMENTAL_COREPACK",
"NEXT_PUBLIC_ADMIN_BASE_PATH",
"NEXT_PUBLIC_SPACE_BASE_URL",
"NEXT_PUBLIC_SPACE_BASE_PATH",
"NEXT_PUBLIC_LIVE_BASE_URL",
"NEXT_PUBLIC_LIVE_BASE_PATH",
"NEXT_PUBLIC_WEB_BASE_URL",
"NEXT_PUBLIC_WEB_BASE_PATH",
"NEXT_PUBLIC_WEBSITE_URL",
"NEXT_PUBLIC_SUPPORT_EMAIL",
"NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_POSTHOG_HOST",
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
"NEXT_PUBLIC_ADMIN_BASE_URL",
"NEXT_PUBLIC_API_BASE_PATH",
"NEXT_PUBLIC_API_BASE_URL",
"NEXT_PUBLIC_CRISP_ID",
"NEXT_PUBLIC_ENABLE_SESSION_RECORDER",
"NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS",
"NEXT_PUBLIC_LIVE_BASE_PATH",
"NEXT_PUBLIC_LIVE_BASE_URL",
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
"NEXT_PUBLIC_POSTHOG_DEBUG",
"NEXT_PUBLIC_POSTHOG_HOST",
"NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_SESSION_RECORDER_KEY",
"NEXT_PUBLIC_SPACE_BASE_PATH",
"NEXT_PUBLIC_SPACE_BASE_URL",
"NEXT_PUBLIC_SUPPORT_EMAIL",
"NEXT_PUBLIC_WEB_BASE_PATH",
"NEXT_PUBLIC_WEB_BASE_URL",
"NEXT_PUBLIC_WEBSITE_URL",
"NODE_ENV",
];
const publicEnv = PUBLIC_ENV_KEYS.reduce<Record<string, string>>((acc, key) => {
+1 -1
View File
@@ -44,8 +44,8 @@
"@plane/hooks": "workspace:*",
"@plane/types": "workspace:*",
"@plane/ui": "workspace:*",
"@plane/utils": "workspace:*",
"@plane/propel": "workspace:*",
"@plane/utils": "workspace:*",
"@tiptap/core": "catalog:",
"@tiptap/extension-blockquote": "^2.22.3",
"@tiptap/extension-character-count": "^2.22.3",
@@ -59,7 +59,7 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
})
);
return additionalExtensions;
}, []);
}, [disabledExtensions, editable, extendedEditorProps, fileHandler, flaggedExtensions, user]);
const editor = useEditor({
disabledExtensions,
@@ -1,6 +1,7 @@
// plane imports
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
import { cn, convertHexEmojiToDecimal } from "@plane/utils";
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
import type { TLogoProps } from "@plane/types";
import { cn } from "@plane/utils";
// types
import { TCalloutBlockAttributes } from "./types";
// utils
@@ -17,7 +18,7 @@ type Props = {
export const CalloutBlockLogoSelector: React.FC<Props> = (props) => {
const { blockAttributes, disabled, handleOpen, isOpen, updateAttributes } = props;
const logoValue: TEmojiLogoProps = {
const logoValue: TLogoProps = {
in_use: blockAttributes["data-logo-in-use"],
icon: {
color: blockAttributes["data-icon-color"],
@@ -31,7 +32,7 @@ export const CalloutBlockLogoSelector: React.FC<Props> = (props) => {
return (
<div contentEditable={false}>
<EmojiIconPicker
<EmojiPicker
closeOnSelect={false}
isOpen={isOpen}
handleToggle={handleOpen}
@@ -43,7 +44,7 @@ export const CalloutBlockLogoSelector: React.FC<Props> = (props) => {
onChange={(val) => {
// construct the new logo value based on the type of value
let newLogoValue: Partial<TCalloutBlockAttributes> = {};
let newLogoValueToStoreInLocalStorage: TEmojiLogoProps = {
let newLogoValueToStoreInLocalStorage: TLogoProps = {
in_use: "emoji",
emoji: {
value: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
@@ -51,15 +52,16 @@ export const CalloutBlockLogoSelector: React.FC<Props> = (props) => {
},
};
if (val.type === "emoji") {
// val.value is now a string in decimal format (e.g. "128512")
newLogoValue = {
"data-emoji-unicode": convertHexEmojiToDecimal(val.value.unified),
"data-emoji-url": val.value.imageUrl,
"data-emoji-unicode": val.value,
"data-emoji-url": undefined,
};
newLogoValueToStoreInLocalStorage = {
in_use: "emoji",
emoji: {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
value: val.value,
url: undefined,
},
};
} else if (val.type === "icon") {
@@ -1,5 +1,5 @@
// plane imports
import type { TEmojiLogoProps } from "@plane/ui";
import type { TLogoProps } from "@plane/types";
import { sanitizeHTML } from "@plane/utils";
// types
import {
@@ -33,7 +33,7 @@ export const getStoredLogo = (): TStoredLogoValue => {
if (typeof window !== "undefined") {
const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo") ?? "");
if (storedData) {
let parsedData: TEmojiLogoProps;
let parsedData: TLogoProps;
try {
parsedData = JSON.parse(storedData);
} catch (error) {
@@ -65,7 +65,7 @@ export const getStoredLogo = (): TStoredLogoValue => {
return fallBackValues;
};
// function to update the stored logo on local storage
export const updateStoredLogo = (value: TEmojiLogoProps): void => {
export const updateStoredLogo = (value: TLogoProps): void => {
if (typeof window === "undefined") return;
localStorage.setItem("editor-calloutComponent-logo", JSON.stringify(value));
};
@@ -19,10 +19,11 @@ export type EmojiListRef = {
export type EmojisListDropdownProps = SuggestionProps<EmojiItem, { name: string }> & {
onClose: () => void;
forceOpen?: boolean;
};
export const EmojisListDropdown = forwardRef<EmojiListRef, EmojisListDropdownProps>((props, ref) => {
const { items, command, query, onClose } = props;
const { items, command, query, onClose, forceOpen = false } = props;
// states
const [selectedIndex, setSelectedIndex] = useState<number>(0);
const [isVisible, setIsVisible] = useState(false);
@@ -41,7 +42,13 @@ export const EmojisListDropdown = forwardRef<EmojiListRef, EmojisListDropdownPro
const handleKeyDown = useCallback(
(event: KeyboardEvent): boolean => {
if (query.length <= 0) {
// Allow keyboard navigation if we have items to show
if (items.length === 0) {
return false;
}
// Don't handle keyboard if modal shouldn't be visible (query empty without forceOpen)
if (query.length === 0 && !forceOpen) {
return false;
}
@@ -62,7 +69,7 @@ export const EmojisListDropdown = forwardRef<EmojiListRef, EmojisListDropdownPro
return false;
},
[query.length, items.length, selectItem, selectedIndex]
[items.length, query.length, forceOpen, selectItem, selectedIndex]
);
// Show animation
@@ -101,7 +108,7 @@ export const EmojisListDropdown = forwardRef<EmojiListRef, EmojisListDropdownPro
useOutsideClickDetector(dropdownContainerRef, onClose);
if (query.length <= 0) return null;
if (query.length === 0 && !forceOpen) return null;
return (
<>
@@ -11,22 +11,17 @@ import {
removeDuplicates,
} from "@tiptap/core";
import { EmojiStorage, emojis, emojiToShortcode, shortcodeToEmoji } from "@tiptap/extension-emoji";
import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
import { Fragment } from "@tiptap/pm/model";
import { Plugin, PluginKey, TextSelection, Transaction } from "@tiptap/pm/state";
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
import emojiRegex from "emoji-regex";
import { isEmojiSupported } from "is-emoji-supported";
// helpers
import { customFindSuggestionMatch } from "@/helpers/find-suggestion-match";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
emoji: {
/**
* Add an emoji
*/
setEmoji: (shortcode: string) => ReturnType;
};
}
// Extended storage type to include our custom forceOpen flag
export interface ExtendedEmojiStorage extends EmojiStorage {
forceOpen: boolean;
}
export type EmojiItem = {
@@ -114,18 +109,22 @@ export const Emoji = Node.create<EmojiOptions, EmojiStorage>({
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: this.name,
attrs: props,
},
{
type: "text",
text: " ",
},
])
.command(({ tr, state }) => {
tr.setStoredMarks(state.doc.resolve(state.selection.to - 2).marks());
.command(({ tr, state, dispatch }) => {
if (!dispatch) return true;
const { schema } = state;
const emojiNode = schema.nodes[this.name].create(props);
const spaceNode = schema.text(" ");
const fragment = Fragment.from([emojiNode, spaceNode]);
tr.replaceWith(range.from, range.to, fragment);
const newPos = range.from + fragment.size;
tr.setSelection(TextSelection.near(tr.doc.resolve(newPos)));
tr.setStoredMarks(tr.doc.resolve(range.from).marks());
return true;
})
.run();
@@ -157,6 +156,7 @@ export const Emoji = Node.create<EmojiOptions, EmojiStorage>({
return {
emojis: this.options.emojis,
isSupported: (emojiItem) => (emojiItem.version ? supportMap[emojiItem.version] : false),
forceOpen: false,
};
},
@@ -7,6 +7,7 @@ import { updateFloatingUIFloaterPosition } from "@/helpers/floating-ui";
import { CommandListInstance, DROPDOWN_NAVIGATION_KEYS } from "@/helpers/tippy";
// local imports
import { type EmojiItem, EmojisListDropdown, EmojisListDropdownProps } from "./components/emojis-list";
import type { ExtendedEmojiStorage } from "./emoji";
const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"];
@@ -54,16 +55,21 @@ export const emojiSuggestion: EmojiOptions["suggestion"] = {
component?.destroy();
component = null;
(editor || editorRef)?.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.EMOJI);
const emojiStorage = editor?.storage.emoji as ExtendedEmojiStorage;
emojiStorage.forceOpen = false;
cleanup();
};
return {
onStart: (props) => {
editorRef = props.editor;
const emojiStorage = props.editor.storage.emoji as ExtendedEmojiStorage;
const forceOpen = emojiStorage.forceOpen || false;
component = new ReactRenderer<CommandListInstance, EmojisListDropdownProps>(EmojisListDropdown, {
props: {
...props,
onClose: () => handleClose(props.editor),
forceOpen,
} satisfies EmojisListDropdownProps,
editor: props.editor,
className: "fixed z-[100]",
@@ -76,7 +82,9 @@ export const emojiSuggestion: EmojiOptions["suggestion"] = {
onUpdate: (props) => {
if (!component || !component.element) return;
component.updateProps(props);
const emojiStorage = props.editor.storage.emoji as ExtendedEmojiStorage;
const forceOpen = emojiStorage.forceOpen || false;
component.updateProps({ ...props, forceOpen });
if (!props.clientRect) return;
cleanup();
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element as HTMLElement).cleanup;
@@ -33,6 +33,7 @@ import {
insertImage,
insertCallout,
setText,
openEmojiPicker,
} from "@/helpers/editor-commands";
// plane editor extensions
import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions";
@@ -198,7 +199,7 @@ export const getSlashCommandFilteredSections =
searchTerms: ["emoji", "icons", "reaction", "emoticon", "emotags"],
icon: <Smile className="size-3.5" />,
command: ({ editor, range }) => {
editor.chain().focus().insertContentAt(range, "<p>:</p>").run();
openEmojiPicker(editor, range);
},
},
],
@@ -12,7 +12,7 @@ import {
} from "@floating-ui/react";
import type { Editor } from "@tiptap/core";
import { Ellipsis } from "lucide-react";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
// plane imports
import { cn } from "@plane/utils";
// constants
@@ -78,6 +78,17 @@ export const ColumnDragHandle: React.FC<ColumnDragHandleProps> = (props) => {
const role = useRole(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]);
useEffect(() => {
if (!isDropdownOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
context.onOpenChange(false);
event.preventDefault();
event.stopPropagation();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isDropdownOpen, context]);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
@@ -12,7 +12,7 @@ import {
} from "@floating-ui/react";
import type { Editor } from "@tiptap/core";
import { Ellipsis } from "lucide-react";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
// plane imports
import { cn } from "@plane/utils";
// constants
@@ -78,6 +78,17 @@ export const RowDragHandle: React.FC<RowDragHandleProps> = (props) => {
const role = useRole(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]);
useEffect(() => {
if (!isDropdownOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
context.onOpenChange(false);
event.preventDefault();
event.stopPropagation();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isDropdownOpen, context]);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
@@ -5,6 +5,7 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
import type { InsertImageComponentProps } from "@/extensions/custom-image/types";
// helpers
import { ExtendedEmojiStorage } from "@/extensions/emoji/emoji";
import { findTableAncestor } from "@/helpers/common";
export const setText = (editor: Editor, range?: Range) => {
@@ -184,3 +185,10 @@ export const insertCallout = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).insertCallout().run();
else editor.chain().focus().insertCallout().run();
};
export const openEmojiPicker = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).run();
const emojiStorage = editor.storage.emoji as ExtendedEmojiStorage;
emojiStorage.forceOpen = true;
editor.chain().focus().insertContent(":").run();
};
@@ -8,9 +8,9 @@ export interface AnimatedCounterProps {
}
const sizeClasses = {
sm: "text-xs h-4 w-4",
md: "text-sm h-5 w-5",
lg: "text-base h-6 w-6",
sm: "text-xs",
md: "text-sm",
lg: "text-base",
};
export const AnimatedCounter: React.FC<AnimatedCounterProps> = ({ count, className, size = "md" }) => {
@@ -44,7 +44,7 @@ export const AnimatedCounter: React.FC<AnimatedCounterProps> = ({ count, classNa
const sizeClass = sizeClasses[size];
return (
<div className={cn("relative inline-flex items-center justify-center overflow-hidden", sizeClass)}>
<div className={cn("relative inline-flex items-center justify-center overflow-hidden min-w-2", sizeClass)}>
{/* Previous number sliding out */}
{isAnimating && (
<span
@@ -106,6 +106,7 @@ export const EmojiPicker: React.FC<TCustomEmojiPicker> = (props) => {
side={finalSide}
align={finalAlign}
sideOffset={8}
data-prevent-outside-click="true"
>
<Tabs.Root defaultValue={defaultOpen}>
<Tabs.List className="grid grid-cols-2 gap-1 px-3.5 pt-3">
@@ -1,4 +1,5 @@
export * from "./emoji-picker";
export * from "./helper";
export * from "./logo";
export * from "./lucide-icons";
export * from "./material-icons";
@@ -6,8 +6,10 @@ import type { FC } from "react";
// eslint-disable-next-line import/order
import useFontFaceObserver from "use-font-face-observer";
// plane imports
import { getEmojiSize, LUCIDE_ICONS_LIST, stringToEmoji } from "@plane/propel/emoji-icon-picker";
import type { TLogoProps } from "@plane/types";
// local imports
import { getEmojiSize, stringToEmoji } from "./helper";
import { LUCIDE_ICONS_LIST } from "./lucide-icons";
type Props = {
logo: TLogoProps;
@@ -18,6 +18,12 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Pick Emoji",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
@@ -46,6 +52,12 @@ export const Default: Story = {
};
export const WithCustomLabel: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Add Reaction",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
@@ -71,6 +83,12 @@ export const WithCustomLabel: Story = {
};
export const InlineReactions: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Add",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [reactions, setReactions] = useState<EmojiReactionType[]>([
@@ -128,6 +146,12 @@ export const InlineReactions: Story = {
};
export const DifferentPlacements: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Placements",
},
render() {
const [isOpen1, setIsOpen1] = useState(false);
const [isOpen2, setIsOpen2] = useState(false);
@@ -182,6 +206,12 @@ export const DifferentPlacements: Story = {
};
export const SearchDisabled: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "No Search",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
@@ -207,6 +237,12 @@ export const SearchDisabled: Story = {
};
export const CustomSearchPlaceholder: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Custom Search",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
@@ -232,6 +268,12 @@ export const CustomSearchPlaceholder: Story = {
};
export const CloseOnSelectDisabled: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Select Multiple",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedEmojis, setSelectedEmojis] = useState<string[]>([]);
@@ -279,6 +321,12 @@ export const CloseOnSelectDisabled: Story = {
};
export const InMessageContext: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Message",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [reactions, setReactions] = useState<EmojiReactionType[]>([
@@ -70,6 +70,7 @@ export const EmojiReactionPicker: React.FC<EmojiReactionPickerProps> = (props) =
side={finalSide}
align={finalAlign}
sideOffset={8}
data-prevent-outside-click="true"
>
<div className="h-80 overflow-hidden overflow-y-auto">
<EmojiRoot
@@ -33,6 +33,10 @@ export const Reacted: Story = {
};
export const Interactive: Story = {
args: {
emoji: "👍",
count: 0,
},
render() {
const [reacted, setReacted] = useState(false);
const [count, setCount] = useState(5);
@@ -75,28 +79,11 @@ export const WithoutCount: Story = {
},
};
export const Sizes: Story = {
render() {
return (
<div className="flex flex-col gap-4 items-center">
<div className="flex flex-col gap-2">
<span className="text-xs text-custom-text-400">Small</span>
<EmojiReaction emoji="👍" count={5} size="sm" users={["Alice", "Bob"]} />
</div>
<div className="flex flex-col gap-2">
<span className="text-xs text-custom-text-400">Medium (default)</span>
<EmojiReaction emoji="👍" count={5} size="md" users={["Alice", "Bob"]} />
</div>
<div className="flex flex-col gap-2">
<span className="text-xs text-custom-text-400">Large</span>
<EmojiReaction emoji="👍" count={5} size="lg" users={["Alice", "Bob"]} />
</div>
</div>
);
},
};
export const MultipleReactions: Story = {
args: {
emoji: "👍",
count: 0,
},
render() {
const [reactions, setReactions] = useState<EmojiReactionType[]>([
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
@@ -137,6 +124,10 @@ export const MultipleReactions: Story = {
};
export const AddButton: Story = {
args: {
emoji: "",
count: 0,
},
render() {
const handleAdd = () => {
alert("Add reaction clicked");
@@ -146,28 +137,11 @@ export const AddButton: Story = {
},
};
export const AddButtonSizes: Story = {
render() {
return (
<div className="flex gap-4 items-center">
<div className="flex flex-col gap-2 items-center">
<span className="text-xs text-custom-text-400">Small</span>
<EmojiReactionButton size="sm" />
</div>
<div className="flex flex-col gap-2 items-center">
<span className="text-xs text-custom-text-400">Medium</span>
<EmojiReactionButton size="md" />
</div>
<div className="flex flex-col gap-2 items-center">
<span className="text-xs text-custom-text-400">Large</span>
<EmojiReactionButton size="lg" />
</div>
</div>
);
},
};
export const ReactionGroup: Story = {
args: {
emoji: "👍",
count: 0,
},
render() {
const [reactions, setReactions] = useState<EmojiReactionType[]>([
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
@@ -205,34 +179,11 @@ export const ReactionGroup: Story = {
},
};
export const ReactionGroupSizes: Story = {
render() {
const reactions: EmojiReactionType[] = [
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob"] },
{ emoji: "❤️", count: 12, reacted: true, users: ["Charlie", "David"] },
{ emoji: "🎉", count: 3, reacted: false, users: ["Emma"] },
];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-xs text-custom-text-400">Small</span>
<EmojiReactionGroup reactions={reactions} size="sm" />
</div>
<div className="flex flex-col gap-2">
<span className="text-xs text-custom-text-400">Medium</span>
<EmojiReactionGroup reactions={reactions} size="md" />
</div>
<div className="flex flex-col gap-2">
<span className="text-xs text-custom-text-400">Large</span>
<EmojiReactionGroup reactions={reactions} size="lg" />
</div>
</div>
);
},
};
export const InMessageContext: Story = {
args: {
emoji: "👍",
count: 0,
},
render() {
const [reactions, setReactions] = useState<EmojiReactionType[]>([
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
@@ -1,7 +1,7 @@
import * as React from "react";
import { Plus } from "lucide-react";
import { AnimatedCounter } from "../animated-counter";
import { stringToEmoji } from "../emoji-icon-picker";
import { AddReactionIcon } from "../icons";
import { Tooltip } from "../tooltip";
import { cn } from "../utils";
@@ -20,7 +20,6 @@ export interface EmojiReactionProps extends React.ButtonHTMLAttributes<HTMLButto
onReactionClick?: (emoji: string) => void;
className?: string;
showCount?: boolean;
size?: "sm" | "md" | "lg";
}
export interface EmojiReactionGroupProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -30,46 +29,15 @@ export interface EmojiReactionGroupProps extends React.HTMLAttributes<HTMLDivEle
className?: string;
showAddButton?: boolean;
maxDisplayUsers?: number;
size?: "sm" | "md" | "lg";
}
export interface EmojiReactionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
onAddReaction?: () => void;
className?: string;
size?: "sm" | "md" | "lg";
}
const sizeClasses = {
sm: {
button: "px-2 py-1 text-xs gap-1",
emoji: "text-sm",
count: "text-xs",
addButton: "h-6 w-6",
addIcon: "h-3 w-3",
},
md: {
button: "px-2.5 py-1.5 text-sm gap-1.5",
emoji: "text-base",
count: "text-sm",
addButton: "h-7 w-7",
addIcon: "h-3.5 w-3.5",
},
lg: {
button: "px-3 py-2 text-base gap-2",
emoji: "text-lg",
count: "text-base",
addButton: "h-8 w-8",
addIcon: "h-4 w-4",
},
};
const EmojiReaction = React.forwardRef<HTMLButtonElement, EmojiReactionProps>(
(
{ emoji, count, reacted = false, users = [], onReactionClick, className, showCount = true, size = "md", ...props },
ref
) => {
const sizeClass = sizeClasses[size];
({ emoji, count, reacted = false, users = [], onReactionClick, className, showCount = true, ...props }, ref) => {
const handleClick = () => {
onReactionClick?.(emoji);
};
@@ -96,9 +64,7 @@ const EmojiReaction = React.forwardRef<HTMLButtonElement, EmojiReactionProps>(
ref={ref}
onClick={handleClick}
className={cn(
"inline-flex items-center rounded-full border transition-all duration-200 hover:scale-105",
"focus:outline-none focus:ring-2 focus:ring-custom-primary-100/20 focus:ring-offset-1",
sizeClass.button,
"inline-flex items-center rounded-full border px-1.5 text-xs gap-0.5 transition-all duration-200",
reacted
? "bg-custom-primary-100/10 border-custom-primary-100 text-custom-primary-100"
: "bg-custom-background-100 border-custom-border-200 text-custom-text-300 hover:border-custom-border-300 hover:bg-custom-background-90",
@@ -106,8 +72,8 @@ const EmojiReaction = React.forwardRef<HTMLButtonElement, EmojiReactionProps>(
)}
{...props}
>
<span className={sizeClass.emoji}>{emoji}</span>
{showCount && count > 0 && <AnimatedCounter count={count} size={size} className={sizeClass.count} />}
<span className="text-base leading-unset">{emoji}</span>
{showCount && count > 0 && <AnimatedCounter count={count} size="sm" className="text-xs leading-normal" />}
</button>
);
@@ -120,42 +86,29 @@ const EmojiReaction = React.forwardRef<HTMLButtonElement, EmojiReactionProps>(
);
const EmojiReactionButton = React.forwardRef<HTMLButtonElement, EmojiReactionButtonProps>(
({ onAddReaction, className, size = "md", ...props }, ref) => {
const sizeClass = sizeClasses[size];
return (
<button
ref={ref}
onClick={onAddReaction}
className={cn(
"inline-flex items-center justify-center rounded-full border border-dashed border-custom-border-300",
"bg-custom-background-100 text-custom-text-400 transition-all duration-200",
"hover:border-custom-primary-100 hover:text-custom-primary-100 hover:bg-custom-primary-100/5",
"focus:outline-none focus:ring-2 focus:ring-custom-primary-100/20 focus:ring-offset-1",
sizeClass.addButton,
className
)}
title="Add reaction"
{...props}
>
<Plus className={sizeClass.addIcon} />
</button>
);
}
({ onAddReaction, className, ...props }, ref) => (
<button
ref={ref}
onClick={onAddReaction}
className={cn(
"inline-flex items-center justify-center rounded-full border border-dashed border-custom-border-300",
"bg-custom-background-100 text-custom-text-400 transition-all duration-200",
"hover:border-custom-primary-100 hover:text-custom-primary-100 hover:bg-custom-primary-100/5",
"focus:outline-none focus:ring-2 focus:ring-custom-primary-100/20 focus:ring-offset-1",
"h-6 w-6",
className
)}
title="Add reaction"
{...props}
>
<AddReactionIcon className="h-3 w-3" />
</button>
)
);
const EmojiReactionGroup = React.forwardRef<HTMLDivElement, EmojiReactionGroupProps>(
(
{
reactions,
onReactionClick,
onAddReaction,
className,
showAddButton = true,
maxDisplayUsers = 5,
size = "md",
...props
},
{ reactions, onReactionClick, onAddReaction, className, showAddButton = true, maxDisplayUsers = 5, ...props },
ref
) => (
<div ref={ref} className={cn("flex flex-wrap items-center gap-2", className)} {...props}>
@@ -167,10 +120,9 @@ const EmojiReactionGroup = React.forwardRef<HTMLDivElement, EmojiReactionGroupPr
reacted={reaction.reacted}
users={reaction.users?.slice(0, maxDisplayUsers)}
onReactionClick={onReactionClick}
size={size}
/>
))}
{showAddButton && <EmojiReactionButton onAddReaction={onAddReaction} size={size} />}
{showAddButton && <EmojiReactionButton onAddReaction={onAddReaction} />}
</div>
)
);
@@ -0,0 +1,21 @@
import * as React from "react";
import { IconWrapper } from "../icon-wrapper";
import { ISvgIcons } from "../type";
export const AddReactionIcon: React.FC<ISvgIcons> = ({ color = "currentColor", ...rest }) => {
const clipPathId = React.useId();
return (
<IconWrapper color={color} clipPathId={clipPathId} {...rest}>
<path
d="M7.74205 1.11803C8.08705 1.11822 8.36704 1.39797 8.36705 1.74303C8.36705 2.08808 8.08706 2.36783 7.74205 2.36803C6.19489 2.36804 4.64941 2.95774 3.46959 4.13756C1.11022 6.49693 1.11033 10.3221 3.46959 12.6815C5.829 15.0409 9.65407 15.0409 12.0135 12.6815C13.424 11.271 13.9923 9.33686 13.7157 7.50279C13.6644 7.16166 13.899 6.84336 14.2401 6.79185C14.5814 6.74037 14.8995 6.97593 14.951 7.31725C15.2842 9.52673 14.6003 11.8624 12.8973 13.5653C10.0497 16.4129 5.43337 16.4129 2.5858 13.5653C-0.261624 10.7177 -0.26172 6.10129 2.5858 3.25377C4.00945 1.83012 5.87693 1.11804 7.74205 1.11803ZM5.57408 9.36705C5.57469 9.36783 5.57576 9.36938 5.57701 9.37096C5.58126 9.37632 5.58935 9.38606 5.60045 9.39928C5.62309 9.42625 5.65949 9.4679 5.70884 9.51939C5.80839 9.62327 5.9578 9.76446 6.15123 9.90514C6.54052 10.1882 7.08107 10.452 7.74205 10.452C8.40303 10.4519 8.94362 10.1882 9.33287 9.90514C9.52624 9.76448 9.67573 9.62324 9.77525 9.51939C9.82463 9.46786 9.86106 9.42619 9.88365 9.39928C9.89492 9.38584 9.90289 9.37626 9.90709 9.37096C9.90903 9.36851 9.90964 9.36657 9.91002 9.36607L9.90904 9.36705C10.1164 9.09209 10.5074 9.03728 10.7831 9.244C11.0248 9.42531 11.098 9.74773 10.9735 10.0106L10.9081 10.119L10.9051 10.1229C10.9039 10.1246 10.902 10.1265 10.9003 10.1288C10.8967 10.1334 10.8921 10.1393 10.8866 10.1463C10.8753 10.1606 10.86 10.18 10.8407 10.203C10.8021 10.2489 10.7477 10.3114 10.6776 10.3846C10.5376 10.5308 10.3321 10.7232 10.0672 10.9159C9.53994 11.2993 8.74724 11.7019 7.74205 11.702C6.73657 11.702 5.94323 11.2994 5.41588 10.9159C5.15117 10.7234 4.94653 10.5307 4.8065 10.3846C4.73627 10.3113 4.681 10.2489 4.64244 10.203C4.62313 10.18 4.6078 10.1606 4.59654 10.1463C4.59098 10.1393 4.58643 10.1334 4.58287 10.1288C4.58108 10.1264 4.57927 10.1246 4.57798 10.1229C4.57738 10.1221 4.57653 10.1216 4.57603 10.121L4.57506 10.119C4.36799 9.84291 4.42405 9.45114 4.70006 9.244C4.9759 9.03712 5.3668 9.09167 5.57408 9.36705ZM5.8397 5.45689C6.32285 5.50596 6.69989 5.91397 6.70006 6.41002C6.70006 6.93918 6.27117 7.36883 5.74205 7.369C5.21278 7.369 4.78306 6.93929 4.78306 6.41002C4.78324 5.8809 5.21288 5.45201 5.74205 5.45201L5.8397 5.45689ZM9.8397 5.45689C10.3228 5.50596 10.6999 5.91395 10.7001 6.41002C10.7001 6.93921 10.2711 7.36883 9.74205 7.369C9.21284 7.369 8.78306 6.93932 8.78306 6.41002C8.78324 5.88087 9.21294 5.45201 9.74205 5.45201L9.8397 5.45689Z"
fill={color}
/>
<path
d="M12.2486 4.875V3.5H10.8736C10.5284 3.5 10.2486 3.22018 10.2486 2.875C10.2486 2.52982 10.5284 2.25 10.8736 2.25H12.2486V0.875C12.2486 0.529822 12.5284 0.25 12.8736 0.25C13.2188 0.25 13.4986 0.529822 13.4986 0.875V2.25H14.8736C15.2188 2.25 15.4986 2.52982 15.4986 2.875C15.4986 3.22018 15.2188 3.5 14.8736 3.5H13.4986V4.875C13.4986 5.22018 13.2188 5.5 12.8736 5.5C12.5284 5.5 12.2486 5.22018 12.2486 4.875Z"
fill={color}
/>
</IconWrapper>
);
};
@@ -1,2 +1,3 @@
export * from "./add-icon";
export * from "./add-reaction-icon";
export * from "./close-icon";
+1
View File
@@ -1,6 +1,7 @@
import { Icon } from "./icon";
export const ActionsIconsMap = [
{ icon: <Icon name="action.add" />, title: "AddIcon" },
{ icon: <Icon name="action.add-reaction" />, title: "AddReactionIcon" },
{ icon: <Icon name="action.close" />, title: "CloseIcon" },
];
+2
View File
@@ -1,3 +1,4 @@
import { AddReactionIcon } from "./actions";
import { AddIcon } from "./actions/add-icon";
import { CloseIcon } from "./actions/close-icon";
import { ChevronDownIcon } from "./arrows/chevron-down";
@@ -112,6 +113,7 @@ export const ICON_REGISTRY = {
// Action icons
"action.add": AddIcon,
"action.add-reaction": AddReactionIcon,
"action.close": CloseIcon,
// Arrow icons

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