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} +
: +
+ {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: ( +
+
+ {label.name} +
+ ), + 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 ( +
+
+ ( + ( +

" : 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/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)}

+
+
+
+
+ { + setValue("comment_json", comment_json); + setValue("comment_html", comment_html); + }} + mentionSuggestions={editorSuggestions.mentionSuggestions} + mentionHighlights={editorSuggestions.mentionHighlights} + /> +
+
+ + +
+
+
+ {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 ( + <> +
+ +
+ {isAllowed ? ( + ( +