refactor: issue embed (#174)

* refactor: add args to useIssueEmbed hook

* chore: embed serach endpoint

* chore: optimised the query type

* chore: serach response change

* chore: project issue search filter

* refactor: issue embed

* fix: issue embed range error

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
Aaryan Khandelwal
2024-04-24 17:22:01 +05:30
committed by GitHub
parent 924a693c26
commit c23a5146b9
35 changed files with 731 additions and 595 deletions

View File

@@ -4,6 +4,7 @@ from django.urls import path
from plane.app.views import (
GlobalSearchEndpoint,
IssueSearchEndpoint,
SearchEndpoint,
)
@@ -18,4 +19,9 @@ urlpatterns = [
IssueSearchEndpoint.as_view(),
name="project-issue-search",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/search/",
SearchEndpoint.as_view(),
name="search",
),
]

View File

@@ -194,7 +194,7 @@ from .page.base import (
SubPagesEndpoint,
)
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
from .search import GlobalSearchEndpoint, IssueSearchEndpoint, SearchEndpoint
from .external.base import (

View File

@@ -18,6 +18,7 @@ from plane.db.models import (
Module,
Page,
IssueView,
ProjectMember,
)
from plane.utils.issue_search import search_issues
@@ -249,7 +250,7 @@ class IssueSearchEndpoint(BaseAPIView):
workspace__slug=slug,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True
project__archived_at__isnull=True,
)
if workspace_search == "false":
@@ -300,3 +301,201 @@ class IssueSearchEndpoint(BaseAPIView):
),
status=status.HTTP_200_OK,
)
class SearchEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
query = request.query_params.get("query", False)
query_type = request.query_params.get("query_type", "issue")
count = int(request.query_params.get("count", 5))
if query_type == "mention":
fields = ["member__first_name", "member__last_name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
users = (
ProjectMember.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project_id=project_id,
workspace__slug=slug,
)
.order_by("-created_at")
.values(
"member__first_name",
"member__last_name",
"member__avatar",
"member__display_name",
"member__id",
)[:count]
)
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
pages = (
Page.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
access=0,
)
.order_by("-created_at")
.values("name", "id")[:count]
)
return Response(
{"users": users, "pages": pages}, status=status.HTTP_200_OK
)
if query_type == "project":
fields = ["name", "identifier"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user)
| Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values("name", "id", "identifier", "workspace__slug")[:count]
)
return Response(projects, status=status.HTTP_200_OK)
if query_type == "issue":
fields = ["name", "sequence_id", "project__identifier"]
q = Q()
if query:
for field in fields:
if field == "sequence_id":
# Match whole integers only (exclude decimal numbers)
sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
issues = (
Issue.issue_objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
project_id=project_id,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"sequence_id",
"project__identifier",
"project_id",
"priority",
"state_id",
)[:count]
)
return Response(issues, status=status.HTTP_200_OK)
if query_type == "cycle":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
cycles = (
Cycle.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)[:count]
)
return Response(cycles, status=status.HTTP_200_OK)
if query_type == "module":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
modules = (
Module.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)[:count]
)
return Response(modules, status=status.HTTP_200_OK)
if query_type == "page":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
pages = (
Page.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project_id=project_id,
workspace__slug=slug,
access=0,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)[:count]
)
return Response(pages, status=status.HTTP_200_OK)
return Response(
{"error": "Please provide a valid query"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -348,3 +348,7 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
margin-top: 0;
}
/* end tailwind typography */
.ProseMirror .issue-embed img {
margin: 0 !important;
}

View File

@@ -14,10 +14,7 @@ export function CoreEditorProps(editorClassName: string): EditorProps {
// prevent default event listeners from firing when slash command is active
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
console.log("registered");
return true;
}
if (slashCommand) return true;
}
},
},

View File

@@ -7,3 +7,5 @@ export { useEditorMarkings } from "src/hooks/use-editor-markings";
export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core";
export type { IMarking } from "src/types/editor-types";
export type { TEmbedItem } from "src/ui/extensions/widgets/issue-embed/block/types";

View File

@@ -2,17 +2,14 @@ import Placeholder from "@tiptap/extension-placeholder";
// plane imports
import { SlashCommand, DragAndDrop } from "@plane/editor-extensions";
import { UploadImage, ISlashCommandItem } from "@plane/editor-core";
// local
import { IssueWidgetExtension } from "src/ui/extensions/widgets/issue-embed-widget";
import { IssueSuggestions } from "src/ui/extensions/widgets/issue-embed-suggestion-list";
import { IIssueEmbedConfig } from "src/ui/extensions/widgets/issue-embed-widget/types";
// ui
import { LayersIcon } from "@plane/ui";
import { IssueEmbedSuggestions, IssueWidget, IssueListRenderer, TIssueEmbedConfig } from "src/ui/extensions";
export const DocumentEditorExtensions = (
uploadFile: UploadImage,
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void,
issueEmbedConfig?: IIssueEmbedConfig
issueEmbedConfig?: TIssueEmbedConfig
) => {
const additionalOptions: ISlashCommandItem[] = [
{
@@ -34,7 +31,7 @@ export const DocumentEditorExtensions = (
},
];
return [
const extensions = [
SlashCommand(uploadFile, additionalOptions),
DragAndDrop(setHideDragHandle),
Placeholder.configure({
@@ -50,7 +47,24 @@ export const DocumentEditorExtensions = (
},
includeChildren: true,
}),
IssueWidgetExtension({ issueEmbedConfig }),
IssueSuggestions(issueEmbedConfig ? issueEmbedConfig.issues : []),
];
if (issueEmbedConfig) {
extensions.push(
// TODO: check this
// @ts-expect-error resolve this
IssueWidget({
widgetCallback: issueEmbedConfig.widgetCallback,
}).configure({
issueEmbedConfig,
}),
IssueEmbedSuggestions.configure({
suggestion: {
render: () => IssueListRenderer(issueEmbedConfig.searchCallback),
},
})
);
}
return extensions;
};

View File

@@ -0,0 +1,2 @@
export * from "./widgets";
export * from "./extensions";

View File

@@ -0,0 +1 @@
export * from "./issue-embed";

View File

@@ -1,54 +0,0 @@
import { Editor, Range } from "@tiptap/react";
import { IssueEmbedSuggestions } from "src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension";
import { getIssueSuggestionItems } from "src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-items";
import { IssueListRenderer } from "src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer";
import { v4 as uuidv4 } from "uuid";
export type CommandProps = {
editor: Editor;
range: Range;
};
export interface IIssueListSuggestion {
title: string;
priority: "high" | "low" | "medium" | "urgent";
identifier: string;
state: "Cancelled" | "In Progress" | "Todo" | "Done" | "Backlog";
command: ({ editor, range }: CommandProps) => void;
}
export const IssueSuggestions = (suggestions: any[]) => {
const mappedSuggestions: IIssueListSuggestion[] = suggestions.map((suggestion): IIssueListSuggestion => {
const transactionId = uuidv4();
return {
title: suggestion?.name,
priority: suggestion?.priority.toString(),
identifier: `${suggestion?.project_detail?.identifier}-${suggestion?.sequence_id}`,
state: suggestion?.state_detail && suggestion?.state_detail?.name ? suggestion?.state_detail?.name : "Todo",
command: ({ editor, range }) => {
editor
.chain()
.focus()
.insertContentAt(range, {
type: "issue-embed-component",
attrs: {
entity_identifier: suggestion?.id,
id: transactionId,
title: suggestion?.name,
project_identifier: suggestion?.project_detail?.identifier,
sequence_id: suggestion?.sequence_id,
entity_name: "issue",
},
})
.run();
},
};
});
return IssueEmbedSuggestions.configure({
suggestion: {
items: getIssueSuggestionItems(mappedSuggestions),
render: IssueListRenderer,
},
});
};

View File

@@ -1,15 +0,0 @@
import { IIssueListSuggestion } from "src/ui/extensions/widgets/issue-embed-suggestion-list";
export const getIssueSuggestionItems =
(issueSuggestions: Array<IIssueListSuggestion>) =>
({ query }: { query: string }) => {
const search = query.toLowerCase();
const filteredSuggestions = issueSuggestions.filter(
(item) =>
item.title.toLowerCase().includes(search) ||
item.identifier.toLowerCase().includes(search) ||
item.priority.toLowerCase().includes(search)
);
return filteredSuggestions;
};

View File

@@ -1,268 +0,0 @@
import { cn } from "@plane/editor-core";
import { Editor } from "@tiptap/core";
import tippy from "tippy.js";
import { ReactRenderer } from "@tiptap/react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { PriorityIcon } from "@plane/ui";
const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
const itemHeight = item ? item.offsetHeight : 0;
const top = item.offsetTop;
const bottom = top + itemHeight;
if (top < container.scrollTop) {
// container.scrollTop = top - containerHeight;
item.scrollIntoView({
behavior: "smooth",
block: "center",
});
} else if (bottom > containerHeight + container.scrollTop) {
// container.scrollTop = bottom - containerHeight;
item.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
};
interface IssueSuggestionProps {
title: string;
priority: "high" | "low" | "medium" | "urgent" | "none";
state: "Cancelled" | "In Progress" | "Todo" | "Done" | "Backlog";
identifier: string;
}
const IssueSuggestionList = ({
items,
command,
editor,
}: {
items: IssueSuggestionProps[];
command: any;
editor: Editor;
range: any;
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const [currentSection, setCurrentSection] = useState<string>("Backlog");
const sections = ["Backlog", "In Progress", "Todo", "Done", "Cancelled"];
const [displayedItems, setDisplayedItems] = useState<{
[key: string]: IssueSuggestionProps[];
}>({});
const [displayedTotalLength, setDisplayedTotalLength] = useState(0);
const commandListContainer = useRef<HTMLDivElement>(null);
useEffect(() => {
const newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {};
let totalLength = 0;
let selectedSection = "Backlog";
let selectedCurrentSection = false;
sections.forEach((section) => {
newDisplayedItems[section] = items.filter((item) => item.state === section).slice(0, 5);
if (newDisplayedItems[section].length > 0 && selectedCurrentSection === false) {
selectedSection = section;
selectedCurrentSection = true;
}
totalLength += newDisplayedItems[section].length;
});
setCurrentSection(selectedSection);
setDisplayedTotalLength(totalLength);
setDisplayedItems(newDisplayedItems);
}, [items]);
const selectItem = useCallback(
(section: string, index: number) => {
const item = displayedItems[section][index];
if (item) {
command(item);
}
},
[command, displayedItems, currentSection]
);
useEffect(() => {
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"];
const onKeyDown = (e: KeyboardEvent) => {
if (navigationKeys.includes(e.key)) {
// if (editor.isFocused) {
// editor.chain().blur();
// commandListContainer.current?.focus();
// }
if (e.key === "ArrowUp") {
setSelectedIndex(
(selectedIndex + displayedItems[currentSection].length - 1) % displayedItems[currentSection].length
);
return true;
}
if (e.key === "ArrowDown") {
const nextIndex = (selectedIndex + 1) % displayedItems[currentSection].length;
setSelectedIndex(nextIndex);
if (nextIndex === 4) {
const nextItems = items
.filter((item) => item.state === currentSection)
.slice(displayedItems[currentSection].length, displayedItems[currentSection].length + 5);
setDisplayedItems((prevItems) => ({
...prevItems,
[currentSection]: [...prevItems[currentSection], ...nextItems],
}));
}
return true;
}
if (e.key === "Enter") {
selectItem(currentSection, selectedIndex);
return true;
}
if (e.key === "Tab") {
const currentSectionIndex = sections.indexOf(currentSection);
const nextSectionIndex = (currentSectionIndex + 1) % sections.length;
setCurrentSection(sections[nextSectionIndex]);
setSelectedIndex(0);
return true;
}
return false;
} else if (e.key === "Escape") {
if (!editor.isFocused) {
editor.chain().focus();
}
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [displayedItems, selectedIndex, setSelectedIndex, selectItem, currentSection]);
useLayoutEffect(() => {
const container = commandListContainer?.current;
if (container) {
const sectionContainer = container?.querySelector(`#${currentSection}-container`) as HTMLDivElement;
if (sectionContainer) {
updateScrollView(container, sectionContainer);
}
const sectionScrollContainer = container?.querySelector(`#${currentSection}`) as HTMLElement;
const item = sectionScrollContainer?.children[selectedIndex] as HTMLElement;
if (item && sectionScrollContainer) {
updateScrollView(sectionScrollContainer, item);
}
}
}, [selectedIndex, currentSection]);
return displayedTotalLength > 0 ? (
<div
id="issue-list-container"
ref={commandListContainer}
className="z-10 max-h-80 w-96 overflow-y-auto overflow-x-hidden rounded-md border border-custom-border-100 bg-custom-background-100 px-1 shadow-custom-shadow-xs transition-all"
>
{sections.map((section) => {
const sectionItems = displayedItems[section];
return (
sectionItems &&
sectionItems.length > 0 && (
<div className={"flex h-full w-full flex-col"} key={`${section}-container`} id={`${section}-container`}>
<h6 className="sticky top-0 z-10 bg-custom-background-100 px-2 py-1 text-xs font-medium text-custom-text-400">
{section}
</h6>
<div key={section} id={section} className={"max-h-[140px] overflow-x-hidden overflow-y-scroll"}>
{sectionItems.map((item: IssueSuggestionProps, index: number) => (
<button
className={cn(
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
{
"bg-custom-primary-100/5 text-custom-text-100":
section === currentSection && index === selectedIndex,
}
)}
key={item?.identifier}
onClick={() => selectItem(section, index)}
>
<h5 className="whitespace-nowrap text-xs text-custom-text-300">{item?.identifier}</h5>
<PriorityIcon priority={item?.priority} />
<div className="w-full truncate">
<p className="flex-grow w-full truncate text-xs">{item?.title}</p>
</div>
</button>
))}
</div>
</div>
)
);
})}
</div>
) : (
<div
id="issue-list-container"
ref={commandListContainer}
className="fixed z-[10] max-h-80 w-60 overflow-y-auto overflow-x-hidden rounded-md border border-custom-border-100 bg-custom-background-100 px-1 shadow-custom-shadow-xs transition-all"
>
<div id="no-issue-container" className="h-full w-full">
<h6 className="z-10 bg-custom-background-100 px-2 py-1 text-xs font-medium text-custom-text-400">
No issues found
</h6>
</div>
</div>
);
};
export const IssueListRenderer = () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
const tippyContainer = document.querySelector(".active-editor") ?? document.querySelector("#editor-container");
component = new ReactRenderer(IssueSuggestionList, {
props,
editor: props.editor,
});
// @ts-expect-error Tippy overloads are messed up
popup = tippy("body", {
flipbehavior: ["bottom", "top"],
appendTo: tippyContainer,
flip: true,
flipOnUpdate: true,
getReferenceClientRect: props.clientRect,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
tippyContainer?.addEventListener("scroll", () => {
popup?.[0].destroy();
});
},
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component?.updateProps(props);
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"];
if (navigationKeys.includes(props.event.key)) {
// @ts-expect-error fix the types
component?.ref?.onKeyDown(props);
return true;
}
return false;
},
onExit: () => {
const container = document.querySelector(".frame-renderer") as HTMLElement;
if (container) {
container.removeEventListener("scroll", () => {});
}
popup?.[0].destroy();
setTimeout(() => {
component?.destroy();
}, 300);
},
};
};

View File

@@ -1,12 +0,0 @@
import { IssueWidget } from "src/ui/extensions/widgets/issue-embed-widget/issue-widget-node";
import { IIssueEmbedConfig } from "src/ui/extensions/widgets/issue-embed-widget/types";
export * from "./issue-widget-card";
interface IssueWidgetExtensionProps {
issueEmbedConfig?: IIssueEmbedConfig;
}
export const IssueWidgetExtension = ({ issueEmbedConfig }: IssueWidgetExtensionProps) =>
IssueWidget.configure({
issueEmbedConfig,
});

View File

@@ -1,74 +0,0 @@
// @ts-nocheck
import { useState, useEffect } from "react";
import { NodeViewWrapper } from "@tiptap/react";
import { Avatar, AvatarGroup, Loader, PriorityIcon } from "@plane/ui";
import { Calendar, AlertTriangle } from "lucide-react";
export const IssueWidgetCard = (props) => {
const [loading, setLoading] = useState<number>(1);
const [issueDetails, setIssueDetails] = useState<any>();
useEffect(() => {
const issue = props.issueEmbedConfig.fetchIssue(props.node.attrs.entity_identifier);
if (issue) {
setIssueDetails(issue);
setLoading(0);
} else {
setLoading(-1);
}
}, []);
if (!issueDetails) return <></>;
const completeIssueEmbedAction = () => {
props.issueEmbedConfig.clickAction(issueDetails?.id, props.node.attrs.title);
};
return (
<NodeViewWrapper className="issue-embed-component my-2">
{loading == 0 ? (
<div
onClick={completeIssueEmbedAction}
className="w-full cursor-pointer space-y-2 rounded-md bg-custom-background-90 border-[0.5px] border-custom-border-300 p-3 shadow-custom-shadow-2xs"
>
<h5 className="text-xs text-custom-text-300">
{issueDetails?.project_detail?.identifier}-{issueDetails?.sequence_id}
</h5>
<h4 className="break-words text-sm font-medium">{issueDetails?.name}</h4>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<PriorityIcon priority={issueDetails?.priority} />
<div>
<AvatarGroup size="sm">
{(issueDetails?.assignee_details).map((assignee, index) => {
if (!assignee) null;
return <Avatar key={index} name={assignee?.display_name} src={assignee?.avatar} className="!m-0" />;
})}
</AvatarGroup>
</div>
{issueDetails?.target_date && (
<div className="flex h-5 items-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs text-custom-text-100">
<Calendar className="h-3 w-3" strokeWidth={1.5} />
{new Date(issueDetails?.target_date).toLocaleDateString()}
</div>
)}
</div>
</div>
) : loading == -1 ? (
<div className="flex items-center gap-[8px] rounded border-2 border-[#D97706] bg-[#FFFBEB] pb-[10px] pl-[13px] pt-[10px] text-[#D97706]">
<AlertTriangle color={"#D97706"} />
{"This Issue embed is not found in any project. It can no longer be updated or accessed from here."}
</div>
) : (
<div className="w-full space-y-2 rounded-md border-[0.5px] border-custom-border-200 p-3 shadow-custom-shadow-2xs">
<Loader className={"px-6"}>
<Loader.Item height={"30px"} />
<div className={"mt-3 space-y-2"}>
<Loader.Item height={"20px"} width={"70%"} />
<Loader.Item height={"20px"} width={"60%"} />
</div>
</Loader>
</div>
)}
</NodeViewWrapper>
);
};

View File

@@ -1,65 +0,0 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { IssueWidgetCard } from "src/ui/extensions/widgets/issue-embed-widget/issue-widget-card";
import { ReactNodeViewRenderer } from "@tiptap/react";
export const IssueWidget = Node.create({
name: "issue-embed-component",
group: "block",
atom: true,
addAttributes() {
return {
id: {
default: null,
},
class: {
default: "w-[600px]",
},
title: {
default: null,
},
entity_name: {
default: null,
},
entity_identifier: {
default: null,
},
project_identifier: {
default: null,
},
sequence_id: {
default: null,
},
};
},
addNodeView() {
return ReactNodeViewRenderer((props: Object) => (
<IssueWidgetCard {...props} issueEmbedConfig={this.options.issueEmbedConfig} />
));
},
parseHTML() {
return [
{
tag: "issue-embed-component",
getAttrs: (node: string | HTMLElement) => {
if (typeof node === "string") {
return null;
}
return {
id: node.getAttribute("id") || "",
title: node.getAttribute("title") || "",
entity_name: node.getAttribute("entity_name") || "",
entity_identifier: node.getAttribute("entity_identifier") || "",
project_identifier: node.getAttribute("project_identifier") || "",
sequence_id: node.getAttribute("sequence_id") || "",
};
},
},
];
},
renderHTML({ HTMLAttributes }) {
return ["issue-embed-component", mergeAttributes(HTMLAttributes)];
},
});

View File

@@ -1,9 +0,0 @@
export interface IEmbedConfig {
issueEmbedConfig: IIssueEmbedConfig;
}
export interface IIssueEmbedConfig {
fetchIssue: (issueId: string) => any;
clickAction: (issueId: string, issueTitle: string) => void;
issues: Array<any>;
}

View File

@@ -0,0 +1,2 @@
export * from "./issue-widget-node";
export * from "./types";

View File

@@ -0,0 +1,54 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
type Props = {
widgetCallback: (issueId: string) => React.ReactNode;
};
export const IssueWidget = (props: Props) =>
Node.create({
name: "issue-embed-component",
group: "block",
atom: true,
addAttributes() {
return {
entity_identifier: {
default: undefined,
},
id: {
default: undefined,
},
};
},
addNodeView() {
return ReactNodeViewRenderer((issueProps: any) => (
<NodeViewWrapper>{props.widgetCallback(issueProps.node.attrs.entity_identifier)}</NodeViewWrapper>
));
},
parseHTML() {
return [
{
tag: "issue-embed-component",
getAttrs: (node: string | HTMLElement) => {
if (typeof node === "string") {
return null;
}
return {
id: node.getAttribute("id") || "",
title: node.getAttribute("title") || "",
entity_name: node.getAttribute("entity_name") || "",
entity_identifier: node.getAttribute("entity_identifier") || "",
project_identifier: node.getAttribute("project_identifier") || "",
sequence_id: node.getAttribute("sequence_id") || "",
};
},
},
];
},
renderHTML({ HTMLAttributes }) {
return ["issue-embed-component", mergeAttributes(HTMLAttributes)];
},
});

View File

@@ -0,0 +1,19 @@
export type TEmbedConfig = {
issue?: TIssueEmbedConfig;
};
export type TReadOnlyEmbedConfig = {
issue?: Omit<TIssueEmbedConfig, "searchCallback">;
};
export type TIssueEmbedConfig = {
searchCallback: (searchQuery: string) => Promise<TEmbedItem[]>;
widgetCallback: (issueId: string) => React.ReactNode;
};
export type TEmbedItem = {
id: string;
title: string;
subTitle: string;
icon: React.ReactNode;
};

View File

@@ -0,0 +1,2 @@
export * from "./block";
export * from "./suggestions-list";

View File

@@ -0,0 +1,2 @@
export * from "./issue-suggestion-extension";
export * from "./issue-suggestion-renderer";

View File

@@ -0,0 +1,188 @@
import { useCallback, useEffect, useState } from "react";
import { Editor } from "@tiptap/core";
import { ReactRenderer, Range } from "@tiptap/react";
import tippy from "tippy.js";
import { v4 as uuidv4 } from "uuid";
// core
import { cn } from "@plane/editor-core";
// types
import { TEmbedItem } from "src/ui/extensions";
type TSuggestionsListProps = {
editor: Editor;
searchCallback: (searchQuery: string) => Promise<TEmbedItem[]>;
query: string;
range: Range;
};
const IssueSuggestionList = (props: TSuggestionsListProps) => {
const { editor, searchCallback, query, range } = props;
// states
const [items, setItems] = useState<TEmbedItem[] | undefined>(undefined);
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = useCallback(
(item: TEmbedItem) => {
try {
const docSize = editor.state.doc.content.size;
if (range.from < 0 || range.to >= docSize) return;
const transactionId = uuidv4();
editor
.chain()
.focus()
.insertContentAt(range, {
type: "issue-embed-component",
attrs: {
entity_identifier: item?.id,
id: transactionId,
},
})
.run();
} catch (error) {
console.log("Error inserting issue embed", error);
}
},
[editor, range]
);
useEffect(() => {
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"];
const onKeyDown = (e: KeyboardEvent) => {
if (!items) return;
if (navigationKeys.includes(e.key)) {
if (e.key === "ArrowUp") {
const newIndex = selectedIndex - 1;
setSelectedIndex(newIndex < 0 ? items.length - 1 : newIndex);
return true;
}
if (e.key === "ArrowDown") {
const newIndex = selectedIndex + 1;
setSelectedIndex(newIndex >= items.length ? 0 : newIndex);
return true;
}
if (e.key === "Enter") {
const item = items[selectedIndex];
selectItem(item);
return true;
}
return false;
} else if (e.key === "Escape") {
if (!editor.isFocused) editor.chain().focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [editor, items, selectedIndex, setSelectedIndex, selectItem]);
useEffect(() => {
setItems(undefined);
searchCallback(query).then((data) => {
setItems(data);
});
}, [query, searchCallback]);
return (
<div
id="issue-list-container"
className="z-10 overflow-y-auto overflow-x-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 max-h-60 w-96 px-2 py-2.5 shadow-custom-shadow-rg whitespace-nowrap transition-all"
>
{items ? (
items.length > 0 ? (
items.map((item, index) => (
<button
key={item.id}
type="button"
className={cn(
"w-full flex items-center gap-2 select-none truncate rounded px-1 py-1.5 text-left text-xs text-custom-text-200 hover:bg-custom-background-90",
{
"bg-custom-background-90": index === selectedIndex,
}
)}
onClick={() => selectItem(item)}
>
<h5 className="whitespace-nowrap text-xs text-custom-text-300 flex-shrink-0">{item.subTitle}</h5>
{item.icon}
<p className="flex-grow w-full truncate text-xs">{item.title}</p>
</button>
))
) : (
<div className="text-center text-xs text-custom-text-400">No results found</div>
)
) : (
<div className="text-center text-xs text-custom-text-400">Loading</div>
)}
</div>
);
};
export const IssueListRenderer = (searchCallback: (searchQuery: string) => Promise<TEmbedItem[]>) => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
const tippyContainer = document.querySelector(".active-editor") ?? document.querySelector("#editor-container");
component = new ReactRenderer(IssueSuggestionList, {
props: {
...props,
searchCallback,
},
editor: props.editor,
});
// @ts-expect-error Tippy overloads are messed up
popup = tippy("body", {
flipbehavior: ["bottom", "top"],
appendTo: tippyContainer,
flip: true,
flipOnUpdate: true,
getReferenceClientRect: props.clientRect,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
tippyContainer?.addEventListener("scroll", () => {
popup?.[0].destroy();
});
},
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component?.updateProps(props);
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"];
if (navigationKeys.includes(props.event.key)) {
// @ts-expect-error fix the types
component?.ref?.onKeyDown(props);
return true;
}
return false;
},
onExit: () => {
const container = document.querySelector(".frame-renderer") as HTMLElement;
if (container) {
container.removeEventListener("scroll", () => {});
}
popup?.[0].destroy();
setTimeout(() => {
component?.destroy();
}, 300);
},
};
};

View File

@@ -9,9 +9,8 @@ import {
IMentionHighlight,
IMentionSuggestion,
} from "@plane/editor-core";
import { DocumentEditorExtensions } from "src/ui/extensions";
import { PageRenderer } from "src/ui/components/page-renderer";
import { IEmbedConfig } from "src/ui/extensions/widgets/issue-embed-widget/types";
import { DocumentEditorExtensions, TEmbedConfig } from "src/ui/extensions";
interface IDocumentEditor {
initialValue: string;
@@ -33,7 +32,7 @@ interface IDocumentEditor {
};
tabIndex?: number;
// embed configuration
embedConfig?: IEmbedConfig;
embedHandler?: TEmbedConfig;
placeholder?: string | ((isFocused: boolean) => string);
}
@@ -49,7 +48,7 @@ const DocumentEditor = (props: IDocumentEditor) => {
handleEditorReady,
forwardedRef,
tabIndex,
embedConfig,
embedHandler,
placeholder,
} = props;
// states
@@ -75,7 +74,7 @@ const DocumentEditor = (props: IDocumentEditor) => {
handleEditorReady,
forwardedRef,
mentionHandler,
extensions: DocumentEditorExtensions(fileHandler.upload, setHideDragHandleFunction, embedConfig?.issueEmbedConfig),
extensions: DocumentEditorExtensions(fileHandler.upload, setHideDragHandleFunction, embedHandler?.issue),
placeholder,
tabIndex,
});

View File

@@ -2,8 +2,7 @@ import { forwardRef, MutableRefObject } from "react";
import { EditorReadOnlyRefApi, getEditorClassNames, IMentionHighlight, useReadOnlyEditor } from "@plane/editor-core";
// components
import { PageRenderer } from "src/ui/components/page-renderer";
import { IssueWidgetExtension } from "../extensions/widgets/issue-embed-widget";
import { IEmbedConfig } from "../extensions/widgets/issue-embed-widget/types";
import { IssueWidget, TReadOnlyEmbedConfig } from "src/ui/extensions";
interface IDocumentReadOnlyEditor {
initialValue: string;
@@ -15,7 +14,7 @@ interface IDocumentReadOnlyEditor {
highlights: () => Promise<IMentionHighlight[]>;
};
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
embedConfig?: IEmbedConfig;
embedHandler?: TReadOnlyEmbedConfig;
}
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
@@ -27,7 +26,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
tabIndex,
handleEditorReady,
mentionHandler,
embedConfig,
embedHandler,
} = props;
const editor = useReadOnlyEditor({
initialValue,
@@ -35,7 +34,15 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
mentionHandler,
forwardedRef,
handleEditorReady,
extensions: [IssueWidgetExtension({ issueEmbedConfig: embedConfig?.issueEmbedConfig })],
extensions: embedHandler?.issue
? [
IssueWidget({
widgetCallback: embedHandler?.issue?.widgetCallback,
}).configure({
issueEmbedConfig: embedHandler?.issue,
}),
]
: [],
});
if (!editor) {

View File

@@ -75,6 +75,7 @@
width: calc(100% + (var(--horizontal-offset) * 2));
background-color: rgba(var(--color-primary-100), 0.2);
border-radius: 4px;
pointer-events: none;
}
.ProseMirror img {

View File

@@ -1,4 +1,5 @@
import { EPageAccess } from "./enums";
import { TIssuePriorities } from "./issues";
export type TPage = {
access: EPageAccess | undefined;
@@ -48,3 +49,15 @@ export type TPageFilters = {
sortBy: TPageFiltersSortBy;
filters?: TPageFilterProps;
};
export type TPageEmbedType = "mention" | "issue";
export type TPageEmbedResponse = {
id: string;
name: string;
priority: TIssuePriorities;
project__identifier: string;
project_id: string;
sequence_id: string;
state_id: string;
};

View File

@@ -12,15 +12,13 @@ import {
} from "@plane/document-editor";
// types
import { IUserLite, TPage } from "@plane/types";
// ui
import { Spinner } from "@plane/ui";
// components
import { PageContentBrowser, PageEditorTitle } from "@/components/pages";
import { IssueEmbedCard, PageContentBrowser, PageEditorTitle } from "@/components/pages";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
import { useIssueEmbeds } from "@/hooks/use-issue-embeds";
import { useIssueEmbed } from "@/hooks/use-issue-embed";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// services
import { FileService } from "@/services/file.service";
@@ -83,7 +81,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
user: currentUser ?? undefined,
});
// issue-embed
const { issues, fetchIssue, issueWidgetClickAction, issuesLoading } = useIssueEmbeds();
const { fetchIssues } = useIssueEmbed(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "");
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
@@ -91,12 +89,10 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
updateMarkings(description_html ?? "<p></p>");
}, [description_html, updateMarkings]);
if (!issues || issuesLoading)
return (
<div className="h-full w-full grid place-items-center">
<Spinner />
</div>
);
const handleIssueSearch = async (searchQuery: string) => {
const response = await fetchIssues(searchQuery);
return response;
};
return (
<div className="flex items-center h-full w-full overflow-y-auto">
@@ -157,11 +153,22 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
highlights: mentionHighlights,
suggestions: mentionSuggestions,
}}
embedConfig={{
issueEmbedConfig: {
issues,
fetchIssue,
clickAction: issueWidgetClickAction,
embedHandler={{
issue: {
searchCallback: async (query) =>
new Promise((resolve) => {
setTimeout(async () => {
const response = await handleIssueSearch(query);
resolve(response);
}, 300);
}),
widgetCallback: (issueId) => (
<IssueEmbedCard
issueId={issueId}
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
),
},
}}
/>
@@ -177,11 +184,15 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
mentionHandler={{
highlights: mentionHighlights,
}}
embedConfig={{
issueEmbedConfig: {
issues,
fetchIssue,
clickAction: issueWidgetClickAction,
embedHandler={{
issue: {
widgetCallback: (issueId) => (
<IssueEmbedCard
issueId={issueId}
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
),
},
}}
/>

View File

@@ -0,0 +1 @@
export * from "./issue-embed";

View File

@@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { AlertTriangle } from "lucide-react";
// types
import { IIssueDisplayProperties } from "@plane/types";
// ui
import { Loader } from "@plane/ui";
// components
import { IssueProperties } from "@/components/issues/issue-layouts/properties/all-properties";
// constants
import { ISSUE_DISPLAY_PROPERTIES } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project";
// hooks
import { useIssueDetail, useProject, useUser } from "@/hooks/store";
type Props = {
issueId: string;
projectId: string;
workspaceSlug: string;
};
export const IssueEmbedCard: React.FC<Props> = observer((props) => {
const { issueId, projectId, workspaceSlug } = props;
// states
const [error, setError] = useState<any | null>(null);
// store hooks
const {
membership: { currentWorkspaceAllProjectsRole },
} = useUser();
const { getProjectById } = useProject();
const {
setPeekIssue,
issue: { fetchIssue, getIssueById, updateIssue },
} = useIssueDetail();
// derived values
const projectRole = currentWorkspaceAllProjectsRole?.[projectId];
const projectDetails = getProjectById(projectId);
const issueDetails = getIssueById(issueId);
// auth
const isReadOnly = !!projectRole && projectRole < EUserProjectRoles.MEMBER;
// issue display properties
const displayProperties: IIssueDisplayProperties = {};
ISSUE_DISPLAY_PROPERTIES.forEach((property) => {
displayProperties[property.key] = true;
});
// fetch issue details if not available
useEffect(() => {
if (!issueDetails) {
fetchIssue(workspaceSlug, projectId, issueId)
.then(() => setError(null))
.catch((error) => setError(error));
}
}, [fetchIssue, issueDetails, issueId, projectId, workspaceSlug]);
if (!issueDetails && !error)
return (
<div className="rounded-md p-3 my-2">
<Loader className="px-6">
<Loader.Item height="30px" />
<div className="mt-3 space-y-2">
<Loader.Item height="20px" width="70%" />
<Loader.Item height="20px" width="60%" />
</div>
</Loader>
</div>
);
if (error)
return (
<div className="flex items-center gap-3 rounded-md border-2 border-orange-500 bg-orange-500/10 text-orange-500 px-4 py-3 my-2 text-base">
<AlertTriangle className="text-orange-500 size-8" />
This Issue embed is not found in any project. It can no longer be updated or accessed from here.
</div>
);
return (
<div
className="issue-embed cursor-pointer space-y-2 rounded-md bg-custom-background-90 p-3 my-2"
role="button"
onClick={() =>
setPeekIssue({
issueId,
projectId,
workspaceSlug,
})
}
>
<h5 className="text-xs text-custom-text-300">
{projectDetails?.identifier}-{issueDetails?.sequence_id}
</h5>
<h4 className="text-sm font-medium line-clamp-2 break-words">{issueDetails?.name}</h4>
{issueDetails && (
<IssueProperties
className="flex flex-wrap items-center gap-2 whitespace-nowrap text-custom-text-300 pt-1.5"
issue={issueDetails}
displayProperties={displayProperties}
activeLayout="Page issue embed"
updateIssue={async (projectId, issueId, data) => await updateIssue(workspaceSlug, projectId, issueId, data)}
isReadOnly={isReadOnly}
/>
)}
</div>
);
});

View File

@@ -1,3 +1,4 @@
export * from "./embed";
export * from "./header";
export * from "./summary";
export * from "./editor-body";

View File

@@ -0,0 +1,37 @@
// editor
import { TEmbedItem } from "@plane/document-editor";
// types
import { TPageEmbedResponse } from "@plane/types";
// ui
import { PriorityIcon } from "@plane/ui";
// services
import { PageService } from "@/services/page.service";
const pageService = new PageService();
export const useIssueEmbed = (workspaceSlug: string, projectId: string) => {
const fetchIssues = async (searchQuery: string): Promise<TEmbedItem[]> =>
await pageService
.searchEmbed<TPageEmbedResponse[]>(workspaceSlug, projectId, {
query_type: "issue",
query: searchQuery,
count: 10,
})
.then((res) => {
const structuredIssues: TEmbedItem[] = (res ?? []).map((issue) => ({
id: issue.id,
subTitle: `${issue.project__identifier}-${issue.sequence_id}`,
title: issue.name,
icon: <PriorityIcon priority={issue.priority} />,
}));
return structuredIssues;
})
.catch((err) => {
throw Error(err);
});
return {
fetchIssues,
};
};

View File

@@ -1,51 +0,0 @@
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
import { StoreContext } from "contexts/store-context";
import { toJS } from "mobx";
import { useContext } from "react";
import { IssueService } from "services/issue";
import useSWR from "swr";
import { useIssueDetail, useMember, useProject, useProjectState } from "./store";
const issueService = new IssueService();
export const useIssueEmbeds = () => {
const workspaceSlug = useContext(StoreContext).app.router.workspaceSlug;
const projectId = useContext(StoreContext).app.router.projectId;
const { getProjectById } = useProject();
const { setPeekIssue } = useIssueDetail();
const { getStateById } = useProjectState();
const { getUserDetails } = useMember();
const { data: issuesResponse, isLoading: issuesLoading } = useSWR(
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null,
{
revalidateOnFocus: false,
revalidateIfStale: false,
}
);
const issues = Object.values(issuesResponse ?? {});
const issuesWithStateAndProject = issues.map((issue) => ({
...issue,
state_detail: toJS(getStateById(issue.state_id)),
project_detail: toJS(getProjectById(issue.project_id)),
assignee_details: issue.assignee_ids.map((assigneeid) => toJS(getUserDetails(assigneeid))),
}));
const fetchIssue = (issueId: string) => issuesWithStateAndProject.find((issue) => issue.id === issueId);
const issueWidgetClickAction = (issueId: string) => {
if (!workspaceSlug || !projectId) return;
setPeekIssue({ workspaceSlug, projectId: projectId, issueId });
};
return {
issues: issuesWithStateAndProject,
issuesLoading,
fetchIssue,
issueWidgetClickAction,
};
};

View File

@@ -47,7 +47,6 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
description_html: "",
},
});
// const { issues, fetchIssue, issueWidgetClickAction, issuesLoading } = useIssueEmbeds();
// fetching page details
const {

View File

@@ -1,5 +1,5 @@
// types
import { TPage } from "@plane/types";
import { TPage, TPageEmbedType } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
@@ -119,4 +119,22 @@ export class PageService extends APIService {
throw error?.response?.data;
});
}
async searchEmbed<T>(
workspaceSlug: string,
projectId: string,
params: {
query_type: TPageEmbedType;
count?: number;
query: string;
}
): Promise<T | undefined> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/search/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}