forked from github/plane
[FEATURE] Enabled User @mentions
and @mention-filters
in core editor package (#2544)
* feat: created custom mention component * feat: added mention suggestions and suggestion highlights * feat: created mention suggestion list for displaying mention suggestions * feat: created custom mention text component, for handling click event * feat: exposed mention component * feat: integrated and exposed `mentions` componenet with `editor-core` * feat: integrated mentions extension with the core editor package * feat: exposed suggestion types from mentions * feat: added `mention-suggestion` parameters in `r-t-e` and `l-t-e` * feat: added `IssueMention` model in apiserver models * chore: updated activities background job and added bs4 in requirements * feat: added mention removal logic in issue_activity * chore: exposed mention types from `r-t-e` and `l-t-e` * feat: integrated mentions in side peek view description form * feat: added mentions in issue modal form * feat: created custom react-hook for editor suggestions * feat: integrated mention suggestions block in RichTextEditor * feat: added `mentions` integration in `lite-text-editor` instances * fix: tailwind loading nodemodules from packages * feat: added styles for the mention suggestion list * fix: update module import to resolve build failure * feat: added mentions as an issue filter * feat: added UI Changes to Implement `mention` filters * feat: added `mentions` as a filter option in the header * feat: added mentions in the filter list options * feat: added mentions in default display filter options * feat: added filters in applied and issue params in store * feat: modified types for adding mentions as a filter option * feat: modified `notification-card` to display message when it exists in object * feat: rewrote user mention management upon the changes made in develop * chore: merged debounce PR with the current PR for tracing changes * fix: mentions_filters updated with the new setup * feat: updated requirements for bs4 * feat: modified `mentions-filter` to remove many to many dependency * feat: implemented list manipulation instead of for loop * feat: added readonly functionality in `read-only` editor core * feat: added UI Changes for read-only mode * feat: added mentions store in web Root Store * chore: renamed `use-editor-suggestions` hook * feat: UI Improvements for conditional highlights w.r.t readonly in mentionNode * fix: removed mentions from `filter_set` parameters * fix: minor merge fixes * fix: package lock updates --------- Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
parent
490e032ac6
commit
d511799f31
@ -1534,6 +1534,8 @@ def issue_activity(
|
|||||||
IssueActivitySerializer(issue_activities_created, many=True).data,
|
IssueActivitySerializer(issue_activities_created, many=True).data,
|
||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
),
|
),
|
||||||
|
requested_data=requested_data,
|
||||||
|
current_instance=current_instance
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
@ -5,16 +5,98 @@ import json
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.db.models import IssueSubscriber, Project, IssueAssignee, Issue, Notification
|
from plane.db.models import IssueMention, IssueSubscriber, Project, User, IssueAssignee, Issue, Notification
|
||||||
|
|
||||||
# Third Party imports
|
# Third Party imports
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
if not IssueSubscriber.objects.filter(
|
||||||
|
issue_id=issue_id,
|
||||||
|
subscriber=mention_id,
|
||||||
|
project=project_id,
|
||||||
|
).exists():
|
||||||
|
mentioned_user = User.objects.get(pk=mention_id)
|
||||||
|
|
||||||
|
project = Project.objects.get(pk=project_id)
|
||||||
|
issue = Issue.objects.get(pk=issue_id)
|
||||||
|
|
||||||
|
bulk_mention_subscribers.append(IssueSubscriber(
|
||||||
|
workspace=project.workspace,
|
||||||
|
project=project,
|
||||||
|
issue=issue,
|
||||||
|
subscriber=mentioned_user,
|
||||||
|
))
|
||||||
|
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.
|
||||||
|
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={'target': 'users'})
|
||||||
|
|
||||||
|
mentions = [mention_tag['id'] for mention_tag in mention_tags]
|
||||||
|
|
||||||
|
return list(set(mentions))
|
||||||
|
except Exception as e:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created):
|
def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created, requested_data, current_instance):
|
||||||
issue_activities_created = (
|
issue_activities_created = (
|
||||||
json.loads(issue_activities_created) if issue_activities_created is not None else None
|
json.loads(
|
||||||
|
issue_activities_created) if issue_activities_created is not None else None
|
||||||
)
|
)
|
||||||
if type not in [
|
if type not in [
|
||||||
"cycle.activity.created",
|
"cycle.activity.created",
|
||||||
@ -33,14 +115,35 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
|
|||||||
]:
|
]:
|
||||||
# Create Notifications
|
# Create Notifications
|
||||||
bulk_notifications = []
|
bulk_notifications = []
|
||||||
|
|
||||||
|
"""
|
||||||
|
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 new mentions from the newer instance
|
||||||
|
new_mentions = get_new_mentions(
|
||||||
|
requested_instance=requested_data, current_instance=current_instance)
|
||||||
|
removed_mention = get_removed_mentions(
|
||||||
|
requested_instance=requested_data, current_instance=current_instance)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
issue_subscribers = list(
|
issue_subscribers = list(
|
||||||
IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id)
|
IssueSubscriber.objects.filter(
|
||||||
.exclude(subscriber_id=actor_id)
|
project_id=project_id, issue_id=issue_id)
|
||||||
|
.exclude(subscriber_id__in=list(new_mentions + [actor_id]))
|
||||||
.values_list("subscriber", flat=True)
|
.values_list("subscriber", flat=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
issue_assignees = list(
|
issue_assignees = list(
|
||||||
IssueAssignee.objects.filter(project_id=project_id, issue_id=issue_id)
|
IssueAssignee.objects.filter(
|
||||||
|
project_id=project_id, issue_id=issue_id)
|
||||||
.exclude(assignee_id=actor_id)
|
.exclude(assignee_id=actor_id)
|
||||||
.values_list("assignee", flat=True)
|
.values_list("assignee", flat=True)
|
||||||
)
|
)
|
||||||
@ -89,7 +192,8 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
|
|||||||
"new_value": str(issue_activity.get("new_value")),
|
"new_value": str(issue_activity.get("new_value")),
|
||||||
"old_value": str(issue_activity.get("old_value")),
|
"old_value": str(issue_activity.get("old_value")),
|
||||||
"issue_comment": str(
|
"issue_comment": str(
|
||||||
issue_activity.get("issue_comment").comment_stripped
|
issue_activity.get(
|
||||||
|
"issue_comment").comment_stripped
|
||||||
if issue_activity.get("issue_comment") is not None
|
if issue_activity.get("issue_comment") is not None
|
||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
@ -98,5 +202,62 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add Mentioned as Issue Subscribers
|
||||||
|
IssueSubscriber.objects.bulk_create(
|
||||||
|
mention_subscribers, batch_size=100)
|
||||||
|
|
||||||
|
for mention_id in new_mentions:
|
||||||
|
if (mention_id != actor_id):
|
||||||
|
for issue_activity in issue_activities_created:
|
||||||
|
bulk_notifications.append(
|
||||||
|
Notification(
|
||||||
|
workspace=project.workspace,
|
||||||
|
sender="in_app:issue_activities:mention",
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
"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")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create New Mentions Here
|
||||||
|
aggregated_issue_mentions = []
|
||||||
|
|
||||||
|
for mention_id in new_mentions:
|
||||||
|
mentioned_user = User.objects.get(pk=mention_id)
|
||||||
|
aggregated_issue_mentions.append(
|
||||||
|
IssueMention(
|
||||||
|
mention=mentioned_user,
|
||||||
|
issue=issue,
|
||||||
|
project=project,
|
||||||
|
workspace=project.workspace
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
IssueMention.objects.bulk_create(
|
||||||
|
aggregated_issue_mentions, batch_size=100)
|
||||||
|
IssueMention.objects.filter(
|
||||||
|
issue=issue.id, mention__in=removed_mention).delete()
|
||||||
|
|
||||||
# Bulk create notifications
|
# Bulk create notifications
|
||||||
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
|
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
|
||||||
|
@ -5,7 +5,6 @@ from django.db import migrations, models
|
|||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import plane.db.models.issue
|
import plane.db.models.issue
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@ -18,4 +17,4 @@ class Migration(migrations.Migration):
|
|||||||
name='properties',
|
name='properties',
|
||||||
field=models.JSONField(default=plane.db.models.issue.get_default_properties),
|
field=models.JSONField(default=plane.db.models.issue.get_default_properties),
|
||||||
),
|
),
|
||||||
]
|
]
|
45
apiserver/plane/db/migrations/0047_issue_mention_and_more.py
Normal file
45
apiserver/plane/db/migrations/0047_issue_mention_and_more.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Generated by Django 4.2.5 on 2023-10-25 05:01
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('db', '0046_alter_analyticview_created_by_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="issue_mentions",
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(
|
||||||
|
auto_now_add=True, verbose_name='Created At')),
|
||||||
|
('updated_at', models.DateTimeField(
|
||||||
|
auto_now=True, verbose_name='Last Modified At')),
|
||||||
|
('id', models.UUIDField(db_index=True, default=uuid.uuid4,
|
||||||
|
editable=False, primary_key=True, serialize=False, unique=True)),
|
||||||
|
('mention', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='issue_mention', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name='issuemention_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||||
|
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='issue_mention', to='db.issue')),
|
||||||
|
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='project_issuemention', to='db.project')),
|
||||||
|
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name='issuemention_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||||
|
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='workspace_issuemention', to='db.workspace')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'IssueMention',
|
||||||
|
'verbose_name_plural': 'IssueMentions',
|
||||||
|
'db_table': 'issue_mentions',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
@ -33,6 +33,7 @@ from .issue import (
|
|||||||
Label,
|
Label,
|
||||||
IssueBlocker,
|
IssueBlocker,
|
||||||
IssueRelation,
|
IssueRelation,
|
||||||
|
IssueMention,
|
||||||
IssueLink,
|
IssueLink,
|
||||||
IssueSequence,
|
IssueSequence,
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
|
@ -226,7 +226,26 @@ class IssueRelation(ProjectBaseModel):
|
|||||||
ordering = ("-created_at",)
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.issue.name} {self.related_issue.name}"
|
return f"{self.issue.name} {self.related_issue.name}"
|
||||||
|
|
||||||
|
class IssueMention(ProjectBaseModel):
|
||||||
|
issue = models.ForeignKey(
|
||||||
|
Issue, on_delete=models.CASCADE, related_name="issue_mention"
|
||||||
|
)
|
||||||
|
mention = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="issue_mention",
|
||||||
|
)
|
||||||
|
class Meta:
|
||||||
|
unique_together = ["issue", "mention"]
|
||||||
|
verbose_name = "Issue Mention"
|
||||||
|
verbose_name_plural = "Issue Mentions"
|
||||||
|
db_table = "issue_mentions"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.issue.name} {self.mention.email}"
|
||||||
|
|
||||||
|
|
||||||
class IssueAssignee(ProjectBaseModel):
|
class IssueAssignee(ProjectBaseModel):
|
||||||
|
@ -150,6 +150,17 @@ def filter_assignees(params, filter, method):
|
|||||||
filter["assignees__in"] = params.get("assignees")
|
filter["assignees__in"] = params.get("assignees")
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
def filter_mentions(params, filter, method):
|
||||||
|
if method == "GET":
|
||||||
|
mentions = [item for item in params.get("mentions").split(",") if item != 'null']
|
||||||
|
mentions = filter_valid_uuids(mentions)
|
||||||
|
if len(mentions) and "" not in mentions:
|
||||||
|
filter["issue_mention__mention__id__in"] = mentions
|
||||||
|
else:
|
||||||
|
if params.get("mentions", None) and len(params.get("mentions")) and params.get("mentions") != 'null':
|
||||||
|
filter["issue_mention__mention__id__in"] = params.get("mentions")
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_created_by(params, filter, method):
|
def filter_created_by(params, filter, method):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
@ -326,6 +337,7 @@ def issue_filters(query_params, method):
|
|||||||
"parent": filter_parent,
|
"parent": filter_parent,
|
||||||
"labels": filter_labels,
|
"labels": filter_labels,
|
||||||
"assignees": filter_assignees,
|
"assignees": filter_assignees,
|
||||||
|
"mentions": filter_mentions,
|
||||||
"created_by": filter_created_by,
|
"created_by": filter_created_by,
|
||||||
"name": filter_name,
|
"name": filter_name,
|
||||||
"created_at": filter_created_at,
|
"created_at": filter_created_at,
|
||||||
|
@ -33,4 +33,5 @@ django_celery_beat==2.5.0
|
|||||||
psycopg-binary==3.1.10
|
psycopg-binary==3.1.10
|
||||||
psycopg-c==3.1.10
|
psycopg-c==3.1.10
|
||||||
scout-apm==2.26.1
|
scout-apm==2.26.1
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
|
beautifulsoup4==4.12.2
|
@ -21,18 +21,18 @@
|
|||||||
"check-types": "tsc --noEmit"
|
"check-types": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "18.2.0",
|
|
||||||
"next": "12.3.2",
|
"next": "12.3.2",
|
||||||
"next-themes": "^0.2.1"
|
"next-themes": "^0.2.1",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "18.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-moveable" : "^0.54.2",
|
|
||||||
"@blueprintjs/popover2": "^2.0.10",
|
"@blueprintjs/popover2": "^2.0.10",
|
||||||
"@tiptap/core": "^2.1.7",
|
"@tiptap/core": "^2.1.7",
|
||||||
"@tiptap/extension-color": "^2.1.11",
|
"@tiptap/extension-color": "^2.1.11",
|
||||||
"@tiptap/extension-image": "^2.1.7",
|
"@tiptap/extension-image": "^2.1.7",
|
||||||
"@tiptap/extension-link": "^2.1.7",
|
"@tiptap/extension-link": "^2.1.7",
|
||||||
|
"@tiptap/extension-mention": "^2.1.12",
|
||||||
"@tiptap/extension-table": "^2.1.6",
|
"@tiptap/extension-table": "^2.1.6",
|
||||||
"@tiptap/extension-table-cell": "^2.1.6",
|
"@tiptap/extension-table-cell": "^2.1.6",
|
||||||
"@tiptap/extension-table-header": "^2.1.6",
|
"@tiptap/extension-table-header": "^2.1.6",
|
||||||
@ -44,9 +44,10 @@
|
|||||||
"@tiptap/pm": "^2.1.7",
|
"@tiptap/pm": "^2.1.7",
|
||||||
"@tiptap/react": "^2.1.7",
|
"@tiptap/react": "^2.1.7",
|
||||||
"@tiptap/starter-kit": "^2.1.10",
|
"@tiptap/starter-kit": "^2.1.10",
|
||||||
|
"@tiptap/suggestion": "^2.0.4",
|
||||||
|
"@types/node": "18.15.3",
|
||||||
"@types/react": "^18.2.5",
|
"@types/react": "^18.2.5",
|
||||||
"@types/react-dom": "18.0.11",
|
"@types/react-dom": "18.0.11",
|
||||||
"@types/node": "18.15.3",
|
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"eslint": "8.36.0",
|
"eslint": "8.36.0",
|
||||||
@ -54,6 +55,7 @@
|
|||||||
"eventsource-parser": "^0.1.0",
|
"eventsource-parser": "^0.1.0",
|
||||||
"lucide-react": "^0.244.0",
|
"lucide-react": "^0.244.0",
|
||||||
"react-markdown": "^8.0.7",
|
"react-markdown": "^8.0.7",
|
||||||
|
"react-moveable": "^0.54.2",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-markdown": "^0.8.2",
|
"tiptap-markdown": "^0.8.2",
|
||||||
|
10
packages/editor/core/src/types/mention-suggestion.ts
Normal file
10
packages/editor/core/src/types/mention-suggestion.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export type IMentionSuggestion = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
avatar: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
redirect_uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IMentionHighlight = string
|
@ -17,9 +17,12 @@ import ImageExtension from "./image";
|
|||||||
|
|
||||||
import { DeleteImage } from "../../types/delete-image";
|
import { DeleteImage } from "../../types/delete-image";
|
||||||
import { isValidHttpUrl } from "../../lib/utils";
|
import { isValidHttpUrl } from "../../lib/utils";
|
||||||
|
import { IMentionSuggestion } from "../../types/mention-suggestion";
|
||||||
|
import { Mentions } from "../mentions";
|
||||||
|
|
||||||
|
|
||||||
export const CoreEditorExtensions = (
|
export const CoreEditorExtensions = (
|
||||||
|
mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] },
|
||||||
deleteFile: DeleteImage,
|
deleteFile: DeleteImage,
|
||||||
) => [
|
) => [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
@ -94,4 +97,5 @@ export const CoreEditorExtensions = (
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
CustomTableCell,
|
CustomTableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false),
|
||||||
];
|
];
|
||||||
|
@ -12,6 +12,7 @@ import { EditorProps } from "@tiptap/pm/view";
|
|||||||
import { getTrimmedHTML } from "../../lib/utils";
|
import { getTrimmedHTML } from "../../lib/utils";
|
||||||
import { UploadImage } from "../../types/upload-image";
|
import { UploadImage } from "../../types/upload-image";
|
||||||
import { useInitializedContent } from "./useInitializedContent";
|
import { useInitializedContent } from "./useInitializedContent";
|
||||||
|
import { IMentionSuggestion } from "../../types/mention-suggestion";
|
||||||
|
|
||||||
interface CustomEditorProps {
|
interface CustomEditorProps {
|
||||||
uploadFile: UploadImage;
|
uploadFile: UploadImage;
|
||||||
@ -26,6 +27,8 @@ interface CustomEditorProps {
|
|||||||
extensions?: any;
|
extensions?: any;
|
||||||
editorProps?: EditorProps;
|
editorProps?: EditorProps;
|
||||||
forwardedRef?: any;
|
forwardedRef?: any;
|
||||||
|
mentionHighlights?: string[];
|
||||||
|
mentionSuggestions?: IMentionSuggestion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useEditor = ({
|
export const useEditor = ({
|
||||||
@ -38,6 +41,8 @@ export const useEditor = ({
|
|||||||
setIsSubmitting,
|
setIsSubmitting,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
setShouldShowAlert,
|
setShouldShowAlert,
|
||||||
|
mentionHighlights,
|
||||||
|
mentionSuggestions
|
||||||
}: CustomEditorProps) => {
|
}: CustomEditorProps) => {
|
||||||
const editor = useCustomEditor(
|
const editor = useCustomEditor(
|
||||||
{
|
{
|
||||||
@ -45,7 +50,7 @@ export const useEditor = ({
|
|||||||
...CoreEditorProps(uploadFile, setIsSubmitting),
|
...CoreEditorProps(uploadFile, setIsSubmitting),
|
||||||
...editorProps,
|
...editorProps,
|
||||||
},
|
},
|
||||||
extensions: [...CoreEditorExtensions(deleteFile), ...extensions],
|
extensions: [...CoreEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}, deleteFile), ...extensions],
|
||||||
content:
|
content:
|
||||||
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
||||||
onUpdate: async ({ editor }) => {
|
onUpdate: async ({ editor }) => {
|
||||||
@ -77,4 +82,4 @@ export const useEditor = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return editor;
|
return editor;
|
||||||
};
|
};
|
@ -7,21 +7,19 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
|
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
|
||||||
import { CoreReadOnlyEditorProps } from "../../ui/read-only/props";
|
import { CoreReadOnlyEditorProps } from "../../ui/read-only/props";
|
||||||
import { EditorProps } from "@tiptap/pm/view";
|
import { EditorProps } from '@tiptap/pm/view';
|
||||||
|
import { IMentionSuggestion } from "../../types/mention-suggestion";
|
||||||
|
|
||||||
interface CustomReadOnlyEditorProps {
|
interface CustomReadOnlyEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
forwardedRef?: any;
|
forwardedRef?: any;
|
||||||
extensions?: any;
|
extensions?: any;
|
||||||
editorProps?: EditorProps;
|
editorProps?: EditorProps;
|
||||||
|
mentionHighlights?: string[];
|
||||||
|
mentionSuggestions?: IMentionSuggestion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useReadOnlyEditor = ({
|
export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editorProps = {}, mentionHighlights, mentionSuggestions}: CustomReadOnlyEditorProps) => {
|
||||||
value,
|
|
||||||
forwardedRef,
|
|
||||||
extensions = [],
|
|
||||||
editorProps = {},
|
|
||||||
}: CustomReadOnlyEditorProps) => {
|
|
||||||
const editor = useCustomEditor({
|
const editor = useCustomEditor({
|
||||||
editable: false,
|
editable: false,
|
||||||
content:
|
content:
|
||||||
@ -30,7 +28,7 @@ export const useReadOnlyEditor = ({
|
|||||||
...CoreReadOnlyEditorProps,
|
...CoreReadOnlyEditorProps,
|
||||||
...editorProps,
|
...editorProps,
|
||||||
},
|
},
|
||||||
extensions: [...CoreReadOnlyEditorExtensions, ...extensions],
|
extensions: [...CoreReadOnlyEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}), ...extensions],
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasIntiliazedContent = useRef(false);
|
const hasIntiliazedContent = useRef(false);
|
||||||
|
@ -8,6 +8,7 @@ import { EditorProps } from '@tiptap/pm/view';
|
|||||||
import { useEditor } from './hooks/useEditor';
|
import { useEditor } from './hooks/useEditor';
|
||||||
import { EditorContainer } from '../ui/components/editor-container';
|
import { EditorContainer } from '../ui/components/editor-container';
|
||||||
import { EditorContentWrapper } from '../ui/components/editor-content';
|
import { EditorContentWrapper } from '../ui/components/editor-content';
|
||||||
|
import { IMentionSuggestion } from '../types/mention-suggestion';
|
||||||
|
|
||||||
interface ICoreEditor {
|
interface ICoreEditor {
|
||||||
value: string;
|
value: string;
|
||||||
@ -30,6 +31,8 @@ interface ICoreEditor {
|
|||||||
key: string;
|
key: string;
|
||||||
label: "Private" | "Public";
|
label: "Private" | "Public";
|
||||||
}[];
|
}[];
|
||||||
|
mentionHighlights?: string[];
|
||||||
|
mentionSuggestions?: IMentionSuggestion[];
|
||||||
extensions?: Extension[];
|
extensions?: Extension[];
|
||||||
editorProps?: EditorProps;
|
editorProps?: EditorProps;
|
||||||
}
|
}
|
||||||
@ -61,7 +64,6 @@ const CoreEditor = ({
|
|||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
onChange,
|
onChange,
|
||||||
debouncedUpdatesEnabled,
|
debouncedUpdatesEnabled,
|
||||||
editable,
|
|
||||||
setIsSubmitting,
|
setIsSubmitting,
|
||||||
setShouldShowAlert,
|
setShouldShowAlert,
|
||||||
value,
|
value,
|
||||||
|
111
packages/editor/core/src/ui/mentions/MentionList.tsx
Normal file
111
packages/editor/core/src/ui/mentions/MentionList.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { Editor } from '@tiptap/react';
|
||||||
|
import React, {
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
import { IMentionSuggestion } from '../../types/mention-suggestion';
|
||||||
|
|
||||||
|
interface MentionListProps {
|
||||||
|
items: IMentionSuggestion[];
|
||||||
|
command: (item: { id: string, label: string, target: string, redirect_uri: string }) => void;
|
||||||
|
editor: Editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react/display-name
|
||||||
|
const MentionList = forwardRef((props: MentionListProps, ref) => {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
const selectItem = (index: number) => {
|
||||||
|
const item = props.items[index]
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
props.command({ id: item.id, label: item.title, target: "users", redirect_uri: item.redirect_uri })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const upHandler = () => {
|
||||||
|
setSelectedIndex(((selectedIndex + props.items.length) - 1) % props.items.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const downHandler = () => {
|
||||||
|
setSelectedIndex((selectedIndex + 1) % props.items.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterHandler = () => {
|
||||||
|
selectItem(selectedIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0)
|
||||||
|
}, [props.items])
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
upHandler()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
downHandler()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
enterHandler()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
props.items && props.items.length !== 0 ? <div className="items">
|
||||||
|
{ props.items.length ? props.items.map((item, index) => (
|
||||||
|
<div className={`item ${index === selectedIndex ? 'is-selected' : ''} w-72 flex items-center p-3 rounded shadow-md`} onClick={() => selectItem(index)}>
|
||||||
|
{item.avatar ? <div
|
||||||
|
className={`rounded border-[0.5px] ${index ? "border-custom-border-200 bg-custom-background-100" : "border-transparent"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
height: "24px",
|
||||||
|
width: "24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={item.avatar}
|
||||||
|
className="absolute top-0 left-0 h-full w-full object-cover rounded"
|
||||||
|
alt={item.title}
|
||||||
|
/>
|
||||||
|
</div> :
|
||||||
|
<div
|
||||||
|
className="grid place-items-center text-xs capitalize text-white rounded bg-gray-700 border-[0.5px] border-custom-border-200"
|
||||||
|
style={{
|
||||||
|
height: "24px",
|
||||||
|
width: "24px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.title.charAt(0)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div className="ml-7 space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">{item.title}</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{item.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: <div className="item">No result</div>
|
||||||
|
}
|
||||||
|
</div> : <></>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
MentionList.displayName = "MentionList"
|
||||||
|
|
||||||
|
export default MentionList
|
59
packages/editor/core/src/ui/mentions/custom.tsx
Normal file
59
packages/editor/core/src/ui/mentions/custom.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { Mention, MentionOptions } from '@tiptap/extension-mention'
|
||||||
|
import { mergeAttributes } from '@tiptap/core'
|
||||||
|
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||||
|
import mentionNodeView from './mentionNodeView'
|
||||||
|
import { IMentionHighlight } from '../../types/mention-suggestion'
|
||||||
|
export interface CustomMentionOptions extends MentionOptions {
|
||||||
|
mentionHighlights: IMentionHighlight[]
|
||||||
|
readonly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomMention = Mention.extend<CustomMentionOptions>({
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
id: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
self: {
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
redirect_uri: {
|
||||||
|
default: "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(mentionNodeView)
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{
|
||||||
|
tag: 'mention-component',
|
||||||
|
getAttrs: (node: string | HTMLElement) => {
|
||||||
|
if (typeof node === 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: node.getAttribute('data-mention-id') || '',
|
||||||
|
target: node.getAttribute('data-mention-target') || '',
|
||||||
|
label: node.innerText.slice(1) || '',
|
||||||
|
redirect_uri: node.getAttribute('redirect_uri')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['mention-component', mergeAttributes(HTMLAttributes)]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
15
packages/editor/core/src/ui/mentions/index.tsx
Normal file
15
packages/editor/core/src/ui/mentions/index.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import suggestion from "./suggestion";
|
||||||
|
import { CustomMention } from "./custom";
|
||||||
|
import { IMentionHighlight, IMentionSuggestion } from "../../types/mention-suggestion";
|
||||||
|
|
||||||
|
export const Mentions = (mentionSuggestions: IMentionSuggestion[], mentionHighlights: IMentionHighlight[], readonly) => CustomMention.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
'class' : "mention",
|
||||||
|
},
|
||||||
|
readonly: readonly,
|
||||||
|
mentionHighlights: mentionHighlights,
|
||||||
|
suggestion: suggestion(mentionSuggestions),
|
||||||
|
})
|
||||||
|
|
32
packages/editor/core/src/ui/mentions/mentionNodeView.tsx
Normal file
32
packages/editor/core/src/ui/mentions/mentionNodeView.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
/* eslint-disable react/display-name */
|
||||||
|
// @ts-nocheck
|
||||||
|
import { NodeViewWrapper } from '@tiptap/react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import React from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { IMentionHighlight } from '../../types/mention-suggestion'
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-anonymous-default-export
|
||||||
|
export default props => {
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const highlights = props.extension.options.mentionHighlights as IMentionHighlight[]
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!props.extension.options.readonly){
|
||||||
|
router.push(props.node.attrs.redirect_uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper className="w-fit inline mention-component" >
|
||||||
|
<span className={cn("px-1 py-0.5 inline rounded-md font-bold bg-custom-primary-500 mention", {
|
||||||
|
"text-[#D9C942] bg-[#544D3B] hover:bg-[#544D3B]" : highlights ? highlights.includes(props.node.attrs.id) : false,
|
||||||
|
"cursor-pointer" : !props.extension.options.readonly,
|
||||||
|
"hover:bg-custom-primary-300" : !props.extension.options.readonly && !highlights.includes(props.node.attrs.id)
|
||||||
|
})} onClick={handleClick} data-mention-target={props.node.attrs.target} data-mention-id={props.node.attrs.id}>@{ props.node.attrs.label }</span>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
59
packages/editor/core/src/ui/mentions/suggestion.ts
Normal file
59
packages/editor/core/src/ui/mentions/suggestion.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { ReactRenderer } from '@tiptap/react'
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import tippy from 'tippy.js'
|
||||||
|
|
||||||
|
import MentionList from './MentionList'
|
||||||
|
import { IMentionSuggestion } from './mentions';
|
||||||
|
|
||||||
|
const Suggestion = (suggestions: IMentionSuggestion[]) => ({
|
||||||
|
items: ({ query }: { query: string }) => suggestions.filter(suggestion => suggestion.title.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5),
|
||||||
|
render: () => {
|
||||||
|
let reactRenderer: ReactRenderer | null = null;
|
||||||
|
let popup: any | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||||
|
reactRenderer = new ReactRenderer(MentionList, {
|
||||||
|
props,
|
||||||
|
editor: props.editor,
|
||||||
|
});
|
||||||
|
// @ts-ignore
|
||||||
|
popup = tippy("body", {
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
appendTo: () => document.querySelector("#editor-container"),
|
||||||
|
content: reactRenderer.element,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: "manual",
|
||||||
|
placement: "bottom-start",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||||
|
reactRenderer?.updateProps(props)
|
||||||
|
|
||||||
|
popup &&
|
||||||
|
popup[0].setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
|
if (props.event.key === "Escape") {
|
||||||
|
popup?.[0].hide();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
return reactRenderer?.ref?.onKeyDown(props);
|
||||||
|
},
|
||||||
|
onExit: () => {
|
||||||
|
popup?.[0].destroy();
|
||||||
|
reactRenderer?.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
export default Suggestion;
|
@ -15,8 +15,12 @@ import { TableRow } from "@tiptap/extension-table-row";
|
|||||||
|
|
||||||
import ReadOnlyImageExtension from "../extensions/image/read-only-image";
|
import ReadOnlyImageExtension from "../extensions/image/read-only-image";
|
||||||
import { isValidHttpUrl } from "../../lib/utils";
|
import { isValidHttpUrl } from "../../lib/utils";
|
||||||
|
import { Mentions } from "../mentions";
|
||||||
|
import { IMentionSuggestion } from "../../types/mention-suggestion";
|
||||||
|
|
||||||
export const CoreReadOnlyEditorExtensions = [
|
export const CoreReadOnlyEditorExtensions = (
|
||||||
|
mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] },
|
||||||
|
) => [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
bulletList: {
|
bulletList: {
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
@ -89,4 +93,5 @@ export const CoreReadOnlyEditorExtensions = [
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
CustomTableCell,
|
CustomTableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true),
|
||||||
];
|
];
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
export { LiteTextEditor, LiteTextEditorWithRef } from "./ui";
|
export { LiteTextEditor, LiteTextEditorWithRef } from "./ui";
|
||||||
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "./ui/read-only";
|
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "./ui/read-only";
|
||||||
|
export type { IMentionSuggestion, IMentionHighlight } from "./ui"
|
||||||
|
@ -11,6 +11,16 @@ import { LiteTextEditorExtensions } from "./extensions";
|
|||||||
|
|
||||||
export type UploadImage = (file: File) => Promise<string>;
|
export type UploadImage = (file: File) => Promise<string>;
|
||||||
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||||
|
export type IMentionSuggestion = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
avatar: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
redirect_uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IMentionHighlight = string
|
||||||
|
|
||||||
interface ILiteTextEditor {
|
interface ILiteTextEditor {
|
||||||
value: string;
|
value: string;
|
||||||
@ -38,6 +48,8 @@ interface ILiteTextEditor {
|
|||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
onEnterKeyPress?: (e?: any) => void;
|
onEnterKeyPress?: (e?: any) => void;
|
||||||
|
mentionHighlights?: string[];
|
||||||
|
mentionSuggestions?: IMentionSuggestion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LiteTextEditorProps extends ILiteTextEditor {
|
interface LiteTextEditorProps extends ILiteTextEditor {
|
||||||
@ -64,6 +76,8 @@ const LiteTextEditor = ({
|
|||||||
forwardedRef,
|
forwardedRef,
|
||||||
commentAccessSpecifier,
|
commentAccessSpecifier,
|
||||||
onEnterKeyPress,
|
onEnterKeyPress,
|
||||||
|
mentionHighlights,
|
||||||
|
mentionSuggestions
|
||||||
}: LiteTextEditorProps) => {
|
}: LiteTextEditorProps) => {
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
onChange,
|
onChange,
|
||||||
@ -75,6 +89,8 @@ const LiteTextEditor = ({
|
|||||||
deleteFile,
|
deleteFile,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
extensions: LiteTextEditorExtensions(onEnterKeyPress),
|
extensions: LiteTextEditorExtensions(onEnterKeyPress),
|
||||||
|
mentionHighlights,
|
||||||
|
mentionSuggestions
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorClassNames = getEditorClassNames({
|
const editorClassNames = getEditorClassNames({
|
||||||
|
@ -2,3 +2,4 @@ import "./styles/github-dark.css";
|
|||||||
|
|
||||||
export { RichTextEditor, RichTextEditorWithRef } from "./ui";
|
export { RichTextEditor, RichTextEditorWithRef } from "./ui";
|
||||||
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "./ui/read-only";
|
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "./ui/read-only";
|
||||||
|
export type { IMentionSuggestion, IMentionHighlight } from "./ui"
|
||||||
|
@ -7,6 +7,17 @@ import { RichTextEditorExtensions } from './extensions';
|
|||||||
export type UploadImage = (file: File) => Promise<string>;
|
export type UploadImage = (file: File) => Promise<string>;
|
||||||
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||||
|
|
||||||
|
export type IMentionSuggestion = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
avatar: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
redirect_uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IMentionHighlight = string
|
||||||
|
|
||||||
interface IRichTextEditor {
|
interface IRichTextEditor {
|
||||||
value: string;
|
value: string;
|
||||||
uploadFile: UploadImage;
|
uploadFile: UploadImage;
|
||||||
@ -20,6 +31,8 @@ interface IRichTextEditor {
|
|||||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||||
forwardedRef?: any;
|
forwardedRef?: any;
|
||||||
debouncedUpdatesEnabled?: boolean;
|
debouncedUpdatesEnabled?: boolean;
|
||||||
|
mentionHighlights?: string[];
|
||||||
|
mentionSuggestions?: IMentionSuggestion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RichTextEditorProps extends IRichTextEditor {
|
interface RichTextEditorProps extends IRichTextEditor {
|
||||||
@ -44,6 +57,8 @@ const RichTextEditor = ({
|
|||||||
borderOnFocus,
|
borderOnFocus,
|
||||||
customClassName,
|
customClassName,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
|
mentionHighlights,
|
||||||
|
mentionSuggestions
|
||||||
}: RichTextEditorProps) => {
|
}: RichTextEditorProps) => {
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
onChange,
|
onChange,
|
||||||
@ -54,7 +69,9 @@ const RichTextEditor = ({
|
|||||||
uploadFile,
|
uploadFile,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting)
|
extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting),
|
||||||
|
mentionHighlights,
|
||||||
|
mentionSuggestions
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
|
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
|
||||||
|
@ -12,7 +12,7 @@ module.exports = {
|
|||||||
"./pages/**/*.tsx",
|
"./pages/**/*.tsx",
|
||||||
"./ui/**/*.tsx",
|
"./ui/**/*.tsx",
|
||||||
"../packages/ui/**/*.{js,ts,jsx,tsx}",
|
"../packages/ui/**/*.{js,ts,jsx,tsx}",
|
||||||
"../packages/editor/**/*.{js,ts,jsx,tsx}",
|
"../packages/editor/**/src/**/*.{js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
|
@ -136,6 +136,29 @@ export const FiltersList: React.FC<Props> = ({ filters, setFilters, clearAllFilt
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
))
|
))
|
||||||
|
: key === "mentions"
|
||||||
|
? filters.mentions?.map((mentionId: string) => {
|
||||||
|
const member = members?.find((m) => m.id === mentionId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={mentionId}
|
||||||
|
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
|
||||||
|
>
|
||||||
|
<Avatar name={member?.display_name} src={member?.avatar} showTooltip={false} />
|
||||||
|
<span>{member?.display_name}</span>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
mentions: filters.mentions?.filter((p: any) => p !== mentionId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
: key === "assignees"
|
: key === "assignees"
|
||||||
? filters.assignees?.map((memberId: string) => {
|
? filters.assignees?.map((memberId: string) => {
|
||||||
const member = members?.find((m) => m.id === memberId);
|
const member = members?.find((m) => m.id === memberId);
|
||||||
@ -145,7 +168,7 @@ export const FiltersList: React.FC<Props> = ({ filters, setFilters, clearAllFilt
|
|||||||
key={memberId}
|
key={memberId}
|
||||||
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
|
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
|
||||||
>
|
>
|
||||||
<Avatar name={member?.display_name} src={member?.avatar} showTooltip={false} />
|
<Avatar user={member} />
|
||||||
<span>{member?.display_name}</span>
|
<span>{member?.display_name}</span>
|
||||||
<span
|
<span
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
@ -169,7 +192,7 @@ export const FiltersList: React.FC<Props> = ({ filters, setFilters, clearAllFilt
|
|||||||
key={`${memberId}-${key}`}
|
key={`${memberId}-${key}`}
|
||||||
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
|
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
|
||||||
>
|
>
|
||||||
<Avatar name={member?.display_name} src={member?.avatar} />
|
<Avatar name={member?.display_name} src={member?.avatar} showTooltip={false} />
|
||||||
<span>{member?.display_name}</span>
|
<span>{member?.display_name}</span>
|
||||||
<span
|
<span
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
|
@ -3,7 +3,7 @@ import { useRouter } from "next/router";
|
|||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
|
import { IMentionHighlight, IMentionSuggestion, RichTextEditorWithRef } from "@plane/rich-text-editor";
|
||||||
|
|
||||||
// mobx store
|
// mobx store
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
@ -15,6 +15,8 @@ import { IssuePrioritySelect } from "components/issues/select";
|
|||||||
import { Button, Input, ToggleSwitch } from "@plane/ui";
|
import { Button, Input, ToggleSwitch } from "@plane/ui";
|
||||||
// types
|
// types
|
||||||
import { IIssue } from "types";
|
import { IIssue } from "types";
|
||||||
|
import useProjectMembers from "hooks/use-project-members";
|
||||||
|
import useUser from "hooks/use-user";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
@ -12,6 +12,7 @@ import { Globe2, Lock } from "lucide-react";
|
|||||||
|
|
||||||
// types
|
// types
|
||||||
import type { IIssueComment } from "types";
|
import type { IIssueComment } from "types";
|
||||||
|
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||||
|
|
||||||
const defaultValues: Partial<IIssueComment> = {
|
const defaultValues: Partial<IIssueComment> = {
|
||||||
access: "INTERNAL",
|
access: "INTERNAL",
|
||||||
@ -49,7 +50,9 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit, showAc
|
|||||||
const editorRef = React.useRef<any>(null);
|
const editorRef = React.useRef<any>(null);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
const editorSuggestions = useEditorSuggestions(workspaceSlug as string | undefined, projectId as string | undefined)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
@ -90,6 +93,8 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit, showAc
|
|||||||
debouncedUpdatesEnabled={false}
|
debouncedUpdatesEnabled={false}
|
||||||
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
|
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
|
||||||
commentAccessSpecifier={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }}
|
commentAccessSpecifier={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }}
|
||||||
|
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||||
|
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -15,6 +15,7 @@ import { LiteTextEditorWithRef, LiteReadOnlyEditorWithRef } from "@plane/lite-te
|
|||||||
import { timeAgo } from "helpers/date-time.helper";
|
import { timeAgo } from "helpers/date-time.helper";
|
||||||
// types
|
// types
|
||||||
import type { IIssueComment } from "types";
|
import type { IIssueComment } from "types";
|
||||||
|
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||||
|
|
||||||
// services
|
// services
|
||||||
const fileService = new FileService();
|
const fileService = new FileService();
|
||||||
@ -39,6 +40,8 @@ export const CommentCard: React.FC<Props> = ({
|
|||||||
const editorRef = React.useRef<any>(null);
|
const editorRef = React.useRef<any>(null);
|
||||||
const showEditorRef = React.useRef<any>(null);
|
const showEditorRef = React.useRef<any>(null);
|
||||||
|
|
||||||
|
const editorSuggestions = useEditorSuggestions(workspaceSlug, comment.project_detail.id)
|
||||||
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -112,6 +115,8 @@ export const CommentCard: React.FC<Props> = ({
|
|||||||
setValue("comment_json", comment_json);
|
setValue("comment_json", comment_json);
|
||||||
setValue("comment_html", comment_html);
|
setValue("comment_html", comment_html);
|
||||||
}}
|
}}
|
||||||
|
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||||
|
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 self-end">
|
<div className="flex gap-1 self-end">
|
||||||
|
@ -10,6 +10,7 @@ import { RichTextEditor } from "@plane/rich-text-editor";
|
|||||||
import { IIssue } from "types";
|
import { IIssue } from "types";
|
||||||
// services
|
// services
|
||||||
import { FileService } from "services/file.service";
|
import { FileService } from "services/file.service";
|
||||||
|
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||||
|
|
||||||
export interface IssueDescriptionFormValues {
|
export interface IssueDescriptionFormValues {
|
||||||
name: string;
|
name: string;
|
||||||
@ -20,6 +21,7 @@ export interface IssueDetailsProps {
|
|||||||
issue: {
|
issue: {
|
||||||
name: string;
|
name: string;
|
||||||
description_html: string;
|
description_html: string;
|
||||||
|
project_id?: string;
|
||||||
};
|
};
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>;
|
handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>;
|
||||||
@ -36,6 +38,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
|||||||
|
|
||||||
const { setShowAlert } = useReloadConfirmations();
|
const { setShowAlert } = useReloadConfirmations();
|
||||||
|
|
||||||
|
const editorSuggestion = useEditorSuggestions(workspaceSlug, issue.project_id)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
watch,
|
watch,
|
||||||
@ -154,13 +158,14 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
|||||||
onChange(description_html);
|
onChange(description_html);
|
||||||
debouncedFormSave();
|
debouncedFormSave();
|
||||||
}}
|
}}
|
||||||
|
mentionSuggestions={editorSuggestion.mentionSuggestions}
|
||||||
|
mentionHighlights={editorSuggestion.mentionHighlights}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
|
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${isSubmitting === "saved" ? "fadeOut" : "fadeIn"
|
||||||
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
|
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
|
||||||
</div>
|
</div>
|
||||||
|
@ -30,6 +30,7 @@ import { Sparkle, X } from "lucide-react";
|
|||||||
import type { IUser, IIssue, ISearchIssueResponse } from "types";
|
import type { IUser, IIssue, ISearchIssueResponse } from "types";
|
||||||
// components
|
// components
|
||||||
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
|
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
|
||||||
|
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||||
|
|
||||||
const aiService = new AIService();
|
const aiService = new AIService();
|
||||||
const fileService = new FileService();
|
const fileService = new FileService();
|
||||||
@ -121,6 +122,8 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
|
|||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const editorSuggestions = useEditorSuggestions(workspaceSlug as string | undefined, projectId)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@ -436,6 +439,8 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
|
|||||||
onChange(description_html);
|
onChange(description_html);
|
||||||
setValue("description", description);
|
setValue("description", description);
|
||||||
}}
|
}}
|
||||||
|
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||||
|
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -31,6 +31,7 @@ import { LayoutPanelTop, Sparkle, X } from "lucide-react";
|
|||||||
import type { IIssue, ISearchIssueResponse } from "types";
|
import type { IIssue, ISearchIssueResponse } from "types";
|
||||||
// components
|
// components
|
||||||
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
|
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
|
||||||
|
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||||
|
|
||||||
const defaultValues: Partial<IIssue> = {
|
const defaultValues: Partial<IIssue> = {
|
||||||
project: "",
|
project: "",
|
||||||
@ -107,6 +108,8 @@ export const IssueForm: FC<IssueFormProps> = observer((props) => {
|
|||||||
|
|
||||||
const user = userStore.currentUser;
|
const user = userStore.currentUser;
|
||||||
|
|
||||||
|
const editorSuggestion = useEditorSuggestions(workspaceSlug as string | undefined, projectId)
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -384,6 +387,8 @@ export const IssueForm: FC<IssueFormProps> = observer((props) => {
|
|||||||
onChange(description_html);
|
onChange(description_html);
|
||||||
setValue("description", description);
|
setValue("description", description);
|
||||||
}}
|
}}
|
||||||
|
mentionHighlights={editorSuggestion.mentionHighlights}
|
||||||
|
mentionSuggestions={editorSuggestion.mentionSuggestions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -27,7 +27,7 @@ type Props = {
|
|||||||
states?: IStateResponse | undefined;
|
states?: IStateResponse | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const membersFilters = ["assignees", "created_by", "subscriber"];
|
const membersFilters = ["assignees", "mentions" ,"created_by", "subscriber"];
|
||||||
const dateFilters = ["start_date", "target_date"];
|
const dateFilters = ["start_date", "target_date"];
|
||||||
|
|
||||||
export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||||
|
@ -4,6 +4,7 @@ import { observer } from "mobx-react-lite";
|
|||||||
// components
|
// components
|
||||||
import {
|
import {
|
||||||
FilterAssignees,
|
FilterAssignees,
|
||||||
|
FilterMentions,
|
||||||
FilterCreatedBy,
|
FilterCreatedBy,
|
||||||
FilterLabels,
|
FilterLabels,
|
||||||
FilterPriority,
|
FilterPriority,
|
||||||
@ -73,6 +74,10 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
|||||||
currentLength: 5,
|
currentLength: 5,
|
||||||
totalLength: members?.length ?? 0,
|
totalLength: members?.length ?? 0,
|
||||||
},
|
},
|
||||||
|
mentions: {
|
||||||
|
currentLength: 5,
|
||||||
|
totalLength: members?.length ?? 0,
|
||||||
|
},
|
||||||
created_by: {
|
created_by: {
|
||||||
currentLength: 5,
|
currentLength: 5,
|
||||||
totalLength: members?.length ?? 0,
|
totalLength: members?.length ?? 0,
|
||||||
@ -257,6 +262,27 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* assignees */}
|
||||||
|
{isFilterEnabled("mentions") && (
|
||||||
|
<div className="py-2">
|
||||||
|
<FilterMentions
|
||||||
|
appliedFilters={filters.mentions ?? null}
|
||||||
|
handleUpdate={(val) => handleFiltersUpdate("mentions", val)}
|
||||||
|
itemsToRender={filtersToRender.mentions?.currentLength ?? 0}
|
||||||
|
members={members}
|
||||||
|
searchQuery={filtersSearchQuery}
|
||||||
|
viewButtons={
|
||||||
|
<ViewButtons
|
||||||
|
isViewLessVisible={isViewLessVisible("mentions")}
|
||||||
|
isViewMoreVisible={isViewMoreVisible("mentions")}
|
||||||
|
handleLess={() => handleViewLess("mentions")}
|
||||||
|
handleMore={() => handleViewMore("mentions")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* created_by */}
|
{/* created_by */}
|
||||||
{isFilterEnabled("created_by") && (
|
{isFilterEnabled("created_by") && (
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
export * from "./assignee";
|
export * from "./assignee";
|
||||||
|
export * from "./mentions";
|
||||||
export * from "./created-by";
|
export * from "./created-by";
|
||||||
export * from "./filters-selection";
|
export * from "./filters-selection";
|
||||||
export * from "./labels";
|
export * from "./labels";
|
||||||
|
@ -0,0 +1,68 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import { FilterHeader, FilterOption } from "components/issues";
|
||||||
|
// ui
|
||||||
|
import { Avatar } from "components/ui";
|
||||||
|
import { Loader } from "@plane/ui";
|
||||||
|
// types
|
||||||
|
import { IUserLite } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
appliedFilters: string[] | null;
|
||||||
|
handleUpdate: (val: string) => void;
|
||||||
|
itemsToRender: number;
|
||||||
|
members: IUserLite[] | undefined;
|
||||||
|
searchQuery: string;
|
||||||
|
viewButtons: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FilterMentions: React.FC<Props> = (props) => {
|
||||||
|
const { appliedFilters, handleUpdate, itemsToRender, members, searchQuery, viewButtons } = props;
|
||||||
|
|
||||||
|
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||||
|
|
||||||
|
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||||
|
|
||||||
|
const filteredOptions = members?.filter((member) =>
|
||||||
|
member.display_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FilterHeader
|
||||||
|
title={`Mention${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||||
|
isPreviewEnabled={previewEnabled}
|
||||||
|
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||||
|
/>
|
||||||
|
{previewEnabled && (
|
||||||
|
<div>
|
||||||
|
{filteredOptions ? (
|
||||||
|
filteredOptions.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{filteredOptions.slice(0, itemsToRender).map((member) => (
|
||||||
|
<FilterOption
|
||||||
|
key={`mentions-${member.id}`}
|
||||||
|
isChecked={appliedFilters?.includes(member.id) ? true : false}
|
||||||
|
onClick={() => handleUpdate(member.id)}
|
||||||
|
icon={<Avatar user={member} height="18px" width="18px" />}
|
||||||
|
title={member.display_name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{viewButtons}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-custom-text-400 italic">No matches found</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Loader className="space-y-2">
|
||||||
|
<Loader.Item height="20px" />
|
||||||
|
<Loader.Item height="20px" />
|
||||||
|
<Loader.Item height="20px" />
|
||||||
|
</Loader>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||||
import { Check, Globe2, Lock, MessageSquare, Pencil, Trash2, X } from "lucide-react";
|
import { Check, Globe2, Lock, MessageSquare, Pencil, Trash2, X } from "lucide-react";
|
||||||
// services
|
// services
|
||||||
import { FileService } from "services/file.service";
|
import { FileService } from "services/file.service";
|
||||||
@ -48,6 +49,8 @@ export const IssueCommentCard: React.FC<IIssueCommentCard> = (props) => {
|
|||||||
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
const editorSuggestions = useEditorSuggestions(workspaceSlug, projectId)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@ -121,6 +124,8 @@ export const IssueCommentCard: React.FC<IIssueCommentCard> = (props) => {
|
|||||||
setValue("comment_json", comment_json);
|
setValue("comment_json", comment_json);
|
||||||
setValue("comment_html", comment_html);
|
setValue("comment_html", comment_html);
|
||||||
}}
|
}}
|
||||||
|
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||||
|
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 self-end">
|
<div className="flex gap-1 self-end">
|
||||||
|
@ -12,6 +12,7 @@ import { Globe2, Lock } from "lucide-react";
|
|||||||
|
|
||||||
// types
|
// types
|
||||||
import type { IIssueComment } from "types";
|
import type { IIssueComment } from "types";
|
||||||
|
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||||
|
|
||||||
const defaultValues: Partial<IIssueComment> = {
|
const defaultValues: Partial<IIssueComment> = {
|
||||||
access: "INTERNAL",
|
access: "INTERNAL",
|
||||||
@ -51,7 +52,9 @@ export const IssueCommentEditor: React.FC<IIssueCommentEditor> = (props) => {
|
|||||||
const editorRef = React.useRef<any>(null);
|
const editorRef = React.useRef<any>(null);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
const editorSuggestions = useEditorSuggestions(workspaceSlug as string | undefined, projectId as string | undefined)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
@ -118,6 +121,8 @@ export const IssueCommentEditor: React.FC<IIssueCommentEditor> = (props) => {
|
|||||||
value={!commentValue || commentValue === "" ? "<p></p>" : commentValue}
|
value={!commentValue || commentValue === "" ? "<p></p>" : commentValue}
|
||||||
customClassName="p-3 min-h-[100px] shadow-sm"
|
customClassName="p-3 min-h-[100px] shadow-sm"
|
||||||
debouncedUpdatesEnabled={false}
|
debouncedUpdatesEnabled={false}
|
||||||
|
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||||
|
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||||
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
|
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
|
||||||
commentAccessSpecifier={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }}
|
commentAccessSpecifier={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }}
|
||||||
/>
|
/>
|
||||||
|
@ -8,10 +8,12 @@ import { IssueReaction } from "./reactions";
|
|||||||
// hooks
|
// hooks
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
||||||
|
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||||
// types
|
// types
|
||||||
import { IIssue } from "types";
|
import { IIssue } from "types";
|
||||||
// services
|
// services
|
||||||
import { FileService } from "services/file.service";
|
import { FileService } from "services/file.service";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
const fileService = new FileService();
|
const fileService = new FileService();
|
||||||
|
|
||||||
@ -27,12 +29,16 @@ interface IPeekOverviewIssueDetails {
|
|||||||
|
|
||||||
export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) => {
|
export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) => {
|
||||||
const { workspaceSlug, issue, issueReactions, user, issueUpdate, issueReactionCreate, issueReactionRemove } = props;
|
const { workspaceSlug, issue, issueReactions, user, issueUpdate, issueReactionCreate, issueReactionRemove } = props;
|
||||||
|
// store
|
||||||
|
const { user: userStore } = useMobxStore();
|
||||||
|
const isAllowed = [5, 10].includes(userStore.projectMemberInfo?.role || 0);
|
||||||
|
// states
|
||||||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||||
|
|
||||||
const [characterLimit, setCharacterLimit] = useState(false);
|
const [characterLimit, setCharacterLimit] = useState(false);
|
||||||
|
// hooks
|
||||||
const { setShowAlert } = useReloadConfirmations();
|
const { setShowAlert } = useReloadConfirmations();
|
||||||
|
const editorSuggestions = useEditorSuggestions(workspaceSlug, issue.project_detail.id);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
watch,
|
watch,
|
||||||
@ -94,7 +100,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{true ? (
|
{isAllowed ? (
|
||||||
<Controller
|
<Controller
|
||||||
name="name"
|
name="name"
|
||||||
control={control}
|
control={control}
|
||||||
@ -143,6 +149,8 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
|
|||||||
debouncedIssueDescription(description_html);
|
debouncedIssueDescription(description_html);
|
||||||
}}
|
}}
|
||||||
customClassName="mt-0"
|
customClassName="mt-0"
|
||||||
|
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||||
|
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
@ -26,6 +26,7 @@ export const PeekOverviewIssueDetails: React.FC<Props> = ({
|
|||||||
issue={{
|
issue={{
|
||||||
name: issue.name,
|
name: issue.name,
|
||||||
description_html: issue.description_html,
|
description_html: issue.description_html,
|
||||||
|
project_id: issue.project_detail.id,
|
||||||
}}
|
}}
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
/>
|
/>
|
||||||
|
@ -92,7 +92,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2.5 w-full overflow-hidden">
|
<div className="space-y-2.5 w-full overflow-hidden">
|
||||||
<div className="text-sm w-full break-words">
|
{ !notification.message ? <div className="text-sm w-full break-words">
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{notification.triggered_by_details.is_bot
|
{notification.triggered_by_details.is_bot
|
||||||
? notification.triggered_by_details.first_name
|
? notification.triggered_by_details.first_name
|
||||||
@ -133,7 +133,11 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
|||||||
"the issue and assigned it to you."
|
"the issue and assigned it to you."
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div> : <div className="text-sm w-full break-words">
|
||||||
|
<span className="semi-bold">
|
||||||
|
{ notification.message }
|
||||||
|
</span>
|
||||||
|
</div> }
|
||||||
|
|
||||||
<div className="flex justify-between gap-2 text-xs">
|
<div className="flex justify-between gap-2 text-xs">
|
||||||
<p className="text-custom-text-300">
|
<p className="text-custom-text-300">
|
||||||
|
@ -18,6 +18,7 @@ import { RichTextEditorWithRef } from "@plane/rich-text-editor";
|
|||||||
import { IUser, IPageBlock } from "types";
|
import { IUser, IPageBlock } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
|
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
|
||||||
|
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
@ -55,6 +56,8 @@ export const CreateUpdateBlockInline: FC<Props> = ({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId, pageId } = router.query;
|
const { workspaceSlug, projectId, pageId } = router.query;
|
||||||
|
|
||||||
|
const editorSuggestion = useEditorSuggestions(workspaceSlug as string | undefined, projectId as string | undefined)
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -304,6 +307,8 @@ export const CreateUpdateBlockInline: FC<Props> = ({
|
|||||||
onChange(description_html);
|
onChange(description_html);
|
||||||
setValue("description", description);
|
setValue("description", description);
|
||||||
}}
|
}}
|
||||||
|
mentionHighlights={editorSuggestion.mentionHighlights}
|
||||||
|
mentionSuggestions={editorSuggestion.mentionSuggestions}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
else if (!value || !watch("description_html"))
|
else if (!value || !watch("description_html"))
|
||||||
|
@ -26,6 +26,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
|
|||||||
import { IUser, IIssue, IPageBlock, IProject } from "types";
|
import { IUser, IIssue, IPageBlock, IProject } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
|
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
|
||||||
|
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
block: IPageBlock;
|
block: IPageBlock;
|
||||||
@ -63,6 +64,8 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails, showBl
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const editorSuggestion = useEditorSuggestions(workspaceSlug as string | undefined, projectId as string | undefined)
|
||||||
|
|
||||||
const updatePageBlock = async (formData: Partial<IPageBlock>) => {
|
const updatePageBlock = async (formData: Partial<IPageBlock>) => {
|
||||||
if (!workspaceSlug || !projectId || !pageId) return;
|
if (!workspaceSlug || !projectId || !pageId) return;
|
||||||
|
|
||||||
@ -423,6 +426,8 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails, showBl
|
|||||||
customClassName="text-sm min-h-[150px]"
|
customClassName="text-sm min-h-[150px]"
|
||||||
noBorder
|
noBorder
|
||||||
borderOnFocus={false}
|
borderOnFocus={false}
|
||||||
|
mentionSuggestions={editorSuggestion.mentionSuggestions}
|
||||||
|
mentionHighlights={editorSuggestion.mentionHighlights}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: block.description_stripped.length > 0 && (
|
: block.description_stripped.length > 0 && (
|
||||||
|
@ -251,6 +251,7 @@ export const ProfileIssuesView = () => {
|
|||||||
states={undefined}
|
states={undefined}
|
||||||
clearAllFilters={() =>
|
clearAllFilters={() =>
|
||||||
setFilters({
|
setFilters({
|
||||||
|
mentions: null,
|
||||||
labels: null,
|
labels: null,
|
||||||
priority: null,
|
priority: null,
|
||||||
state_group: null,
|
state_group: null,
|
||||||
|
278
web/components/views/select-filters.tsx
Normal file
278
web/components/views/select-filters.tsx
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import useSWR from "swr";
|
||||||
|
// services
|
||||||
|
import { ProjectStateService, ProjectService } from "services/project";
|
||||||
|
import { IssueLabelService } from "services/issue";
|
||||||
|
// ui
|
||||||
|
import { Avatar, MultiLevelDropdown } from "components/ui";
|
||||||
|
// icons
|
||||||
|
import { PriorityIcon, StateGroupIcon } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { getStatesList } from "helpers/state.helper";
|
||||||
|
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
|
||||||
|
// types
|
||||||
|
import { IIssueFilterOptions } from "types";
|
||||||
|
// fetch-keys
|
||||||
|
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATES_LIST } from "constants/fetch-keys";
|
||||||
|
// constants
|
||||||
|
import { PRIORITIES } from "constants/project";
|
||||||
|
import { DATE_FILTER_OPTIONS } from "constants/filters";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
filters: Partial<IIssueFilterOptions>;
|
||||||
|
onSelect: (option: any) => void;
|
||||||
|
direction?: "left" | "right";
|
||||||
|
height?: "sm" | "md" | "rg" | "lg";
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectService = new ProjectService();
|
||||||
|
const projectStateService = new ProjectStateService();
|
||||||
|
const issueLabelService = new IssueLabelService();
|
||||||
|
|
||||||
|
export const SelectFilters: React.FC<Props> = ({ filters, onSelect, direction = "right", height = "md" }) => {
|
||||||
|
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
|
||||||
|
const [dateFilterType, setDateFilterType] = useState<{
|
||||||
|
title: string;
|
||||||
|
type: "start_date" | "target_date";
|
||||||
|
}>({
|
||||||
|
title: "",
|
||||||
|
type: "start_date",
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
const { data: states } = useSWR(
|
||||||
|
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
|
||||||
|
workspaceSlug && projectId
|
||||||
|
? () => projectStateService.getStates(workspaceSlug as string, projectId as string)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const statesList = getStatesList(states);
|
||||||
|
|
||||||
|
const { data: members } = useSWR(
|
||||||
|
projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||||
|
workspaceSlug && projectId
|
||||||
|
? () => projectService.fetchProjectMembers(workspaceSlug as string, projectId as string)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: issueLabels } = useSWR(
|
||||||
|
projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null,
|
||||||
|
workspaceSlug && projectId
|
||||||
|
? () => issueLabelService.getProjectIssueLabels(workspaceSlug as string, projectId.toString())
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectFilterOption = [
|
||||||
|
{
|
||||||
|
id: "priority",
|
||||||
|
label: "Priority",
|
||||||
|
value: PRIORITIES,
|
||||||
|
hasChildren: true,
|
||||||
|
children: PRIORITIES.map((priority) => ({
|
||||||
|
id: priority === null ? "null" : priority,
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center gap-2 capitalize">
|
||||||
|
<PriorityIcon priority={priority} />
|
||||||
|
{priority ?? "None"}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: {
|
||||||
|
key: "priority",
|
||||||
|
value: priority === null ? "null" : priority,
|
||||||
|
},
|
||||||
|
selected: filters?.priority?.includes(priority === null ? "null" : priority),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "state",
|
||||||
|
label: "State",
|
||||||
|
value: statesList,
|
||||||
|
hasChildren: true,
|
||||||
|
children: statesList?.map((state) => ({
|
||||||
|
id: state.id,
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StateGroupIcon stateGroup={state.group} color={state.color} />
|
||||||
|
{state.name}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: {
|
||||||
|
key: "state",
|
||||||
|
value: state.id,
|
||||||
|
},
|
||||||
|
selected: filters?.state?.includes(state.id),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "assignees",
|
||||||
|
label: "Assignees",
|
||||||
|
value: members,
|
||||||
|
hasChildren: true,
|
||||||
|
children: members?.map((member) => ({
|
||||||
|
id: member.member.id,
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar user={member.member} />
|
||||||
|
{member.member.display_name}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: {
|
||||||
|
key: "assignees",
|
||||||
|
value: member.member.id,
|
||||||
|
},
|
||||||
|
selected: filters?.assignees?.includes(member.member.id),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mentions",
|
||||||
|
label: "Mentions",
|
||||||
|
value: members,
|
||||||
|
hasChildren: true,
|
||||||
|
children: members?.map((member) => ({
|
||||||
|
id: member.member.id,
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar user={member.member} />
|
||||||
|
{member.member.display_name}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: {
|
||||||
|
key: "mentions",
|
||||||
|
value: member.member.id,
|
||||||
|
},
|
||||||
|
selected: filters?.mentions?.includes(member.member.id),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "created_by",
|
||||||
|
label: "Created by",
|
||||||
|
value: members,
|
||||||
|
hasChildren: true,
|
||||||
|
children: members?.map((member) => ({
|
||||||
|
id: member.member.id,
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar user={member.member} />
|
||||||
|
{member.member.display_name}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: {
|
||||||
|
key: "created_by",
|
||||||
|
value: member.member.id,
|
||||||
|
},
|
||||||
|
selected: filters?.created_by?.includes(member.member.id),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "labels",
|
||||||
|
label: "Labels",
|
||||||
|
value: issueLabels,
|
||||||
|
hasChildren: true,
|
||||||
|
children: issueLabels?.map((label) => ({
|
||||||
|
id: label.id,
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: label.color && label.color !== "" ? label.color : "#000000",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{label.name}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: {
|
||||||
|
key: "labels",
|
||||||
|
value: label.id,
|
||||||
|
},
|
||||||
|
selected: filters?.labels?.includes(label.id),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "start_date",
|
||||||
|
label: "Start date",
|
||||||
|
value: DATE_FILTER_OPTIONS,
|
||||||
|
hasChildren: true,
|
||||||
|
children: [
|
||||||
|
...DATE_FILTER_OPTIONS.map((option) => ({
|
||||||
|
id: option.name,
|
||||||
|
label: option.name,
|
||||||
|
value: {
|
||||||
|
key: "start_date",
|
||||||
|
value: option.value,
|
||||||
|
},
|
||||||
|
selected: checkIfArraysHaveSameElements(filters?.start_date ?? [], [option.value]),
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
id: "custom",
|
||||||
|
label: "Custom",
|
||||||
|
value: "custom",
|
||||||
|
element: (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsDateFilterModalOpen(true);
|
||||||
|
setDateFilterType({
|
||||||
|
title: "Start date",
|
||||||
|
type: "start_date",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "target_date",
|
||||||
|
label: "Due date",
|
||||||
|
value: DATE_FILTER_OPTIONS,
|
||||||
|
hasChildren: true,
|
||||||
|
children: [
|
||||||
|
...DATE_FILTER_OPTIONS.map((option) => ({
|
||||||
|
id: option.name,
|
||||||
|
label: option.name,
|
||||||
|
value: {
|
||||||
|
key: "target_date",
|
||||||
|
value: option.value,
|
||||||
|
},
|
||||||
|
selected: checkIfArraysHaveSameElements(filters?.target_date ?? [], [option.value]),
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
id: "custom",
|
||||||
|
label: "Custom",
|
||||||
|
value: "custom",
|
||||||
|
element: (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsDateFilterModalOpen(true);
|
||||||
|
setDateFilterType({
|
||||||
|
title: "Due date",
|
||||||
|
type: "target_date",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MultiLevelDropdown
|
||||||
|
label="Filters"
|
||||||
|
onSelect={onSelect}
|
||||||
|
direction={direction}
|
||||||
|
height={height}
|
||||||
|
options={projectFilterOption}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
112
web/components/web-view/add-comment.tsx
Normal file
112
web/components/web-view/add-comment.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
// hooks
|
||||||
|
import useProjectDetails from "hooks/use-project-details";
|
||||||
|
// components
|
||||||
|
import { LiteTextEditorWithRef } from "@plane/lite-text-editor";
|
||||||
|
import { Button } from "@plane/ui";
|
||||||
|
// icons
|
||||||
|
import { Send } from "lucide-react";
|
||||||
|
// types
|
||||||
|
import type { IIssueComment } from "types";
|
||||||
|
// services
|
||||||
|
import { FileService } from "services/file.service";
|
||||||
|
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||||
|
|
||||||
|
const defaultValues: Partial<IIssueComment> = {
|
||||||
|
access: "INTERNAL",
|
||||||
|
comment_html: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
disabled?: boolean;
|
||||||
|
onSubmit: (data: IIssueComment) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type commentAccessType = {
|
||||||
|
icon: string;
|
||||||
|
key: string;
|
||||||
|
label: "Private" | "Public";
|
||||||
|
};
|
||||||
|
|
||||||
|
const commentAccess: commentAccessType[] = [
|
||||||
|
{
|
||||||
|
icon: "lock",
|
||||||
|
key: "INTERNAL",
|
||||||
|
label: "Private",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "public",
|
||||||
|
key: "EXTERNAL",
|
||||||
|
label: "Public",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fileService = new FileService();
|
||||||
|
|
||||||
|
export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit }) => {
|
||||||
|
const editorRef = React.useRef<any>(null);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
const { projectDetails } = useProjectDetails();
|
||||||
|
|
||||||
|
const editorSuggestions = useEditorSuggestions(workspaceSlug as string | undefined, projectDetails?.id)
|
||||||
|
|
||||||
|
const showAccessSpecifier = projectDetails?.is_deployed || false;
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
} = useForm<IIssueComment>({ defaultValues });
|
||||||
|
|
||||||
|
const handleAddComment = async (formData: IIssueComment) => {
|
||||||
|
if (!formData.comment_html || isSubmitting) return;
|
||||||
|
|
||||||
|
await onSubmit(formData).then(() => {
|
||||||
|
reset(defaultValues);
|
||||||
|
editorRef.current?.clearEditor();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="w-full flex gap-x-2" onSubmit={handleSubmit(handleAddComment)}>
|
||||||
|
<div className="relative flex-grow">
|
||||||
|
<Controller
|
||||||
|
name="access"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { onChange: onAccessChange, value: accessValue } }) => (
|
||||||
|
<Controller
|
||||||
|
name="comment_html"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { onChange: onCommentChange, value: commentValue } }) => (
|
||||||
|
<LiteTextEditorWithRef
|
||||||
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
|
deleteFile={fileService.deleteImage}
|
||||||
|
ref={editorRef}
|
||||||
|
value={!commentValue || commentValue === "" ? "<p></p>" : commentValue}
|
||||||
|
customClassName="p-3 min-h-[100px] shadow-sm"
|
||||||
|
debouncedUpdatesEnabled={false}
|
||||||
|
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||||
|
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||||
|
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
|
||||||
|
commentAccessSpecifier={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inline">
|
||||||
|
<Button variant="primary" type="submit" disabled={isSubmitting || disabled}>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
193
web/components/web-view/comment-card.tsx
Normal file
193
web/components/web-view/comment-card.tsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
// react-hook-form
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
// icons
|
||||||
|
import { Check, Globe2, Lock, MessageSquare, Pencil, Trash2, X } from "lucide-react";
|
||||||
|
// service
|
||||||
|
import { FileService } from "services/file.service";
|
||||||
|
// hooks
|
||||||
|
import useUser from "hooks/use-user";
|
||||||
|
// ui
|
||||||
|
import { CustomMenu } from "@plane/ui";
|
||||||
|
import { CommentReaction } from "components/issues";
|
||||||
|
import { LiteTextEditorWithRef, LiteReadOnlyEditorWithRef } from "@plane/lite-text-editor";
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
import { timeAgo } from "helpers/date-time.helper";
|
||||||
|
// types
|
||||||
|
import type { IIssueComment } from "types";
|
||||||
|
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
comment: IIssueComment;
|
||||||
|
handleCommentDeletion: (comment: string) => void;
|
||||||
|
onSubmit: (commentId: string, data: Partial<IIssueComment>) => void;
|
||||||
|
showAccessSpecifier?: boolean;
|
||||||
|
workspaceSlug: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// services
|
||||||
|
const fileService = new FileService();
|
||||||
|
|
||||||
|
export const CommentCard: React.FC<Props> = (props) => {
|
||||||
|
const { comment, handleCommentDeletion, onSubmit, showAccessSpecifier = false, workspaceSlug, disabled } = props;
|
||||||
|
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
|
const editorSuggestions = useEditorSuggestions(workspaceSlug, comment.project_detail.id)
|
||||||
|
|
||||||
|
const editorRef = React.useRef<any>(null);
|
||||||
|
const showEditorRef = React.useRef<any>(null);
|
||||||
|
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
formState: { isSubmitting },
|
||||||
|
handleSubmit,
|
||||||
|
setFocus,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
} = useForm<IIssueComment>({
|
||||||
|
defaultValues: comment,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onEnter = (formData: Partial<IIssueComment>) => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
setIsEditing(false);
|
||||||
|
|
||||||
|
onSubmit(comment.id, formData);
|
||||||
|
|
||||||
|
editorRef.current?.setEditorValue(formData.comment_html);
|
||||||
|
showEditorRef.current?.setEditorValue(formData.comment_html);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isEditing && setFocus("comment");
|
||||||
|
}, [isEditing, setFocus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-start space-x-3">
|
||||||
|
<div className="relative px-1">
|
||||||
|
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
|
||||||
|
<img
|
||||||
|
src={comment.actor_detail.avatar}
|
||||||
|
alt={
|
||||||
|
comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name
|
||||||
|
}
|
||||||
|
height={30}
|
||||||
|
width={30}
|
||||||
|
className="grid h-7 w-7 place-items-center rounded-full border-2 border-custom-border-200"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}>
|
||||||
|
{comment.actor_detail.is_bot
|
||||||
|
? comment.actor_detail.first_name.charAt(0)
|
||||||
|
: comment.actor_detail.display_name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="absolute -bottom-0.5 -right-1 rounded-tl bg-custom-background-80 px-0.5 py-px">
|
||||||
|
<MessageSquare className="h-3.5 w-3.5 text-custom-text-200" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs">
|
||||||
|
{comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-custom-text-200">commented {timeAgo(comment.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="issue-comments-section p-0">
|
||||||
|
<form className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}>
|
||||||
|
<div>
|
||||||
|
<LiteTextEditorWithRef
|
||||||
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||||
|
deleteFile={fileService.deleteImage}
|
||||||
|
onEnterKeyPress={handleSubmit(onEnter)}
|
||||||
|
ref={editorRef}
|
||||||
|
value={watch("comment_html")}
|
||||||
|
debouncedUpdatesEnabled={false}
|
||||||
|
customClassName="min-h-[50px] p-3 shadow-sm"
|
||||||
|
onChange={(comment_json: Object, comment_html: string) => {
|
||||||
|
setValue("comment_json", comment_json);
|
||||||
|
setValue("comment_html", comment_html);
|
||||||
|
}}
|
||||||
|
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||||
|
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 self-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || disabled}
|
||||||
|
className="group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 hover:bg-green-500"
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3 text-green-500 duration-300 group-hover:text-white" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
|
||||||
|
onClick={() => setIsEditing(false)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 text-red-500 duration-300 group-hover:text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className={`relative ${isEditing ? "hidden" : ""}`}>
|
||||||
|
{showAccessSpecifier && (
|
||||||
|
<div className="absolute top-1 right-1.5 z-[1] text-custom-text-300">
|
||||||
|
{comment.access === "INTERNAL" ? <Lock className="h-3 w-3" /> : <Globe2 className="h-3 w-3" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<LiteReadOnlyEditorWithRef
|
||||||
|
ref={showEditorRef}
|
||||||
|
value={comment.comment_html}
|
||||||
|
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||||
|
/>
|
||||||
|
<CommentReaction readonly={disabled} projectId={comment.project} commentId={comment.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{user?.id === comment.actor && !disabled && (
|
||||||
|
<CustomMenu ellipsis>
|
||||||
|
<CustomMenu.MenuItem onClick={() => setIsEditing(true)} className="flex items-center gap-1">
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
Edit comment
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
{showAccessSpecifier && (
|
||||||
|
<>
|
||||||
|
{comment.access === "INTERNAL" ? (
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
onClick={() => onSubmit(comment.id, { access: "EXTERNAL" })}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Globe2 className="h-3 w-3" />
|
||||||
|
Switch to public comment
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
) : (
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
onClick={() => onSubmit(comment.id, { access: "INTERNAL" })}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Lock className="h-3 w-3" />
|
||||||
|
Switch to private comment
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleCommentDeletion(comment.id);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
Delete comment
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
</CustomMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
158
web/components/web-view/issue-web-view-form.tsx
Normal file
158
web/components/web-view/issue-web-view-form.tsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
|
||||||
|
// services
|
||||||
|
import { FileService } from "services/file.service";
|
||||||
|
// hooks
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
||||||
|
// ui
|
||||||
|
import { TextArea } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { RichTextEditor } from "@plane/rich-text-editor";
|
||||||
|
import { Label } from "components/web-view";
|
||||||
|
// types
|
||||||
|
import type { IIssue } from "types";
|
||||||
|
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isAllowed: boolean;
|
||||||
|
issueDetails: IIssue;
|
||||||
|
submitChanges: (data: Partial<IIssue>) => Promise<void>;
|
||||||
|
register: any;
|
||||||
|
control: any;
|
||||||
|
watch: any;
|
||||||
|
handleSubmit: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
// services
|
||||||
|
const fileService = new FileService();
|
||||||
|
|
||||||
|
export const IssueWebViewForm: React.FC<Props> = (props) => {
|
||||||
|
const { isAllowed, issueDetails, submitChanges, control, watch, handleSubmit } = props;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
const [characterLimit, setCharacterLimit] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||||
|
|
||||||
|
const { setShowAlert } = useReloadConfirmations();
|
||||||
|
|
||||||
|
const editorSuggestion = useEditorSuggestions(workspaceSlug as string | undefined, issueDetails.project_detail.id)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSubmitting === "submitted") {
|
||||||
|
setShowAlert(false);
|
||||||
|
setTimeout(async () => {
|
||||||
|
setIsSubmitting("saved");
|
||||||
|
}, 2000);
|
||||||
|
} else if (isSubmitting === "submitting") {
|
||||||
|
setShowAlert(true);
|
||||||
|
}
|
||||||
|
}, [isSubmitting, setShowAlert]);
|
||||||
|
|
||||||
|
const debouncedTitleSave = useDebouncedCallback(async () => {
|
||||||
|
setTimeout(async () => {
|
||||||
|
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
|
||||||
|
}, 500);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
const handleDescriptionFormSubmit = useCallback(
|
||||||
|
async (formData: Partial<IIssue>) => {
|
||||||
|
if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return;
|
||||||
|
|
||||||
|
await submitChanges({
|
||||||
|
name: formData.name ?? "",
|
||||||
|
description_html: formData.description_html ?? "<p></p>",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[submitChanges]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Label>Title</Label>
|
||||||
|
<div className="relative">
|
||||||
|
{isAllowed ? (
|
||||||
|
<Controller
|
||||||
|
name="name"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value } }) => (
|
||||||
|
<TextArea
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value={value}
|
||||||
|
placeholder="Enter issue name"
|
||||||
|
onFocus={() => setCharacterLimit(true)}
|
||||||
|
onChange={() => {
|
||||||
|
setCharacterLimit(false);
|
||||||
|
setIsSubmitting("submitting");
|
||||||
|
debouncedTitleSave();
|
||||||
|
}}
|
||||||
|
required={true}
|
||||||
|
className="min-h-10 block w-full resize-none overflow-hidden rounded border bg-transparent px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-custom-primary"
|
||||||
|
role="textbox"
|
||||||
|
disabled={!isAllowed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h4 className="break-words text-2xl font-semibold">{issueDetails?.name}</h4>
|
||||||
|
)}
|
||||||
|
{characterLimit && isAllowed && (
|
||||||
|
<div className="pointer-events-none absolute bottom-1 right-1 z-[2] rounded bg-custom-background-100 text-custom-text-200 p-0.5 text-xs">
|
||||||
|
<span className={`${watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""}`}>
|
||||||
|
{watch("name").length}
|
||||||
|
</span>
|
||||||
|
/255
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Description</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Controller
|
||||||
|
name="description_html"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange } }) => {
|
||||||
|
if(value==null)return <></>;
|
||||||
|
return <RichTextEditor
|
||||||
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
|
deleteFile={fileService.deleteImage}
|
||||||
|
value={
|
||||||
|
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)
|
||||||
|
? "<p></p>"
|
||||||
|
: value
|
||||||
|
}
|
||||||
|
debouncedUpdatesEnabled={true}
|
||||||
|
setShouldShowAlert={setShowAlert}
|
||||||
|
setIsSubmitting={setIsSubmitting}
|
||||||
|
customClassName={isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200"}
|
||||||
|
noBorder={!isAllowed}
|
||||||
|
onChange={(description: Object, description_html: string) => {
|
||||||
|
setShowAlert(true);
|
||||||
|
setIsSubmitting("submitting");
|
||||||
|
onChange(description_html);
|
||||||
|
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
|
||||||
|
}}
|
||||||
|
mentionSuggestions={editorSuggestion.mentionSuggestions}
|
||||||
|
mentionHighlights={editorSuggestion.mentionHighlights}
|
||||||
|
/>
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
|
||||||
|
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -6,6 +6,7 @@ const paramsToKey = (params: any) => {
|
|||||||
state,
|
state,
|
||||||
state_group,
|
state_group,
|
||||||
priority,
|
priority,
|
||||||
|
mentions,
|
||||||
assignees,
|
assignees,
|
||||||
created_by,
|
created_by,
|
||||||
labels,
|
labels,
|
||||||
@ -22,6 +23,7 @@ const paramsToKey = (params: any) => {
|
|||||||
let stateKey = state ? state.split(",") : [];
|
let stateKey = state ? state.split(",") : [];
|
||||||
let stateGroupKey = state_group ? state_group.split(",") : [];
|
let stateGroupKey = state_group ? state_group.split(",") : [];
|
||||||
let priorityKey = priority ? priority.split(",") : [];
|
let priorityKey = priority ? priority.split(",") : [];
|
||||||
|
let mentionsKey = mentions ? mentions.split(",") : [];
|
||||||
let assigneesKey = assignees ? assignees.split(",") : [];
|
let assigneesKey = assignees ? assignees.split(",") : [];
|
||||||
let createdByKey = created_by ? created_by.split(",") : [];
|
let createdByKey = created_by ? created_by.split(",") : [];
|
||||||
let labelsKey = labels ? labels.split(",") : [];
|
let labelsKey = labels ? labels.split(",") : [];
|
||||||
@ -40,11 +42,12 @@ const paramsToKey = (params: any) => {
|
|||||||
stateGroupKey = stateGroupKey.sort().join("_");
|
stateGroupKey = stateGroupKey.sort().join("_");
|
||||||
priorityKey = priorityKey.sort().join("_");
|
priorityKey = priorityKey.sort().join("_");
|
||||||
assigneesKey = assigneesKey.sort().join("_");
|
assigneesKey = assigneesKey.sort().join("_");
|
||||||
|
mentionsKey = mentionsKey.sort().join("_");
|
||||||
createdByKey = createdByKey.sort().join("_");
|
createdByKey = createdByKey.sort().join("_");
|
||||||
labelsKey = labelsKey.sort().join("_");
|
labelsKey = labelsKey.sort().join("_");
|
||||||
subscriberKey = subscriberKey.sort().join("_");
|
subscriberKey = subscriberKey.sort().join("_");
|
||||||
|
|
||||||
return `${layoutKey}_${projectKey}_${stateGroupKey}_${stateKey}_${priorityKey}_${assigneesKey}_${createdByKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${startDateKey}_${targetDateKey}_${sub_issue}_${startTargetDate}_${subscriberKey}`;
|
return `${layoutKey}_${projectKey}_${stateGroupKey}_${stateKey}_${priorityKey}_${assigneesKey}_${mentionsKey}_${createdByKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${startDateKey}_${targetDateKey}_${sub_issue}_${startTargetDate}_${subscriberKey}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const myIssuesParamsToKey = (params: any) => {
|
const myIssuesParamsToKey = (params: any) => {
|
||||||
|
@ -133,6 +133,7 @@ export const ISSUE_LAYOUTS: {
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const ISSUE_LIST_FILTERS = [
|
export const ISSUE_LIST_FILTERS = [
|
||||||
|
{ key: "mentions", title: "Mentions"},
|
||||||
{ key: "priority", title: "Priority" },
|
{ key: "priority", title: "Priority" },
|
||||||
{ key: "state", title: "State" },
|
{ key: "state", title: "State" },
|
||||||
{ key: "assignees", title: "Assignees" },
|
{ key: "assignees", title: "Assignees" },
|
||||||
@ -324,7 +325,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
|
|||||||
},
|
},
|
||||||
issues: {
|
issues: {
|
||||||
list: {
|
list: {
|
||||||
filters: ["priority", "state", "assignees", "created_by", "labels", "start_date", "target_date"],
|
filters: ["priority", "state", "assignees", "mentions" ,"created_by", "labels", "start_date", "target_date"],
|
||||||
display_properties: true,
|
display_properties: true,
|
||||||
display_filters: {
|
display_filters: {
|
||||||
group_by: ["state", "priority", "labels", "assignees", "created_by", null],
|
group_by: ["state", "priority", "labels", "assignees", "created_by", null],
|
||||||
@ -337,7 +338,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
kanban: {
|
kanban: {
|
||||||
filters: ["priority", "state", "assignees", "created_by", "labels", "start_date", "target_date"],
|
filters: ["priority", "state", "assignees", "mentions" ,"created_by", "labels", "start_date", "target_date"],
|
||||||
display_properties: true,
|
display_properties: true,
|
||||||
display_filters: {
|
display_filters: {
|
||||||
group_by: ["state", "priority", "labels", "assignees", "created_by"],
|
group_by: ["state", "priority", "labels", "assignees", "created_by"],
|
||||||
@ -351,7 +352,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
calendar: {
|
calendar: {
|
||||||
filters: ["priority", "state", "assignees", "created_by", "labels", "start_date"],
|
filters: ["priority", "state", "assignees", "mentions" ,"created_by", "labels", "start_date"],
|
||||||
display_properties: true,
|
display_properties: true,
|
||||||
display_filters: {
|
display_filters: {
|
||||||
type: [null, "active", "backlog"],
|
type: [null, "active", "backlog"],
|
||||||
@ -362,7 +363,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
spreadsheet: {
|
spreadsheet: {
|
||||||
filters: ["priority", "state", "assignees", "created_by", "labels", "start_date", "target_date"],
|
filters: ["priority", "state", "assignees", "mentions" ,"created_by", "labels", "start_date", "target_date"],
|
||||||
display_properties: true,
|
display_properties: true,
|
||||||
display_filters: {
|
display_filters: {
|
||||||
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"],
|
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"],
|
||||||
@ -374,7 +375,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
gantt_chart: {
|
gantt_chart: {
|
||||||
filters: ["priority", "state", "assignees", "created_by", "labels", "start_date", "target_date"],
|
filters: ["priority", "state", "assignees", "mentions" ,"created_by", "labels", "start_date", "target_date"],
|
||||||
display_properties: false,
|
display_properties: false,
|
||||||
display_filters: {
|
display_filters: {
|
||||||
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"],
|
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"],
|
||||||
|
@ -51,6 +51,7 @@ export const initialState: StateType = {
|
|||||||
labels: null,
|
labels: null,
|
||||||
state: null,
|
state: null,
|
||||||
state_group: null,
|
state_group: null,
|
||||||
|
mentions: null,
|
||||||
subscriber: null,
|
subscriber: null,
|
||||||
created_by: null,
|
created_by: null,
|
||||||
start_date: null,
|
start_date: null,
|
||||||
|
19
web/hooks/use-editor-suggestions.tsx
Normal file
19
web/hooks/use-editor-suggestions.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { IMentionHighlight, IMentionSuggestion } from "@plane/rich-text-editor";
|
||||||
|
import useProjectMembers from "./use-project-members";
|
||||||
|
import useUser from "./use-user";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
import { RootStore } from "store/root";
|
||||||
|
|
||||||
|
const useEditorSuggestions = (
|
||||||
|
_workspaceSlug: string | undefined,
|
||||||
|
_projectId: string | undefined,
|
||||||
|
) => {
|
||||||
|
const { mentionsStore }: RootStore = useMobxStore()
|
||||||
|
|
||||||
|
return {
|
||||||
|
mentionSuggestions: mentionsStore.mentionSuggestions,
|
||||||
|
mentionHighlights: mentionsStore.mentionHighlights
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useEditorSuggestions;
|
@ -50,6 +50,7 @@ const useIssuesView = () => {
|
|||||||
order_by: displayFilters?.order_by,
|
order_by: displayFilters?.order_by,
|
||||||
group_by: displayFilters?.group_by,
|
group_by: displayFilters?.group_by,
|
||||||
assignees: filters?.assignees ? filters?.assignees.join(",") : undefined,
|
assignees: filters?.assignees ? filters?.assignees.join(",") : undefined,
|
||||||
|
mentions: filters?.mentions ? filters?.mentions.join(",") : undefined,
|
||||||
state: filters?.state ? filters?.state.join(",") : undefined,
|
state: filters?.state ? filters?.state.join(",") : undefined,
|
||||||
priority: filters?.priority ? filters?.priority.join(",") : undefined,
|
priority: filters?.priority ? filters?.priority.join(",") : undefined,
|
||||||
type: !isArchivedIssues ? (displayFilters?.type ? displayFilters?.type : undefined) : undefined,
|
type: !isArchivedIssues ? (displayFilters?.type ? displayFilters?.type : undefined) : undefined,
|
||||||
|
1
web/store/editor/index.ts
Normal file
1
web/store/editor/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./mentions.store"
|
45
web/store/editor/mentions.store.ts
Normal file
45
web/store/editor/mentions.store.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { IMentionHighlight, IMentionSuggestion } from "@plane/lite-text-editor";
|
||||||
|
import { RootStore } from "../root";
|
||||||
|
import { computed, makeObservable } from "mobx";
|
||||||
|
|
||||||
|
export interface IMentionsStore {
|
||||||
|
mentionSuggestions: IMentionSuggestion[];
|
||||||
|
mentionHighlights: IMentionHighlight[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MentionsStore implements IMentionsStore{
|
||||||
|
|
||||||
|
// root store
|
||||||
|
rootStore;
|
||||||
|
|
||||||
|
constructor(_rootStore: RootStore ){
|
||||||
|
|
||||||
|
// rootStore
|
||||||
|
this.rootStore = _rootStore;
|
||||||
|
|
||||||
|
makeObservable(this, {
|
||||||
|
mentionHighlights: computed,
|
||||||
|
mentionSuggestions: computed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get mentionSuggestions() {
|
||||||
|
const projectMembers = this.rootStore.project.projectMembers
|
||||||
|
|
||||||
|
const suggestions = projectMembers === null ? [] : projectMembers.map((member) => ({
|
||||||
|
id: member.member.id,
|
||||||
|
type: "User",
|
||||||
|
title: member.member.display_name,
|
||||||
|
subtitle: member.member.email ?? "",
|
||||||
|
avatar: member.member.avatar,
|
||||||
|
redirect_uri: `/${member.workspace.slug}/profile/${member.member.id}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return suggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
get mentionHighlights() {
|
||||||
|
const user = this.rootStore.user.currentUser;
|
||||||
|
return user ? [user.id] : []
|
||||||
|
}
|
||||||
|
}
|
@ -119,6 +119,7 @@ export class IssueFilterStore implements IIssueFilterStore {
|
|||||||
state_group: this.userFilters?.state_group || undefined,
|
state_group: this.userFilters?.state_group || undefined,
|
||||||
state: this.userFilters?.state || undefined,
|
state: this.userFilters?.state || undefined,
|
||||||
assignees: this.userFilters?.assignees || undefined,
|
assignees: this.userFilters?.assignees || undefined,
|
||||||
|
mentions: this.userFilters?.mentions || undefined,
|
||||||
created_by: this.userFilters?.created_by || undefined,
|
created_by: this.userFilters?.created_by || undefined,
|
||||||
labels: this.userFilters?.labels || undefined,
|
labels: this.userFilters?.labels || undefined,
|
||||||
start_date: this.userFilters?.start_date || undefined,
|
start_date: this.userFilters?.start_date || undefined,
|
||||||
|
@ -98,6 +98,11 @@ import {
|
|||||||
InboxStore,
|
InboxStore,
|
||||||
} from "store/inbox";
|
} from "store/inbox";
|
||||||
|
|
||||||
|
import {
|
||||||
|
IMentionsStore,
|
||||||
|
MentionsStore
|
||||||
|
} from "store/editor"
|
||||||
|
|
||||||
enableStaticRendering(typeof window === "undefined");
|
enableStaticRendering(typeof window === "undefined");
|
||||||
|
|
||||||
export class RootStore {
|
export class RootStore {
|
||||||
@ -159,6 +164,8 @@ export class RootStore {
|
|||||||
inboxIssueDetails: IInboxIssueDetailsStore;
|
inboxIssueDetails: IInboxIssueDetailsStore;
|
||||||
inboxFilters: IInboxFiltersStore;
|
inboxFilters: IInboxFiltersStore;
|
||||||
|
|
||||||
|
mentionsStore: IMentionsStore;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.commandPalette = new CommandPaletteStore(this);
|
this.commandPalette = new CommandPaletteStore(this);
|
||||||
this.user = new UserStore(this);
|
this.user = new UserStore(this);
|
||||||
@ -217,5 +224,7 @@ export class RootStore {
|
|||||||
this.inboxIssues = new InboxIssuesStore(this);
|
this.inboxIssues = new InboxIssuesStore(this);
|
||||||
this.inboxIssueDetails = new InboxIssueDetailsStore(this);
|
this.inboxIssueDetails = new InboxIssueDetailsStore(this);
|
||||||
this.inboxFilters = new InboxFiltersStore(this);
|
this.inboxFilters = new InboxFiltersStore(this);
|
||||||
|
|
||||||
|
this.mentionsStore = new MentionsStore(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -229,3 +229,27 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
|||||||
.ProseMirror table * .is-empty::before {
|
.ProseMirror table * .is-empty::before {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.items {
|
||||||
|
position: absolute;
|
||||||
|
max-height: 40vh;
|
||||||
|
background: rgb(var(--color-background-100));
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1);
|
||||||
|
color: rgb(var(--color-text-100));
|
||||||
|
font-size: 0.9rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.is-selected {
|
||||||
|
border-color: rgb(var(--color-border-200));
|
||||||
|
}
|
||||||
|
3
web/types/view-props.d.ts
vendored
3
web/types/view-props.d.ts
vendored
@ -8,6 +8,7 @@ export type TIssueGroupByOptions =
|
|||||||
| "state_detail.group"
|
| "state_detail.group"
|
||||||
| "project"
|
| "project"
|
||||||
| "assignees"
|
| "assignees"
|
||||||
|
| "mentions"
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
export type TIssueOrderByOptions =
|
export type TIssueOrderByOptions =
|
||||||
@ -40,6 +41,7 @@ export type TIssueParams =
|
|||||||
| "state_group"
|
| "state_group"
|
||||||
| "state"
|
| "state"
|
||||||
| "assignees"
|
| "assignees"
|
||||||
|
| "mentions"
|
||||||
| "created_by"
|
| "created_by"
|
||||||
| "subscriber"
|
| "subscriber"
|
||||||
| "labels"
|
| "labels"
|
||||||
@ -58,6 +60,7 @@ export type TCalendarLayouts = "month" | "week";
|
|||||||
|
|
||||||
export interface IIssueFilterOptions {
|
export interface IIssueFilterOptions {
|
||||||
assignees?: string[] | null;
|
assignees?: string[] | null;
|
||||||
|
mentions?: string[] | null;
|
||||||
created_by?: string[] | null;
|
created_by?: string[] | null;
|
||||||
labels?: string[] | null;
|
labels?: string[] | null;
|
||||||
priority?: string[] | null;
|
priority?: string[] | null;
|
||||||
|
@ -2460,6 +2460,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.1.12.tgz#3eb28dc998490a98f14765783770b3cf6587d39e"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.1.12.tgz#3eb28dc998490a98f14765783770b3cf6587d39e"
|
||||||
integrity sha512-Gk7hBFofAPmNQ8+uw8w5QSsZOMEGf7KQXJnx5B022YAUJTYYxO3jYVuzp34Drk9p+zNNIcXD4kc7ff5+nFOTrg==
|
integrity sha512-Gk7hBFofAPmNQ8+uw8w5QSsZOMEGf7KQXJnx5B022YAUJTYYxO3jYVuzp34Drk9p+zNNIcXD4kc7ff5+nFOTrg==
|
||||||
|
|
||||||
|
"@tiptap/extension-mention@^2.1.12":
|
||||||
|
version "2.1.12"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-mention/-/extension-mention-2.1.12.tgz#a395e7757b45630ec3047f14b0ba2dde8e1c9c93"
|
||||||
|
integrity sha512-Nc8wFlyPp+/48IpOFPk2O3hYsF465wizcM3aihMvZM96Ahic7dvv9yVptyOfoOwgpExl2FIn1QPjRDXF60VAUg==
|
||||||
|
|
||||||
"@tiptap/extension-ordered-list@^2.1.12":
|
"@tiptap/extension-ordered-list@^2.1.12":
|
||||||
version "2.1.12"
|
version "2.1.12"
|
||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.1.12.tgz#f41a45bc66b4d19e379d4833f303f2e0cd6b9d60"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.1.12.tgz#f41a45bc66b4d19e379d4833f303f2e0cd6b9d60"
|
||||||
@ -2582,7 +2587,7 @@
|
|||||||
"@tiptap/extension-strike" "^2.1.12"
|
"@tiptap/extension-strike" "^2.1.12"
|
||||||
"@tiptap/extension-text" "^2.1.12"
|
"@tiptap/extension-text" "^2.1.12"
|
||||||
|
|
||||||
"@tiptap/suggestion@^2.1.7":
|
"@tiptap/suggestion@^2.0.4", "@tiptap/suggestion@^2.1.7":
|
||||||
version "2.1.12"
|
version "2.1.12"
|
||||||
resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-2.1.12.tgz#a13782d1e625ec03b3f61b6839ecc95b6b685d3f"
|
resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-2.1.12.tgz#a13782d1e625ec03b3f61b6839ecc95b6b685d3f"
|
||||||
integrity sha512-rhlLWwVkOodBGRMK0mAmE34l2a+BqM2Y7q1ViuQRBhs/6sZ8d83O4hARHKVwqT5stY4i1l7d7PoemV3uAGI6+g==
|
integrity sha512-rhlLWwVkOodBGRMK0mAmE34l2a+BqM2Y7q1ViuQRBhs/6sZ8d83O4hARHKVwqT5stY4i1l7d7PoemV3uAGI6+g==
|
||||||
|
Loading…
Reference in New Issue
Block a user