forked from github/plane
[FEATURE] Enabled User @mentions
and @mention-filters
in core editor package (#2544)
* feat: created custom mention component * feat: added mention suggestions and suggestion highlights * feat: created mention suggestion list for displaying mention suggestions * feat: created custom mention text component, for handling click event * feat: exposed mention component * feat: integrated and exposed `mentions` componenet with `editor-core` * feat: integrated mentions extension with the core editor package * feat: exposed suggestion types from mentions * feat: added `mention-suggestion` parameters in `r-t-e` and `l-t-e` * feat: added `IssueMention` model in apiserver models * chore: updated activities background job and added bs4 in requirements * feat: added mention removal logic in issue_activity * chore: exposed mention types from `r-t-e` and `l-t-e` * feat: integrated mentions in side peek view description form * feat: added mentions in issue modal form * feat: created custom react-hook for editor suggestions * feat: integrated mention suggestions block in RichTextEditor * feat: added `mentions` integration in `lite-text-editor` instances * fix: tailwind loading nodemodules from packages * feat: added styles for the mention suggestion list * fix: update module import to resolve build failure * feat: added mentions as an issue filter * feat: added UI Changes to Implement `mention` filters * feat: added `mentions` as a filter option in the header * feat: added mentions in the filter list options * feat: added mentions in default display filter options * feat: added filters in applied and issue params in store * feat: modified types for adding mentions as a filter option * feat: modified `notification-card` to display message when it exists in object * feat: rewrote user mention management upon the changes made in develop * chore: merged debounce PR with the current PR for tracing changes * fix: mentions_filters updated with the new setup * feat: updated requirements for bs4 * feat: modified `mentions-filter` to remove many to many dependency * feat: implemented list manipulation instead of for loop * feat: added readonly functionality in `read-only` editor core * feat: added UI Changes for read-only mode * feat: added mentions store in web Root Store * chore: renamed `use-editor-suggestions` hook * feat: UI Improvements for conditional highlights w.r.t readonly in mentionNode * fix: removed mentions from `filter_set` parameters * fix: minor merge fixes * fix: package lock updates --------- Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
parent
490e032ac6
commit
d511799f31
@ -1534,6 +1534,8 @@ def issue_activity(
|
||||
IssueActivitySerializer(issue_activities_created, many=True).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
requested_data=requested_data,
|
||||
current_instance=current_instance
|
||||
)
|
||||
|
||||
return
|
||||
|
@ -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)
|
||||
|
@ -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),
|
||||
),
|
||||
]
|
||||
]
|
45
apiserver/plane/db/migrations/0047_issue_mention_and_more.py
Normal file
45
apiserver/plane/db/migrations/0047_issue_mention_and_more.py
Normal file
@ -0,0 +1,45 @@
|
||||
# Generated by Django 4.2.5 on 2023-10-25 05:01
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0046_alter_analyticview_created_by_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="issue_mentions",
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(
|
||||
auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(
|
||||
auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4,
|
||||
editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('mention', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='issue_mention', to=settings.AUTH_USER_MODEL)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='issuemention_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='issue_mention', to='db.issue')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='project_issuemention', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='issuemention_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='workspace_issuemention', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'IssueMention',
|
||||
'verbose_name_plural': 'IssueMentions',
|
||||
'db_table': 'issue_mentions',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
)
|
||||
]
|
@ -33,6 +33,7 @@ from .issue import (
|
||||
Label,
|
||||
IssueBlocker,
|
||||
IssueRelation,
|
||||
IssueMention,
|
||||
IssueLink,
|
||||
IssueSequence,
|
||||
IssueAttachment,
|
||||
|
@ -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):
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
openpyxl==3.1.2
|
||||
beautifulsoup4==4.12.2
|
@ -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",
|
||||
|
10
packages/editor/core/src/types/mention-suggestion.ts
Normal file
10
packages/editor/core/src/types/mention-suggestion.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export type IMentionSuggestion = {
|
||||
id: string;
|
||||
type: string;
|
||||
avatar: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
redirect_uri: string;
|
||||
}
|
||||
|
||||
export type IMentionHighlight = string
|
@ -17,9 +17,12 @@ import ImageExtension from "./image";
|
||||
|
||||
import { DeleteImage } from "../../types/delete-image";
|
||||
import { 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),
|
||||
];
|
||||
|
@ -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 : "<p></p>",
|
||||
onUpdate: async ({ editor }) => {
|
||||
@ -77,4 +82,4 @@ export const useEditor = ({
|
||||
}
|
||||
|
||||
return editor;
|
||||
};
|
||||
};
|
@ -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);
|
||||
|
@ -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,
|
||||
|
111
packages/editor/core/src/ui/mentions/MentionList.tsx
Normal file
111
packages/editor/core/src/ui/mentions/MentionList.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { Editor } from '@tiptap/react';
|
||||
import React, {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
import { IMentionSuggestion } from '../../types/mention-suggestion';
|
||||
|
||||
interface MentionListProps {
|
||||
items: IMentionSuggestion[];
|
||||
command: (item: { id: string, label: string, target: string, redirect_uri: string }) => void;
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
const MentionList = forwardRef((props: MentionListProps, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const selectItem = (index: number) => {
|
||||
const item = props.items[index]
|
||||
|
||||
if (item) {
|
||||
props.command({ id: item.id, label: item.title, target: "users", redirect_uri: item.redirect_uri })
|
||||
}
|
||||
}
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex(((selectedIndex + props.items.length) - 1) % props.items.length)
|
||||
}
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % props.items.length)
|
||||
}
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [props.items])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
upHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
downHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
enterHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
}))
|
||||
|
||||
return (
|
||||
props.items && props.items.length !== 0 ? <div className="items">
|
||||
{ props.items.length ? props.items.map((item, index) => (
|
||||
<div className={`item ${index === selectedIndex ? 'is-selected' : ''} w-72 flex items-center p-3 rounded shadow-md`} onClick={() => selectItem(index)}>
|
||||
{item.avatar ? <div
|
||||
className={`rounded border-[0.5px] ${index ? "border-custom-border-200 bg-custom-background-100" : "border-transparent"
|
||||
}`}
|
||||
style={{
|
||||
height: "24px",
|
||||
width: "24px",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={item.avatar}
|
||||
className="absolute top-0 left-0 h-full w-full object-cover rounded"
|
||||
alt={item.title}
|
||||
/>
|
||||
</div> :
|
||||
<div
|
||||
className="grid place-items-center text-xs capitalize text-white rounded bg-gray-700 border-[0.5px] border-custom-border-200"
|
||||
style={{
|
||||
height: "24px",
|
||||
width: "24px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{item.title.charAt(0)}
|
||||
</div>
|
||||
}
|
||||
<div className="ml-7 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{item.title}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{item.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
: <div className="item">No result</div>
|
||||
}
|
||||
</div> : <></>
|
||||
)
|
||||
})
|
||||
|
||||
MentionList.displayName = "MentionList"
|
||||
|
||||
export default MentionList
|
59
packages/editor/core/src/ui/mentions/custom.tsx
Normal file
59
packages/editor/core/src/ui/mentions/custom.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { Mention, MentionOptions } from '@tiptap/extension-mention'
|
||||
import { mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import mentionNodeView from './mentionNodeView'
|
||||
import { IMentionHighlight } from '../../types/mention-suggestion'
|
||||
export interface CustomMentionOptions extends MentionOptions {
|
||||
mentionHighlights: IMentionHighlight[]
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export const CustomMention = Mention.extend<CustomMentionOptions>({
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
},
|
||||
label: {
|
||||
default: null,
|
||||
},
|
||||
target: {
|
||||
default: null,
|
||||
},
|
||||
self: {
|
||||
default: false
|
||||
},
|
||||
redirect_uri: {
|
||||
default: "/"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(mentionNodeView)
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{
|
||||
tag: 'mention-component',
|
||||
getAttrs: (node: string | HTMLElement) => {
|
||||
if (typeof node === 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: node.getAttribute('data-mention-id') || '',
|
||||
target: node.getAttribute('data-mention-target') || '',
|
||||
label: node.innerText.slice(1) || '',
|
||||
redirect_uri: node.getAttribute('redirect_uri')
|
||||
}
|
||||
},
|
||||
}]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['mention-component', mergeAttributes(HTMLAttributes)]
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
15
packages/editor/core/src/ui/mentions/index.tsx
Normal file
15
packages/editor/core/src/ui/mentions/index.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import suggestion from "./suggestion";
|
||||
import { CustomMention } from "./custom";
|
||||
import { IMentionHighlight, IMentionSuggestion } from "../../types/mention-suggestion";
|
||||
|
||||
export const Mentions = (mentionSuggestions: IMentionSuggestion[], mentionHighlights: IMentionHighlight[], readonly) => CustomMention.configure({
|
||||
HTMLAttributes: {
|
||||
'class' : "mention",
|
||||
},
|
||||
readonly: readonly,
|
||||
mentionHighlights: mentionHighlights,
|
||||
suggestion: suggestion(mentionSuggestions),
|
||||
})
|
||||
|
32
packages/editor/core/src/ui/mentions/mentionNodeView.tsx
Normal file
32
packages/editor/core/src/ui/mentions/mentionNodeView.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
/* eslint-disable react/display-name */
|
||||
// @ts-nocheck
|
||||
import { NodeViewWrapper } from '@tiptap/react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import React from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { IMentionHighlight } from '../../types/mention-suggestion'
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default props => {
|
||||
|
||||
const router = useRouter()
|
||||
const highlights = props.extension.options.mentionHighlights as IMentionHighlight[]
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.extension.options.readonly){
|
||||
router.push(props.node.attrs.redirect_uri)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="w-fit inline mention-component" >
|
||||
<span className={cn("px-1 py-0.5 inline rounded-md font-bold bg-custom-primary-500 mention", {
|
||||
"text-[#D9C942] bg-[#544D3B] hover:bg-[#544D3B]" : highlights ? highlights.includes(props.node.attrs.id) : false,
|
||||
"cursor-pointer" : !props.extension.options.readonly,
|
||||
"hover:bg-custom-primary-300" : !props.extension.options.readonly && !highlights.includes(props.node.attrs.id)
|
||||
})} onClick={handleClick} data-mention-target={props.node.attrs.target} data-mention-id={props.node.attrs.id}>@{ props.node.attrs.label }</span>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
|
59
packages/editor/core/src/ui/mentions/suggestion.ts
Normal file
59
packages/editor/core/src/ui/mentions/suggestion.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { ReactRenderer } from '@tiptap/react'
|
||||
import { Editor } from "@tiptap/core";
|
||||
import tippy from 'tippy.js'
|
||||
|
||||
import MentionList from './MentionList'
|
||||
import { IMentionSuggestion } from './mentions';
|
||||
|
||||
const Suggestion = (suggestions: IMentionSuggestion[]) => ({
|
||||
items: ({ query }: { query: string }) => suggestions.filter(suggestion => suggestion.title.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5),
|
||||
render: () => {
|
||||
let reactRenderer: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
reactRenderer = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.querySelector("#editor-container"),
|
||||
content: reactRenderer.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
reactRenderer?.updateProps(props)
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return reactRenderer?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
popup?.[0].destroy();
|
||||
reactRenderer?.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
export default Suggestion;
|
@ -15,8 +15,12 @@ import { TableRow } from "@tiptap/extension-table-row";
|
||||
|
||||
import ReadOnlyImageExtension from "../extensions/image/read-only-image";
|
||||
import { 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),
|
||||
];
|
||||
|
@ -1,2 +1,3 @@
|
||||
export { LiteTextEditor, LiteTextEditorWithRef } from "./ui";
|
||||
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "./ui/read-only";
|
||||
export type { IMentionSuggestion, IMentionHighlight } from "./ui"
|
||||
|
@ -11,6 +11,16 @@ import { LiteTextEditorExtensions } from "./extensions";
|
||||
|
||||
export type UploadImage = (file: File) => Promise<string>;
|
||||
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||
export type IMentionSuggestion = {
|
||||
id: string;
|
||||
type: string;
|
||||
avatar: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
redirect_uri: string;
|
||||
}
|
||||
|
||||
export type IMentionHighlight = string
|
||||
|
||||
interface ILiteTextEditor {
|
||||
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({
|
||||
|
@ -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"
|
||||
|
@ -7,6 +7,17 @@ import { RichTextEditorExtensions } from './extensions';
|
||||
export type UploadImage = (file: File) => Promise<string>;
|
||||
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||
|
||||
export type IMentionSuggestion = {
|
||||
id: string;
|
||||
type: string;
|
||||
avatar: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
redirect_uri: string;
|
||||
}
|
||||
|
||||
export type IMentionHighlight = string
|
||||
|
||||
interface IRichTextEditor {
|
||||
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 });
|
||||
|
@ -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: {
|
||||
|
@ -136,6 +136,29 @@ export const FiltersList: React.FC<Props> = ({ filters, setFilters, clearAllFilt
|
||||
</span>
|
||||
</p>
|
||||
))
|
||||
: key === "mentions"
|
||||
? filters.mentions?.map((mentionId: string) => {
|
||||
const member = members?.find((m) => m.id === mentionId);
|
||||
return (
|
||||
<div
|
||||
key={mentionId}
|
||||
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
|
||||
>
|
||||
<Avatar name={member?.display_name} src={member?.avatar} showTooltip={false} />
|
||||
<span>{member?.display_name}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
mentions: filters.mentions?.filter((p: any) => p !== mentionId),
|
||||
})
|
||||
}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: key === "assignees"
|
||||
? filters.assignees?.map((memberId: string) => {
|
||||
const member = members?.find((m) => m.id === memberId);
|
||||
@ -145,7 +168,7 @@ export const FiltersList: React.FC<Props> = ({ filters, setFilters, clearAllFilt
|
||||
key={memberId}
|
||||
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
|
||||
>
|
||||
<Avatar name={member?.display_name} src={member?.avatar} showTooltip={false} />
|
||||
<Avatar user={member} />
|
||||
<span>{member?.display_name}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
@ -169,7 +192,7 @@ export const FiltersList: React.FC<Props> = ({ filters, setFilters, clearAllFilt
|
||||
key={`${memberId}-${key}`}
|
||||
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
|
||||
>
|
||||
<Avatar name={member?.display_name} src={member?.avatar} />
|
||||
<Avatar name={member?.display_name} src={member?.avatar} showTooltip={false} />
|
||||
<span>{member?.display_name}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
|
@ -3,7 +3,7 @@ import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
|
||||
import { IMentionHighlight, IMentionSuggestion, RichTextEditorWithRef } from "@plane/rich-text-editor";
|
||||
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
@ -15,6 +15,8 @@ import { IssuePrioritySelect } from "components/issues/select";
|
||||
import { Button, Input, ToggleSwitch } from "@plane/ui";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
import useProjectMembers from "hooks/use-project-members";
|
||||
import useUser from "hooks/use-user";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
|
@ -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<IIssueComment> = {
|
||||
access: "INTERNAL",
|
||||
@ -49,7 +50,9 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit, showAc
|
||||
const editorRef = React.useRef<any>(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<Props> = ({ 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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -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<Props> = ({
|
||||
const editorRef = React.useRef<any>(null);
|
||||
const showEditorRef = React.useRef<any>(null);
|
||||
|
||||
const editorSuggestions = useEditorSuggestions(workspaceSlug, comment.project_detail.id)
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const {
|
||||
@ -112,6 +115,8 @@ export const CommentCard: React.FC<Props> = ({
|
||||
setValue("comment_json", comment_json);
|
||||
setValue("comment_html", comment_html);
|
||||
}}
|
||||
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 self-end">
|
||||
|
@ -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<void>;
|
||||
@ -36,6 +38,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
||||
|
||||
const { setShowAlert } = useReloadConfirmations();
|
||||
|
||||
const editorSuggestion = useEditorSuggestions(workspaceSlug, issue.project_id)
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
@ -154,13 +158,14 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
||||
onChange(description_html);
|
||||
debouncedFormSave();
|
||||
}}
|
||||
mentionSuggestions={editorSuggestion.mentionSuggestions}
|
||||
mentionHighlights={editorSuggestion.mentionHighlights}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
|
||||
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
|
||||
}`}
|
||||
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${isSubmitting === "saved" ? "fadeOut" : "fadeIn"
|
||||
}`}
|
||||
>
|
||||
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
|
||||
</div>
|
||||
|
@ -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<IssueFormProps> = (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<IssueFormProps> = (props) => {
|
||||
onChange(description_html);
|
||||
setValue("description", description);
|
||||
}}
|
||||
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -31,6 +31,7 @@ import { LayoutPanelTop, Sparkle, X } from "lucide-react";
|
||||
import type { IIssue, ISearchIssueResponse } from "types";
|
||||
// components
|
||||
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
|
||||
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||
|
||||
const defaultValues: Partial<IIssue> = {
|
||||
project: "",
|
||||
@ -107,6 +108,8 @@ export const IssueForm: FC<IssueFormProps> = 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<IssueFormProps> = observer((props) => {
|
||||
onChange(description_html);
|
||||
setValue("description", description);
|
||||
}}
|
||||
mentionHighlights={editorSuggestion.mentionHighlights}
|
||||
mentionSuggestions={editorSuggestion.mentionSuggestions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -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<Props> = observer((props) => {
|
||||
|
@ -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<Props> = 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<Props> = observer((props) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* assignees */}
|
||||
{isFilterEnabled("mentions") && (
|
||||
<div className="py-2">
|
||||
<FilterMentions
|
||||
appliedFilters={filters.mentions ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("mentions", val)}
|
||||
itemsToRender={filtersToRender.mentions?.currentLength ?? 0}
|
||||
members={members}
|
||||
searchQuery={filtersSearchQuery}
|
||||
viewButtons={
|
||||
<ViewButtons
|
||||
isViewLessVisible={isViewLessVisible("mentions")}
|
||||
isViewMoreVisible={isViewMoreVisible("mentions")}
|
||||
handleLess={() => handleViewLess("mentions")}
|
||||
handleMore={() => handleViewMore("mentions")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* created_by */}
|
||||
{isFilterEnabled("created_by") && (
|
||||
<div className="py-2">
|
||||
|
@ -1,4 +1,5 @@
|
||||
export * from "./assignee";
|
||||
export * from "./mentions";
|
||||
export * from "./created-by";
|
||||
export * from "./filters-selection";
|
||||
export * from "./labels";
|
||||
|
@ -0,0 +1,68 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
// components
|
||||
import { FilterHeader, FilterOption } from "components/issues";
|
||||
// ui
|
||||
import { Avatar } from "components/ui";
|
||||
import { Loader } from "@plane/ui";
|
||||
// types
|
||||
import { IUserLite } from "types";
|
||||
|
||||
type Props = {
|
||||
appliedFilters: string[] | null;
|
||||
handleUpdate: (val: string) => void;
|
||||
itemsToRender: number;
|
||||
members: IUserLite[] | undefined;
|
||||
searchQuery: string;
|
||||
viewButtons: React.ReactNode;
|
||||
};
|
||||
|
||||
export const FilterMentions: React.FC<Props> = (props) => {
|
||||
const { appliedFilters, handleUpdate, itemsToRender, members, searchQuery, viewButtons } = props;
|
||||
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
|
||||
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||
|
||||
const filteredOptions = members?.filter((member) =>
|
||||
member.display_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
title={`Mention${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||
isPreviewEnabled={previewEnabled}
|
||||
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||
/>
|
||||
{previewEnabled && (
|
||||
<div>
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
<>
|
||||
{filteredOptions.slice(0, itemsToRender).map((member) => (
|
||||
<FilterOption
|
||||
key={`mentions-${member.id}`}
|
||||
isChecked={appliedFilters?.includes(member.id) ? true : false}
|
||||
onClick={() => handleUpdate(member.id)}
|
||||
icon={<Avatar user={member} height="18px" width="18px" />}
|
||||
title={member.display_name}
|
||||
/>
|
||||
))}
|
||||
{viewButtons}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-custom-text-400 italic">No matches found</p>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-2">
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { 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<IIssueCommentCard> = (props) => {
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const editorSuggestions = useEditorSuggestions(workspaceSlug, projectId)
|
||||
|
||||
const {
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
@ -121,6 +124,8 @@ export const IssueCommentCard: React.FC<IIssueCommentCard> = (props) => {
|
||||
setValue("comment_json", comment_json);
|
||||
setValue("comment_html", comment_html);
|
||||
}}
|
||||
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 self-end">
|
||||
|
@ -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<IIssueComment> = {
|
||||
access: "INTERNAL",
|
||||
@ -51,7 +52,9 @@ export const IssueCommentEditor: React.FC<IIssueCommentEditor> = (props) => {
|
||||
const editorRef = React.useRef<any>(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<IIssueCommentEditor> = (props) => {
|
||||
value={!commentValue || commentValue === "" ? "<p></p>" : commentValue}
|
||||
customClassName="p-3 min-h-[100px] shadow-sm"
|
||||
debouncedUpdatesEnabled={false}
|
||||
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
|
||||
commentAccessSpecifier={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }}
|
||||
/>
|
||||
|
@ -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<IPeekOverviewIssueDetails> = (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<IPeekOverviewIssueDetails> = (props) =
|
||||
</span>
|
||||
|
||||
<div className="relative">
|
||||
{true ? (
|
||||
{isAllowed ? (
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
@ -143,6 +149,8 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
|
||||
debouncedIssueDescription(description_html);
|
||||
}}
|
||||
customClassName="mt-0"
|
||||
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||
/>
|
||||
</span>
|
||||
|
||||
|
@ -26,6 +26,7 @@ export const PeekOverviewIssueDetails: React.FC<Props> = ({
|
||||
issue={{
|
||||
name: issue.name,
|
||||
description_html: issue.description_html,
|
||||
project_id: issue.project_detail.id,
|
||||
}}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
|
@ -92,7 +92,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2.5 w-full overflow-hidden">
|
||||
<div className="text-sm w-full break-words">
|
||||
{ !notification.message ? <div className="text-sm w-full break-words">
|
||||
<span className="font-semibold">
|
||||
{notification.triggered_by_details.is_bot
|
||||
? notification.triggered_by_details.first_name
|
||||
@ -133,7 +133,11 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
"the issue and assigned it to you."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div> : <div className="text-sm w-full break-words">
|
||||
<span className="semi-bold">
|
||||
{ notification.message }
|
||||
</span>
|
||||
</div> }
|
||||
|
||||
<div className="flex justify-between gap-2 text-xs">
|
||||
<p className="text-custom-text-300">
|
||||
|
@ -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<Props> = ({
|
||||
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<Props> = ({
|
||||
onChange(description_html);
|
||||
setValue("description", description);
|
||||
}}
|
||||
mentionHighlights={editorSuggestion.mentionHighlights}
|
||||
mentionSuggestions={editorSuggestion.mentionSuggestions}
|
||||
/>
|
||||
);
|
||||
else if (!value || !watch("description_html"))
|
||||
|
@ -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<Props> = ({ block, projectDetails, showBl
|
||||
},
|
||||
});
|
||||
|
||||
const editorSuggestion = useEditorSuggestions(workspaceSlug as string | undefined, projectId as string | undefined)
|
||||
|
||||
const updatePageBlock = async (formData: Partial<IPageBlock>) => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
@ -423,6 +426,8 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails, showBl
|
||||
customClassName="text-sm min-h-[150px]"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
mentionSuggestions={editorSuggestion.mentionSuggestions}
|
||||
mentionHighlights={editorSuggestion.mentionHighlights}
|
||||
/>
|
||||
)
|
||||
: block.description_stripped.length > 0 && (
|
||||
|
@ -251,6 +251,7 @@ export const ProfileIssuesView = () => {
|
||||
states={undefined}
|
||||
clearAllFilters={() =>
|
||||
setFilters({
|
||||
mentions: null,
|
||||
labels: null,
|
||||
priority: null,
|
||||
state_group: null,
|
||||
|
278
web/components/views/select-filters.tsx
Normal file
278
web/components/views/select-filters.tsx
Normal file
@ -0,0 +1,278 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
// services
|
||||
import { ProjectStateService, ProjectService } from "services/project";
|
||||
import { IssueLabelService } from "services/issue";
|
||||
// ui
|
||||
import { Avatar, MultiLevelDropdown } from "components/ui";
|
||||
// icons
|
||||
import { PriorityIcon, StateGroupIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
|
||||
// types
|
||||
import { IIssueFilterOptions } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATES_LIST } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { PRIORITIES } from "constants/project";
|
||||
import { DATE_FILTER_OPTIONS } from "constants/filters";
|
||||
|
||||
type Props = {
|
||||
filters: Partial<IIssueFilterOptions>;
|
||||
onSelect: (option: any) => void;
|
||||
direction?: "left" | "right";
|
||||
height?: "sm" | "md" | "rg" | "lg";
|
||||
};
|
||||
|
||||
const projectService = new ProjectService();
|
||||
const projectStateService = new ProjectStateService();
|
||||
const issueLabelService = new IssueLabelService();
|
||||
|
||||
export const SelectFilters: React.FC<Props> = ({ filters, onSelect, direction = "right", height = "md" }) => {
|
||||
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
|
||||
const [dateFilterType, setDateFilterType] = useState<{
|
||||
title: string;
|
||||
type: "start_date" | "target_date";
|
||||
}>({
|
||||
title: "",
|
||||
type: "start_date",
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: states } = useSWR(
|
||||
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectStateService.getStates(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
const statesList = getStatesList(states);
|
||||
|
||||
const { data: members } = useSWR(
|
||||
projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.fetchProjectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: issueLabels } = useSWR(
|
||||
projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => issueLabelService.getProjectIssueLabels(workspaceSlug as string, projectId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const projectFilterOption = [
|
||||
{
|
||||
id: "priority",
|
||||
label: "Priority",
|
||||
value: PRIORITIES,
|
||||
hasChildren: true,
|
||||
children: PRIORITIES.map((priority) => ({
|
||||
id: priority === null ? "null" : priority,
|
||||
label: (
|
||||
<div className="flex items-center gap-2 capitalize">
|
||||
<PriorityIcon priority={priority} />
|
||||
{priority ?? "None"}
|
||||
</div>
|
||||
),
|
||||
value: {
|
||||
key: "priority",
|
||||
value: priority === null ? "null" : priority,
|
||||
},
|
||||
selected: filters?.priority?.includes(priority === null ? "null" : priority),
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: "State",
|
||||
value: statesList,
|
||||
hasChildren: true,
|
||||
children: statesList?.map((state) => ({
|
||||
id: state.id,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} />
|
||||
{state.name}
|
||||
</div>
|
||||
),
|
||||
value: {
|
||||
key: "state",
|
||||
value: state.id,
|
||||
},
|
||||
selected: filters?.state?.includes(state.id),
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "assignees",
|
||||
label: "Assignees",
|
||||
value: members,
|
||||
hasChildren: true,
|
||||
children: members?.map((member) => ({
|
||||
id: member.member.id,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar user={member.member} />
|
||||
{member.member.display_name}
|
||||
</div>
|
||||
),
|
||||
value: {
|
||||
key: "assignees",
|
||||
value: member.member.id,
|
||||
},
|
||||
selected: filters?.assignees?.includes(member.member.id),
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "mentions",
|
||||
label: "Mentions",
|
||||
value: members,
|
||||
hasChildren: true,
|
||||
children: members?.map((member) => ({
|
||||
id: member.member.id,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar user={member.member} />
|
||||
{member.member.display_name}
|
||||
</div>
|
||||
),
|
||||
value: {
|
||||
key: "mentions",
|
||||
value: member.member.id,
|
||||
},
|
||||
selected: filters?.mentions?.includes(member.member.id),
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "created_by",
|
||||
label: "Created by",
|
||||
value: members,
|
||||
hasChildren: true,
|
||||
children: members?.map((member) => ({
|
||||
id: member.member.id,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar user={member.member} />
|
||||
{member.member.display_name}
|
||||
</div>
|
||||
),
|
||||
value: {
|
||||
key: "created_by",
|
||||
value: member.member.id,
|
||||
},
|
||||
selected: filters?.created_by?.includes(member.member.id),
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "labels",
|
||||
label: "Labels",
|
||||
value: issueLabels,
|
||||
hasChildren: true,
|
||||
children: issueLabels?.map((label) => ({
|
||||
id: label.id,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color && label.color !== "" ? label.color : "#000000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</div>
|
||||
),
|
||||
value: {
|
||||
key: "labels",
|
||||
value: label.id,
|
||||
},
|
||||
selected: filters?.labels?.includes(label.id),
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "start_date",
|
||||
label: "Start date",
|
||||
value: DATE_FILTER_OPTIONS,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
...DATE_FILTER_OPTIONS.map((option) => ({
|
||||
id: option.name,
|
||||
label: option.name,
|
||||
value: {
|
||||
key: "start_date",
|
||||
value: option.value,
|
||||
},
|
||||
selected: checkIfArraysHaveSameElements(filters?.start_date ?? [], [option.value]),
|
||||
})),
|
||||
{
|
||||
id: "custom",
|
||||
label: "Custom",
|
||||
value: "custom",
|
||||
element: (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDateFilterModalOpen(true);
|
||||
setDateFilterType({
|
||||
title: "Start date",
|
||||
type: "start_date",
|
||||
});
|
||||
}}
|
||||
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "target_date",
|
||||
label: "Due date",
|
||||
value: DATE_FILTER_OPTIONS,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
...DATE_FILTER_OPTIONS.map((option) => ({
|
||||
id: option.name,
|
||||
label: option.name,
|
||||
value: {
|
||||
key: "target_date",
|
||||
value: option.value,
|
||||
},
|
||||
selected: checkIfArraysHaveSameElements(filters?.target_date ?? [], [option.value]),
|
||||
})),
|
||||
{
|
||||
id: "custom",
|
||||
label: "Custom",
|
||||
value: "custom",
|
||||
element: (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDateFilterModalOpen(true);
|
||||
setDateFilterType({
|
||||
title: "Due date",
|
||||
type: "target_date",
|
||||
});
|
||||
}}
|
||||
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<MultiLevelDropdown
|
||||
label="Filters"
|
||||
onSelect={onSelect}
|
||||
direction={direction}
|
||||
height={height}
|
||||
options={projectFilterOption}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
112
web/components/web-view/add-comment.tsx
Normal file
112
web/components/web-view/add-comment.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import React from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
// hooks
|
||||
import useProjectDetails from "hooks/use-project-details";
|
||||
// components
|
||||
import { LiteTextEditorWithRef } from "@plane/lite-text-editor";
|
||||
import { Button } from "@plane/ui";
|
||||
// icons
|
||||
import { Send } from "lucide-react";
|
||||
// types
|
||||
import type { IIssueComment } from "types";
|
||||
// services
|
||||
import { FileService } from "services/file.service";
|
||||
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||
|
||||
const defaultValues: Partial<IIssueComment> = {
|
||||
access: "INTERNAL",
|
||||
comment_html: "",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
onSubmit: (data: IIssueComment) => Promise<void>;
|
||||
};
|
||||
|
||||
type commentAccessType = {
|
||||
icon: string;
|
||||
key: string;
|
||||
label: "Private" | "Public";
|
||||
};
|
||||
|
||||
const commentAccess: commentAccessType[] = [
|
||||
{
|
||||
icon: "lock",
|
||||
key: "INTERNAL",
|
||||
label: "Private",
|
||||
},
|
||||
{
|
||||
icon: "public",
|
||||
key: "EXTERNAL",
|
||||
label: "Public",
|
||||
},
|
||||
];
|
||||
|
||||
const fileService = new FileService();
|
||||
|
||||
export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit }) => {
|
||||
const editorRef = React.useRef<any>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { projectDetails } = useProjectDetails();
|
||||
|
||||
const editorSuggestions = useEditorSuggestions(workspaceSlug as string | undefined, projectDetails?.id)
|
||||
|
||||
const showAccessSpecifier = projectDetails?.is_deployed || false;
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = useForm<IIssueComment>({ defaultValues });
|
||||
|
||||
const handleAddComment = async (formData: IIssueComment) => {
|
||||
if (!formData.comment_html || isSubmitting) return;
|
||||
|
||||
await onSubmit(formData).then(() => {
|
||||
reset(defaultValues);
|
||||
editorRef.current?.clearEditor();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="w-full flex gap-x-2" onSubmit={handleSubmit(handleAddComment)}>
|
||||
<div className="relative flex-grow">
|
||||
<Controller
|
||||
name="access"
|
||||
control={control}
|
||||
render={({ field: { onChange: onAccessChange, value: accessValue } }) => (
|
||||
<Controller
|
||||
name="comment_html"
|
||||
control={control}
|
||||
render={({ field: { onChange: onCommentChange, value: commentValue } }) => (
|
||||
<LiteTextEditorWithRef
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||
deleteFile={fileService.deleteImage}
|
||||
ref={editorRef}
|
||||
value={!commentValue || commentValue === "" ? "<p></p>" : commentValue}
|
||||
customClassName="p-3 min-h-[100px] shadow-sm"
|
||||
debouncedUpdatesEnabled={false}
|
||||
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
|
||||
commentAccessSpecifier={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="inline">
|
||||
<Button variant="primary" type="submit" disabled={isSubmitting || disabled}>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
193
web/components/web-view/comment-card.tsx
Normal file
193
web/components/web-view/comment-card.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// icons
|
||||
import { Check, Globe2, Lock, MessageSquare, Pencil, Trash2, X } from "lucide-react";
|
||||
// service
|
||||
import { FileService } from "services/file.service";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { CommentReaction } from "components/issues";
|
||||
import { LiteTextEditorWithRef, LiteReadOnlyEditorWithRef } from "@plane/lite-text-editor";
|
||||
|
||||
// helpers
|
||||
import { timeAgo } from "helpers/date-time.helper";
|
||||
// types
|
||||
import type { IIssueComment } from "types";
|
||||
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||
|
||||
type Props = {
|
||||
comment: IIssueComment;
|
||||
handleCommentDeletion: (comment: string) => void;
|
||||
onSubmit: (commentId: string, data: Partial<IIssueComment>) => void;
|
||||
showAccessSpecifier?: boolean;
|
||||
workspaceSlug: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
// services
|
||||
const fileService = new FileService();
|
||||
|
||||
export const CommentCard: React.FC<Props> = (props) => {
|
||||
const { comment, handleCommentDeletion, onSubmit, showAccessSpecifier = false, workspaceSlug, disabled } = props;
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const editorSuggestions = useEditorSuggestions(workspaceSlug, comment.project_detail.id)
|
||||
|
||||
const editorRef = React.useRef<any>(null);
|
||||
const showEditorRef = React.useRef<any>(null);
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const {
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
setFocus,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<IIssueComment>({
|
||||
defaultValues: comment,
|
||||
});
|
||||
|
||||
const onEnter = (formData: Partial<IIssueComment>) => {
|
||||
if (isSubmitting) return;
|
||||
setIsEditing(false);
|
||||
|
||||
onSubmit(comment.id, formData);
|
||||
|
||||
editorRef.current?.setEditorValue(formData.comment_html);
|
||||
showEditorRef.current?.setEditorValue(formData.comment_html);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
isEditing && setFocus("comment");
|
||||
}, [isEditing, setFocus]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-start space-x-3">
|
||||
<div className="relative px-1">
|
||||
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
|
||||
<img
|
||||
src={comment.actor_detail.avatar}
|
||||
alt={
|
||||
comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name
|
||||
}
|
||||
height={30}
|
||||
width={30}
|
||||
className="grid h-7 w-7 place-items-center rounded-full border-2 border-custom-border-200"
|
||||
/>
|
||||
) : (
|
||||
<div className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}>
|
||||
{comment.actor_detail.is_bot
|
||||
? comment.actor_detail.first_name.charAt(0)
|
||||
: comment.actor_detail.display_name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="absolute -bottom-0.5 -right-1 rounded-tl bg-custom-background-80 px-0.5 py-px">
|
||||
<MessageSquare className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div>
|
||||
<div className="text-xs">
|
||||
{comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-custom-text-200">commented {timeAgo(comment.created_at)}</p>
|
||||
</div>
|
||||
<div className="issue-comments-section p-0">
|
||||
<form className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}>
|
||||
<div>
|
||||
<LiteTextEditorWithRef
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||
deleteFile={fileService.deleteImage}
|
||||
onEnterKeyPress={handleSubmit(onEnter)}
|
||||
ref={editorRef}
|
||||
value={watch("comment_html")}
|
||||
debouncedUpdatesEnabled={false}
|
||||
customClassName="min-h-[50px] p-3 shadow-sm"
|
||||
onChange={(comment_json: Object, comment_html: string) => {
|
||||
setValue("comment_json", comment_json);
|
||||
setValue("comment_html", comment_html);
|
||||
}}
|
||||
mentionSuggestions={editorSuggestions.mentionSuggestions}
|
||||
mentionHighlights={editorSuggestions.mentionHighlights}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 self-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || disabled}
|
||||
className="group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 hover:bg-green-500"
|
||||
>
|
||||
<Check className="h-3 w-3 text-green-500 duration-300 group-hover:text-white" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
<X className="h-3 w-3 text-red-500 duration-300 group-hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className={`relative ${isEditing ? "hidden" : ""}`}>
|
||||
{showAccessSpecifier && (
|
||||
<div className="absolute top-1 right-1.5 z-[1] text-custom-text-300">
|
||||
{comment.access === "INTERNAL" ? <Lock className="h-3 w-3" /> : <Globe2 className="h-3 w-3" />}
|
||||
</div>
|
||||
)}
|
||||
<LiteReadOnlyEditorWithRef
|
||||
ref={showEditorRef}
|
||||
value={comment.comment_html}
|
||||
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||
/>
|
||||
<CommentReaction readonly={disabled} projectId={comment.project} commentId={comment.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{user?.id === comment.actor && !disabled && (
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => setIsEditing(true)} className="flex items-center gap-1">
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit comment
|
||||
</CustomMenu.MenuItem>
|
||||
{showAccessSpecifier && (
|
||||
<>
|
||||
{comment.access === "INTERNAL" ? (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => onSubmit(comment.id, { access: "EXTERNAL" })}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Globe2 className="h-3 w-3" />
|
||||
Switch to public comment
|
||||
</CustomMenu.MenuItem>
|
||||
) : (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => onSubmit(comment.id, { access: "INTERNAL" })}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Lock className="h-3 w-3" />
|
||||
Switch to private comment
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
handleCommentDeletion(comment.id);
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete comment
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
158
web/components/web-view/issue-web-view-form.tsx
Normal file
158
web/components/web-view/issue-web-view-form.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
// services
|
||||
import { FileService } from "services/file.service";
|
||||
// hooks
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
||||
// ui
|
||||
import { TextArea } from "@plane/ui";
|
||||
// components
|
||||
import { RichTextEditor } from "@plane/rich-text-editor";
|
||||
import { Label } from "components/web-view";
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||
|
||||
type Props = {
|
||||
isAllowed: boolean;
|
||||
issueDetails: IIssue;
|
||||
submitChanges: (data: Partial<IIssue>) => Promise<void>;
|
||||
register: any;
|
||||
control: any;
|
||||
watch: any;
|
||||
handleSubmit: any;
|
||||
};
|
||||
|
||||
// services
|
||||
const fileService = new FileService();
|
||||
|
||||
export const IssueWebViewForm: React.FC<Props> = (props) => {
|
||||
const { isAllowed, issueDetails, submitChanges, control, watch, handleSubmit } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const [characterLimit, setCharacterLimit] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||
|
||||
const { setShowAlert } = useReloadConfirmations();
|
||||
|
||||
const editorSuggestion = useEditorSuggestions(workspaceSlug as string | undefined, issueDetails.project_detail.id)
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting === "submitted") {
|
||||
setShowAlert(false);
|
||||
setTimeout(async () => {
|
||||
setIsSubmitting("saved");
|
||||
}, 2000);
|
||||
} else if (isSubmitting === "submitting") {
|
||||
setShowAlert(true);
|
||||
}
|
||||
}, [isSubmitting, setShowAlert]);
|
||||
|
||||
const debouncedTitleSave = useDebouncedCallback(async () => {
|
||||
setTimeout(async () => {
|
||||
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
|
||||
}, 500);
|
||||
}, 1000);
|
||||
|
||||
const handleDescriptionFormSubmit = useCallback(
|
||||
async (formData: Partial<IIssue>) => {
|
||||
if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return;
|
||||
|
||||
await submitChanges({
|
||||
name: formData.name ?? "",
|
||||
description_html: formData.description_html ?? "<p></p>",
|
||||
});
|
||||
},
|
||||
[submitChanges]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<Label>Title</Label>
|
||||
<div className="relative">
|
||||
{isAllowed ? (
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<TextArea
|
||||
id="name"
|
||||
name="name"
|
||||
value={value}
|
||||
placeholder="Enter issue name"
|
||||
onFocus={() => setCharacterLimit(true)}
|
||||
onChange={() => {
|
||||
setCharacterLimit(false);
|
||||
setIsSubmitting("submitting");
|
||||
debouncedTitleSave();
|
||||
}}
|
||||
required={true}
|
||||
className="min-h-10 block w-full resize-none overflow-hidden rounded border bg-transparent px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-custom-primary"
|
||||
role="textbox"
|
||||
disabled={!isAllowed}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<h4 className="break-words text-2xl font-semibold">{issueDetails?.name}</h4>
|
||||
)}
|
||||
{characterLimit && isAllowed && (
|
||||
<div className="pointer-events-none absolute bottom-1 right-1 z-[2] rounded bg-custom-background-100 text-custom-text-200 p-0.5 text-xs">
|
||||
<span className={`${watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""}`}>
|
||||
{watch("name").length}
|
||||
</span>
|
||||
/255
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<div className="relative">
|
||||
<Controller
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
if(value==null)return <></>;
|
||||
return <RichTextEditor
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||
deleteFile={fileService.deleteImage}
|
||||
value={
|
||||
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)
|
||||
? "<p></p>"
|
||||
: value
|
||||
}
|
||||
debouncedUpdatesEnabled={true}
|
||||
setShouldShowAlert={setShowAlert}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
customClassName={isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200"}
|
||||
noBorder={!isAllowed}
|
||||
onChange={(description: Object, description_html: string) => {
|
||||
setShowAlert(true);
|
||||
setIsSubmitting("submitting");
|
||||
onChange(description_html);
|
||||
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
|
||||
}}
|
||||
mentionSuggestions={editorSuggestion.mentionSuggestions}
|
||||
mentionHighlights={editorSuggestion.mentionHighlights}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
|
||||
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
|
||||
}`}
|
||||
>
|
||||
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
@ -6,6 +6,7 @@ const paramsToKey = (params: any) => {
|
||||
state,
|
||||
state_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) => {
|
||||
|
@ -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"],
|
||||
|
@ -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,
|
||||
|
19
web/hooks/use-editor-suggestions.tsx
Normal file
19
web/hooks/use-editor-suggestions.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { IMentionHighlight, IMentionSuggestion } from "@plane/rich-text-editor";
|
||||
import useProjectMembers from "./use-project-members";
|
||||
import useUser from "./use-user";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { RootStore } from "store/root";
|
||||
|
||||
const useEditorSuggestions = (
|
||||
_workspaceSlug: string | undefined,
|
||||
_projectId: string | undefined,
|
||||
) => {
|
||||
const { mentionsStore }: RootStore = useMobxStore()
|
||||
|
||||
return {
|
||||
mentionSuggestions: mentionsStore.mentionSuggestions,
|
||||
mentionHighlights: mentionsStore.mentionHighlights
|
||||
}
|
||||
};
|
||||
|
||||
export default useEditorSuggestions;
|
@ -50,6 +50,7 @@ const useIssuesView = () => {
|
||||
order_by: displayFilters?.order_by,
|
||||
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,
|
||||
|
1
web/store/editor/index.ts
Normal file
1
web/store/editor/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./mentions.store"
|
45
web/store/editor/mentions.store.ts
Normal file
45
web/store/editor/mentions.store.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { IMentionHighlight, IMentionSuggestion } from "@plane/lite-text-editor";
|
||||
import { RootStore } from "../root";
|
||||
import { computed, makeObservable } from "mobx";
|
||||
|
||||
export interface IMentionsStore {
|
||||
mentionSuggestions: IMentionSuggestion[];
|
||||
mentionHighlights: IMentionHighlight[];
|
||||
}
|
||||
|
||||
export class MentionsStore implements IMentionsStore{
|
||||
|
||||
// root store
|
||||
rootStore;
|
||||
|
||||
constructor(_rootStore: RootStore ){
|
||||
|
||||
// rootStore
|
||||
this.rootStore = _rootStore;
|
||||
|
||||
makeObservable(this, {
|
||||
mentionHighlights: computed,
|
||||
mentionSuggestions: computed
|
||||
})
|
||||
}
|
||||
|
||||
get mentionSuggestions() {
|
||||
const projectMembers = this.rootStore.project.projectMembers
|
||||
|
||||
const suggestions = projectMembers === null ? [] : projectMembers.map((member) => ({
|
||||
id: member.member.id,
|
||||
type: "User",
|
||||
title: member.member.display_name,
|
||||
subtitle: member.member.email ?? "",
|
||||
avatar: member.member.avatar,
|
||||
redirect_uri: `/${member.workspace.slug}/profile/${member.member.id}`,
|
||||
}))
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
get mentionHighlights() {
|
||||
const user = this.rootStore.user.currentUser;
|
||||
return user ? [user.id] : []
|
||||
}
|
||||
}
|
@ -119,6 +119,7 @@ export class IssueFilterStore implements IIssueFilterStore {
|
||||
state_group: this.userFilters?.state_group || undefined,
|
||||
state: 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,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
|
3
web/types/view-props.d.ts
vendored
3
web/types/view-props.d.ts
vendored
@ -8,6 +8,7 @@ export type TIssueGroupByOptions =
|
||||
| "state_detail.group"
|
||||
| "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;
|
||||
|
@ -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==
|
||||
|
Loading…
Reference in New Issue
Block a user