[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:
Henit Chobisa 2023-11-01 16:36:37 +05:30 committed by GitHub
parent 490e032ac6
commit d511799f31
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 1662 additions and 52 deletions

View File

@ -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

View File

@ -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)

View File

@ -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),
),
]
]

View 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',),
},
)
]

View File

@ -33,6 +33,7 @@ from .issue import (
Label,
IssueBlocker,
IssueRelation,
IssueMention,
IssueLink,
IssueSequence,
IssueAttachment,

View File

@ -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):

View File

@ -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,

View File

@ -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

View File

@ -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",

View 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

View File

@ -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),
];

View File

@ -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;
};
};

View File

@ -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);

View File

@ -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,

View 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

View 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)]
},
})

View 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),
})

View 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>
)
}

View 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;

View File

@ -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),
];

View File

@ -1,2 +1,3 @@
export { LiteTextEditor, LiteTextEditorWithRef } from "./ui";
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "./ui/read-only";
export type { IMentionSuggestion, IMentionHighlight } from "./ui"

View File

@ -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({

View File

@ -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"

View File

@ -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 });

View File

@ -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: {

View File

@ -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"

View File

@ -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;

View File

@ -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}
/>
)}
/>

View File

@ -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">

View File

@ -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>

View File

@ -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}
/>
)}
/>

View File

@ -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}
/>
)}
/>

View File

@ -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) => {

View File

@ -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">

View File

@ -1,4 +1,5 @@
export * from "./assignee";
export * from "./mentions";
export * from "./created-by";
export * from "./filters-selection";
export * from "./labels";

View File

@ -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>
)}
</>
);
};

View File

@ -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">

View File

@ -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 }}
/>

View File

@ -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>

View File

@ -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}
/>

View File

@ -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">

View File

@ -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"))

View File

@ -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 && (

View File

@ -251,6 +251,7 @@ export const ProfileIssuesView = () => {
states={undefined}
clearAllFilters={() =>
setFilters({
mentions: null,
labels: null,
priority: null,
state_group: null,

View 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}
/>
</>
);
};

View 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>
);
};

View 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>
);
};

View 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>
</>
);
};

View File

@ -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) => {

View File

@ -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"],

View File

@ -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,

View 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;

View File

@ -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,

View File

@ -0,0 +1 @@
export * from "./mentions.store"

View 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] : []
}
}

View File

@ -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,

View File

@ -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);
}
}

View File

@ -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));
}

View File

@ -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;

View File

@ -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==