[ FEATURE ] New Issue Widget for displaying issues inside document-editor (#2920)

* feat: added heading 3 in the editor summary markings

* feat: fixed editor and summary bar sizing

* feat: added `issue-embed` extension

* feat: exposed issue embed extension

* feat: added main embed config configuration to document editor body

* feat: added peek overview and issue embed fetch function

* feat: enabled slash commands to take additonal suggestions from editors

* chore: replaced `IssueEmbedWidget` into widget extension

* chore: removed issue embed from previous places

* feat: added issue embed suggestion extension

* feat: added issue embed suggestion renderer

* feat: added issue embed suggestions into extensions module

* feat: added issues in issueEmbedConfiguration in document editor

* chore: package fixes

* chore: removed log statements

* feat: added title updation logic into document editor

* fix: issue suggestion items, not rendering issue widget on enter

* feat: added error card for issue widget

* feat: improved focus logic for issue search and navigate

* feat: appended transactionid for issueWidgetTransaction

* chore: packages update

* feat: disabled editing of title in readonly mode

* feat: added issueEmbedConfig in readonly editor

* fix: issue suggestions not loading after structure changed to object

* feat: added toast messages for success/error messages from doc editor

* fix: issue suggestions sorting issue

* fix: formatting errors resolved

* fix: infinite reloading of the readonly document editor

* fix: css in avatar of issue widget card

* feat: added show alert on pages reload

* feat: added saving state for the pages editor

* fix: issue with heading 3 in side bar view

* style: updated issue suggestions dropdown ui

* fix: Pages intiliazation and mutation with updated MobX store

* fixed image uploads being cancelled on refocus due to swr

* fix: issue with same description rerendering empty content fixed

* fix: scroll in issue suggestion view

* fix: added submission prop

* fix: Updated the comment update to take issue id in inbox issues

* feat:changed date representation in IssueEmbedCard

* fix: page details mutation with optimistic updates using swr

* fix: menu options in read only editor with auth fixed

* fix: add error handling for title and page desc

* fixed yarn.lock

* fix: read-only editor title wrapping

* fix: build error with rich text editor

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com>
This commit is contained in:
Henit Chobisa 2023-12-07 12:04:21 +05:30 committed by Aaryan Khandelwal
parent 7397313543
commit f7e140bf68
33 changed files with 1412 additions and 381 deletions

View File

@ -14,7 +14,10 @@ import {
interface CustomEditorProps {
uploadFile: UploadImage;
restoreFile: RestoreImage;
text_html?: string;
rerenderOnPropsChange?: {
id: string;
description_html: string;
};
deleteFile: DeleteImage;
cancelUploadImage?: () => any;
setIsSubmitting?: (
@ -38,7 +41,7 @@ export const useEditor = ({
cancelUploadImage,
editorProps = {},
value,
text_html,
rerenderOnPropsChange,
extensions = [],
onStart,
onChange,
@ -79,7 +82,7 @@ export const useEditor = ({
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
},
},
[text_html],
[rerenderOnPropsChange],
);
const editorRef: MutableRefObject<Editor | null> = useRef(null);

View File

@ -1,10 +1,5 @@
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import {
useImperativeHandle,
useRef,
MutableRefObject,
useEffect,
} from "react";
import { useImperativeHandle, useRef, MutableRefObject } from "react";
import { CoreReadOnlyEditorExtensions } from "../read-only/extensions";
import { CoreReadOnlyEditorProps } from "../read-only/props";
import { EditorProps } from "@tiptap/pm/view";
@ -15,6 +10,10 @@ interface CustomReadOnlyEditorProps {
forwardedRef?: any;
extensions?: any;
editorProps?: EditorProps;
rerenderOnPropsChange?: {
id: string;
description_html: string;
};
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
}
@ -24,10 +23,12 @@ export const useReadOnlyEditor = ({
forwardedRef,
extensions = [],
editorProps = {},
rerenderOnPropsChange,
mentionHighlights,
mentionSuggestions,
}: CustomReadOnlyEditorProps) => {
const editor = useCustomEditor({
const editor = useCustomEditor(
{
editable: false,
content:
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
@ -42,15 +43,9 @@ export const useReadOnlyEditor = ({
}),
...extensions,
],
});
const hasIntiliazedContent = useRef(false);
useEffect(() => {
if (editor && !value && !hasIntiliazedContent.current) {
editor.commands.setContent(value);
hasIntiliazedContent.current = true;
}
}, [value]);
},
[rerenderOnPropsChange],
);
const editorRef: MutableRefObject<Editor | null> = useRef(null);
editorRef.current = editor;

View File

@ -28,15 +28,22 @@
"react-dom": "18.2.0"
},
"dependencies": {
"@plane/ui": "*",
"@plane/editor-core": "*",
"@plane/editor-extensions": "*",
"@plane/editor-types": "*",
"@plane/ui": "*",
"@tiptap/core": "^2.1.7",
"@tiptap/extension-placeholder": "^2.1.11",
"@tiptap/pm": "^2.1.12",
"@tiptap/suggestion": "^2.1.12",
"@types/node": "18.15.3",
"@types/react": "^18.2.39",
"@types/react-dom": "18.0.11",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"react-popper": "^2.3.0"
"react-popper": "^2.3.0",
"tippy.js": "^6.3.7",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/node": "18.15.3",

View File

@ -1,4 +1,8 @@
import { HeadingComp, SubheadingComp } from "./heading-component";
import {
HeadingComp,
HeadingThreeComp,
SubheadingComp,
} from "./heading-component";
import { IMarking } from "..";
import { Editor } from "@tiptap/react";
import { scrollSummary } from "../utils/editor-summary-utils";
@ -22,11 +26,16 @@ export const ContentBrowser = (props: ContentBrowserProps) => {
onClick={() => scrollSummary(editor, marking)}
heading={marking.text}
/>
) : (
) : marking.level === 2 ? (
<SubheadingComp
onClick={() => scrollSummary(editor, marking)}
subHeading={marking.text}
/>
) : (
<HeadingThreeComp
heading={marking.text}
onClick={() => scrollSummary(editor, marking)}
/>
),
)
) : (

View File

@ -1,7 +1,8 @@
import { Editor } from "@tiptap/react";
import { Archive, Info, Lock } from "lucide-react";
import { IMarking, UploadImage } from "..";
import { Archive, RefreshCw, Lock } from "lucide-react";
import { IMarking } from "..";
import { FixedMenu } from "../menu";
import { UploadImage } from "@plane/editor-types";
import { DocumentDetails } from "../types/editor-types";
import { AlertLabel } from "./alert-label";
import {
@ -26,6 +27,7 @@ interface IEditorHeader {
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
documentDetails: DocumentDetails;
isSubmitting?: "submitting" | "submitted" | "saved";
}
export const EditorHeader = (props: IEditorHeader) => {
@ -42,6 +44,7 @@ export const EditorHeader = (props: IEditorHeader) => {
KanbanMenuOptions,
isArchived,
isLocked,
isSubmitting,
} = props;
return (
@ -82,6 +85,21 @@ export const EditorHeader = (props: IEditorHeader) => {
label={`Archived at ${new Date(archivedAt).toLocaleString()}`}
/>
)}
{!isLocked && !isArchived ? (
<div
className={`flex absolute right-[120px] transition-all duration-300 items-center gap-x-2 ${
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
}`}
>
{isSubmitting !== "submitted" && isSubmitting !== "saved" && (
<RefreshCw className="h-4 w-4 stroke-custom-text-300" />
)}
<span className="text-sm text-custom-text-300">
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
</span>
</div>
) : null}
{!isArchived && <InfoPopover documentDetails={documentDetails} />}
<VerticalDropdownMenu items={KanbanMenuOptions} />
</div>

View File

@ -29,3 +29,19 @@ export const SubheadingComp = ({
{subHeading}
</p>
);
export const HeadingThreeComp = ({
heading,
onClick,
}: {
heading: string;
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
}) => (
<p
onClick={onClick}
className="ml-8 mt-2 text-xs cursor-pointer font-medium tracking-tight text-gray-400 hover:text-custom-primary"
role="button"
>
{heading}
</p>
);

View File

@ -48,7 +48,7 @@ export const InfoPopover: React.FC<Props> = (props) => {
onMouseEnter={() => setIsPopoverOpen(true)}
onMouseLeave={() => setIsPopoverOpen(false)}
>
<button type="button" ref={setReferenceElement} className="block mt-1.5">
<button type="button" ref={setReferenceElement} className="block">
<Info className="h-3.5 w-3.5" />
</button>
{isPopoverOpen && (

View File

@ -1,13 +1,28 @@
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core";
import { Editor } from "@tiptap/react";
import { useState } from "react";
import { DocumentDetails } from "../types/editor-types";
interface IPageRenderer {
type IPageRenderer = {
documentDetails: DocumentDetails;
updatePageTitle: (title: string) => Promise<void>;
editor: Editor;
editorClassNames: string;
editorContentCustomClassNames?: string;
}
readonly: boolean;
};
const debounce = (func: (...args: any[]) => void, wait: number) => {
let timeout: NodeJS.Timeout | null = null;
return function executedFunction(...args: any[]) {
const later = () => {
if (timeout) clearTimeout(timeout);
func(...args);
};
if (timeout) clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
export const PageRenderer = (props: IPageRenderer) => {
const {
@ -15,13 +30,35 @@ export const PageRenderer = (props: IPageRenderer) => {
editor,
editorClassNames,
editorContentCustomClassNames,
updatePageTitle,
readonly,
} = props;
const [pageTitle, setPagetitle] = useState(documentDetails.title);
const debouncedUpdatePageTitle = debounce(updatePageTitle, 300);
const handlePageTitleChange = (title: string) => {
setPagetitle(title);
debouncedUpdatePageTitle(title);
};
return (
<div className="w-full pl-7 pt-5 pb-64">
<h1 className="text-4xl font-bold break-words pr-5 -mt-2">
{documentDetails.title}
</h1>
{!readonly ? (
<input
onChange={(e) => handlePageTitleChange(e.target.value)}
className="text-4xl bg-custom-background font-bold break-words pr-5 -mt-2 w-full border-none outline-none"
value={pageTitle}
/>
) : (
<input
onChange={(e) => handlePageTitleChange(e.target.value)}
className="text-4xl bg-custom-background font-bold break-words pr-5 -mt-2 w-full border-none outline-none overflow-x-clip"
value={pageTitle}
disabled
/>
)}
<div className="flex flex-col h-full w-full pr-5">
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<EditorContentWrapper

View File

@ -39,8 +39,8 @@ const VerticalDropdownItem = ({
export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => {
return (
<CustomMenu
maxHeight={"lg"}
className={"h-4"}
maxHeight={"md"}
className={"h-4.5 mt-1"}
placement={"bottom-start"}
optionsClassName={
"border-custom-border border-r border-solid transition-all duration-200 ease-in-out "

View File

@ -1,16 +1,41 @@
import Placeholder from "@tiptap/extension-placeholder";
import { SlashCommand } from "@plane/editor-extensions";
import { IssueWidgetExtension } from "./widgets/IssueEmbedWidget";
import { UploadImage } from "@plane/editor-types";
import { DragAndDrop } from "@plane/editor-extensions";
import { IIssueEmbedConfig } from "./widgets/IssueEmbedWidget/types";
import { SlashCommand, DragAndDrop } from "@plane/editor-extensions";
import { ISlashCommandItem, UploadImage } from "@plane/editor-types";
import { IssueSuggestions } from "./widgets/IssueEmbedSuggestionList";
import { LayersIcon } from "@plane/ui";
export const DocumentEditorExtensions = (
uploadFile: UploadImage,
issueEmbedConfig?: IIssueEmbedConfig,
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
) => [
SlashCommand(uploadFile, setIsSubmitting),
) => {
const additonalOptions: ISlashCommandItem[] = [
{
title: "Issue Embed",
description: "Embed an issue from the project",
searchTerms: ["Issue", "Iss"],
icon: <LayersIcon height={"20px"} width={"20px"} />,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.insertContentAt(
range,
"<p class='text-sm bg-gray-300 w-fit pl-3 pr-3 pt-1 pb-1 rounded shadow-sm'>#issue_</p>",
)
.run();
},
},
];
return [
SlashCommand(uploadFile, setIsSubmitting, additonalOptions),
DragAndDrop,
Placeholder.configure({
placeholder: ({ node }) => {
@ -25,4 +50,7 @@ export const DocumentEditorExtensions = (
},
includeChildren: true,
}),
];
IssueWidgetExtension({ issueEmbedConfig }),
IssueSuggestions(issueEmbedConfig ? issueEmbedConfig.issues : []),
];
};

View File

@ -0,0 +1,56 @@
import { Editor, Range } from "@tiptap/react";
import { IssueEmbedSuggestions } from "./issue-suggestion-extension";
import { getIssueSuggestionItems } from "./issue-suggestion-items";
import { IssueListRenderer } from "./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 => {
let transactionId = uuidv4();
return {
title: suggestion.name,
priority: suggestion.priority.toString(),
identifier: `${suggestion.project_detail.identifier}-${suggestion.sequence_id}`,
state: suggestion.state_detail.name,
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

@ -0,0 +1,38 @@
import { Extension, Range } from "@tiptap/core";
import { PluginKey } from "@tiptap/pm/state";
import { Editor } from "@tiptap/react";
import Suggestion from "@tiptap/suggestion";
export const IssueEmbedSuggestions = Extension.create({
name: "issue-embed-suggestions",
addOptions() {
return {
suggestion: {
command: ({
editor,
range,
props,
}: {
editor: Editor;
range: Range;
props: any;
}) => {
props.command({ editor, range });
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
char: "#issue_",
pluginKey: new PluginKey("issue-embed-suggestions"),
editor: this.editor,
allowSpaces: true,
...this.options.suggestion,
}),
];
},
});

View File

@ -0,0 +1,18 @@
import { IIssueListSuggestion } from ".";
export const getIssueSuggestionItems = (
issueSuggestions: Array<IIssueListSuggestion>,
) => {
return ({ query }: { query: string }) => {
const search = query.toLowerCase();
const filteredSuggestions = issueSuggestions.filter((item) => {
return (
item.title.toLowerCase().includes(search) ||
item.identifier.toLowerCase().includes(search) ||
item.priority.toLowerCase().includes(search)
);
});
return filteredSuggestions;
};
};

View File

@ -0,0 +1,279 @@
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(() => {
let newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {};
let totalLength = 0;
sections.forEach((section) => {
newDisplayedItems[section] = items
.filter((item) => item.state === section)
.slice(0, 5);
totalLength += newDisplayedItems[section].length;
});
setDisplayedTotalLength(totalLength);
setDisplayedItems(newDisplayedItems);
}, [items]);
const selectItem = useCallback(
(index: number) => {
const item = displayedItems[currentSection][index];
if (item) {
command(item);
}
},
[command, displayedItems, currentSection],
);
useEffect(() => {
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"];
const onKeyDown = (e: KeyboardEvent) => {
if (navigationKeys.includes(e.key)) {
e.preventDefault();
// 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(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] fixed 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"
>
{sections.map((section) => {
const sectionItems = displayedItems[section];
return (
sectionItems &&
sectionItems.length > 0 && (
<div
className={"h-full w-full flex flex-col"}
key={`${section}-container`}
id={`${section}-container`}
>
<h6
className={
"sticky top-0 z-[10] bg-custom-background-100 text-xs text-custom-text-400 font-medium px-2 py-1"
}
>
{section}
</h6>
<div
key={section}
id={section}
className={"max-h-[140px] overflow-y-scroll overflow-x-hidden"}
>
{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={index}
onClick={() => selectItem(index)}
>
<h5 className="text-xs text-custom-text-300 whitespace-nowrap">
{item.identifier}
</h5>
<PriorityIcon priority={item.priority} />
<div>
<p className="flex-grow text-xs truncate">
{item.title}
</p>
</div>
</button>
),
)}
</div>
</div>
)
);
})}
</div>
) : null;
};
export const IssueListRenderer = () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
component = new ReactRenderer(IssueSuggestionList, {
props,
// @ts-ignore
editor: props.editor,
});
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.querySelector("#editor-container"),
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "right",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
// @ts-ignore
return component?.ref?.onKeyDown(props);
},
onExit: (e) => {
popup?.[0].destroy();
setTimeout(() => {
component?.destroy();
}, 300);
},
};
};

View File

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

View File

@ -0,0 +1,89 @@
// @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";
const IssueWidgetCard = (props) => {
const [loading, setLoading] = useState<number>(1);
const [issueDetails, setIssueDetails] = useState();
useEffect(() => {
props.issueEmbedConfig
.fetchIssue(props.node.attrs.entity_identifier)
.then((issue) => {
setIssueDetails(issue);
setLoading(0);
})
.catch((error) => {
console.log(error);
setLoading(-1);
});
}, []);
const completeIssueEmbedAction = () => {
props.issueEmbedConfig.clickAction(issueDetails.id, props.node.attrs.title);
};
return (
<NodeViewWrapper className="issue-embed-component m-2">
{loading == 0 ? (
<div
onClick={completeIssueEmbedAction}
className="cursor-pointer w-full space-y-2 border-[0.5px] border-custom-border-200 rounded-md 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 items-center flex-wrap gap-x-3 gap-y-2">
<div>
<PriorityIcon priority={issueDetails.priority} />
</div>
<div>
<AvatarGroup size="sm">
{issueDetails.assignee_details.map((assignee) => {
return (
<Avatar
key={assignee.id}
name={assignee.display_name}
src={assignee.avatar}
className={"m-0"}
/>
);
})}
</AvatarGroup>
</div>
{issueDetails.target_date && (
<div className="rounded flex px-2.5 py-1 items-center border-[0.5px] border-custom-border-300 gap-1 text-custom-text-100 text-xs h-5">
<Calendar className="h-3 w-3" strokeWidth={1.5} />
{new Date(issueDetails.target_date).toLocaleDateString()}
</div>
)}
</div>
</div>
) : loading == -1 ? (
<div className="flex gap-[8px] items-center pb-[10px] pt-[10px] pl-[13px] rounded border-[#D97706] border-2 bg-[#FFFBEB] 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 border-[0.5px] border-custom-border-200 rounded-md p-3 shadow-custom-shadow-2xs">
<Loader className={"px-6"}>
<Loader.Item height={"30px"} />
<div className={"space-y-2 mt-3"}>
<Loader.Item height={"20px"} width={"70%"} />
<Loader.Item height={"20px"} width={"60%"} />
</div>
</Loader>
</div>
)}
</NodeViewWrapper>
);
};
export default IssueWidgetCard;

View File

@ -0,0 +1,68 @@
import { mergeAttributes, Node } from "@tiptap/core";
import IssueWidgetCard from "./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

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

View File

@ -10,18 +10,26 @@ export const useEditorMarkings = () => {
const tempMarkings: IMarking[] = [];
let h1Sequence: number = 0;
let h2Sequence: number = 0;
let h3Sequence: number = 0;
if (nodes) {
nodes.forEach((node) => {
if (
node.type === "heading" &&
(node.attrs.level === 1 || node.attrs.level === 2) &&
(node.attrs.level === 1 ||
node.attrs.level === 2 ||
node.attrs.level === 3) &&
node.content
) {
tempMarkings.push({
type: "heading",
level: node.attrs.level,
text: node.content[0].text,
sequence: node.attrs.level === 1 ? ++h1Sequence : ++h2Sequence,
sequence:
node.attrs.level === 1
? ++h1Sequence
: node.attrs.level === 2
? ++h2Sequence
: ++h3Sequence,
});
}
});

View File

@ -14,14 +14,30 @@ import { DocumentDetails } from "./types/editor-types";
import { PageRenderer } from "./components/page-renderer";
import { getMenuOptions } from "./utils/menu-options";
import { useRouter } from "next/router";
import { IEmbedConfig } from "./extensions/widgets/IssueEmbedWidget/types";
import { UploadImage, DeleteImage, RestoreImage } from "@plane/editor-types";
interface IDocumentEditor {
// document info
documentDetails: DocumentDetails;
value: string;
rerenderOnPropsChange: {
id: string;
description_html: string;
};
// file operations
uploadFile: UploadImage;
deleteFile: DeleteImage;
restoreFile: RestoreImage;
cancelUploadImage: () => any;
// editor state managers
onActionCompleteHandler: (action: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
customClassName?: string;
editorContentCustomClassNames?: string;
onChange: (json: any, html: string) => void;
@ -30,10 +46,15 @@ interface IDocumentEditor {
) => void;
setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any;
updatePageTitle: (title: string) => Promise<void>;
debouncedUpdatesEnabled?: boolean;
isSubmitting: "submitting" | "submitted" | "saved";
// embed configuration
duplicationConfig?: IDuplicationConfig;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
embedConfig?: IEmbedConfig;
}
interface DocumentEditorProps extends IDocumentEditor {
forwardedRef?: React.Ref<EditorHandle>;
@ -62,11 +83,17 @@ const DocumentEditor = ({
uploadFile,
deleteFile,
restoreFile,
isSubmitting,
customClassName,
forwardedRef,
duplicationConfig,
pageLockConfig,
pageArchiveConfig,
embedConfig,
updatePageTitle,
cancelUploadImage,
onActionCompleteHandler,
rerenderOnPropsChange,
}: IDocumentEditor) => {
// const [alert, setAlert] = useState<string>("")
const { markings, updateMarkings } = useEditorMarkings();
@ -88,8 +115,14 @@ const DocumentEditor = ({
value,
uploadFile,
deleteFile,
cancelUploadImage,
rerenderOnPropsChange,
forwardedRef,
extensions: DocumentEditorExtensions(uploadFile, setIsSubmitting),
extensions: DocumentEditorExtensions(
uploadFile,
embedConfig?.issueEmbedConfig,
setIsSubmitting,
),
});
if (!editor) {
@ -102,7 +135,9 @@ const DocumentEditor = ({
duplicationConfig: duplicationConfig,
pageLockConfig: pageLockConfig,
pageArchiveConfig: pageArchiveConfig,
onActionCompleteHandler,
});
const editorClassNames = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
@ -126,6 +161,7 @@ const DocumentEditor = ({
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
documentDetails={documentDetails}
isSubmitting={isSubmitting}
/>
<div className="h-full w-full flex overflow-y-auto">
<div className="flex-shrink-0 h-full w-56 lg:w-72 sticky top-0">
@ -137,10 +173,12 @@ const DocumentEditor = ({
</div>
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)]">
<PageRenderer
readonly={false}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
editorClassNames={editorClassNames}
documentDetails={documentDetails}
updatePageTitle={updatePageTitle}
/>
</div>
<div className="hidden lg:block flex-shrink-0 w-56 lg:w-72" />

View File

@ -4,6 +4,8 @@ import { useState, forwardRef, useEffect } from "react";
import { EditorHeader } from "../components/editor-header";
import { PageRenderer } from "../components/page-renderer";
import { SummarySideBar } from "../components/summary-side-bar";
import { IssueWidgetExtension } from "../extensions/widgets/IssueEmbedWidget";
import { IEmbedConfig } from "../extensions/widgets/IssueEmbedWidget/types";
import { useEditorMarkings } from "../hooks/use-editor-markings";
import { DocumentDetails } from "../types/editor-types";
import {
@ -15,6 +17,10 @@ import { getMenuOptions } from "../utils/menu-options";
interface IDocumentReadOnlyEditor {
value: string;
rerenderOnPropsChange?: {
id: string;
description_html: string;
};
noBorder: boolean;
borderOnFocus: boolean;
customClassName: string;
@ -22,6 +28,12 @@ interface IDocumentReadOnlyEditor {
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
pageDuplicationConfig?: IDuplicationConfig;
onActionCompleteHandler: (action: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
embedConfig?: IEmbedConfig;
}
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
@ -43,6 +55,9 @@ const DocumentReadOnlyEditor = ({
pageDuplicationConfig,
pageLockConfig,
pageArchiveConfig,
embedConfig,
rerenderOnPropsChange,
onActionCompleteHandler,
}: DocumentReadOnlyEditorProps) => {
const router = useRouter();
const [sidePeekVisible, setSidePeekVisible] = useState(true);
@ -51,13 +66,17 @@ const DocumentReadOnlyEditor = ({
const editor = useReadOnlyEditor({
value,
forwardedRef,
rerenderOnPropsChange,
extensions: [
IssueWidgetExtension({ issueEmbedConfig: embedConfig?.issueEmbedConfig }),
],
});
useEffect(() => {
if (editor) {
updateMarkings(editor.getJSON());
}
}, [editor?.getJSON()]);
}, [editor]);
if (!editor) {
return null;
@ -75,6 +94,7 @@ const DocumentReadOnlyEditor = ({
pageArchiveConfig: pageArchiveConfig,
pageLockConfig: pageLockConfig,
duplicationConfig: pageDuplicationConfig,
onActionCompleteHandler,
});
return (
@ -101,6 +121,8 @@ const DocumentReadOnlyEditor = ({
</div>
<div className="h-full w-full">
<PageRenderer
updatePageTitle={() => Promise.resolve()}
readonly={true}
editor={editor}
editorClassNames={editorClassNames}
documentDetails={documentDetails}

View File

@ -25,6 +25,11 @@ export interface MenuOptionsProps {
duplicationConfig?: IDuplicationConfig;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
onActionCompleteHandler: (action: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
}
export const getMenuOptions = ({
@ -33,13 +38,21 @@ export const getMenuOptions = ({
duplicationConfig,
pageLockConfig,
pageArchiveConfig,
onActionCompleteHandler,
}: MenuOptionsProps) => {
const KanbanMenuOptions: IVerticalDropdownItemProps[] = [
{
key: 1,
type: "copy_markdown",
Icon: ClipboardIcon,
action: () => copyMarkdownToClipboard(editor),
action: () => {
onActionCompleteHandler({
title: "Markdown Copied",
message: "Page Copied as Markdown",
type: "success",
});
copyMarkdownToClipboard(editor);
},
label: "Copy markdown",
},
// {
@ -53,7 +66,14 @@ export const getMenuOptions = ({
key: 3,
type: "copy_page_link",
Icon: Link,
action: () => CopyPageLink(),
action: () => {
onActionCompleteHandler({
title: "Link Copied",
message: "Link to the page has been copied to clipboard",
type: "success",
});
CopyPageLink();
},
label: "Copy page link",
},
];
@ -64,7 +84,25 @@ export const getMenuOptions = ({
key: KanbanMenuOptions.length++,
type: "duplicate_page",
Icon: Copy,
action: duplicationConfig.action,
action: () => {
duplicationConfig
.action()
.then(() => {
onActionCompleteHandler({
title: "Page Copied",
message:
"Page has been copied as 'Copy of' followed by page title",
type: "success",
});
})
.catch(() => {
onActionCompleteHandler({
title: "Copy Failed",
message: "Sorry, page cannot be copied, please try again later.",
type: "error",
});
});
},
label: "Make a copy",
});
}
@ -75,7 +113,25 @@ export const getMenuOptions = ({
type: pageLockConfig.is_locked ? "unlock_page" : "lock_page",
Icon: pageLockConfig.is_locked ? Unlock : Lock,
label: pageLockConfig.is_locked ? "Unlock page" : "Lock page",
action: pageLockConfig.action,
action: () => {
const state = pageLockConfig.is_locked ? "Unlocked" : "Locked";
pageLockConfig
.action()
.then(() => {
onActionCompleteHandler({
title: `Page ${state}`,
message: `Page has been ${state}, no one will be able to change the state of lock except you.`,
type: "success",
});
})
.catch(() => {
onActionCompleteHandler({
title: `Page cannot be ${state}`,
message: `Sorry, page cannot be ${state}, please try again later`,
type: "error",
});
});
},
});
}
@ -86,7 +142,25 @@ export const getMenuOptions = ({
type: pageArchiveConfig.is_archived ? "unarchive_page" : "archive_page",
Icon: pageArchiveConfig.is_archived ? ArchiveRestoreIcon : Archive,
label: pageArchiveConfig.is_archived ? "Restore page" : "Archive page",
action: pageArchiveConfig.action,
action: () => {
const state = pageArchiveConfig.is_archived ? "Unarchived" : "Archived";
pageArchiveConfig
.action()
.then(() => {
onActionCompleteHandler({
title: `Page ${state}`,
message: `Page has been ${state}, you can checkout all archived tab and can restore the page later.`,
type: "success",
});
})
.catch(() => {
onActionCompleteHandler({
title: `Page cannot be ${state}`,
message: `Sorry, page cannot be ${state}, please try again later.`,
type: "success",
});
});
},
});
}

View File

@ -10,7 +10,7 @@ import { Editor, Range, Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
import tippy from "tippy.js";
import type { UploadImage } from "@plane/editor-types";
import type { UploadImage, ISlashCommandItem, CommandProps } from "@plane/editor-types";
import {
Heading1,
Heading2,
@ -44,11 +44,6 @@ interface CommandItemProps {
icon: ReactNode;
}
interface CommandProps {
editor: Editor;
range: Range;
}
const Command = Extension.create({
name: "slash-command",
addOptions() {
@ -88,9 +83,10 @@ const getSuggestionItems =
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
additonalOptions?: Array<ISlashCommandItem>
) =>
({ query }: { query: string }) =>
[
({ query }: { query: string }) => {
let slashCommands: ISlashCommandItem[] = [
{
title: "Text",
description: "Just start typing with plain text.",
@ -204,7 +200,15 @@ const getSuggestionItems =
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
},
},
].filter((item) => {
]
if (additonalOptions) {
additonalOptions.map(item => {
slashCommands.push(item)
})
}
slashCommands = slashCommands.filter((item) => {
if (typeof query === "string" && query.length > 0) {
const search = query.toLowerCase();
return (
@ -215,7 +219,10 @@ const getSuggestionItems =
);
}
return true;
});
})
return slashCommands
};
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
@ -376,10 +383,11 @@ export const SlashCommand = (
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
additonalOptions?: Array<ISlashCommandItem>,
) =>
Command.configure({
suggestion: {
items: getSuggestionItems(uploadFile, setIsSubmitting),
items: getSuggestionItems(uploadFile, setIsSubmitting, additonalOptions),
render: renderItems,
},
});

View File

@ -24,7 +24,10 @@ export type IRichTextEditor = {
noBorder?: boolean;
borderOnFocus?: boolean;
cancelUploadImage?: () => any;
text_html?: string;
rerenderOnPropsChange?: {
id: string;
description_html: string;
};
customClassName?: string;
editorContentCustomClassNames?: string;
onChange?: (json: any, html: string) => void;
@ -49,7 +52,6 @@ interface EditorHandle {
const RichTextEditor = ({
onChange,
text_html,
dragDropEnabled,
debouncedUpdatesEnabled,
setIsSubmitting,
@ -65,6 +67,7 @@ const RichTextEditor = ({
restoreFile,
forwardedRef,
mentionHighlights,
rerenderOnPropsChange,
mentionSuggestions,
}: RichTextEditorProps) => {
const editor = useEditor({
@ -78,7 +81,7 @@ const RichTextEditor = ({
deleteFile,
restoreFile,
forwardedRef,
text_html,
rerenderOnPropsChange,
extensions: RichTextEditorExtensions(
uploadFile,
setIsSubmitting,

View File

@ -32,6 +32,7 @@
"eslint-config-next": "13.2.4"
},
"devDependencies": {
"@tiptap/core": "^2.1.12",
"@types/node": "18.15.3",
"@types/react": "^18.2.39",
"@types/react-dom": "^18.2.14",

View File

@ -5,3 +5,4 @@ export type {
IMentionHighlight,
IMentionSuggestion,
} from "./types/mention-suggestion";
export type { ISlashCommandItem, CommandProps } from "./types/slash-commands-suggestion"

View File

@ -0,0 +1,15 @@
import { ReactNode } from "react";
import { Editor, Range } from "@tiptap/core"
export type CommandProps = {
editor: Editor;
range: Range;
}
export type ISlashCommandItem = {
title: string;
description: string;
searchTerms: string[];
icon: ReactNode;
command: ({ editor, range }: CommandProps) => void;
}

View File

@ -22,9 +22,13 @@ const issueCommentService = new IssueCommentService();
export const InboxIssueActivity: React.FC<Props> = observer(({ issueDetails }) => {
const router = useRouter();
const { workspaceSlug, projectId, inboxIssueId } = router.query;
const { workspaceSlug, projectId } = router.query;
const { user: userStore, trackEvent: { postHogEventTracker }, workspace: { currentWorkspace } } = useMobxStore();
const {
user: userStore,
trackEvent: { postHogEventTracker },
workspace: { currentWorkspace },
} = useMobxStore();
const { setToastAlert } = useToast();
@ -38,50 +42,48 @@ export const InboxIssueActivity: React.FC<Props> = observer(({ issueDetails }) =
const user = userStore.currentUser;
const handleCommentUpdate = async (commentId: string, data: Partial<IIssueComment>) => {
if (!workspaceSlug || !projectId || !inboxIssueId || !user) return;
if (!workspaceSlug || !projectId || !issueDetails.id || !user) return;
await issueCommentService
.patchIssueComment(workspaceSlug as string, projectId as string, inboxIssueId as string, commentId, data)
.patchIssueComment(workspaceSlug as string, projectId as string, issueDetails.id as string, commentId, data)
.then((res) => {
mutateIssueActivity();
postHogEventTracker(
"COMMENT_UPDATED",
{
...res,
state: "SUCCESS"
state: "SUCCESS",
},
{
isGrouping: true,
groupType: "Workspace_metrics",
gorupId: currentWorkspace?.id!
}
);
gorupId: currentWorkspace?.id!,
}
);
});
};
const handleCommentDelete = async (commentId: string) => {
if (!workspaceSlug || !projectId || !inboxIssueId || !user) return;
if (!workspaceSlug || !projectId || !issueDetails.id || !user) return;
mutateIssueActivity((prevData: any) => prevData?.filter((p: any) => p.id !== commentId), false);
await issueCommentService
.deleteIssueComment(workspaceSlug as string, projectId as string, inboxIssueId as string, commentId)
.deleteIssueComment(workspaceSlug as string, projectId as string, issueDetails.id as string, commentId)
.then(() => {
mutateIssueActivity();
postHogEventTracker(
"COMMENT_DELETED",
{
state: "SUCCESS"
state: "SUCCESS",
},
{
isGrouping: true,
groupType: "Workspace_metrics",
gorupId: currentWorkspace?.id!
}
);
gorupId: currentWorkspace?.id!,
}
);
});
};
const handleAddComment = async (formData: IIssueComment) => {
@ -95,12 +97,12 @@ export const InboxIssueActivity: React.FC<Props> = observer(({ issueDetails }) =
"COMMENT_ADDED",
{
...res,
state: "SUCCESS"
state: "SUCCESS",
},
{
isGrouping: true,
groupType: "Workspace_metrics",
gorupId: currentWorkspace?.id!
gorupId: currentWorkspace?.id!,
}
);
})

View File

@ -56,11 +56,16 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
});
const [localTitleValue, setLocalTitleValue] = useState("");
const [localIssueDescription, setLocalIssueDescription] = useState("");
const [localIssueDescription, setLocalIssueDescription] = useState({
id: issue.id,
description_html: issue.description_html,
});
console.log("in form", localIssueDescription);
useEffect(() => {
if (issue.id) {
setLocalIssueDescription(issue.description_html);
setLocalIssueDescription({ id: issue.id, description_html: issue.description_html });
setLocalTitleValue(issue.name);
}
}, [issue.id, issue.name, issue.description_html]);
@ -153,8 +158,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
value={localIssueDescription}
text_html={localIssueDescription}
value={localIssueDescription.description_html}
rerenderOnPropsChange={localIssueDescription}
setShouldShowAlert={setShowAlert}
setIsSubmitting={setIsSubmitting}
dragDropEnabled

View File

@ -78,12 +78,15 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
[issue, issueUpdate]
);
const [localTitleValue, setLocalTitleValue] = useState(issue.name);
const [localIssueDescription, setLocalIssueDescription] = useState(issue.description_html);
const [localTitleValue, setLocalTitleValue] = useState("");
const [localIssueDescription, setLocalIssueDescription] = useState({
id: issue.id,
description_html: issue.description_html,
});
useEffect(() => {
if (issue.id) {
setLocalIssueDescription(issue.description_html);
setLocalIssueDescription({ id: issue.id, description_html: issue.description_html });
setLocalTitleValue(issue.name);
}
}, [issue.id]);
@ -168,8 +171,8 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
value={localIssueDescription}
text_html={localIssueDescription}
value={localIssueDescription.description_html}
rerenderOnPropsChange={localIssueDescription}
setShouldShowAlert={setShowAlert}
setIsSubmitting={setIsSubmitting}
dragDropEnabled

View File

@ -1,9 +1,7 @@
import React, { useEffect, useRef, useState, ReactElement } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import useSWR, { MutatorOptions } from "swr";
import { Controller, useForm } from "react-hook-form";
import { Sparkle } from "lucide-react";
import { observer } from "mobx-react-lite";
// services
import { PageService } from "services/page.service";
import { FileService } from "services/file.service";
@ -16,7 +14,6 @@ import { AppLayout } from "layouts/app-layout";
// components
import { PageDetailsHeader } from "components/headers/page-details";
import { EmptyState } from "components/common";
import { GptAssistantModal } from "components/core";
// ui
import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor";
import { Spinner } from "@plane/ui";
@ -26,158 +23,52 @@ import emptyPage from "public/empty-state/page.svg";
import { renderDateFormat } from "helpers/date-time.helper";
// types
import { NextPageWithLayout } from "types/app";
import { IPage } from "types";
import { IPage, IIssue } from "types";
// fetch-keys
import { PAGE_DETAILS } from "constants/fetch-keys";
import { PAGE_DETAILS, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
import { IssuePeekOverview } from "components/issues/issue-peek-overview";
import { IssueService } from "services/issue";
import useToast from "hooks/use-toast";
import useReloadConfirmations from "hooks/use-reload-confirmation";
import { EUserWorkspaceRoles } from "constants/workspace";
import { GptAssistantModal } from "components/core";
import { Sparkle } from "lucide-react";
import { observer } from "mobx-react-lite";
// services
const fileService = new FileService();
const pageService = new PageService();
const issueService = new IssueService();
const PageDetailsPage: NextPageWithLayout = observer(() => {
const editorRef = useRef<any>(null);
// states
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const [gptModalOpen, setGptModal] = useState(false);
// store
const {
projectIssues: { updateIssue },
appConfig: { envConfig },
user: { currentProjectRole },
} = useMobxStore();
// router
const editorRef = useRef<any>(null);
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const [gptModalOpen, setGptModal] = useState(false);
const { setShowAlert } = useReloadConfirmations();
const router = useRouter();
const { workspaceSlug, projectId, pageId } = router.query;
const { workspaceSlug, projectId, pageId, peekIssueId } = router.query;
const { setToastAlert } = useToast();
const { user } = useUser();
const { handleSubmit, reset, getValues, control, setValue, watch } = useForm<IPage>({
defaultValues: { name: "", description_html: "<p></p>" },
const { handleSubmit, reset, setValue, watch, getValues, control } = useForm<IPage>({
defaultValues: { name: "", description_html: "" },
});
// =================== Fetching Page Details ======================
const {
data: pageDetails,
mutate: mutatePageDetails,
error,
} = useSWR(
workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId.toString()) : null,
workspaceSlug && projectId && pageId
? () => pageService.getPageDetails(workspaceSlug.toString(), projectId.toString(), pageId.toString())
: null
const { data: issuesResponse } = useSWR(
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null
);
const updatePage = async (formData: IPage) => {
if (!workspaceSlug || !projectId || !pageId) return;
if (!formData.name || formData.name.length === 0 || formData.name === "") return;
await pageService
.patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), formData)
.then(() => {
mutatePageDetails(
(prevData) => ({
...prevData,
...formData,
}),
false
);
});
};
const createPage = async (payload: Partial<IPage>) => {
if (!workspaceSlug || !projectId) return;
await pageService.createPage(workspaceSlug.toString(), projectId.toString(), payload);
};
// ================ Page Menu Actions ==================
const duplicate_page = async () => {
const currentPageValues = getValues();
const formData: Partial<IPage> = {
name: "Copy of " + currentPageValues.name,
description_html: currentPageValues.description_html,
};
await createPage(formData);
};
const archivePage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
try {
mutatePageDetails((prevData) => {
if (!prevData) return;
return {
...prevData,
archived_at: renderDateFormat(new Date()),
};
}, true);
await pageService.archivePage(workspaceSlug.toString(), projectId.toString(), pageId.toString());
} catch (e) {
mutatePageDetails();
}
};
const unArchivePage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
try {
mutatePageDetails((prevData) => {
if (!prevData) return;
return {
...prevData,
archived_at: null,
};
}, false);
await pageService.restorePage(workspaceSlug.toString(), projectId.toString(), pageId.toString());
} catch (e) {
mutatePageDetails();
}
};
// ========================= Page Lock ==========================
const lockPage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
try {
mutatePageDetails((prevData) => {
if (!prevData) return;
return {
...prevData,
is_locked: true,
};
}, false);
await pageService.lockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString());
} catch (e) {
mutatePageDetails();
}
};
const unlockPage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
try {
mutatePageDetails((prevData) => {
if (!prevData) return;
return {
...prevData,
is_locked: false,
};
}, false);
await pageService.unlockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString());
} catch (e) {
mutatePageDetails();
}
};
const issues = Object.values(issuesResponse ?? {});
const handleAiAssistance = async (response: string) => {
if (!workspaceSlug || !projectId || !pageId) return;
@ -195,13 +86,245 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
});
};
useEffect(() => {
if (!pageDetails) return;
// =================== Fetching Page Details ======================
const {
data: pageDetails,
mutate: mutatePageDetails,
error,
} = useSWR(
workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId.toString()) : null,
workspaceSlug && projectId && pageId
? () => pageService.getPageDetails(workspaceSlug.toString(), projectId.toString(), pageId.toString())
: null,
{
revalidateOnFocus: false,
}
);
reset({
...pageDetails,
const handleUpdateIssue = (issueId: string, data: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !user) return;
updateIssue(workspaceSlug.toString(), projectId.toString(), issueId, data);
};
const fetchIssue = async (issueId: string) => {
const issue = await issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string);
return issue as IIssue;
};
const issueWidgetClickAction = (issueId: string, issueTitle: string) => {
const url = new URL(router.asPath, window.location.origin);
const params = new URLSearchParams(url.search);
if (params.has("peekIssueId")) {
params.set("peekIssueId", issueId);
} else {
params.append("peekIssueId", issueId);
}
// Replace the current URL with the new one
router.replace(`${url.pathname}?${params.toString()}`, undefined, { shallow: true });
};
const actionCompleteAlert = ({
title,
message,
type,
}: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => {
setToastAlert({
title,
message,
type,
});
};
useEffect(() => {
if (isSubmitting === "submitted") {
setShowAlert(false);
setTimeout(async () => {
setIsSubmitting("saved");
}, 2000);
} else if (isSubmitting === "submitting") {
setShowAlert(true);
}
}, [isSubmitting, setShowAlert]);
useEffect(() => {
if (pageDetails?.description_html) {
setLocalIssueDescription({ id: pageId as string, description_html: pageDetails.description_html });
}
}, [pageDetails?.description_html]);
function createObjectFromArray(keys: string[], options: any): any {
return keys.reduce((obj, key) => {
if (options[key] !== undefined) {
obj[key] = options[key];
}
return obj;
}, {} as { [key: string]: any });
}
const mutatePageDetailsHelper = (
serverMutatorFn: Promise<any>,
dataToMutate: Partial<IPage>,
formDataValues: Array<keyof IPage>,
onErrorAction: () => void
) => {
const commonSwrOptions: MutatorOptions = {
revalidate: true,
populateCache: false,
rollbackOnError: (e) => {
onErrorAction();
return true;
},
};
const formData = getValues();
const formDataMutationObject = createObjectFromArray(formDataValues, formData);
mutatePageDetails(async () => serverMutatorFn, {
optimisticData: (prevData) => {
if (!prevData) return;
return {
...prevData,
description_html: formData["description_html"],
...formDataMutationObject,
...dataToMutate,
};
},
...commonSwrOptions,
});
};
const updatePage = async (formData: IPage) => {
if (!workspaceSlug || !projectId || !pageId) return;
formData.name = pageDetails?.name as string;
if (!formData?.name || formData?.name.length === 0) return;
try {
await pageService.patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), formData);
} catch (error) {
actionCompleteAlert({
title: `Page could not be updated`,
message: `Sorry, page could not be updated, please try again later`,
type: "error",
});
}
};
const updatePageTitle = async (title: string) => {
if (!workspaceSlug || !projectId || !pageId) return;
mutatePageDetailsHelper(
pageService.patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), { name: title }),
{
name: title,
},
[],
() =>
actionCompleteAlert({
title: `Page Title could not be updated`,
message: `Sorry, page title could not be updated, please try again later`,
type: "error",
})
);
};
const createPage = async (payload: Partial<IPage>) => {
if (!workspaceSlug || !projectId) return;
await pageService.createPage(workspaceSlug.toString(), projectId.toString(), payload);
};
// ================ Page Menu Actions ==================
const duplicate_page = async () => {
const currentPageValues = getValues();
const formData: Partial<IPage> = {
name: "Copy of " + pageDetails?.name,
description_html: currentPageValues.description_html,
};
await createPage(formData);
};
const archivePage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
mutatePageDetailsHelper(
pageService.archivePage(workspaceSlug.toString(), projectId.toString(), pageId.toString()),
{
archived_at: renderDateFormat(new Date()),
},
["description_html"],
() =>
actionCompleteAlert({
title: `Page could not be Archived`,
message: `Sorry, page could not be Archived, please try again later`,
type: "error",
})
);
};
const unArchivePage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
mutatePageDetailsHelper(
pageService.restorePage(workspaceSlug.toString(), projectId.toString(), pageId.toString()),
{
archived_at: null,
},
["description_html"],
() =>
actionCompleteAlert({
title: `Page could not be Restored`,
message: `Sorry, page could not be Restored, please try again later`,
type: "error",
})
);
};
// ========================= Page Lock ==========================
const lockPage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
mutatePageDetailsHelper(
pageService.lockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString()),
{
is_locked: true,
},
["description_html"],
() =>
actionCompleteAlert({
title: `Page cannot be Locked`,
message: `Sorry, page cannot be Locked, please try again later`,
type: "error",
})
);
};
const unlockPage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
mutatePageDetailsHelper(
pageService.unlockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString()),
{
is_locked: false,
},
["description_html"],
() =>
actionCompleteAlert({
title: `Page could not be Unlocked`,
message: `Sorry, page could not be Unlocked, please try again later`,
type: "error",
})
);
};
const [localPageDescription, setLocalIssueDescription] = useState({
id: pageId as string,
description_html: "",
});
}, [reset, pageDetails]);
const debouncedFormSave = debounce(async () => {
handleSubmit(updatePage)().finally(() => setIsSubmitting("submitted"));
@ -222,6 +345,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
const isPageReadOnly =
pageDetails?.is_locked ||
pageDetails?.archived_at ||
(currentProjectRole && [EUserWorkspaceRoles.VIEWER, EUserWorkspaceRoles.GUEST].includes(currentProjectRole));
const isCurrentUserOwner = pageDetails?.owned_by === user?.id;
@ -234,14 +358,16 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
return (
<>
{pageDetails ? (
{pageDetails && issuesResponse ? (
<div className="flex h-full flex-col justify-between">
<div className="h-full w-full overflow-hidden">
{isPageReadOnly ? (
<DocumentReadOnlyEditorWithRef
onActionCompleteHandler={actionCompleteAlert}
ref={editorRef}
value={pageDetails.description_html}
customClassName={"tracking-tight self-center w-full max-w-full px-0"}
value={localPageDescription.description_html}
rerenderOnPropsChange={localPageDescription}
customClassName={"tracking-tight w-full px-0"}
borderOnFocus={false}
noBorder
documentDetails={{
@ -252,12 +378,15 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
last_updated_by: pageDetails.updated_by,
}}
pageLockConfig={
!pageDetails.archived_at && user && pageDetails.owned_by === user.id
userCanLock && !pageDetails.archived_at
? { action: unlockPage, is_locked: pageDetails.is_locked }
: undefined
}
pageDuplicationConfig={
userCanDuplicate && !pageDetails.archived_at ? { action: duplicate_page } : undefined
}
pageArchiveConfig={
user && pageDetails.owned_by === user.id
userCanArchive
? {
action: pageDetails.archived_at ? unArchivePage : archivePage,
is_archived: pageDetails.archived_at ? true : false,
@ -265,6 +394,13 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
}
: undefined
}
embedConfig={{
issueEmbedConfig: {
issues: issues,
fetchIssue: fetchIssue,
clickAction: issueWidgetClickAction,
},
}}
/>
) : (
<div className="h-full w-full relative overflow-hidden">
@ -273,6 +409,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
control={control}
render={({ field: { value, onChange } }) => (
<DocumentEditorWithRef
isSubmitting={isSubmitting}
documentDetails={{
title: pageDetails.name,
created_by: pageDetails.created_by,
@ -281,14 +418,20 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
last_updated_by: pageDetails.updated_by,
}}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
setShouldShowAlert={setShowAlert}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
cancelUploadImage={fileService.cancelUpload}
ref={editorRef}
debouncedUpdatesEnabled={false}
setIsSubmitting={setIsSubmitting}
value={!value || value === "" ? "<p></p>" : value}
updatePageTitle={updatePageTitle}
value={localPageDescription.description_html}
rerenderOnPropsChange={localPageDescription}
onActionCompleteHandler={actionCompleteAlert}
customClassName="tracking-tight self-center px-0 h-full w-full"
onChange={(_description_json: Object, description_html: string) => {
setShowAlert(true);
onChange(description_html);
setIsSubmitting("submitting");
debouncedFormSave();
@ -303,6 +446,13 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
: undefined
}
pageLockConfig={userCanLock ? { is_locked: false, action: lockPage } : undefined}
embedConfig={{
issueEmbedConfig: {
issues: issues,
fetchIssue: fetchIssue,
clickAction: issueWidgetClickAction,
},
}}
/>
)}
/>
@ -310,7 +460,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
<>
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 absolute top-3 right-[68px]"
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 absolute top-2.5 right-[68px]"
onClick={() => setGptModal((prevData) => !prevData)}
>
<Sparkle className="h-4 w-4" />
@ -332,6 +482,17 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
)}
</div>
)}
<IssuePeekOverview
workspaceSlug={workspaceSlug as string}
projectId={projectId as string}
issueId={peekIssueId ? (peekIssueId as string) : ""}
isArchived={false}
handleIssue={(issueToUpdate) => {
if (peekIssueId && typeof peekIssueId === "string") {
handleUpdateIssue(peekIssueId, issueToUpdate);
}
}}
/>
</div>
</div>
) : (

View File

@ -21,6 +21,7 @@ export class PageService extends APIService {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, data)
.then((response) => response?.data)
.catch((error) => {
console.log("error", error?.response?.data);
throw error?.response?.data;
});
}
@ -165,7 +166,7 @@ export class PageService extends APIService {
// =============== Archiving & Unarchiving Pages =================
async archivePage(workspaceSlug: string, projectId: string, pageId: string): Promise<void> {
this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/archive/`)
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/archive/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
@ -173,7 +174,7 @@ export class PageService extends APIService {
}
async restorePage(workspaceSlug: string, projectId: string, pageId: string): Promise<void> {
this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/unarchive/`)
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/unarchive/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
@ -189,7 +190,7 @@ export class PageService extends APIService {
}
// ==================== Pages Locking Services ==========================
async lockPage(workspaceSlug: string, projectId: string, pageId: string): Promise<any> {
this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/lock/`)
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/lock/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
@ -197,7 +198,7 @@ export class PageService extends APIService {
}
async unlockPage(workspaceSlug: string, projectId: string, pageId: string): Promise<any> {
this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/unlock/`)
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/unlock/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;

View File

@ -2356,7 +2356,7 @@
lodash.merge "^4.6.2"
postcss-selector-parser "6.0.10"
"@tiptap/core@^2.1.11", "@tiptap/core@^2.1.13", "@tiptap/core@^2.1.7":
"@tiptap/core@^2.1.11", "@tiptap/core@^2.1.12", "@tiptap/core@^2.1.13", "@tiptap/core@^2.1.7":
version "2.1.13"
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.1.13.tgz#e21f566e81688c826c6f26d2940886734189e193"
integrity sha512-cMC8bgTN63dj1Mv82iDeeLl6sa9kY0Pug8LSalxVEptRmyFVsVxGgu2/6Y3T+9aCYScxfS06EkA8SdzFMAwYTQ==
@ -2537,7 +2537,7 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-2.1.13.tgz#170b4e8e3f03b9defbb7de7cafe4b0a066cea679"
integrity sha512-z0CNKPjcvU8TrUSTui1voM7owssyXE9WvEGhIZMHzWwlx2ZXY2/L5+Hh33X/LzSKB9OGf/g1HAuHxrPcYxFuAQ==
"@tiptap/pm@^2.1.7":
"@tiptap/pm@^2.1.12", "@tiptap/pm@^2.1.7":
version "2.1.13"
resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-2.1.13.tgz#857753691580be760da13629fab2712c52750741"
integrity sha512-zNbA7muWsHuVg12GrTgN/j119rLePPq5M8dZgkKxUwdw8VmU3eUyBp1SihPEXJ2U0MGdZhNhFX7Y74g11u66sg==
@ -2599,7 +2599,7 @@
"@tiptap/extension-strike" "^2.1.13"
"@tiptap/extension-text" "^2.1.13"
"@tiptap/suggestion@^2.0.4":
"@tiptap/suggestion@^2.0.4", "@tiptap/suggestion@^2.1.12":
version "2.1.13"
resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-2.1.13.tgz#0a8317260baed764a523a09099c0889a0e5b507e"
integrity sha512-Y05TsiXTFAJ5SrfoV+21MAxig5UNbY0AVa03lQlh/yicTRPpIc6hgZzblB0uxDSYoj6+kaHE4MIZvPvhUD8BJQ==
@ -2776,6 +2776,13 @@
date-fns "^2.0.1"
react-popper "^2.2.5"
"@types/react-dom@18.0.11":
version "18.0.11"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.11.tgz#321351c1459bc9ca3d216aefc8a167beec334e33"
integrity sha512-O38bPbI2CWtgw/OoQoY+BRelw7uysmXbWvw3nLWO21H1HSh+GOlqPuXshJfjmpNlKiiSDG9cc1JZAaMmVdcTlw==
dependencies:
"@types/react" "*"
"@types/react-dom@^18.2.14", "@types/react-dom@^18.2.17":
version "18.2.17"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.17.tgz#375c55fab4ae671bd98448dcfa153268d01d6f64"
@ -8774,7 +8781,7 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2:
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
uuid@^9.0.0:
uuid@^9.0.0, uuid@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"
integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==