mirror of
https://github.com/makeplane/plane.git
synced 2025-12-29 00:24:56 +01:00
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:
committed by
GitHub
parent
924a693c26
commit
c23a5146b9
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -194,7 +194,7 @@ from .page.base import (
|
||||
SubPagesEndpoint,
|
||||
)
|
||||
|
||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint, SearchEndpoint
|
||||
|
||||
|
||||
from .external.base import (
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./widgets";
|
||||
export * from "./extensions";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./issue-embed";
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)];
|
||||
},
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./issue-widget-node";
|
||||
export * from "./types";
|
||||
@@ -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)];
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./block";
|
||||
export * from "./suggestions-list";
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./issue-suggestion-extension";
|
||||
export * from "./issue-suggestion-renderer";
|
||||
@@ -17,6 +17,7 @@ export const IssueEmbedSuggestions = Extension.create({
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
13
packages/types/src/pages.d.ts
vendored
13
packages/types/src/pages.d.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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() ?? ""}
|
||||
/>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
1
web/components/pages/editor/embed/index.ts
Normal file
1
web/components/pages/editor/embed/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./issue-embed";
|
||||
104
web/components/pages/editor/embed/issue-embed.tsx
Normal file
104
web/components/pages/editor/embed/issue-embed.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./embed";
|
||||
export * from "./header";
|
||||
export * from "./summary";
|
||||
export * from "./editor-body";
|
||||
|
||||
37
web/hooks/use-issue-embed.tsx
Normal file
37
web/hooks/use-issue-embed.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -47,7 +47,6 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
||||
description_html: "",
|
||||
},
|
||||
});
|
||||
// const { issues, fetchIssue, issueWidgetClickAction, issuesLoading } = useIssueEmbeds();
|
||||
|
||||
// fetching page details
|
||||
const {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user