forked from github/plane
[ 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:
parent
7397313543
commit
f7e140bf68
@ -14,7 +14,10 @@ import {
|
|||||||
interface CustomEditorProps {
|
interface CustomEditorProps {
|
||||||
uploadFile: UploadImage;
|
uploadFile: UploadImage;
|
||||||
restoreFile: RestoreImage;
|
restoreFile: RestoreImage;
|
||||||
text_html?: string;
|
rerenderOnPropsChange?: {
|
||||||
|
id: string;
|
||||||
|
description_html: string;
|
||||||
|
};
|
||||||
deleteFile: DeleteImage;
|
deleteFile: DeleteImage;
|
||||||
cancelUploadImage?: () => any;
|
cancelUploadImage?: () => any;
|
||||||
setIsSubmitting?: (
|
setIsSubmitting?: (
|
||||||
@ -38,7 +41,7 @@ export const useEditor = ({
|
|||||||
cancelUploadImage,
|
cancelUploadImage,
|
||||||
editorProps = {},
|
editorProps = {},
|
||||||
value,
|
value,
|
||||||
text_html,
|
rerenderOnPropsChange,
|
||||||
extensions = [],
|
extensions = [],
|
||||||
onStart,
|
onStart,
|
||||||
onChange,
|
onChange,
|
||||||
@ -79,7 +82,7 @@ export const useEditor = ({
|
|||||||
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
|
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[text_html],
|
[rerenderOnPropsChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||||
|
@ -1,10 +1,5 @@
|
|||||||
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
||||||
import {
|
import { useImperativeHandle, useRef, MutableRefObject } from "react";
|
||||||
useImperativeHandle,
|
|
||||||
useRef,
|
|
||||||
MutableRefObject,
|
|
||||||
useEffect,
|
|
||||||
} from "react";
|
|
||||||
import { CoreReadOnlyEditorExtensions } from "../read-only/extensions";
|
import { CoreReadOnlyEditorExtensions } from "../read-only/extensions";
|
||||||
import { CoreReadOnlyEditorProps } from "../read-only/props";
|
import { CoreReadOnlyEditorProps } from "../read-only/props";
|
||||||
import { EditorProps } from "@tiptap/pm/view";
|
import { EditorProps } from "@tiptap/pm/view";
|
||||||
@ -15,6 +10,10 @@ interface CustomReadOnlyEditorProps {
|
|||||||
forwardedRef?: any;
|
forwardedRef?: any;
|
||||||
extensions?: any;
|
extensions?: any;
|
||||||
editorProps?: EditorProps;
|
editorProps?: EditorProps;
|
||||||
|
rerenderOnPropsChange?: {
|
||||||
|
id: string;
|
||||||
|
description_html: string;
|
||||||
|
};
|
||||||
mentionHighlights?: string[];
|
mentionHighlights?: string[];
|
||||||
mentionSuggestions?: IMentionSuggestion[];
|
mentionSuggestions?: IMentionSuggestion[];
|
||||||
}
|
}
|
||||||
@ -24,33 +23,29 @@ export const useReadOnlyEditor = ({
|
|||||||
forwardedRef,
|
forwardedRef,
|
||||||
extensions = [],
|
extensions = [],
|
||||||
editorProps = {},
|
editorProps = {},
|
||||||
|
rerenderOnPropsChange,
|
||||||
mentionHighlights,
|
mentionHighlights,
|
||||||
mentionSuggestions,
|
mentionSuggestions,
|
||||||
}: CustomReadOnlyEditorProps) => {
|
}: CustomReadOnlyEditorProps) => {
|
||||||
const editor = useCustomEditor({
|
const editor = useCustomEditor(
|
||||||
editable: false,
|
{
|
||||||
content:
|
editable: false,
|
||||||
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
content:
|
||||||
editorProps: {
|
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
||||||
...CoreReadOnlyEditorProps,
|
editorProps: {
|
||||||
...editorProps,
|
...CoreReadOnlyEditorProps,
|
||||||
|
...editorProps,
|
||||||
|
},
|
||||||
|
extensions: [
|
||||||
|
...CoreReadOnlyEditorExtensions({
|
||||||
|
mentionSuggestions: mentionSuggestions ?? [],
|
||||||
|
mentionHighlights: mentionHighlights ?? [],
|
||||||
|
}),
|
||||||
|
...extensions,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
extensions: [
|
[rerenderOnPropsChange],
|
||||||
...CoreReadOnlyEditorExtensions({
|
);
|
||||||
mentionSuggestions: mentionSuggestions ?? [],
|
|
||||||
mentionHighlights: mentionHighlights ?? [],
|
|
||||||
}),
|
|
||||||
...extensions,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasIntiliazedContent = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (editor && !value && !hasIntiliazedContent.current) {
|
|
||||||
editor.commands.setContent(value);
|
|
||||||
hasIntiliazedContent.current = true;
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
|
@ -28,15 +28,22 @@
|
|||||||
"react-dom": "18.2.0"
|
"react-dom": "18.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@plane/ui": "*",
|
|
||||||
"@plane/editor-core": "*",
|
"@plane/editor-core": "*",
|
||||||
"@plane/editor-extensions": "*",
|
"@plane/editor-extensions": "*",
|
||||||
"@plane/editor-types": "*",
|
"@plane/editor-types": "*",
|
||||||
|
"@plane/ui": "*",
|
||||||
"@tiptap/core": "^2.1.7",
|
"@tiptap/core": "^2.1.7",
|
||||||
"@tiptap/extension-placeholder": "^2.1.11",
|
"@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": "8.36.0",
|
||||||
"eslint-config-next": "13.2.4",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/node": "18.15.3",
|
"@types/node": "18.15.3",
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
import { HeadingComp, SubheadingComp } from "./heading-component";
|
import {
|
||||||
|
HeadingComp,
|
||||||
|
HeadingThreeComp,
|
||||||
|
SubheadingComp,
|
||||||
|
} from "./heading-component";
|
||||||
import { IMarking } from "..";
|
import { IMarking } from "..";
|
||||||
import { Editor } from "@tiptap/react";
|
import { Editor } from "@tiptap/react";
|
||||||
import { scrollSummary } from "../utils/editor-summary-utils";
|
import { scrollSummary } from "../utils/editor-summary-utils";
|
||||||
@ -22,11 +26,16 @@ export const ContentBrowser = (props: ContentBrowserProps) => {
|
|||||||
onClick={() => scrollSummary(editor, marking)}
|
onClick={() => scrollSummary(editor, marking)}
|
||||||
heading={marking.text}
|
heading={marking.text}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : marking.level === 2 ? (
|
||||||
<SubheadingComp
|
<SubheadingComp
|
||||||
onClick={() => scrollSummary(editor, marking)}
|
onClick={() => scrollSummary(editor, marking)}
|
||||||
subHeading={marking.text}
|
subHeading={marking.text}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<HeadingThreeComp
|
||||||
|
heading={marking.text}
|
||||||
|
onClick={() => scrollSummary(editor, marking)}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { Editor } from "@tiptap/react";
|
import { Editor } from "@tiptap/react";
|
||||||
import { Archive, Info, Lock } from "lucide-react";
|
import { Archive, RefreshCw, Lock } from "lucide-react";
|
||||||
import { IMarking, UploadImage } from "..";
|
import { IMarking } from "..";
|
||||||
import { FixedMenu } from "../menu";
|
import { FixedMenu } from "../menu";
|
||||||
|
import { UploadImage } from "@plane/editor-types";
|
||||||
import { DocumentDetails } from "../types/editor-types";
|
import { DocumentDetails } from "../types/editor-types";
|
||||||
import { AlertLabel } from "./alert-label";
|
import { AlertLabel } from "./alert-label";
|
||||||
import {
|
import {
|
||||||
@ -26,6 +27,7 @@ interface IEditorHeader {
|
|||||||
isSubmitting: "submitting" | "submitted" | "saved",
|
isSubmitting: "submitting" | "submitted" | "saved",
|
||||||
) => void;
|
) => void;
|
||||||
documentDetails: DocumentDetails;
|
documentDetails: DocumentDetails;
|
||||||
|
isSubmitting?: "submitting" | "submitted" | "saved";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditorHeader = (props: IEditorHeader) => {
|
export const EditorHeader = (props: IEditorHeader) => {
|
||||||
@ -42,6 +44,7 @@ export const EditorHeader = (props: IEditorHeader) => {
|
|||||||
KanbanMenuOptions,
|
KanbanMenuOptions,
|
||||||
isArchived,
|
isArchived,
|
||||||
isLocked,
|
isLocked,
|
||||||
|
isSubmitting,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -82,6 +85,21 @@ export const EditorHeader = (props: IEditorHeader) => {
|
|||||||
label={`Archived at ${new Date(archivedAt).toLocaleString()}`}
|
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} />}
|
{!isArchived && <InfoPopover documentDetails={documentDetails} />}
|
||||||
<VerticalDropdownMenu items={KanbanMenuOptions} />
|
<VerticalDropdownMenu items={KanbanMenuOptions} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -29,3 +29,19 @@ export const SubheadingComp = ({
|
|||||||
{subHeading}
|
{subHeading}
|
||||||
</p>
|
</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>
|
||||||
|
);
|
||||||
|
@ -48,7 +48,7 @@ export const InfoPopover: React.FC<Props> = (props) => {
|
|||||||
onMouseEnter={() => setIsPopoverOpen(true)}
|
onMouseEnter={() => setIsPopoverOpen(true)}
|
||||||
onMouseLeave={() => setIsPopoverOpen(false)}
|
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" />
|
<Info className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
{isPopoverOpen && (
|
{isPopoverOpen && (
|
||||||
|
@ -1,13 +1,28 @@
|
|||||||
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core";
|
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core";
|
||||||
import { Editor } from "@tiptap/react";
|
import { Editor } from "@tiptap/react";
|
||||||
|
import { useState } from "react";
|
||||||
import { DocumentDetails } from "../types/editor-types";
|
import { DocumentDetails } from "../types/editor-types";
|
||||||
|
|
||||||
interface IPageRenderer {
|
type IPageRenderer = {
|
||||||
documentDetails: DocumentDetails;
|
documentDetails: DocumentDetails;
|
||||||
|
updatePageTitle: (title: string) => Promise<void>;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
editorClassNames: string;
|
editorClassNames: string;
|
||||||
editorContentCustomClassNames?: 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) => {
|
export const PageRenderer = (props: IPageRenderer) => {
|
||||||
const {
|
const {
|
||||||
@ -15,13 +30,35 @@ export const PageRenderer = (props: IPageRenderer) => {
|
|||||||
editor,
|
editor,
|
||||||
editorClassNames,
|
editorClassNames,
|
||||||
editorContentCustomClassNames,
|
editorContentCustomClassNames,
|
||||||
|
updatePageTitle,
|
||||||
|
readonly,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const [pageTitle, setPagetitle] = useState(documentDetails.title);
|
||||||
|
|
||||||
|
const debouncedUpdatePageTitle = debounce(updatePageTitle, 300);
|
||||||
|
|
||||||
|
const handlePageTitleChange = (title: string) => {
|
||||||
|
setPagetitle(title);
|
||||||
|
debouncedUpdatePageTitle(title);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full pl-7 pt-5 pb-64">
|
<div className="w-full pl-7 pt-5 pb-64">
|
||||||
<h1 className="text-4xl font-bold break-words pr-5 -mt-2">
|
{!readonly ? (
|
||||||
{documentDetails.title}
|
<input
|
||||||
</h1>
|
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">
|
<div className="flex flex-col h-full w-full pr-5">
|
||||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||||
<EditorContentWrapper
|
<EditorContentWrapper
|
||||||
|
@ -39,8 +39,8 @@ const VerticalDropdownItem = ({
|
|||||||
export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => {
|
export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => {
|
||||||
return (
|
return (
|
||||||
<CustomMenu
|
<CustomMenu
|
||||||
maxHeight={"lg"}
|
maxHeight={"md"}
|
||||||
className={"h-4"}
|
className={"h-4.5 mt-1"}
|
||||||
placement={"bottom-start"}
|
placement={"bottom-start"}
|
||||||
optionsClassName={
|
optionsClassName={
|
||||||
"border-custom-border border-r border-solid transition-all duration-200 ease-in-out "
|
"border-custom-border border-r border-solid transition-all duration-200 ease-in-out "
|
||||||
|
@ -1,28 +1,56 @@
|
|||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
import { SlashCommand } from "@plane/editor-extensions";
|
import { IssueWidgetExtension } from "./widgets/IssueEmbedWidget";
|
||||||
|
|
||||||
import { UploadImage } from "@plane/editor-types";
|
import { IIssueEmbedConfig } from "./widgets/IssueEmbedWidget/types";
|
||||||
import { DragAndDrop } from "@plane/editor-extensions";
|
|
||||||
|
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 = (
|
export const DocumentEditorExtensions = (
|
||||||
uploadFile: UploadImage,
|
uploadFile: UploadImage,
|
||||||
|
issueEmbedConfig?: IIssueEmbedConfig,
|
||||||
setIsSubmitting?: (
|
setIsSubmitting?: (
|
||||||
isSubmitting: "submitting" | "submitted" | "saved",
|
isSubmitting: "submitting" | "submitted" | "saved",
|
||||||
) => void,
|
) => void,
|
||||||
) => [
|
) => {
|
||||||
SlashCommand(uploadFile, setIsSubmitting),
|
const additonalOptions: ISlashCommandItem[] = [
|
||||||
DragAndDrop,
|
{
|
||||||
Placeholder.configure({
|
title: "Issue Embed",
|
||||||
placeholder: ({ node }) => {
|
description: "Embed an issue from the project",
|
||||||
if (node.type.name === "heading") {
|
searchTerms: ["Issue", "Iss"],
|
||||||
return `Heading ${node.attrs.level}`;
|
icon: <LayersIcon height={"20px"} width={"20px"} />,
|
||||||
}
|
command: ({ editor, range }) => {
|
||||||
if (node.type.name === "image" || node.type.name === "table") {
|
editor
|
||||||
return "";
|
.chain()
|
||||||
}
|
.focus()
|
||||||
|
.insertContentAt(
|
||||||
return "Press '/' for commands...";
|
range,
|
||||||
|
"<p class='text-sm bg-gray-300 w-fit pl-3 pr-3 pt-1 pb-1 rounded shadow-sm'>#issue_</p>",
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
includeChildren: true,
|
];
|
||||||
}),
|
|
||||||
];
|
return [
|
||||||
|
SlashCommand(uploadFile, setIsSubmitting, additonalOptions),
|
||||||
|
DragAndDrop,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: ({ node }) => {
|
||||||
|
if (node.type.name === "heading") {
|
||||||
|
return `Heading ${node.attrs.level}`;
|
||||||
|
}
|
||||||
|
if (node.type.name === "image" || node.type.name === "table") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Press '/' for commands...";
|
||||||
|
},
|
||||||
|
includeChildren: true,
|
||||||
|
}),
|
||||||
|
IssueWidgetExtension({ issueEmbedConfig }),
|
||||||
|
IssueSuggestions(issueEmbedConfig ? issueEmbedConfig.issues : []),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
@ -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,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
@ -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;
|
||||||
|
};
|
||||||
|
};
|
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
@ -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,
|
||||||
|
});
|
@ -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;
|
@ -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)];
|
||||||
|
},
|
||||||
|
});
|
@ -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>;
|
||||||
|
}
|
@ -10,18 +10,26 @@ export const useEditorMarkings = () => {
|
|||||||
const tempMarkings: IMarking[] = [];
|
const tempMarkings: IMarking[] = [];
|
||||||
let h1Sequence: number = 0;
|
let h1Sequence: number = 0;
|
||||||
let h2Sequence: number = 0;
|
let h2Sequence: number = 0;
|
||||||
|
let h3Sequence: number = 0;
|
||||||
if (nodes) {
|
if (nodes) {
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
if (
|
if (
|
||||||
node.type === "heading" &&
|
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
|
node.content
|
||||||
) {
|
) {
|
||||||
tempMarkings.push({
|
tempMarkings.push({
|
||||||
type: "heading",
|
type: "heading",
|
||||||
level: node.attrs.level,
|
level: node.attrs.level,
|
||||||
text: node.content[0].text,
|
text: node.content[0].text,
|
||||||
sequence: node.attrs.level === 1 ? ++h1Sequence : ++h2Sequence,
|
sequence:
|
||||||
|
node.attrs.level === 1
|
||||||
|
? ++h1Sequence
|
||||||
|
: node.attrs.level === 2
|
||||||
|
? ++h2Sequence
|
||||||
|
: ++h3Sequence,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -14,14 +14,30 @@ import { DocumentDetails } from "./types/editor-types";
|
|||||||
import { PageRenderer } from "./components/page-renderer";
|
import { PageRenderer } from "./components/page-renderer";
|
||||||
import { getMenuOptions } from "./utils/menu-options";
|
import { getMenuOptions } from "./utils/menu-options";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { IEmbedConfig } from "./extensions/widgets/IssueEmbedWidget/types";
|
||||||
import { UploadImage, DeleteImage, RestoreImage } from "@plane/editor-types";
|
import { UploadImage, DeleteImage, RestoreImage } from "@plane/editor-types";
|
||||||
|
|
||||||
interface IDocumentEditor {
|
interface IDocumentEditor {
|
||||||
|
// document info
|
||||||
documentDetails: DocumentDetails;
|
documentDetails: DocumentDetails;
|
||||||
value: string;
|
value: string;
|
||||||
|
rerenderOnPropsChange: {
|
||||||
|
id: string;
|
||||||
|
description_html: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// file operations
|
||||||
uploadFile: UploadImage;
|
uploadFile: UploadImage;
|
||||||
deleteFile: DeleteImage;
|
deleteFile: DeleteImage;
|
||||||
restoreFile: RestoreImage;
|
restoreFile: RestoreImage;
|
||||||
|
cancelUploadImage: () => any;
|
||||||
|
|
||||||
|
// editor state managers
|
||||||
|
onActionCompleteHandler: (action: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
type: "success" | "error" | "warning" | "info";
|
||||||
|
}) => void;
|
||||||
customClassName?: string;
|
customClassName?: string;
|
||||||
editorContentCustomClassNames?: string;
|
editorContentCustomClassNames?: string;
|
||||||
onChange: (json: any, html: string) => void;
|
onChange: (json: any, html: string) => void;
|
||||||
@ -30,10 +46,15 @@ interface IDocumentEditor {
|
|||||||
) => void;
|
) => void;
|
||||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||||
forwardedRef?: any;
|
forwardedRef?: any;
|
||||||
|
updatePageTitle: (title: string) => Promise<void>;
|
||||||
debouncedUpdatesEnabled?: boolean;
|
debouncedUpdatesEnabled?: boolean;
|
||||||
|
isSubmitting: "submitting" | "submitted" | "saved";
|
||||||
|
|
||||||
|
// embed configuration
|
||||||
duplicationConfig?: IDuplicationConfig;
|
duplicationConfig?: IDuplicationConfig;
|
||||||
pageLockConfig?: IPageLockConfig;
|
pageLockConfig?: IPageLockConfig;
|
||||||
pageArchiveConfig?: IPageArchiveConfig;
|
pageArchiveConfig?: IPageArchiveConfig;
|
||||||
|
embedConfig?: IEmbedConfig;
|
||||||
}
|
}
|
||||||
interface DocumentEditorProps extends IDocumentEditor {
|
interface DocumentEditorProps extends IDocumentEditor {
|
||||||
forwardedRef?: React.Ref<EditorHandle>;
|
forwardedRef?: React.Ref<EditorHandle>;
|
||||||
@ -62,11 +83,17 @@ const DocumentEditor = ({
|
|||||||
uploadFile,
|
uploadFile,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
restoreFile,
|
restoreFile,
|
||||||
|
isSubmitting,
|
||||||
customClassName,
|
customClassName,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
duplicationConfig,
|
duplicationConfig,
|
||||||
pageLockConfig,
|
pageLockConfig,
|
||||||
pageArchiveConfig,
|
pageArchiveConfig,
|
||||||
|
embedConfig,
|
||||||
|
updatePageTitle,
|
||||||
|
cancelUploadImage,
|
||||||
|
onActionCompleteHandler,
|
||||||
|
rerenderOnPropsChange,
|
||||||
}: IDocumentEditor) => {
|
}: IDocumentEditor) => {
|
||||||
// const [alert, setAlert] = useState<string>("")
|
// const [alert, setAlert] = useState<string>("")
|
||||||
const { markings, updateMarkings } = useEditorMarkings();
|
const { markings, updateMarkings } = useEditorMarkings();
|
||||||
@ -88,8 +115,14 @@ const DocumentEditor = ({
|
|||||||
value,
|
value,
|
||||||
uploadFile,
|
uploadFile,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
|
cancelUploadImage,
|
||||||
|
rerenderOnPropsChange,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
extensions: DocumentEditorExtensions(uploadFile, setIsSubmitting),
|
extensions: DocumentEditorExtensions(
|
||||||
|
uploadFile,
|
||||||
|
embedConfig?.issueEmbedConfig,
|
||||||
|
setIsSubmitting,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
@ -102,7 +135,9 @@ const DocumentEditor = ({
|
|||||||
duplicationConfig: duplicationConfig,
|
duplicationConfig: duplicationConfig,
|
||||||
pageLockConfig: pageLockConfig,
|
pageLockConfig: pageLockConfig,
|
||||||
pageArchiveConfig: pageArchiveConfig,
|
pageArchiveConfig: pageArchiveConfig,
|
||||||
|
onActionCompleteHandler,
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorClassNames = getEditorClassNames({
|
const editorClassNames = getEditorClassNames({
|
||||||
noBorder: true,
|
noBorder: true,
|
||||||
borderOnFocus: false,
|
borderOnFocus: false,
|
||||||
@ -126,6 +161,7 @@ const DocumentEditor = ({
|
|||||||
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
|
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
|
||||||
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
|
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
|
||||||
documentDetails={documentDetails}
|
documentDetails={documentDetails}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
/>
|
/>
|
||||||
<div className="h-full w-full flex overflow-y-auto">
|
<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">
|
<div className="flex-shrink-0 h-full w-56 lg:w-72 sticky top-0">
|
||||||
@ -137,10 +173,12 @@ const DocumentEditor = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)]">
|
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)]">
|
||||||
<PageRenderer
|
<PageRenderer
|
||||||
|
readonly={false}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||||
editorClassNames={editorClassNames}
|
editorClassNames={editorClassNames}
|
||||||
documentDetails={documentDetails}
|
documentDetails={documentDetails}
|
||||||
|
updatePageTitle={updatePageTitle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden lg:block flex-shrink-0 w-56 lg:w-72" />
|
<div className="hidden lg:block flex-shrink-0 w-56 lg:w-72" />
|
||||||
|
@ -4,6 +4,8 @@ import { useState, forwardRef, useEffect } from "react";
|
|||||||
import { EditorHeader } from "../components/editor-header";
|
import { EditorHeader } from "../components/editor-header";
|
||||||
import { PageRenderer } from "../components/page-renderer";
|
import { PageRenderer } from "../components/page-renderer";
|
||||||
import { SummarySideBar } from "../components/summary-side-bar";
|
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 { useEditorMarkings } from "../hooks/use-editor-markings";
|
||||||
import { DocumentDetails } from "../types/editor-types";
|
import { DocumentDetails } from "../types/editor-types";
|
||||||
import {
|
import {
|
||||||
@ -15,6 +17,10 @@ import { getMenuOptions } from "../utils/menu-options";
|
|||||||
|
|
||||||
interface IDocumentReadOnlyEditor {
|
interface IDocumentReadOnlyEditor {
|
||||||
value: string;
|
value: string;
|
||||||
|
rerenderOnPropsChange?: {
|
||||||
|
id: string;
|
||||||
|
description_html: string;
|
||||||
|
};
|
||||||
noBorder: boolean;
|
noBorder: boolean;
|
||||||
borderOnFocus: boolean;
|
borderOnFocus: boolean;
|
||||||
customClassName: string;
|
customClassName: string;
|
||||||
@ -22,6 +28,12 @@ interface IDocumentReadOnlyEditor {
|
|||||||
pageLockConfig?: IPageLockConfig;
|
pageLockConfig?: IPageLockConfig;
|
||||||
pageArchiveConfig?: IPageArchiveConfig;
|
pageArchiveConfig?: IPageArchiveConfig;
|
||||||
pageDuplicationConfig?: IDuplicationConfig;
|
pageDuplicationConfig?: IDuplicationConfig;
|
||||||
|
onActionCompleteHandler: (action: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
type: "success" | "error" | "warning" | "info";
|
||||||
|
}) => void;
|
||||||
|
embedConfig?: IEmbedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
|
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
|
||||||
@ -43,6 +55,9 @@ const DocumentReadOnlyEditor = ({
|
|||||||
pageDuplicationConfig,
|
pageDuplicationConfig,
|
||||||
pageLockConfig,
|
pageLockConfig,
|
||||||
pageArchiveConfig,
|
pageArchiveConfig,
|
||||||
|
embedConfig,
|
||||||
|
rerenderOnPropsChange,
|
||||||
|
onActionCompleteHandler,
|
||||||
}: DocumentReadOnlyEditorProps) => {
|
}: DocumentReadOnlyEditorProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [sidePeekVisible, setSidePeekVisible] = useState(true);
|
const [sidePeekVisible, setSidePeekVisible] = useState(true);
|
||||||
@ -51,13 +66,17 @@ const DocumentReadOnlyEditor = ({
|
|||||||
const editor = useReadOnlyEditor({
|
const editor = useReadOnlyEditor({
|
||||||
value,
|
value,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
|
rerenderOnPropsChange,
|
||||||
|
extensions: [
|
||||||
|
IssueWidgetExtension({ issueEmbedConfig: embedConfig?.issueEmbedConfig }),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
updateMarkings(editor.getJSON());
|
updateMarkings(editor.getJSON());
|
||||||
}
|
}
|
||||||
}, [editor?.getJSON()]);
|
}, [editor]);
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return null;
|
return null;
|
||||||
@ -75,6 +94,7 @@ const DocumentReadOnlyEditor = ({
|
|||||||
pageArchiveConfig: pageArchiveConfig,
|
pageArchiveConfig: pageArchiveConfig,
|
||||||
pageLockConfig: pageLockConfig,
|
pageLockConfig: pageLockConfig,
|
||||||
duplicationConfig: pageDuplicationConfig,
|
duplicationConfig: pageDuplicationConfig,
|
||||||
|
onActionCompleteHandler,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -101,6 +121,8 @@ const DocumentReadOnlyEditor = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<PageRenderer
|
<PageRenderer
|
||||||
|
updatePageTitle={() => Promise.resolve()}
|
||||||
|
readonly={true}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
editorClassNames={editorClassNames}
|
editorClassNames={editorClassNames}
|
||||||
documentDetails={documentDetails}
|
documentDetails={documentDetails}
|
||||||
|
@ -25,6 +25,11 @@ export interface MenuOptionsProps {
|
|||||||
duplicationConfig?: IDuplicationConfig;
|
duplicationConfig?: IDuplicationConfig;
|
||||||
pageLockConfig?: IPageLockConfig;
|
pageLockConfig?: IPageLockConfig;
|
||||||
pageArchiveConfig?: IPageArchiveConfig;
|
pageArchiveConfig?: IPageArchiveConfig;
|
||||||
|
onActionCompleteHandler: (action: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
type: "success" | "error" | "warning" | "info";
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getMenuOptions = ({
|
export const getMenuOptions = ({
|
||||||
@ -33,13 +38,21 @@ export const getMenuOptions = ({
|
|||||||
duplicationConfig,
|
duplicationConfig,
|
||||||
pageLockConfig,
|
pageLockConfig,
|
||||||
pageArchiveConfig,
|
pageArchiveConfig,
|
||||||
|
onActionCompleteHandler,
|
||||||
}: MenuOptionsProps) => {
|
}: MenuOptionsProps) => {
|
||||||
const KanbanMenuOptions: IVerticalDropdownItemProps[] = [
|
const KanbanMenuOptions: IVerticalDropdownItemProps[] = [
|
||||||
{
|
{
|
||||||
key: 1,
|
key: 1,
|
||||||
type: "copy_markdown",
|
type: "copy_markdown",
|
||||||
Icon: ClipboardIcon,
|
Icon: ClipboardIcon,
|
||||||
action: () => copyMarkdownToClipboard(editor),
|
action: () => {
|
||||||
|
onActionCompleteHandler({
|
||||||
|
title: "Markdown Copied",
|
||||||
|
message: "Page Copied as Markdown",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
copyMarkdownToClipboard(editor);
|
||||||
|
},
|
||||||
label: "Copy markdown",
|
label: "Copy markdown",
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
@ -53,7 +66,14 @@ export const getMenuOptions = ({
|
|||||||
key: 3,
|
key: 3,
|
||||||
type: "copy_page_link",
|
type: "copy_page_link",
|
||||||
Icon: 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",
|
label: "Copy page link",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -64,7 +84,25 @@ export const getMenuOptions = ({
|
|||||||
key: KanbanMenuOptions.length++,
|
key: KanbanMenuOptions.length++,
|
||||||
type: "duplicate_page",
|
type: "duplicate_page",
|
||||||
Icon: Copy,
|
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",
|
label: "Make a copy",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -75,7 +113,25 @@ export const getMenuOptions = ({
|
|||||||
type: pageLockConfig.is_locked ? "unlock_page" : "lock_page",
|
type: pageLockConfig.is_locked ? "unlock_page" : "lock_page",
|
||||||
Icon: pageLockConfig.is_locked ? Unlock : Lock,
|
Icon: pageLockConfig.is_locked ? Unlock : Lock,
|
||||||
label: pageLockConfig.is_locked ? "Unlock page" : "Lock page",
|
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",
|
type: pageArchiveConfig.is_archived ? "unarchive_page" : "archive_page",
|
||||||
Icon: pageArchiveConfig.is_archived ? ArchiveRestoreIcon : Archive,
|
Icon: pageArchiveConfig.is_archived ? ArchiveRestoreIcon : Archive,
|
||||||
label: pageArchiveConfig.is_archived ? "Restore page" : "Archive page",
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ import { Editor, Range, Extension } from "@tiptap/core";
|
|||||||
import Suggestion from "@tiptap/suggestion";
|
import Suggestion from "@tiptap/suggestion";
|
||||||
import { ReactRenderer } from "@tiptap/react";
|
import { ReactRenderer } from "@tiptap/react";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
import type { UploadImage } from "@plane/editor-types";
|
import type { UploadImage, ISlashCommandItem, CommandProps } from "@plane/editor-types";
|
||||||
import {
|
import {
|
||||||
Heading1,
|
Heading1,
|
||||||
Heading2,
|
Heading2,
|
||||||
@ -44,11 +44,6 @@ interface CommandItemProps {
|
|||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CommandProps {
|
|
||||||
editor: Editor;
|
|
||||||
range: Range;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Command = Extension.create({
|
const Command = Extension.create({
|
||||||
name: "slash-command",
|
name: "slash-command",
|
||||||
addOptions() {
|
addOptions() {
|
||||||
@ -88,134 +83,146 @@ const getSuggestionItems =
|
|||||||
setIsSubmitting?: (
|
setIsSubmitting?: (
|
||||||
isSubmitting: "submitting" | "submitted" | "saved",
|
isSubmitting: "submitting" | "submitted" | "saved",
|
||||||
) => void,
|
) => void,
|
||||||
|
additonalOptions?: Array<ISlashCommandItem>
|
||||||
) =>
|
) =>
|
||||||
({ query }: { query: string }) =>
|
({ query }: { query: string }) => {
|
||||||
[
|
let slashCommands: ISlashCommandItem[] = [
|
||||||
{
|
{
|
||||||
title: "Text",
|
title: "Text",
|
||||||
description: "Just start typing with plain text.",
|
description: "Just start typing with plain text.",
|
||||||
searchTerms: ["p", "paragraph"],
|
searchTerms: ["p", "paragraph"],
|
||||||
icon: <Text size={18} />,
|
icon: <Text size={18} />,
|
||||||
command: ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.deleteRange(range)
|
.deleteRange(range)
|
||||||
.toggleNode("paragraph", "paragraph")
|
.toggleNode("paragraph", "paragraph")
|
||||||
.run();
|
.run();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
title: "Heading 1",
|
||||||
title: "Heading 1",
|
description: "Big section heading.",
|
||||||
description: "Big section heading.",
|
searchTerms: ["title", "big", "large"],
|
||||||
searchTerms: ["title", "big", "large"],
|
icon: <Heading1 size={18} />,
|
||||||
icon: <Heading1 size={18} />,
|
command: ({ editor, range }: CommandProps) => {
|
||||||
command: ({ editor, range }: CommandProps) => {
|
toggleHeadingOne(editor, range);
|
||||||
toggleHeadingOne(editor, range);
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
title: "Heading 2",
|
||||||
title: "Heading 2",
|
description: "Medium section heading.",
|
||||||
description: "Medium section heading.",
|
searchTerms: ["subtitle", "medium"],
|
||||||
searchTerms: ["subtitle", "medium"],
|
icon: <Heading2 size={18} />,
|
||||||
icon: <Heading2 size={18} />,
|
command: ({ editor, range }: CommandProps) => {
|
||||||
command: ({ editor, range }: CommandProps) => {
|
toggleHeadingTwo(editor, range);
|
||||||
toggleHeadingTwo(editor, range);
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
title: "Heading 3",
|
||||||
title: "Heading 3",
|
description: "Small section heading.",
|
||||||
description: "Small section heading.",
|
searchTerms: ["subtitle", "small"],
|
||||||
searchTerms: ["subtitle", "small"],
|
icon: <Heading3 size={18} />,
|
||||||
icon: <Heading3 size={18} />,
|
command: ({ editor, range }: CommandProps) => {
|
||||||
command: ({ editor, range }: CommandProps) => {
|
toggleHeadingThree(editor, range);
|
||||||
toggleHeadingThree(editor, range);
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
title: "To-do List",
|
||||||
title: "To-do List",
|
description: "Track tasks with a to-do list.",
|
||||||
description: "Track tasks with a to-do list.",
|
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
icon: <CheckSquare size={18} />,
|
||||||
icon: <CheckSquare size={18} />,
|
command: ({ editor, range }: CommandProps) => {
|
||||||
command: ({ editor, range }: CommandProps) => {
|
toggleTaskList(editor, range);
|
||||||
toggleTaskList(editor, range);
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
title: "Bullet List",
|
||||||
title: "Bullet List",
|
description: "Create a simple bullet list.",
|
||||||
description: "Create a simple bullet list.",
|
searchTerms: ["unordered", "point"],
|
||||||
searchTerms: ["unordered", "point"],
|
icon: <List size={18} />,
|
||||||
icon: <List size={18} />,
|
command: ({ editor, range }: CommandProps) => {
|
||||||
command: ({ editor, range }: CommandProps) => {
|
toggleBulletList(editor, range);
|
||||||
toggleBulletList(editor, range);
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
title: "Divider",
|
||||||
title: "Divider",
|
description: "Visually divide blocks",
|
||||||
description: "Visually divide blocks",
|
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
||||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
icon: <MinusSquare size={18} />,
|
||||||
icon: <MinusSquare size={18} />,
|
command: ({ editor, range }: CommandProps) => {
|
||||||
command: ({ editor, range }: CommandProps) => {
|
// @ts-expect-error I have to move this to the core
|
||||||
// @ts-expect-error I have to move this to the core
|
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
title: "Table",
|
||||||
title: "Table",
|
description: "Create a Table",
|
||||||
description: "Create a Table",
|
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
icon: <Table size={18} />,
|
||||||
icon: <Table size={18} />,
|
command: ({ editor, range }: CommandProps) => {
|
||||||
command: ({ editor, range }: CommandProps) => {
|
insertTableCommand(editor, range);
|
||||||
insertTableCommand(editor, range);
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
title: "Numbered List",
|
||||||
title: "Numbered List",
|
description: "Create a list with numbering.",
|
||||||
description: "Create a list with numbering.",
|
searchTerms: ["ordered"],
|
||||||
searchTerms: ["ordered"],
|
icon: <ListOrdered size={18} />,
|
||||||
icon: <ListOrdered size={18} />,
|
command: ({ editor, range }: CommandProps) => {
|
||||||
command: ({ editor, range }: CommandProps) => {
|
toggleOrderedList(editor, range);
|
||||||
toggleOrderedList(editor, range);
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
title: "Quote",
|
||||||
title: "Quote",
|
description: "Capture a quote.",
|
||||||
description: "Capture a quote.",
|
searchTerms: ["blockquote"],
|
||||||
searchTerms: ["blockquote"],
|
icon: <TextQuote size={18} />,
|
||||||
icon: <TextQuote size={18} />,
|
command: ({ editor, range }: CommandProps) =>
|
||||||
command: ({ editor, range }: CommandProps) =>
|
toggleBlockquote(editor, range),
|
||||||
toggleBlockquote(editor, range),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Code",
|
|
||||||
description: "Capture a code snippet.",
|
|
||||||
searchTerms: ["codeblock"],
|
|
||||||
icon: <Code size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) =>
|
|
||||||
// @ts-expect-error I have to move this to the core
|
|
||||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Image",
|
|
||||||
description: "Upload an image from your computer.",
|
|
||||||
searchTerms: ["photo", "picture", "media"],
|
|
||||||
icon: <ImageIcon size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
].filter((item) => {
|
title: "Code",
|
||||||
if (typeof query === "string" && query.length > 0) {
|
description: "Capture a code snippet.",
|
||||||
const search = query.toLowerCase();
|
searchTerms: ["codeblock"],
|
||||||
return (
|
icon: <Code size={18} />,
|
||||||
item.title.toLowerCase().includes(search) ||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
item.description.toLowerCase().includes(search) ||
|
// @ts-expect-error I have to move this to the core
|
||||||
(item.searchTerms &&
|
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||||
item.searchTerms.some((term: string) => term.includes(search)))
|
},
|
||||||
);
|
{
|
||||||
|
title: "Image",
|
||||||
|
description: "Upload an image from your computer.",
|
||||||
|
searchTerms: ["photo", "picture", "media"],
|
||||||
|
icon: <ImageIcon size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (additonalOptions) {
|
||||||
|
additonalOptions.map(item => {
|
||||||
|
slashCommands.push(item)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
});
|
slashCommands = slashCommands.filter((item) => {
|
||||||
|
if (typeof query === "string" && query.length > 0) {
|
||||||
|
const search = query.toLowerCase();
|
||||||
|
return (
|
||||||
|
item.title.toLowerCase().includes(search) ||
|
||||||
|
item.description.toLowerCase().includes(search) ||
|
||||||
|
(item.searchTerms &&
|
||||||
|
item.searchTerms.some((term: string) => term.includes(search)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
|
||||||
|
return slashCommands
|
||||||
|
};
|
||||||
|
|
||||||
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||||
const containerHeight = container.offsetHeight;
|
const containerHeight = container.offsetHeight;
|
||||||
@ -376,10 +383,11 @@ export const SlashCommand = (
|
|||||||
setIsSubmitting?: (
|
setIsSubmitting?: (
|
||||||
isSubmitting: "submitting" | "submitted" | "saved",
|
isSubmitting: "submitting" | "submitted" | "saved",
|
||||||
) => void,
|
) => void,
|
||||||
|
additonalOptions?: Array<ISlashCommandItem>,
|
||||||
) =>
|
) =>
|
||||||
Command.configure({
|
Command.configure({
|
||||||
suggestion: {
|
suggestion: {
|
||||||
items: getSuggestionItems(uploadFile, setIsSubmitting),
|
items: getSuggestionItems(uploadFile, setIsSubmitting, additonalOptions),
|
||||||
render: renderItems,
|
render: renderItems,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -24,7 +24,10 @@ export type IRichTextEditor = {
|
|||||||
noBorder?: boolean;
|
noBorder?: boolean;
|
||||||
borderOnFocus?: boolean;
|
borderOnFocus?: boolean;
|
||||||
cancelUploadImage?: () => any;
|
cancelUploadImage?: () => any;
|
||||||
text_html?: string;
|
rerenderOnPropsChange?: {
|
||||||
|
id: string;
|
||||||
|
description_html: string;
|
||||||
|
};
|
||||||
customClassName?: string;
|
customClassName?: string;
|
||||||
editorContentCustomClassNames?: string;
|
editorContentCustomClassNames?: string;
|
||||||
onChange?: (json: any, html: string) => void;
|
onChange?: (json: any, html: string) => void;
|
||||||
@ -49,7 +52,6 @@ interface EditorHandle {
|
|||||||
|
|
||||||
const RichTextEditor = ({
|
const RichTextEditor = ({
|
||||||
onChange,
|
onChange,
|
||||||
text_html,
|
|
||||||
dragDropEnabled,
|
dragDropEnabled,
|
||||||
debouncedUpdatesEnabled,
|
debouncedUpdatesEnabled,
|
||||||
setIsSubmitting,
|
setIsSubmitting,
|
||||||
@ -65,6 +67,7 @@ const RichTextEditor = ({
|
|||||||
restoreFile,
|
restoreFile,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
mentionHighlights,
|
mentionHighlights,
|
||||||
|
rerenderOnPropsChange,
|
||||||
mentionSuggestions,
|
mentionSuggestions,
|
||||||
}: RichTextEditorProps) => {
|
}: RichTextEditorProps) => {
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
@ -78,7 +81,7 @@ const RichTextEditor = ({
|
|||||||
deleteFile,
|
deleteFile,
|
||||||
restoreFile,
|
restoreFile,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
text_html,
|
rerenderOnPropsChange,
|
||||||
extensions: RichTextEditorExtensions(
|
extensions: RichTextEditorExtensions(
|
||||||
uploadFile,
|
uploadFile,
|
||||||
setIsSubmitting,
|
setIsSubmitting,
|
||||||
|
@ -32,6 +32,7 @@
|
|||||||
"eslint-config-next": "13.2.4"
|
"eslint-config-next": "13.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tiptap/core": "^2.1.12",
|
||||||
"@types/node": "18.15.3",
|
"@types/node": "18.15.3",
|
||||||
"@types/react": "^18.2.39",
|
"@types/react": "^18.2.39",
|
||||||
"@types/react-dom": "^18.2.14",
|
"@types/react-dom": "^18.2.14",
|
||||||
|
@ -5,3 +5,4 @@ export type {
|
|||||||
IMentionHighlight,
|
IMentionHighlight,
|
||||||
IMentionSuggestion,
|
IMentionSuggestion,
|
||||||
} from "./types/mention-suggestion";
|
} from "./types/mention-suggestion";
|
||||||
|
export type { ISlashCommandItem, CommandProps } from "./types/slash-commands-suggestion"
|
||||||
|
15
packages/editor/types/src/types/slash-commands-suggestion.ts
Normal file
15
packages/editor/types/src/types/slash-commands-suggestion.ts
Normal 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;
|
||||||
|
}
|
@ -22,9 +22,13 @@ const issueCommentService = new IssueCommentService();
|
|||||||
|
|
||||||
export const InboxIssueActivity: React.FC<Props> = observer(({ issueDetails }) => {
|
export const InboxIssueActivity: React.FC<Props> = observer(({ issueDetails }) => {
|
||||||
const router = useRouter();
|
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();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
@ -38,50 +42,48 @@ export const InboxIssueActivity: React.FC<Props> = observer(({ issueDetails }) =
|
|||||||
const user = userStore.currentUser;
|
const user = userStore.currentUser;
|
||||||
|
|
||||||
const handleCommentUpdate = async (commentId: string, data: Partial<IIssueComment>) => {
|
const handleCommentUpdate = async (commentId: string, data: Partial<IIssueComment>) => {
|
||||||
if (!workspaceSlug || !projectId || !inboxIssueId || !user) return;
|
if (!workspaceSlug || !projectId || !issueDetails.id || !user) return;
|
||||||
|
|
||||||
await issueCommentService
|
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) => {
|
.then((res) => {
|
||||||
mutateIssueActivity();
|
mutateIssueActivity();
|
||||||
postHogEventTracker(
|
postHogEventTracker(
|
||||||
"COMMENT_UPDATED",
|
"COMMENT_UPDATED",
|
||||||
{
|
{
|
||||||
...res,
|
...res,
|
||||||
state: "SUCCESS"
|
state: "SUCCESS",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isGrouping: true,
|
isGrouping: true,
|
||||||
groupType: "Workspace_metrics",
|
groupType: "Workspace_metrics",
|
||||||
gorupId: currentWorkspace?.id!
|
gorupId: currentWorkspace?.id!,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCommentDelete = async (commentId: string) => {
|
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);
|
mutateIssueActivity((prevData: any) => prevData?.filter((p: any) => p.id !== commentId), false);
|
||||||
|
|
||||||
await issueCommentService
|
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(() => {
|
.then(() => {
|
||||||
mutateIssueActivity();
|
mutateIssueActivity();
|
||||||
postHogEventTracker(
|
postHogEventTracker(
|
||||||
"COMMENT_DELETED",
|
"COMMENT_DELETED",
|
||||||
{
|
{
|
||||||
state: "SUCCESS"
|
state: "SUCCESS",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isGrouping: true,
|
isGrouping: true,
|
||||||
groupType: "Workspace_metrics",
|
groupType: "Workspace_metrics",
|
||||||
gorupId: currentWorkspace?.id!
|
gorupId: currentWorkspace?.id!,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddComment = async (formData: IIssueComment) => {
|
const handleAddComment = async (formData: IIssueComment) => {
|
||||||
@ -95,12 +97,12 @@ export const InboxIssueActivity: React.FC<Props> = observer(({ issueDetails }) =
|
|||||||
"COMMENT_ADDED",
|
"COMMENT_ADDED",
|
||||||
{
|
{
|
||||||
...res,
|
...res,
|
||||||
state: "SUCCESS"
|
state: "SUCCESS",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isGrouping: true,
|
isGrouping: true,
|
||||||
groupType: "Workspace_metrics",
|
groupType: "Workspace_metrics",
|
||||||
gorupId: currentWorkspace?.id!
|
gorupId: currentWorkspace?.id!,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
@ -56,11 +56,16 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [localTitleValue, setLocalTitleValue] = useState("");
|
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(() => {
|
useEffect(() => {
|
||||||
if (issue.id) {
|
if (issue.id) {
|
||||||
setLocalIssueDescription(issue.description_html);
|
setLocalIssueDescription({ id: issue.id, description_html: issue.description_html });
|
||||||
setLocalTitleValue(issue.name);
|
setLocalTitleValue(issue.name);
|
||||||
}
|
}
|
||||||
}, [issue.id, issue.name, issue.description_html]);
|
}, [issue.id, issue.name, issue.description_html]);
|
||||||
@ -153,8 +158,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
|||||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
restoreFile={fileService.restoreImage}
|
restoreFile={fileService.restoreImage}
|
||||||
value={localIssueDescription}
|
value={localIssueDescription.description_html}
|
||||||
text_html={localIssueDescription}
|
rerenderOnPropsChange={localIssueDescription}
|
||||||
setShouldShowAlert={setShowAlert}
|
setShouldShowAlert={setShowAlert}
|
||||||
setIsSubmitting={setIsSubmitting}
|
setIsSubmitting={setIsSubmitting}
|
||||||
dragDropEnabled
|
dragDropEnabled
|
||||||
|
@ -78,12 +78,15 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
|
|||||||
[issue, issueUpdate]
|
[issue, issueUpdate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [localTitleValue, setLocalTitleValue] = useState(issue.name);
|
const [localTitleValue, setLocalTitleValue] = useState("");
|
||||||
const [localIssueDescription, setLocalIssueDescription] = useState(issue.description_html);
|
const [localIssueDescription, setLocalIssueDescription] = useState({
|
||||||
|
id: issue.id,
|
||||||
|
description_html: issue.description_html,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (issue.id) {
|
if (issue.id) {
|
||||||
setLocalIssueDescription(issue.description_html);
|
setLocalIssueDescription({ id: issue.id, description_html: issue.description_html });
|
||||||
setLocalTitleValue(issue.name);
|
setLocalTitleValue(issue.name);
|
||||||
}
|
}
|
||||||
}, [issue.id]);
|
}, [issue.id]);
|
||||||
@ -168,8 +171,8 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
|
|||||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
restoreFile={fileService.restoreImage}
|
restoreFile={fileService.restoreImage}
|
||||||
value={localIssueDescription}
|
value={localIssueDescription.description_html}
|
||||||
text_html={localIssueDescription}
|
rerenderOnPropsChange={localIssueDescription}
|
||||||
setShouldShowAlert={setShowAlert}
|
setShouldShowAlert={setShowAlert}
|
||||||
setIsSubmitting={setIsSubmitting}
|
setIsSubmitting={setIsSubmitting}
|
||||||
dragDropEnabled
|
dragDropEnabled
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import React, { useEffect, useRef, useState, ReactElement } from "react";
|
import React, { useEffect, useRef, useState, ReactElement } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import useSWR from "swr";
|
import useSWR, { MutatorOptions } from "swr";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { Sparkle } from "lucide-react";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
// services
|
// services
|
||||||
import { PageService } from "services/page.service";
|
import { PageService } from "services/page.service";
|
||||||
import { FileService } from "services/file.service";
|
import { FileService } from "services/file.service";
|
||||||
@ -16,7 +14,6 @@ import { AppLayout } from "layouts/app-layout";
|
|||||||
// components
|
// components
|
||||||
import { PageDetailsHeader } from "components/headers/page-details";
|
import { PageDetailsHeader } from "components/headers/page-details";
|
||||||
import { EmptyState } from "components/common";
|
import { EmptyState } from "components/common";
|
||||||
import { GptAssistantModal } from "components/core";
|
|
||||||
// ui
|
// ui
|
||||||
import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor";
|
import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor";
|
||||||
import { Spinner } from "@plane/ui";
|
import { Spinner } from "@plane/ui";
|
||||||
@ -26,158 +23,52 @@ import emptyPage from "public/empty-state/page.svg";
|
|||||||
import { renderDateFormat } from "helpers/date-time.helper";
|
import { renderDateFormat } from "helpers/date-time.helper";
|
||||||
// types
|
// types
|
||||||
import { NextPageWithLayout } from "types/app";
|
import { NextPageWithLayout } from "types/app";
|
||||||
import { IPage } from "types";
|
import { IPage, IIssue } from "types";
|
||||||
// fetch-keys
|
// 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 { EUserWorkspaceRoles } from "constants/workspace";
|
||||||
|
import { GptAssistantModal } from "components/core";
|
||||||
|
import { Sparkle } from "lucide-react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
// services
|
// services
|
||||||
const fileService = new FileService();
|
const fileService = new FileService();
|
||||||
const pageService = new PageService();
|
const pageService = new PageService();
|
||||||
|
const issueService = new IssueService();
|
||||||
|
|
||||||
const PageDetailsPage: NextPageWithLayout = observer(() => {
|
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 {
|
const {
|
||||||
|
projectIssues: { updateIssue },
|
||||||
appConfig: { envConfig },
|
appConfig: { envConfig },
|
||||||
user: { currentProjectRole },
|
user: { currentProjectRole },
|
||||||
} = useMobxStore();
|
} = 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 router = useRouter();
|
||||||
const { workspaceSlug, projectId, pageId } = router.query;
|
const { workspaceSlug, projectId, pageId, peekIssueId } = router.query;
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
|
||||||
const { handleSubmit, reset, getValues, control, setValue, watch } = useForm<IPage>({
|
const { handleSubmit, reset, setValue, watch, getValues, control } = useForm<IPage>({
|
||||||
defaultValues: { name: "", description_html: "<p></p>" },
|
defaultValues: { name: "", description_html: "" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// =================== Fetching Page Details ======================
|
const { data: issuesResponse } = useSWR(
|
||||||
const {
|
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
|
||||||
data: pageDetails,
|
workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null
|
||||||
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 updatePage = async (formData: IPage) => {
|
const issues = Object.values(issuesResponse ?? {});
|
||||||
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 handleAiAssistance = async (response: string) => {
|
const handleAiAssistance = async (response: string) => {
|
||||||
if (!workspaceSlug || !projectId || !pageId) return;
|
if (!workspaceSlug || !projectId || !pageId) return;
|
||||||
@ -195,13 +86,245 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
// =================== Fetching Page Details ======================
|
||||||
if (!pageDetails) return;
|
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({
|
const handleUpdateIssue = (issueId: string, data: Partial<IIssue>) => {
|
||||||
...pageDetails,
|
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,
|
||||||
});
|
});
|
||||||
}, [reset, pageDetails]);
|
};
|
||||||
|
|
||||||
|
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: "",
|
||||||
|
});
|
||||||
|
|
||||||
const debouncedFormSave = debounce(async () => {
|
const debouncedFormSave = debounce(async () => {
|
||||||
handleSubmit(updatePage)().finally(() => setIsSubmitting("submitted"));
|
handleSubmit(updatePage)().finally(() => setIsSubmitting("submitted"));
|
||||||
@ -222,6 +345,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
|
|
||||||
const isPageReadOnly =
|
const isPageReadOnly =
|
||||||
pageDetails?.is_locked ||
|
pageDetails?.is_locked ||
|
||||||
|
pageDetails?.archived_at ||
|
||||||
(currentProjectRole && [EUserWorkspaceRoles.VIEWER, EUserWorkspaceRoles.GUEST].includes(currentProjectRole));
|
(currentProjectRole && [EUserWorkspaceRoles.VIEWER, EUserWorkspaceRoles.GUEST].includes(currentProjectRole));
|
||||||
|
|
||||||
const isCurrentUserOwner = pageDetails?.owned_by === user?.id;
|
const isCurrentUserOwner = pageDetails?.owned_by === user?.id;
|
||||||
@ -234,14 +358,16 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{pageDetails ? (
|
{pageDetails && issuesResponse ? (
|
||||||
<div className="flex h-full flex-col justify-between">
|
<div className="flex h-full flex-col justify-between">
|
||||||
<div className="h-full w-full overflow-hidden">
|
<div className="h-full w-full overflow-hidden">
|
||||||
{isPageReadOnly ? (
|
{isPageReadOnly ? (
|
||||||
<DocumentReadOnlyEditorWithRef
|
<DocumentReadOnlyEditorWithRef
|
||||||
|
onActionCompleteHandler={actionCompleteAlert}
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={pageDetails.description_html}
|
value={localPageDescription.description_html}
|
||||||
customClassName={"tracking-tight self-center w-full max-w-full px-0"}
|
rerenderOnPropsChange={localPageDescription}
|
||||||
|
customClassName={"tracking-tight w-full px-0"}
|
||||||
borderOnFocus={false}
|
borderOnFocus={false}
|
||||||
noBorder
|
noBorder
|
||||||
documentDetails={{
|
documentDetails={{
|
||||||
@ -252,12 +378,15 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
last_updated_by: pageDetails.updated_by,
|
last_updated_by: pageDetails.updated_by,
|
||||||
}}
|
}}
|
||||||
pageLockConfig={
|
pageLockConfig={
|
||||||
!pageDetails.archived_at && user && pageDetails.owned_by === user.id
|
userCanLock && !pageDetails.archived_at
|
||||||
? { action: unlockPage, is_locked: pageDetails.is_locked }
|
? { action: unlockPage, is_locked: pageDetails.is_locked }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
pageDuplicationConfig={
|
||||||
|
userCanDuplicate && !pageDetails.archived_at ? { action: duplicate_page } : undefined
|
||||||
|
}
|
||||||
pageArchiveConfig={
|
pageArchiveConfig={
|
||||||
user && pageDetails.owned_by === user.id
|
userCanArchive
|
||||||
? {
|
? {
|
||||||
action: pageDetails.archived_at ? unArchivePage : archivePage,
|
action: pageDetails.archived_at ? unArchivePage : archivePage,
|
||||||
is_archived: pageDetails.archived_at ? true : false,
|
is_archived: pageDetails.archived_at ? true : false,
|
||||||
@ -265,6 +394,13 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
embedConfig={{
|
||||||
|
issueEmbedConfig: {
|
||||||
|
issues: issues,
|
||||||
|
fetchIssue: fetchIssue,
|
||||||
|
clickAction: issueWidgetClickAction,
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full w-full relative overflow-hidden">
|
<div className="h-full w-full relative overflow-hidden">
|
||||||
@ -273,6 +409,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<DocumentEditorWithRef
|
<DocumentEditorWithRef
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
documentDetails={{
|
documentDetails={{
|
||||||
title: pageDetails.name,
|
title: pageDetails.name,
|
||||||
created_by: pageDetails.created_by,
|
created_by: pageDetails.created_by,
|
||||||
@ -281,14 +418,20 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
last_updated_by: pageDetails.updated_by,
|
last_updated_by: pageDetails.updated_by,
|
||||||
}}
|
}}
|
||||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
|
setShouldShowAlert={setShowAlert}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
restoreFile={fileService.restoreImage}
|
restoreFile={fileService.restoreImage}
|
||||||
|
cancelUploadImage={fileService.cancelUpload}
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
debouncedUpdatesEnabled={false}
|
debouncedUpdatesEnabled={false}
|
||||||
setIsSubmitting={setIsSubmitting}
|
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"
|
customClassName="tracking-tight self-center px-0 h-full w-full"
|
||||||
onChange={(_description_json: Object, description_html: string) => {
|
onChange={(_description_json: Object, description_html: string) => {
|
||||||
|
setShowAlert(true);
|
||||||
onChange(description_html);
|
onChange(description_html);
|
||||||
setIsSubmitting("submitting");
|
setIsSubmitting("submitting");
|
||||||
debouncedFormSave();
|
debouncedFormSave();
|
||||||
@ -303,6 +446,13 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
pageLockConfig={userCanLock ? { is_locked: false, action: lockPage } : 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
|
<button
|
||||||
type="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)}
|
onClick={() => setGptModal((prevData) => !prevData)}
|
||||||
>
|
>
|
||||||
<Sparkle className="h-4 w-4" />
|
<Sparkle className="h-4 w-4" />
|
||||||
@ -332,6 +482,17 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
@ -21,6 +21,7 @@ export class PageService extends APIService {
|
|||||||
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, data)
|
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, data)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
console.log("error", error?.response?.data);
|
||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -165,7 +166,7 @@ export class PageService extends APIService {
|
|||||||
|
|
||||||
// =============== Archiving & Unarchiving Pages =================
|
// =============== Archiving & Unarchiving Pages =================
|
||||||
async archivePage(workspaceSlug: string, projectId: string, pageId: string): Promise<void> {
|
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)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
@ -173,7 +174,7 @@ export class PageService extends APIService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async restorePage(workspaceSlug: string, projectId: string, pageId: string): Promise<void> {
|
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)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
@ -189,7 +190,7 @@ export class PageService extends APIService {
|
|||||||
}
|
}
|
||||||
// ==================== Pages Locking Services ==========================
|
// ==================== Pages Locking Services ==========================
|
||||||
async lockPage(workspaceSlug: string, projectId: string, pageId: string): Promise<any> {
|
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)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
@ -197,7 +198,7 @@ export class PageService extends APIService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async unlockPage(workspaceSlug: string, projectId: string, pageId: string): Promise<any> {
|
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)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
|
15
yarn.lock
15
yarn.lock
@ -2356,7 +2356,7 @@
|
|||||||
lodash.merge "^4.6.2"
|
lodash.merge "^4.6.2"
|
||||||
postcss-selector-parser "6.0.10"
|
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"
|
version "2.1.13"
|
||||||
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.1.13.tgz#e21f566e81688c826c6f26d2940886734189e193"
|
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.1.13.tgz#e21f566e81688c826c6f26d2940886734189e193"
|
||||||
integrity sha512-cMC8bgTN63dj1Mv82iDeeLl6sa9kY0Pug8LSalxVEptRmyFVsVxGgu2/6Y3T+9aCYScxfS06EkA8SdzFMAwYTQ==
|
integrity sha512-cMC8bgTN63dj1Mv82iDeeLl6sa9kY0Pug8LSalxVEptRmyFVsVxGgu2/6Y3T+9aCYScxfS06EkA8SdzFMAwYTQ==
|
||||||
@ -2537,7 +2537,7 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-2.1.13.tgz#170b4e8e3f03b9defbb7de7cafe4b0a066cea679"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-2.1.13.tgz#170b4e8e3f03b9defbb7de7cafe4b0a066cea679"
|
||||||
integrity sha512-z0CNKPjcvU8TrUSTui1voM7owssyXE9WvEGhIZMHzWwlx2ZXY2/L5+Hh33X/LzSKB9OGf/g1HAuHxrPcYxFuAQ==
|
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"
|
version "2.1.13"
|
||||||
resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-2.1.13.tgz#857753691580be760da13629fab2712c52750741"
|
resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-2.1.13.tgz#857753691580be760da13629fab2712c52750741"
|
||||||
integrity sha512-zNbA7muWsHuVg12GrTgN/j119rLePPq5M8dZgkKxUwdw8VmU3eUyBp1SihPEXJ2U0MGdZhNhFX7Y74g11u66sg==
|
integrity sha512-zNbA7muWsHuVg12GrTgN/j119rLePPq5M8dZgkKxUwdw8VmU3eUyBp1SihPEXJ2U0MGdZhNhFX7Y74g11u66sg==
|
||||||
@ -2599,7 +2599,7 @@
|
|||||||
"@tiptap/extension-strike" "^2.1.13"
|
"@tiptap/extension-strike" "^2.1.13"
|
||||||
"@tiptap/extension-text" "^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"
|
version "2.1.13"
|
||||||
resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-2.1.13.tgz#0a8317260baed764a523a09099c0889a0e5b507e"
|
resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-2.1.13.tgz#0a8317260baed764a523a09099c0889a0e5b507e"
|
||||||
integrity sha512-Y05TsiXTFAJ5SrfoV+21MAxig5UNbY0AVa03lQlh/yicTRPpIc6hgZzblB0uxDSYoj6+kaHE4MIZvPvhUD8BJQ==
|
integrity sha512-Y05TsiXTFAJ5SrfoV+21MAxig5UNbY0AVa03lQlh/yicTRPpIc6hgZzblB0uxDSYoj6+kaHE4MIZvPvhUD8BJQ==
|
||||||
@ -2776,6 +2776,13 @@
|
|||||||
date-fns "^2.0.1"
|
date-fns "^2.0.1"
|
||||||
react-popper "^2.2.5"
|
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":
|
"@types/react-dom@^18.2.14", "@types/react-dom@^18.2.17":
|
||||||
version "18.2.17"
|
version "18.2.17"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.17.tgz#375c55fab4ae671bd98448dcfa153268d01d6f64"
|
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"
|
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||||
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
||||||
|
|
||||||
uuid@^9.0.0:
|
uuid@^9.0.0, uuid@^9.0.1:
|
||||||
version "9.0.1"
|
version "9.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"
|
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"
|
||||||
integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==
|
integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==
|
||||||
|
Loading…
Reference in New Issue
Block a user