Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] Enabled User @mentions in tiptap editor component #2352

Closed
wants to merge 21 commits into from
Closed
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
47edd17
feat: modified `issue_activity` background job for handling mentions
henit-chobisa Oct 2, 2023
fb8c339
feat: Extended Tiptap Mentions for adding type, id & label
henit-chobisa Oct 2, 2023
7f5abf7
feat: Intialized Mentions Extension
henit-chobisa Oct 2, 2023
7f8f80f
feat: added suggestion type to be used for passing suggestions
henit-chobisa Oct 2, 2023
101d8a8
feat: added suggestion controller & renderer
henit-chobisa Oct 2, 2023
7f9a38c
feat: added mentions in tiptap extensions
henit-chobisa Oct 2, 2023
d290715
feat: provided mentionSuggestion from issue creation form
henit-chobisa Oct 2, 2023
ea9f102
feat: added mention styles
henit-chobisa Oct 2, 2023
7058c11
chore: added tiptap-mention dependency in package.json
henit-chobisa Oct 2, 2023
d33045c
feat: modified notification-card to provide priviledge to preconstruc…
henit-chobisa Oct 2, 2023
58b0b17
feat: removed print log from issue_activities background job
henit-chobisa Oct 2, 2023
ae91d58
feat: added mention implementation on issue form
henit-chobisa Oct 3, 2023
8da9781
feat: added mention node custom implementation
henit-chobisa Oct 3, 2023
f5415c3
feat: added self and redirect uri in list component
henit-chobisa Oct 3, 2023
daeafee
feat: added tag based parsing in backend instead of class based
henit-chobisa Oct 3, 2023
26bc9d4
feat: removed saving self tag
henit-chobisa Oct 3, 2023
9ac4e7d
feat: added mention highlights and implementation
henit-chobisa Oct 3, 2023
29edc0c
feat: removed self tag from mentions
henit-chobisa Oct 3, 2023
2fec762
fix: created subscriber and mention notification together
henit-chobisa Oct 9, 2023
d7891ff
feat: added `issue_mention` table
henit-chobisa Oct 11, 2023
616561c
feat: commiting stashed changes
henit-chobisa Oct 18, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
392 changes: 268 additions & 124 deletions apiserver/plane/bgtasks/issue_activites_task.py

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions web/components/issues/description-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { TextArea } from "components/ui";
import { TipTapEditor } from "components/tiptap";
// types
import { IIssue } from "types";
import { IMentionSuggestion } from "components/tiptap/mentions/mentions";
import useProjectMembers from "hooks/use-project-members";
import useUser from "hooks/use-user";

export interface IssueDescriptionFormValues {
name: string;
Expand All @@ -20,6 +23,7 @@ export interface IssueDetailsProps {
issue: {
name: string;
description_html: string;
project_id?: string;
};
workspaceSlug: string;
handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>;
Expand All @@ -37,6 +41,21 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({

const { setShowAlert } = useReloadConfirmations();

const projectMembers = useProjectMembers( workspaceSlug, issue.project_id ).members
const user = useUser().user

const mentionSuggestions: IMentionSuggestion[] = !projectMembers ? [] : 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}`,
})
)

const mentionHighlights = user ? [ user.id ] : []

const {
handleSubmit,
watch,
Expand Down Expand Up @@ -142,6 +161,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
? "<p></p>"
: value
}
mentionSuggestions={mentionSuggestions}
mentionHighlights={mentionHighlights}
workspaceSlug={workspaceSlug}
debouncedUpdatesEnabled={true}
setShouldShowAlert={setShowAlert}
Expand Down
18 changes: 18 additions & 0 deletions web/components/issues/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import { TipTapEditor } from "components/tiptap";
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
// types
import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
import useProjectMembers from "hooks/use-project-members";
import { IMentionHighlight, IMentionSuggestion } from "components/tiptap/mentions/mentions";

const defaultValues: Partial<IIssue> = {
project: "",
Expand Down Expand Up @@ -109,6 +111,20 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
const router = useRouter();
const { workspaceSlug } = router.query;

const projectMembers = useProjectMembers( workspaceSlug as string | undefined, projectId ).members

const mentionSuggestions: IMentionSuggestion[] = !projectMembers ? [] : 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}`,
})
)

const mentionHighlights: IMentionHighlight[] = user ? [ user.id ] : []

const { setToastAlert } = useToast();

const {
Expand Down Expand Up @@ -381,6 +397,8 @@ export const IssueForm: FC<IssueFormProps> = (props) => {

return (
<TipTapEditor
mentionSuggestions={mentionSuggestions}
mentionHighlights={mentionHighlights}
workspaceSlug={workspaceSlug as string}
ref={editorRef}
debouncedUpdatesEnabled={false}
Expand Down
1 change: 1 addition & 0 deletions web/components/issues/peek-overview/issue-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const PeekOverviewIssueDetails: React.FC<Props> = ({
isAllowed={!readOnly}
issue={{
name: issue.name,
project_id: issue.project_detail.id,
description_html: issue.description_html,
}}
workspaceSlug={workspaceSlug}
Expand Down
8 changes: 6 additions & 2 deletions web/components/notifications/notification-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -135,7 +135,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">
Expand Down
4 changes: 4 additions & 0 deletions web/components/tiptap/extensions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ import { CustomTableCell } from "./table/table-cell";
import { Table } from "./table/table";
import { TableHeader } from "./table/table-header";
import { TableRow } from "@tiptap/extension-table-row";
import { Mentions } from "../mentions";
import { IMentionSuggestion } from "../mentions/mentions";

lowlight.registerLanguage("ts", ts);

export const TiptapExtensions = (
mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] },
workspaceSlug: string,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) => [
Expand Down Expand Up @@ -144,6 +147,7 @@ export const TiptapExtensions = (
}),
Table,
TableHeader,
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights),
CustomTableCell,
TableRow,
];
36 changes: 34 additions & 2 deletions web/components/tiptap/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { TiptapExtensions } from "./extensions";
import { TiptapEditorProps } from "./props";
import { ImageResizer } from "./extensions/image-resize";
import { TableMenu } from "./table-menu";
import { IMentionSuggestion } from "./mentions/mentions";

export interface ITipTapRichTextEditor {
value: string;
Expand All @@ -20,6 +21,8 @@ export interface ITipTapRichTextEditor {
workspaceSlug: string;
editable?: boolean;
forwardedRef?: any;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
debouncedUpdatesEnabled?: boolean;
}

Expand All @@ -34,16 +37,45 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
editorContentCustomClassNames,
value,
noBorder,
mentionHighlights,
mentionSuggestions,
workspaceSlug,
borderOnFocus,
customClassName,
} = props;

// const projectMembersAsSuggestions = useProjectMembers(workspaceSlug)

const editor = useEditor({
editable: editable ?? true,
editorProps: TiptapEditorProps(workspaceSlug, setIsSubmitting),
extensions: TiptapExtensions(workspaceSlug, setIsSubmitting),
extensions: TiptapExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}, workspaceSlug, setIsSubmitting),
content: value,
onCreate: async ({ editor }) => {
const jsonContent = editor.getJSON().content

// if json content is available for the editor
if (jsonContent){
// iterate through the content for finding out if we're dealing with a paragraph
const content = jsonContent.map((contentData) => {
if (contentData.type && contentData.type === 'paragraph' && contentData.content){
// iterate the nodes of the paragraph & mark the id of the mention
const paraContent = contentData.content.map((paraNode) => {
if (paraNode.type && paraNode.type === 'mention' && paraNode.attrs){
if (mentionHighlights && mentionHighlights.includes(paraNode.attrs.id)){
paraNode.attrs.self = true
}
}
return paraNode
})
contentData.content = paraContent
}
return contentData
})

editor.commands.setContent(content)
}
},
onUpdate: async ({ editor }) => {
// for instant feedback loop
setIsSubmitting?.("submitting");
Expand Down Expand Up @@ -85,7 +117,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => {

return (
<div
id="tiptap-container"
id="editor-container"
onClick={() => {
editor?.chain().focus().run();
}}
Expand Down
112 changes: 112 additions & 0 deletions web/components/tiptap/mentions/MentionList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { Editor } from '@tiptap/react';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from 'react'

import { IMentionSuggestion } from './mentions';

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 (

<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-sm text-muted-foreground grey">
{item.subtitle}
</p>
</div>
</div>
)
)
: <div className="item">No result</div>
}
</div>
)
})

MentionList.displayName = "MentionList"

export default MentionList
58 changes: 58 additions & 0 deletions web/components/tiptap/mentions/custom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Mention, MentionOptions } from '@tiptap/extension-mention'
import { mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import mentionNodeView from './mentionNodeView'
import { IMentionHighlight } from './mentions'
export interface CustomMentionOptions extends MentionOptions {
mentionHighlights: IMentionHighlight[]
}

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



Loading
Loading