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