From d511799f313880521ce6ace61024317bfb2b0fe3 Mon Sep 17 00:00:00 2001
From: Henit Chobisa
Date: Wed, 1 Nov 2023 16:36:37 +0530
Subject: [PATCH] [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
---
.../plane/bgtasks/issue_activites_task.py | 2 +
apiserver/plane/bgtasks/notification_task.py | 175 ++++++++++-
..._alter_analyticview_created_by_and_more.py | 3 +-
.../migrations/0047_issue_mention_and_more.py | 45 +++
apiserver/plane/db/models/__init__.py | 1 +
apiserver/plane/db/models/issue.py | 21 +-
apiserver/plane/utils/issue_filters.py | 12 +
apiserver/requirements/base.txt | 3 +-
packages/editor/core/package.json | 12 +-
.../core/src/types/mention-suggestion.ts | 10 +
.../editor/core/src/ui/extensions/index.tsx | 4 +
.../editor/core/src/ui/hooks/useEditor.tsx | 9 +-
.../core/src/ui/hooks/useReadOnlyEditor.tsx | 14 +-
packages/editor/core/src/ui/index.tsx | 4 +-
.../core/src/ui/mentions/MentionList.tsx | 111 +++++++
.../editor/core/src/ui/mentions/custom.tsx | 59 ++++
.../editor/core/src/ui/mentions/index.tsx | 15 +
.../core/src/ui/mentions/mentionNodeView.tsx | 32 ++
.../editor/core/src/ui/mentions/suggestion.ts | 59 ++++
.../core/src/ui/read-only/extensions.tsx | 7 +-
packages/editor/lite-text-editor/src/index.ts | 1 +
.../editor/lite-text-editor/src/ui/index.tsx | 16 +
packages/editor/rich-text-editor/src/index.ts | 1 +
.../editor/rich-text-editor/src/ui/index.tsx | 19 +-
.../tailwind-config-custom/tailwind.config.js | 2 +-
web/components/core/filters/filters-list.tsx | 27 +-
.../inbox/modals/create-issue-modal.tsx | 4 +-
web/components/issues/comment/add-comment.tsx | 7 +-
.../issues/comment/comment-card.tsx | 5 +
web/components/issues/description-form.tsx | 11 +-
web/components/issues/draft-issue-form.tsx | 5 +
web/components/issues/form.tsx | 5 +
.../filters/applied-filters/filters-list.tsx | 2 +-
.../header/filters/filters-selection.tsx | 26 ++
.../filters/header/filters/index.ts | 1 +
.../filters/header/filters/mentions.tsx | 68 +++++
.../activity/comment-card.tsx | 5 +
.../activity/comment-editor.tsx | 7 +-
.../issue-peek-overview/issue-detail.tsx | 16 +-
.../issues/peek-overview/issue-details.tsx | 1 +
.../notifications/notification-card.tsx | 8 +-
.../pages/create-update-block-inline.tsx | 5 +
web/components/pages/single-page-block.tsx | 5 +
.../profile/profile-issues-view.tsx | 1 +
web/components/views/select-filters.tsx | 278 ++++++++++++++++++
web/components/web-view/add-comment.tsx | 112 +++++++
web/components/web-view/comment-card.tsx | 193 ++++++++++++
.../web-view/issue-web-view-form.tsx | 158 ++++++++++
web/constants/fetch-keys.ts | 5 +-
web/constants/issue.ts | 11 +-
web/contexts/issue-view.context.tsx | 1 +
web/hooks/use-editor-suggestions.tsx | 19 ++
web/hooks/use-issues-view.tsx | 1 +
web/store/editor/index.ts | 1 +
web/store/editor/mentions.store.ts | 45 +++
web/store/issue/issue_filters.store.ts | 1 +
web/store/root.ts | 9 +
web/styles/editor.css | 24 ++
web/types/view-props.d.ts | 3 +
yarn.lock | 7 +-
60 files changed, 1662 insertions(+), 52 deletions(-)
create mode 100644 apiserver/plane/db/migrations/0047_issue_mention_and_more.py
create mode 100644 packages/editor/core/src/types/mention-suggestion.ts
create mode 100644 packages/editor/core/src/ui/mentions/MentionList.tsx
create mode 100644 packages/editor/core/src/ui/mentions/custom.tsx
create mode 100644 packages/editor/core/src/ui/mentions/index.tsx
create mode 100644 packages/editor/core/src/ui/mentions/mentionNodeView.tsx
create mode 100644 packages/editor/core/src/ui/mentions/suggestion.ts
create mode 100644 web/components/issues/issue-layouts/filters/header/filters/mentions.tsx
create mode 100644 web/components/views/select-filters.tsx
create mode 100644 web/components/web-view/add-comment.tsx
create mode 100644 web/components/web-view/comment-card.tsx
create mode 100644 web/components/web-view/issue-web-view-form.tsx
create mode 100644 web/hooks/use-editor-suggestions.tsx
create mode 100644 web/store/editor/index.ts
create mode 100644 web/store/editor/mentions.store.ts
diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py
index 162dccdcd9..03e9d09e91 100644
--- a/apiserver/plane/bgtasks/issue_activites_task.py
+++ b/apiserver/plane/bgtasks/issue_activites_task.py
@@ -1534,6 +1534,8 @@ def issue_activity(
IssueActivitySerializer(issue_activities_created, many=True).data,
cls=DjangoJSONEncoder,
),
+ requested_data=requested_data,
+ current_instance=current_instance
)
return
diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py
index f290a38c00..c95e941ef6 100644
--- a/apiserver/plane/bgtasks/notification_task.py
+++ b/apiserver/plane/bgtasks/notification_task.py
@@ -5,16 +5,98 @@ import json
from django.utils import timezone
# 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
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
-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 = (
- 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 [
"cycle.activity.created",
@@ -33,14 +115,35 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
]:
# Create 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(
- IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id)
- .exclude(subscriber_id=actor_id)
+ IssueSubscriber.objects.filter(
+ project_id=project_id, issue_id=issue_id)
+ .exclude(subscriber_id__in=list(new_mentions + [actor_id]))
.values_list("subscriber", flat=True)
)
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)
.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")),
"old_value": str(issue_activity.get("old_value")),
"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
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
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
diff --git a/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py
index 4890ec9d58..51082d14c7 100644
--- a/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py
+++ b/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py
@@ -5,7 +5,6 @@ from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.issue
-
class Migration(migrations.Migration):
dependencies = [
@@ -18,4 +17,4 @@ class Migration(migrations.Migration):
name='properties',
field=models.JSONField(default=plane.db.models.issue.get_default_properties),
),
- ]
+ ]
\ No newline at end of file
diff --git a/apiserver/plane/db/migrations/0047_issue_mention_and_more.py b/apiserver/plane/db/migrations/0047_issue_mention_and_more.py
new file mode 100644
index 0000000000..4300f1eb39
--- /dev/null
+++ b/apiserver/plane/db/migrations/0047_issue_mention_and_more.py
@@ -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',),
+ },
+ )
+ ]
diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py
index 9496b5906c..42f566baff 100644
--- a/apiserver/plane/db/models/__init__.py
+++ b/apiserver/plane/db/models/__init__.py
@@ -33,6 +33,7 @@ from .issue import (
Label,
IssueBlocker,
IssueRelation,
+ IssueMention,
IssueLink,
IssueSequence,
IssueAttachment,
diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py
index 9ba73fd433..0c227a158d 100644
--- a/apiserver/plane/db/models/issue.py
+++ b/apiserver/plane/db/models/issue.py
@@ -226,7 +226,26 @@ class IssueRelation(ProjectBaseModel):
ordering = ("-created_at",)
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):
diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py
index eaeb4553ec..cc466c847c 100644
--- a/apiserver/plane/utils/issue_filters.py
+++ b/apiserver/plane/utils/issue_filters.py
@@ -150,6 +150,17 @@ def filter_assignees(params, filter, method):
filter["assignees__in"] = params.get("assignees")
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):
if method == "GET":
@@ -326,6 +337,7 @@ def issue_filters(query_params, method):
"parent": filter_parent,
"labels": filter_labels,
"assignees": filter_assignees,
+ "mentions": filter_mentions,
"created_by": filter_created_by,
"name": filter_name,
"created_at": filter_created_at,
diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt
index 969ab3c893..249b29d48c 100644
--- a/apiserver/requirements/base.txt
+++ b/apiserver/requirements/base.txt
@@ -33,4 +33,5 @@ django_celery_beat==2.5.0
psycopg-binary==3.1.10
psycopg-c==3.1.10
scout-apm==2.26.1
-openpyxl==3.1.2
\ No newline at end of file
+openpyxl==3.1.2
+beautifulsoup4==4.12.2
\ No newline at end of file
diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json
index 2c35ead1c8..b6f4df8e59 100644
--- a/packages/editor/core/package.json
+++ b/packages/editor/core/package.json
@@ -21,18 +21,18 @@
"check-types": "tsc --noEmit"
},
"peerDependencies": {
- "react": "^18.2.0",
- "react-dom": "18.2.0",
"next": "12.3.2",
- "next-themes": "^0.2.1"
+ "next-themes": "^0.2.1",
+ "react": "^18.2.0",
+ "react-dom": "18.2.0"
},
"dependencies": {
- "react-moveable" : "^0.54.2",
"@blueprintjs/popover2": "^2.0.10",
"@tiptap/core": "^2.1.7",
"@tiptap/extension-color": "^2.1.11",
"@tiptap/extension-image": "^2.1.7",
"@tiptap/extension-link": "^2.1.7",
+ "@tiptap/extension-mention": "^2.1.12",
"@tiptap/extension-table": "^2.1.6",
"@tiptap/extension-table-cell": "^2.1.6",
"@tiptap/extension-table-header": "^2.1.6",
@@ -44,9 +44,10 @@
"@tiptap/pm": "^2.1.7",
"@tiptap/react": "^2.1.7",
"@tiptap/starter-kit": "^2.1.10",
+ "@tiptap/suggestion": "^2.0.4",
+ "@types/node": "18.15.3",
"@types/react": "^18.2.5",
"@types/react-dom": "18.0.11",
- "@types/node": "18.15.3",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"eslint": "8.36.0",
@@ -54,6 +55,7 @@
"eventsource-parser": "^0.1.0",
"lucide-react": "^0.244.0",
"react-markdown": "^8.0.7",
+ "react-moveable": "^0.54.2",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.2",
diff --git a/packages/editor/core/src/types/mention-suggestion.ts b/packages/editor/core/src/types/mention-suggestion.ts
new file mode 100644
index 0000000000..9c9ab76069
--- /dev/null
+++ b/packages/editor/core/src/types/mention-suggestion.ts
@@ -0,0 +1,10 @@
+export type IMentionSuggestion = {
+ id: string;
+ type: string;
+ avatar: string;
+ title: string;
+ subtitle: string;
+ redirect_uri: string;
+}
+
+export type IMentionHighlight = string
\ No newline at end of file
diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx
index 62d53e0e1d..9bf5f0d9b4 100644
--- a/packages/editor/core/src/ui/extensions/index.tsx
+++ b/packages/editor/core/src/ui/extensions/index.tsx
@@ -17,9 +17,12 @@ import ImageExtension from "./image";
import { DeleteImage } from "../../types/delete-image";
import { isValidHttpUrl } from "../../lib/utils";
+import { IMentionSuggestion } from "../../types/mention-suggestion";
+import { Mentions } from "../mentions";
export const CoreEditorExtensions = (
+ mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] },
deleteFile: DeleteImage,
) => [
StarterKit.configure({
@@ -94,4 +97,5 @@ export const CoreEditorExtensions = (
TableHeader,
CustomTableCell,
TableRow,
+ Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false),
];
diff --git a/packages/editor/core/src/ui/hooks/useEditor.tsx b/packages/editor/core/src/ui/hooks/useEditor.tsx
index f58c7964b6..9fcf200fb9 100644
--- a/packages/editor/core/src/ui/hooks/useEditor.tsx
+++ b/packages/editor/core/src/ui/hooks/useEditor.tsx
@@ -12,6 +12,7 @@ import { EditorProps } from "@tiptap/pm/view";
import { getTrimmedHTML } from "../../lib/utils";
import { UploadImage } from "../../types/upload-image";
import { useInitializedContent } from "./useInitializedContent";
+import { IMentionSuggestion } from "../../types/mention-suggestion";
interface CustomEditorProps {
uploadFile: UploadImage;
@@ -26,6 +27,8 @@ interface CustomEditorProps {
extensions?: any;
editorProps?: EditorProps;
forwardedRef?: any;
+ mentionHighlights?: string[];
+ mentionSuggestions?: IMentionSuggestion[];
}
export const useEditor = ({
@@ -38,6 +41,8 @@ export const useEditor = ({
setIsSubmitting,
forwardedRef,
setShouldShowAlert,
+ mentionHighlights,
+ mentionSuggestions
}: CustomEditorProps) => {
const editor = useCustomEditor(
{
@@ -45,7 +50,7 @@ export const useEditor = ({
...CoreEditorProps(uploadFile, setIsSubmitting),
...editorProps,
},
- extensions: [...CoreEditorExtensions(deleteFile), ...extensions],
+ extensions: [...CoreEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}, deleteFile), ...extensions],
content:
typeof value === "string" && value.trim() !== "" ? value : "",
onUpdate: async ({ editor }) => {
@@ -77,4 +82,4 @@ export const useEditor = ({
}
return editor;
-};
+};
\ No newline at end of file
diff --git a/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx b/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx
index 522cd94b83..9243c2f4e7 100644
--- a/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx
+++ b/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx
@@ -7,21 +7,19 @@ import {
} from "react";
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
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 {
value: string;
forwardedRef?: any;
extensions?: any;
editorProps?: EditorProps;
+ mentionHighlights?: string[];
+ mentionSuggestions?: IMentionSuggestion[];
}
-export const useReadOnlyEditor = ({
- value,
- forwardedRef,
- extensions = [],
- editorProps = {},
-}: CustomReadOnlyEditorProps) => {
+export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editorProps = {}, mentionHighlights, mentionSuggestions}: CustomReadOnlyEditorProps) => {
const editor = useCustomEditor({
editable: false,
content:
@@ -30,7 +28,7 @@ export const useReadOnlyEditor = ({
...CoreReadOnlyEditorProps,
...editorProps,
},
- extensions: [...CoreReadOnlyEditorExtensions, ...extensions],
+ extensions: [...CoreReadOnlyEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}), ...extensions],
});
const hasIntiliazedContent = useRef(false);
diff --git a/packages/editor/core/src/ui/index.tsx b/packages/editor/core/src/ui/index.tsx
index 3c64e8ba68..513bb106ee 100644
--- a/packages/editor/core/src/ui/index.tsx
+++ b/packages/editor/core/src/ui/index.tsx
@@ -8,6 +8,7 @@ import { EditorProps } from '@tiptap/pm/view';
import { useEditor } from './hooks/useEditor';
import { EditorContainer } from '../ui/components/editor-container';
import { EditorContentWrapper } from '../ui/components/editor-content';
+import { IMentionSuggestion } from '../types/mention-suggestion';
interface ICoreEditor {
value: string;
@@ -30,6 +31,8 @@ interface ICoreEditor {
key: string;
label: "Private" | "Public";
}[];
+ mentionHighlights?: string[];
+ mentionSuggestions?: IMentionSuggestion[];
extensions?: Extension[];
editorProps?: EditorProps;
}
@@ -61,7 +64,6 @@ const CoreEditor = ({
const editor = useEditor({
onChange,
debouncedUpdatesEnabled,
- editable,
setIsSubmitting,
setShouldShowAlert,
value,
diff --git a/packages/editor/core/src/ui/mentions/MentionList.tsx b/packages/editor/core/src/ui/mentions/MentionList.tsx
new file mode 100644
index 0000000000..0591b2421d
--- /dev/null
+++ b/packages/editor/core/src/ui/mentions/MentionList.tsx
@@ -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 ?
+ { props.items.length ? props.items.map((item, index) => (
+
selectItem(index)}>
+ {item.avatar ?
+

+
:
+
+ {item.title.charAt(0)}
+
+ }
+
+
{item.title}
+
+ {item.subtitle}
+
+
+
+ )
+ )
+ :
No result
+ }
+
: <>>
+ )
+})
+
+MentionList.displayName = "MentionList"
+
+export default MentionList
\ No newline at end of file
diff --git a/packages/editor/core/src/ui/mentions/custom.tsx b/packages/editor/core/src/ui/mentions/custom.tsx
new file mode 100644
index 0000000000..6ffb97af1a
--- /dev/null
+++ b/packages/editor/core/src/ui/mentions/custom.tsx
@@ -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({
+
+ 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)]
+ },
+})
+
+
+
diff --git a/packages/editor/core/src/ui/mentions/index.tsx b/packages/editor/core/src/ui/mentions/index.tsx
new file mode 100644
index 0000000000..ba1a9ed0b9
--- /dev/null
+++ b/packages/editor/core/src/ui/mentions/index.tsx
@@ -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),
+})
+
diff --git a/packages/editor/core/src/ui/mentions/mentionNodeView.tsx b/packages/editor/core/src/ui/mentions/mentionNodeView.tsx
new file mode 100644
index 0000000000..09fba3aa19
--- /dev/null
+++ b/packages/editor/core/src/ui/mentions/mentionNodeView.tsx
@@ -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 (
+
+ @{ props.node.attrs.label }
+
+ )
+}
+
+
diff --git a/packages/editor/core/src/ui/mentions/suggestion.ts b/packages/editor/core/src/ui/mentions/suggestion.ts
new file mode 100644
index 0000000000..56d3bfce39
--- /dev/null
+++ b/packages/editor/core/src/ui/mentions/suggestion.ts
@@ -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;
diff --git a/packages/editor/core/src/ui/read-only/extensions.tsx b/packages/editor/core/src/ui/read-only/extensions.tsx
index 2246c64f9f..6b326f951c 100644
--- a/packages/editor/core/src/ui/read-only/extensions.tsx
+++ b/packages/editor/core/src/ui/read-only/extensions.tsx
@@ -15,8 +15,12 @@ import { TableRow } from "@tiptap/extension-table-row";
import ReadOnlyImageExtension from "../extensions/image/read-only-image";
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({
bulletList: {
HTMLAttributes: {
@@ -89,4 +93,5 @@ export const CoreReadOnlyEditorExtensions = [
TableHeader,
CustomTableCell,
TableRow,
+ Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true),
];
diff --git a/packages/editor/lite-text-editor/src/index.ts b/packages/editor/lite-text-editor/src/index.ts
index de9323b3c5..392928ccff 100644
--- a/packages/editor/lite-text-editor/src/index.ts
+++ b/packages/editor/lite-text-editor/src/index.ts
@@ -1,2 +1,3 @@
export { LiteTextEditor, LiteTextEditorWithRef } from "./ui";
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "./ui/read-only";
+export type { IMentionSuggestion, IMentionHighlight } from "./ui"
diff --git a/packages/editor/lite-text-editor/src/ui/index.tsx b/packages/editor/lite-text-editor/src/ui/index.tsx
index 5b525d92be..041d7c6d73 100644
--- a/packages/editor/lite-text-editor/src/ui/index.tsx
+++ b/packages/editor/lite-text-editor/src/ui/index.tsx
@@ -11,6 +11,16 @@ import { LiteTextEditorExtensions } from "./extensions";
export type UploadImage = (file: File) => Promise;
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise;
+export type IMentionSuggestion = {
+ id: string;
+ type: string;
+ avatar: string;
+ title: string;
+ subtitle: string;
+ redirect_uri: string;
+}
+
+export type IMentionHighlight = string
interface ILiteTextEditor {
value: string;
@@ -38,6 +48,8 @@ interface ILiteTextEditor {
}[];
};
onEnterKeyPress?: (e?: any) => void;
+ mentionHighlights?: string[];
+ mentionSuggestions?: IMentionSuggestion[];
}
interface LiteTextEditorProps extends ILiteTextEditor {
@@ -64,6 +76,8 @@ const LiteTextEditor = ({
forwardedRef,
commentAccessSpecifier,
onEnterKeyPress,
+ mentionHighlights,
+ mentionSuggestions
}: LiteTextEditorProps) => {
const editor = useEditor({
onChange,
@@ -75,6 +89,8 @@ const LiteTextEditor = ({
deleteFile,
forwardedRef,
extensions: LiteTextEditorExtensions(onEnterKeyPress),
+ mentionHighlights,
+ mentionSuggestions
});
const editorClassNames = getEditorClassNames({
diff --git a/packages/editor/rich-text-editor/src/index.ts b/packages/editor/rich-text-editor/src/index.ts
index 36d0a95f90..e296a61717 100644
--- a/packages/editor/rich-text-editor/src/index.ts
+++ b/packages/editor/rich-text-editor/src/index.ts
@@ -2,3 +2,4 @@ import "./styles/github-dark.css";
export { RichTextEditor, RichTextEditorWithRef } from "./ui";
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "./ui/read-only";
+export type { IMentionSuggestion, IMentionHighlight } from "./ui"
diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx
index ce14962c80..a0dbe7226e 100644
--- a/packages/editor/rich-text-editor/src/ui/index.tsx
+++ b/packages/editor/rich-text-editor/src/ui/index.tsx
@@ -7,6 +7,17 @@ import { RichTextEditorExtensions } from './extensions';
export type UploadImage = (file: File) => Promise;
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise;
+export type IMentionSuggestion = {
+ id: string;
+ type: string;
+ avatar: string;
+ title: string;
+ subtitle: string;
+ redirect_uri: string;
+}
+
+export type IMentionHighlight = string
+
interface IRichTextEditor {
value: string;
uploadFile: UploadImage;
@@ -20,6 +31,8 @@ interface IRichTextEditor {
setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any;
debouncedUpdatesEnabled?: boolean;
+ mentionHighlights?: string[];
+ mentionSuggestions?: IMentionSuggestion[];
}
interface RichTextEditorProps extends IRichTextEditor {
@@ -44,6 +57,8 @@ const RichTextEditor = ({
borderOnFocus,
customClassName,
forwardedRef,
+ mentionHighlights,
+ mentionSuggestions
}: RichTextEditorProps) => {
const editor = useEditor({
onChange,
@@ -54,7 +69,9 @@ const RichTextEditor = ({
uploadFile,
deleteFile,
forwardedRef,
- extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting)
+ extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting),
+ mentionHighlights,
+ mentionSuggestions
});
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js
index b877dc7c0b..efc47b4c7e 100644
--- a/packages/tailwind-config-custom/tailwind.config.js
+++ b/packages/tailwind-config-custom/tailwind.config.js
@@ -12,7 +12,7 @@ module.exports = {
"./pages/**/*.tsx",
"./ui/**/*.tsx",
"../packages/ui/**/*.{js,ts,jsx,tsx}",
- "../packages/editor/**/*.{js,ts,jsx,tsx}",
+ "../packages/editor/**/src/**/*.{js,ts,jsx,tsx}",
],
},
theme: {
diff --git a/web/components/core/filters/filters-list.tsx b/web/components/core/filters/filters-list.tsx
index 4bc9daee64..acb307d2a0 100644
--- a/web/components/core/filters/filters-list.tsx
+++ b/web/components/core/filters/filters-list.tsx
@@ -136,6 +136,29 @@ export const FiltersList: React.FC = ({ filters, setFilters, clearAllFilt
))
+ : key === "mentions"
+ ? filters.mentions?.map((mentionId: string) => {
+ const member = members?.find((m) => m.id === mentionId);
+ return (
+
+
+
{member?.display_name}
+
+ setFilters({
+ mentions: filters.mentions?.filter((p: any) => p !== mentionId),
+ })
+ }
+ >
+
+
+
+ );
+ })
: key === "assignees"
? filters.assignees?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
@@ -145,7 +168,7 @@ export const FiltersList: React.FC = ({ filters, setFilters, clearAllFilt
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
-
+
{member?.display_name}
= ({ filters, setFilters, clearAllFilt
key={`${memberId}-${key}`}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
>
-
+
{member?.display_name}
= {
access: "INTERNAL",
@@ -49,7 +50,9 @@ export const AddComment: React.FC = ({ disabled = false, onSubmit, showAc
const editorRef = React.useRef(null);
const router = useRouter();
- const { workspaceSlug } = router.query;
+ const { workspaceSlug, projectId } = router.query;
+
+ const editorSuggestions = useEditorSuggestions(workspaceSlug as string | undefined, projectId as string | undefined)
const {
control,
@@ -90,6 +93,8 @@ export const AddComment: React.FC = ({ disabled = false, onSubmit, showAc
debouncedUpdatesEnabled={false}
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
commentAccessSpecifier={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }}
+ mentionSuggestions={editorSuggestions.mentionSuggestions}
+ mentionHighlights={editorSuggestions.mentionHighlights}
/>
)}
/>
diff --git a/web/components/issues/comment/comment-card.tsx b/web/components/issues/comment/comment-card.tsx
index 9281ee00d5..9adff158ef 100644
--- a/web/components/issues/comment/comment-card.tsx
+++ b/web/components/issues/comment/comment-card.tsx
@@ -15,6 +15,7 @@ import { LiteTextEditorWithRef, LiteReadOnlyEditorWithRef } from "@plane/lite-te
import { timeAgo } from "helpers/date-time.helper";
// types
import type { IIssueComment } from "types";
+import useEditorSuggestions from "hooks/use-editor-suggestions";
// services
const fileService = new FileService();
@@ -39,6 +40,8 @@ export const CommentCard: React.FC = ({
const editorRef = React.useRef(null);
const showEditorRef = React.useRef(null);
+ const editorSuggestions = useEditorSuggestions(workspaceSlug, comment.project_detail.id)
+
const [isEditing, setIsEditing] = useState(false);
const {
@@ -112,6 +115,8 @@ export const CommentCard: React.FC = ({
setValue("comment_json", comment_json);
setValue("comment_html", comment_html);
}}
+ mentionSuggestions={editorSuggestions.mentionSuggestions}
+ mentionHighlights={editorSuggestions.mentionHighlights}
/>
diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx
index 1887b8cd35..6dd569f8fd 100644
--- a/web/components/issues/description-form.tsx
+++ b/web/components/issues/description-form.tsx
@@ -10,6 +10,7 @@ import { RichTextEditor } from "@plane/rich-text-editor";
import { IIssue } from "types";
// services
import { FileService } from "services/file.service";
+import useEditorSuggestions from "hooks/use-editor-suggestions";
export interface IssueDescriptionFormValues {
name: string;
@@ -20,6 +21,7 @@ export interface IssueDetailsProps {
issue: {
name: string;
description_html: string;
+ project_id?: string;
};
workspaceSlug: string;
handleFormSubmit: (value: IssueDescriptionFormValues) => Promise
;
@@ -36,6 +38,8 @@ export const IssueDescriptionForm: FC = (props) => {
const { setShowAlert } = useReloadConfirmations();
+ const editorSuggestion = useEditorSuggestions(workspaceSlug, issue.project_id)
+
const {
handleSubmit,
watch,
@@ -154,13 +158,14 @@ export const IssueDescriptionForm: FC = (props) => {
onChange(description_html);
debouncedFormSave();
}}
+ mentionSuggestions={editorSuggestion.mentionSuggestions}
+ mentionHighlights={editorSuggestion.mentionHighlights}
/>
)}
/>
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
diff --git a/web/components/issues/draft-issue-form.tsx b/web/components/issues/draft-issue-form.tsx
index 6ba7ed3291..b923bae634 100644
--- a/web/components/issues/draft-issue-form.tsx
+++ b/web/components/issues/draft-issue-form.tsx
@@ -30,6 +30,7 @@ import { Sparkle, X } from "lucide-react";
import type { IUser, IIssue, ISearchIssueResponse } from "types";
// components
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
+import useEditorSuggestions from "hooks/use-editor-suggestions";
const aiService = new AIService();
const fileService = new FileService();
@@ -121,6 +122,8 @@ export const DraftIssueForm: FC = (props) => {
const { setToastAlert } = useToast();
+ const editorSuggestions = useEditorSuggestions(workspaceSlug as string | undefined, projectId)
+
const {
formState: { errors, isSubmitting },
handleSubmit,
@@ -436,6 +439,8 @@ export const DraftIssueForm: FC = (props) => {
onChange(description_html);
setValue("description", description);
}}
+ mentionHighlights={editorSuggestions.mentionHighlights}
+ mentionSuggestions={editorSuggestions.mentionSuggestions}
/>
)}
/>
diff --git a/web/components/issues/form.tsx b/web/components/issues/form.tsx
index 0d5f176e4b..7aff699f53 100644
--- a/web/components/issues/form.tsx
+++ b/web/components/issues/form.tsx
@@ -31,6 +31,7 @@ import { LayoutPanelTop, Sparkle, X } from "lucide-react";
import type { IIssue, ISearchIssueResponse } from "types";
// components
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
+import useEditorSuggestions from "hooks/use-editor-suggestions";
const defaultValues: Partial = {
project: "",
@@ -107,6 +108,8 @@ export const IssueForm: FC = observer((props) => {
const user = userStore.currentUser;
+ const editorSuggestion = useEditorSuggestions(workspaceSlug as string | undefined, projectId)
+
const { setToastAlert } = useToast();
const {
@@ -384,6 +387,8 @@ export const IssueForm: FC = observer((props) => {
onChange(description_html);
setValue("description", description);
}}
+ mentionHighlights={editorSuggestion.mentionHighlights}
+ mentionSuggestions={editorSuggestion.mentionSuggestions}
/>
)}
/>
diff --git a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx
index f379f675aa..2f98b67f4c 100644
--- a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx
+++ b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx
@@ -27,7 +27,7 @@ type Props = {
states?: IStateResponse | undefined;
};
-const membersFilters = ["assignees", "created_by", "subscriber"];
+const membersFilters = ["assignees", "mentions" ,"created_by", "subscriber"];
const dateFilters = ["start_date", "target_date"];
export const AppliedFiltersList: React.FC = observer((props) => {
diff --git a/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx b/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx
index 51857217e4..66ebf3a788 100644
--- a/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx
+++ b/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx
@@ -4,6 +4,7 @@ import { observer } from "mobx-react-lite";
// components
import {
FilterAssignees,
+ FilterMentions,
FilterCreatedBy,
FilterLabels,
FilterPriority,
@@ -73,6 +74,10 @@ export const FilterSelection: React.FC = observer((props) => {
currentLength: 5,
totalLength: members?.length ?? 0,
},
+ mentions: {
+ currentLength: 5,
+ totalLength: members?.length ?? 0,
+ },
created_by: {
currentLength: 5,
totalLength: members?.length ?? 0,
@@ -257,6 +262,27 @@ export const FilterSelection: React.FC = observer((props) => {
)}
+ {/* assignees */}
+ {isFilterEnabled("mentions") && (
+
+ handleFiltersUpdate("mentions", val)}
+ itemsToRender={filtersToRender.mentions?.currentLength ?? 0}
+ members={members}
+ searchQuery={filtersSearchQuery}
+ viewButtons={
+ handleViewLess("mentions")}
+ handleMore={() => handleViewMore("mentions")}
+ />
+ }
+ />
+
+ )}
+
{/* created_by */}
{isFilterEnabled("created_by") && (
diff --git a/web/components/issues/issue-layouts/filters/header/filters/index.ts b/web/components/issues/issue-layouts/filters/header/filters/index.ts
index 5c381709e8..2d3a04d0f7 100644
--- a/web/components/issues/issue-layouts/filters/header/filters/index.ts
+++ b/web/components/issues/issue-layouts/filters/header/filters/index.ts
@@ -1,4 +1,5 @@
export * from "./assignee";
+export * from "./mentions";
export * from "./created-by";
export * from "./filters-selection";
export * from "./labels";
diff --git a/web/components/issues/issue-layouts/filters/header/filters/mentions.tsx b/web/components/issues/issue-layouts/filters/header/filters/mentions.tsx
new file mode 100644
index 0000000000..34c84fd40b
--- /dev/null
+++ b/web/components/issues/issue-layouts/filters/header/filters/mentions.tsx
@@ -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) => {
+ 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 (
+ <>
+ 0 ? ` (${appliedFiltersCount})` : ""}`}
+ isPreviewEnabled={previewEnabled}
+ handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
+ />
+ {previewEnabled && (
+
+ {filteredOptions ? (
+ filteredOptions.length > 0 ? (
+ <>
+ {filteredOptions.slice(0, itemsToRender).map((member) => (
+
handleUpdate(member.id)}
+ icon={}
+ title={member.display_name}
+ />
+ ))}
+ {viewButtons}
+ >
+ ) : (
+ No matches found
+ )
+ ) : (
+
+
+
+
+
+ )}
+
+ )}
+ >
+ );
+};
\ No newline at end of file
diff --git a/web/components/issues/issue-peek-overview/activity/comment-card.tsx b/web/components/issues/issue-peek-overview/activity/comment-card.tsx
index 665a0a74a0..c47c0ed9a3 100644
--- a/web/components/issues/issue-peek-overview/activity/comment-card.tsx
+++ b/web/components/issues/issue-peek-overview/activity/comment-card.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
+import useEditorSuggestions from "hooks/use-editor-suggestions";
import { Check, Globe2, Lock, MessageSquare, Pencil, Trash2, X } from "lucide-react";
// services
import { FileService } from "services/file.service";
@@ -48,6 +49,8 @@ export const IssueCommentCard: React.FC = (props) => {
const [isEditing, setIsEditing] = useState(false);
+ const editorSuggestions = useEditorSuggestions(workspaceSlug, projectId)
+
const {
formState: { isSubmitting },
handleSubmit,
@@ -121,6 +124,8 @@ export const IssueCommentCard: React.FC = (props) => {
setValue("comment_json", comment_json);
setValue("comment_html", comment_html);
}}
+ mentionSuggestions={editorSuggestions.mentionSuggestions}
+ mentionHighlights={editorSuggestions.mentionHighlights}
/>
diff --git a/web/components/issues/issue-peek-overview/activity/comment-editor.tsx b/web/components/issues/issue-peek-overview/activity/comment-editor.tsx
index dabdc355f6..3855127626 100644
--- a/web/components/issues/issue-peek-overview/activity/comment-editor.tsx
+++ b/web/components/issues/issue-peek-overview/activity/comment-editor.tsx
@@ -12,6 +12,7 @@ import { Globe2, Lock } from "lucide-react";
// types
import type { IIssueComment } from "types";
+import useEditorSuggestions from "hooks/use-editor-suggestions";
const defaultValues: Partial
= {
access: "INTERNAL",
@@ -51,7 +52,9 @@ export const IssueCommentEditor: React.FC = (props) => {
const editorRef = React.useRef(null);
const router = useRouter();
- const { workspaceSlug } = router.query;
+ const { workspaceSlug, projectId } = router.query;
+
+ const editorSuggestions = useEditorSuggestions(workspaceSlug as string | undefined, projectId as string | undefined)
const {
control,
@@ -118,6 +121,8 @@ export const IssueCommentEditor: React.FC = (props) => {
value={!commentValue || commentValue === "" ? "" : 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 }}
/>
diff --git a/web/components/issues/issue-peek-overview/issue-detail.tsx b/web/components/issues/issue-peek-overview/issue-detail.tsx
index 82fb5ea428..8218c53e8f 100644
--- a/web/components/issues/issue-peek-overview/issue-detail.tsx
+++ b/web/components/issues/issue-peek-overview/issue-detail.tsx
@@ -8,10 +8,12 @@ import { IssueReaction } from "./reactions";
// hooks
import { useDebouncedCallback } from "use-debounce";
import useReloadConfirmations from "hooks/use-reload-confirmation";
+import useEditorSuggestions from "hooks/use-editor-suggestions";
// types
import { IIssue } from "types";
// services
import { FileService } from "services/file.service";
+import { useMobxStore } from "lib/mobx/store-provider";
const fileService = new FileService();
@@ -27,12 +29,16 @@ interface IPeekOverviewIssueDetails {
export const PeekOverviewIssueDetails: FC = (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 [characterLimit, setCharacterLimit] = useState(false);
-
+ // hooks
const { setShowAlert } = useReloadConfirmations();
+ const editorSuggestions = useEditorSuggestions(workspaceSlug, issue.project_detail.id);
+
const {
handleSubmit,
watch,
@@ -94,7 +100,7 @@ export const PeekOverviewIssueDetails: FC = (props) =
- {true ? (
+ {isAllowed ? (
= (props) =
debouncedIssueDescription(description_html);
}}
customClassName="mt-0"
+ mentionSuggestions={editorSuggestions.mentionSuggestions}
+ mentionHighlights={editorSuggestions.mentionHighlights}
/>
diff --git a/web/components/issues/peek-overview/issue-details.tsx b/web/components/issues/peek-overview/issue-details.tsx
index 5b9db8dbd5..317234fb01 100644
--- a/web/components/issues/peek-overview/issue-details.tsx
+++ b/web/components/issues/peek-overview/issue-details.tsx
@@ -26,6 +26,7 @@ export const PeekOverviewIssueDetails: React.FC = ({
issue={{
name: issue.name,
description_html: issue.description_html,
+ project_id: issue.project_detail.id,
}}
workspaceSlug={workspaceSlug}
/>
diff --git a/web/components/notifications/notification-card.tsx b/web/components/notifications/notification-card.tsx
index f8a2b17002..7518df92a5 100644
--- a/web/components/notifications/notification-card.tsx
+++ b/web/components/notifications/notification-card.tsx
@@ -92,7 +92,7 @@ export const NotificationCard: React.FC = (props) => {
)}
-
+ { !notification.message ?
{notification.triggered_by_details.is_bot
? notification.triggered_by_details.first_name
@@ -133,7 +133,11 @@ export const NotificationCard: React.FC = (props) => {
"the issue and assigned it to you."
)}
-
+
:
+
+ { notification.message }
+
+
}
diff --git a/web/components/pages/create-update-block-inline.tsx b/web/components/pages/create-update-block-inline.tsx
index 45d4635557..1d0da50185 100644
--- a/web/components/pages/create-update-block-inline.tsx
+++ b/web/components/pages/create-update-block-inline.tsx
@@ -18,6 +18,7 @@ import { RichTextEditorWithRef } from "@plane/rich-text-editor";
import { IUser, IPageBlock } from "types";
// fetch-keys
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
+import useEditorSuggestions from "hooks/use-editor-suggestions";
type Props = {
handleClose: () => void;
@@ -55,6 +56,8 @@ export const CreateUpdateBlockInline: FC = ({
const router = useRouter();
const { workspaceSlug, projectId, pageId } = router.query;
+ const editorSuggestion = useEditorSuggestions(workspaceSlug as string | undefined, projectId as string | undefined)
+
const { setToastAlert } = useToast();
const {
@@ -304,6 +307,8 @@ export const CreateUpdateBlockInline: FC = ({
onChange(description_html);
setValue("description", description);
}}
+ mentionHighlights={editorSuggestion.mentionHighlights}
+ mentionSuggestions={editorSuggestion.mentionSuggestions}
/>
);
else if (!value || !watch("description_html"))
diff --git a/web/components/pages/single-page-block.tsx b/web/components/pages/single-page-block.tsx
index 486984070b..32092c0362 100644
--- a/web/components/pages/single-page-block.tsx
+++ b/web/components/pages/single-page-block.tsx
@@ -26,6 +26,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
import { IUser, IIssue, IPageBlock, IProject } from "types";
// fetch-keys
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
+import useEditorSuggestions from "hooks/use-editor-suggestions";
type Props = {
block: IPageBlock;
@@ -63,6 +64,8 @@ export const SinglePageBlock: React.FC = ({ block, projectDetails, showBl
},
});
+ const editorSuggestion = useEditorSuggestions(workspaceSlug as string | undefined, projectId as string | undefined)
+
const updatePageBlock = async (formData: Partial) => {
if (!workspaceSlug || !projectId || !pageId) return;
@@ -423,6 +426,8 @@ export const SinglePageBlock: React.FC = ({ block, projectDetails, showBl
customClassName="text-sm min-h-[150px]"
noBorder
borderOnFocus={false}
+ mentionSuggestions={editorSuggestion.mentionSuggestions}
+ mentionHighlights={editorSuggestion.mentionHighlights}
/>
)
: block.description_stripped.length > 0 && (
diff --git a/web/components/profile/profile-issues-view.tsx b/web/components/profile/profile-issues-view.tsx
index 7c47235372..41daf4e88b 100644
--- a/web/components/profile/profile-issues-view.tsx
+++ b/web/components/profile/profile-issues-view.tsx
@@ -251,6 +251,7 @@ export const ProfileIssuesView = () => {
states={undefined}
clearAllFilters={() =>
setFilters({
+ mentions: null,
labels: null,
priority: null,
state_group: null,
diff --git a/web/components/views/select-filters.tsx b/web/components/views/select-filters.tsx
new file mode 100644
index 0000000000..8a5f9dd121
--- /dev/null
+++ b/web/components/views/select-filters.tsx
@@ -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;
+ 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 = ({ 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: (
+
+
+ {priority ?? "None"}
+
+ ),
+ 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: (
+
+
+ {state.name}
+
+ ),
+ 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: (
+
+
+ {member.member.display_name}
+
+ ),
+ 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: (
+
+
+ {member.member.display_name}
+
+ ),
+ 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: (
+
+
+ {member.member.display_name}
+
+ ),
+ 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: (
+
+ ),
+ 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: (
+
+ ),
+ },
+ ],
+ },
+ {
+ 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: (
+
+ ),
+ },
+ ],
+ },
+ ];
+ return (
+ <>
+
+ >
+ );
+};
diff --git a/web/components/web-view/add-comment.tsx b/web/components/web-view/add-comment.tsx
new file mode 100644
index 0000000000..ee1aee5ddb
--- /dev/null
+++ b/web/components/web-view/add-comment.tsx
@@ -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 = {
+ access: "INTERNAL",
+ comment_html: "",
+};
+
+type Props = {
+ disabled?: boolean;
+ onSubmit: (data: IIssueComment) => Promise;
+};
+
+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 = ({ disabled = false, onSubmit }) => {
+ const editorRef = React.useRef(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({ defaultValues });
+
+ const handleAddComment = async (formData: IIssueComment) => {
+ if (!formData.comment_html || isSubmitting) return;
+
+ await onSubmit(formData).then(() => {
+ reset(defaultValues);
+ editorRef.current?.clearEditor();
+ });
+ };
+
+ return (
+
+ );
+};
diff --git a/web/components/web-view/comment-card.tsx b/web/components/web-view/comment-card.tsx
new file mode 100644
index 0000000000..545cc63dc5
--- /dev/null
+++ b/web/components/web-view/comment-card.tsx
@@ -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) => void;
+ showAccessSpecifier?: boolean;
+ workspaceSlug: string;
+ disabled?: boolean;
+};
+
+// services
+const fileService = new FileService();
+
+export const CommentCard: React.FC = (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(null);
+ const showEditorRef = React.useRef(null);
+
+ const [isEditing, setIsEditing] = useState(false);
+
+ const {
+ formState: { isSubmitting },
+ handleSubmit,
+ setFocus,
+ watch,
+ setValue,
+ } = useForm({
+ defaultValues: comment,
+ });
+
+ const onEnter = (formData: Partial) => {
+ 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 (
+
+
+ {comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
+

+ ) : (
+
+ {comment.actor_detail.is_bot
+ ? comment.actor_detail.first_name.charAt(0)
+ : comment.actor_detail.display_name.charAt(0)}
+
+ )}
+
+
+
+
+
+
+
+
+ {comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name}
+
+
commented {timeAgo(comment.created_at)}
+
+
+
+
+ {showAccessSpecifier && (
+
+ {comment.access === "INTERNAL" ? : }
+
+ )}
+
+
+
+
+
+ {user?.id === comment.actor && !disabled && (
+
+ setIsEditing(true)} className="flex items-center gap-1">
+
+ Edit comment
+
+ {showAccessSpecifier && (
+ <>
+ {comment.access === "INTERNAL" ? (
+ onSubmit(comment.id, { access: "EXTERNAL" })}
+ className="flex items-center gap-1"
+ >
+
+ Switch to public comment
+
+ ) : (
+ onSubmit(comment.id, { access: "INTERNAL" })}
+ className="flex items-center gap-1"
+ >
+
+ Switch to private comment
+
+ )}
+ >
+ )}
+ {
+ handleCommentDeletion(comment.id);
+ }}
+ className="flex items-center gap-1"
+ >
+
+ Delete comment
+
+
+ )}
+
+ );
+};
diff --git a/web/components/web-view/issue-web-view-form.tsx b/web/components/web-view/issue-web-view-form.tsx
new file mode 100644
index 0000000000..e805886b4e
--- /dev/null
+++ b/web/components/web-view/issue-web-view-form.tsx
@@ -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) => Promise;
+ register: any;
+ control: any;
+ watch: any;
+ handleSubmit: any;
+};
+
+// services
+const fileService = new FileService();
+
+export const IssueWebViewForm: React.FC = (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) => {
+ if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return;
+
+ await submitChanges({
+ name: formData.name ?? "",
+ description_html: formData.description_html ?? "",
+ });
+ },
+ [submitChanges]
+ );
+
+ return (
+ <>
+
+
+
+
+
{
+ if(value==null)return <>>;
+ return "
+ : 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}
+ />
+ }}
+ />
+
+ {isSubmitting === "submitting" ? "Saving..." : "Saved"}
+
+
+
+ >
+ );
+};
diff --git a/web/constants/fetch-keys.ts b/web/constants/fetch-keys.ts
index fc1b5544de..9ee4f630d1 100644
--- a/web/constants/fetch-keys.ts
+++ b/web/constants/fetch-keys.ts
@@ -6,6 +6,7 @@ const paramsToKey = (params: any) => {
state,
state_group,
priority,
+ mentions,
assignees,
created_by,
labels,
@@ -22,6 +23,7 @@ const paramsToKey = (params: any) => {
let stateKey = state ? state.split(",") : [];
let stateGroupKey = state_group ? state_group.split(",") : [];
let priorityKey = priority ? priority.split(",") : [];
+ let mentionsKey = mentions ? mentions.split(",") : [];
let assigneesKey = assignees ? assignees.split(",") : [];
let createdByKey = created_by ? created_by.split(",") : [];
let labelsKey = labels ? labels.split(",") : [];
@@ -40,11 +42,12 @@ const paramsToKey = (params: any) => {
stateGroupKey = stateGroupKey.sort().join("_");
priorityKey = priorityKey.sort().join("_");
assigneesKey = assigneesKey.sort().join("_");
+ mentionsKey = mentionsKey.sort().join("_");
createdByKey = createdByKey.sort().join("_");
labelsKey = labelsKey.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) => {
diff --git a/web/constants/issue.ts b/web/constants/issue.ts
index 3f86482e10..292ea53216 100644
--- a/web/constants/issue.ts
+++ b/web/constants/issue.ts
@@ -133,6 +133,7 @@ export const ISSUE_LAYOUTS: {
];
export const ISSUE_LIST_FILTERS = [
+ { key: "mentions", title: "Mentions"},
{ key: "priority", title: "Priority" },
{ key: "state", title: "State" },
{ key: "assignees", title: "Assignees" },
@@ -324,7 +325,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
},
issues: {
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_filters: {
group_by: ["state", "priority", "labels", "assignees", "created_by", null],
@@ -337,7 +338,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
},
},
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_filters: {
group_by: ["state", "priority", "labels", "assignees", "created_by"],
@@ -351,7 +352,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
},
},
calendar: {
- filters: ["priority", "state", "assignees", "created_by", "labels", "start_date"],
+ filters: ["priority", "state", "assignees", "mentions" ,"created_by", "labels", "start_date"],
display_properties: true,
display_filters: {
type: [null, "active", "backlog"],
@@ -362,7 +363,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
},
},
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_filters: {
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"],
@@ -374,7 +375,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
},
},
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_filters: {
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"],
diff --git a/web/contexts/issue-view.context.tsx b/web/contexts/issue-view.context.tsx
index 9b3a32b701..7807523e44 100644
--- a/web/contexts/issue-view.context.tsx
+++ b/web/contexts/issue-view.context.tsx
@@ -51,6 +51,7 @@ export const initialState: StateType = {
labels: null,
state: null,
state_group: null,
+ mentions: null,
subscriber: null,
created_by: null,
start_date: null,
diff --git a/web/hooks/use-editor-suggestions.tsx b/web/hooks/use-editor-suggestions.tsx
new file mode 100644
index 0000000000..251f809e8a
--- /dev/null
+++ b/web/hooks/use-editor-suggestions.tsx
@@ -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;
\ No newline at end of file
diff --git a/web/hooks/use-issues-view.tsx b/web/hooks/use-issues-view.tsx
index fa5f26d4e0..148f8b7f79 100644
--- a/web/hooks/use-issues-view.tsx
+++ b/web/hooks/use-issues-view.tsx
@@ -50,6 +50,7 @@ const useIssuesView = () => {
order_by: displayFilters?.order_by,
group_by: displayFilters?.group_by,
assignees: filters?.assignees ? filters?.assignees.join(",") : undefined,
+ mentions: filters?.mentions ? filters?.mentions.join(",") : undefined,
state: filters?.state ? filters?.state.join(",") : undefined,
priority: filters?.priority ? filters?.priority.join(",") : undefined,
type: !isArchivedIssues ? (displayFilters?.type ? displayFilters?.type : undefined) : undefined,
diff --git a/web/store/editor/index.ts b/web/store/editor/index.ts
new file mode 100644
index 0000000000..ff3ce7a334
--- /dev/null
+++ b/web/store/editor/index.ts
@@ -0,0 +1 @@
+export * from "./mentions.store"
\ No newline at end of file
diff --git a/web/store/editor/mentions.store.ts b/web/store/editor/mentions.store.ts
new file mode 100644
index 0000000000..4bf1f45c3a
--- /dev/null
+++ b/web/store/editor/mentions.store.ts
@@ -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] : []
+ }
+}
\ No newline at end of file
diff --git a/web/store/issue/issue_filters.store.ts b/web/store/issue/issue_filters.store.ts
index eba03ce867..715484b4d5 100644
--- a/web/store/issue/issue_filters.store.ts
+++ b/web/store/issue/issue_filters.store.ts
@@ -119,6 +119,7 @@ export class IssueFilterStore implements IIssueFilterStore {
state_group: this.userFilters?.state_group || undefined,
state: this.userFilters?.state || undefined,
assignees: this.userFilters?.assignees || undefined,
+ mentions: this.userFilters?.mentions || undefined,
created_by: this.userFilters?.created_by || undefined,
labels: this.userFilters?.labels || undefined,
start_date: this.userFilters?.start_date || undefined,
diff --git a/web/store/root.ts b/web/store/root.ts
index 525d18e720..50c68e2a40 100644
--- a/web/store/root.ts
+++ b/web/store/root.ts
@@ -98,6 +98,11 @@ import {
InboxStore,
} from "store/inbox";
+import {
+ IMentionsStore,
+ MentionsStore
+} from "store/editor"
+
enableStaticRendering(typeof window === "undefined");
export class RootStore {
@@ -159,6 +164,8 @@ export class RootStore {
inboxIssueDetails: IInboxIssueDetailsStore;
inboxFilters: IInboxFiltersStore;
+ mentionsStore: IMentionsStore;
+
constructor() {
this.commandPalette = new CommandPaletteStore(this);
this.user = new UserStore(this);
@@ -217,5 +224,7 @@ export class RootStore {
this.inboxIssues = new InboxIssuesStore(this);
this.inboxIssueDetails = new InboxIssueDetailsStore(this);
this.inboxFilters = new InboxFiltersStore(this);
+
+ this.mentionsStore = new MentionsStore(this);
}
}
diff --git a/web/styles/editor.css b/web/styles/editor.css
index 85d881eeb4..338cccc40e 100644
--- a/web/styles/editor.css
+++ b/web/styles/editor.css
@@ -229,3 +229,27 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
.ProseMirror table * .is-empty::before {
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));
+}
diff --git a/web/types/view-props.d.ts b/web/types/view-props.d.ts
index a7e1ccda69..67884e5e59 100644
--- a/web/types/view-props.d.ts
+++ b/web/types/view-props.d.ts
@@ -8,6 +8,7 @@ export type TIssueGroupByOptions =
| "state_detail.group"
| "project"
| "assignees"
+ | "mentions"
| null;
export type TIssueOrderByOptions =
@@ -40,6 +41,7 @@ export type TIssueParams =
| "state_group"
| "state"
| "assignees"
+ | "mentions"
| "created_by"
| "subscriber"
| "labels"
@@ -58,6 +60,7 @@ export type TCalendarLayouts = "month" | "week";
export interface IIssueFilterOptions {
assignees?: string[] | null;
+ mentions?: string[] | null;
created_by?: string[] | null;
labels?: string[] | null;
priority?: string[] | null;
diff --git a/yarn.lock b/yarn.lock
index e1a3a4a85e..28068404f2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2460,6 +2460,11 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.1.12.tgz#3eb28dc998490a98f14765783770b3cf6587d39e"
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":
version "2.1.12"
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-text" "^2.1.12"
-"@tiptap/suggestion@^2.1.7":
+"@tiptap/suggestion@^2.0.4", "@tiptap/suggestion@^2.1.7":
version "2.1.12"
resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-2.1.12.tgz#a13782d1e625ec03b3f61b6839ecc95b6b685d3f"
integrity sha512-rhlLWwVkOodBGRMK0mAmE34l2a+BqM2Y7q1ViuQRBhs/6sZ8d83O4hARHKVwqT5stY4i1l7d7PoemV3uAGI6+g==