Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1d6282d23 | ||
|
|
bec90c2eb9 | ||
|
|
dfc49eea27 | ||
|
|
60cab0c964 | ||
|
|
1e5f583a24 | ||
|
|
d05c222d0a | ||
|
|
8853637e98 | ||
|
|
12a050e7d3 | ||
|
|
fd542a85fa | ||
|
|
0a738d419e | ||
|
|
c719d67f71 | ||
|
|
7178627f6a | ||
|
|
10590ffa13 | ||
|
|
37aabe7d1d | ||
|
|
fd38b9b6d8 | ||
|
|
d709465a89 | ||
|
|
9b3bd11c03 | ||
|
|
4927ef8c71 | ||
|
|
0dcea8db70 | ||
|
|
0941856f67 |
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.25
|
||||
Django==4.2.26
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 === "/") {
|
||||
|
||||
@@ -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";
|
||||
|
||||
+5
-10
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
-1
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
-1
@@ -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";
|
||||
|
||||
+3
-1
@@ -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,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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user