From f7e140bf68b8e457c537ae81d82ef69e049ceeba Mon Sep 17 00:00:00 2001 From: Henit Chobisa Date: Thu, 7 Dec 2023 12:04:21 +0530 Subject: [PATCH] [ 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 Co-authored-by: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> --- .../editor/core/src/ui/hooks/use-editor.tsx | 9 +- .../src/ui/hooks/use-read-only-editor.tsx | 53 +- packages/editor/document-editor/package.json | 11 +- .../src/ui/components/content-browser.tsx | 13 +- .../src/ui/components/editor-header.tsx | 22 +- .../src/ui/components/heading-component.tsx | 16 + .../src/ui/components/info-popover.tsx | 2 +- .../src/ui/components/page-renderer.tsx | 47 +- .../ui/components/vertical-dropdown-menu.tsx | 4 +- .../src/ui/extensions/index.tsx | 66 ++- .../IssueEmbedSuggestionList/index.tsx | 56 +++ .../issue-suggestion-extension.tsx | 38 ++ .../issue-suggestion-items.tsx | 18 + .../issue-suggestion-renderer.tsx | 279 +++++++++++ .../widgets/IssueEmbedWidget/index.tsx | 12 + .../IssueEmbedWidget/issue-widget-card.tsx | 89 ++++ .../IssueEmbedWidget/issue-widget-node.tsx | 68 +++ .../widgets/IssueEmbedWidget/types.ts | 9 + .../src/ui/hooks/use-editor-markings.tsx | 12 +- .../editor/document-editor/src/ui/index.tsx | 40 +- .../document-editor/src/ui/readonly/index.tsx | 24 +- .../src/ui/utils/menu-options.ts | 84 +++- .../src/extensions/slash-commands.tsx | 254 +++++----- .../editor/rich-text-editor/src/ui/index.tsx | 9 +- packages/editor/types/package.json | 1 + packages/editor/types/src/index.ts | 1 + .../src/types/slash-commands-suggestion.ts | 15 + web/components/inbox/issue-activity.tsx | 34 +- web/components/issues/description-form.tsx | 13 +- .../issue-peek-overview/issue-detail.tsx | 13 +- .../projects/[projectId]/pages/[pageId].tsx | 457 ++++++++++++------ web/services/page.service.ts | 9 +- yarn.lock | 15 +- 33 files changed, 1412 insertions(+), 381 deletions(-) create mode 100644 packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/index.tsx create mode 100644 packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-extension.tsx create mode 100644 packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-items.tsx create mode 100644 packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-renderer.tsx create mode 100644 packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/index.tsx create mode 100644 packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/issue-widget-card.tsx create mode 100644 packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/issue-widget-node.tsx create mode 100644 packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/types.ts create mode 100644 packages/editor/types/src/types/slash-commands-suggestion.ts diff --git a/packages/editor/core/src/ui/hooks/use-editor.tsx b/packages/editor/core/src/ui/hooks/use-editor.tsx index bd349f3ef..d30e8ca89 100644 --- a/packages/editor/core/src/ui/hooks/use-editor.tsx +++ b/packages/editor/core/src/ui/hooks/use-editor.tsx @@ -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 = useRef(null); diff --git a/packages/editor/core/src/ui/hooks/use-read-only-editor.tsx b/packages/editor/core/src/ui/hooks/use-read-only-editor.tsx index 3207e5e56..3339e095f 100644 --- a/packages/editor/core/src/ui/hooks/use-read-only-editor.tsx +++ b/packages/editor/core/src/ui/hooks/use-read-only-editor.tsx @@ -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 : "

", - editorProps: { - ...CoreReadOnlyEditorProps, - ...editorProps, + const editor = useCustomEditor( + { + editable: false, + content: + typeof value === "string" && value.trim() !== "" ? value : "

", + 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 = useRef(null); editorRef.current = editor; diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json index 8789f37ee..4e0eeffb2 100644 --- a/packages/editor/document-editor/package.json +++ b/packages/editor/document-editor/package.json @@ -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", diff --git a/packages/editor/document-editor/src/ui/components/content-browser.tsx b/packages/editor/document-editor/src/ui/components/content-browser.tsx index bb0e3fb8b..68f6469b8 100644 --- a/packages/editor/document-editor/src/ui/components/content-browser.tsx +++ b/packages/editor/document-editor/src/ui/components/content-browser.tsx @@ -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 ? ( scrollSummary(editor, marking)} subHeading={marking.text} /> + ) : ( + scrollSummary(editor, marking)} + /> ), ) ) : ( diff --git a/packages/editor/document-editor/src/ui/components/editor-header.tsx b/packages/editor/document-editor/src/ui/components/editor-header.tsx index 74372ae16..6d548669e 100644 --- a/packages/editor/document-editor/src/ui/components/editor-header.tsx +++ b/packages/editor/document-editor/src/ui/components/editor-header.tsx @@ -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 ? ( +
+ {isSubmitting !== "submitted" && isSubmitting !== "saved" && ( + + )} + + {isSubmitting === "submitting" ? "Saving..." : "Saved"} + +
+ ) : null} {!isArchived && } diff --git a/packages/editor/document-editor/src/ui/components/heading-component.tsx b/packages/editor/document-editor/src/ui/components/heading-component.tsx index ea31b67c1..d8ceea8f9 100644 --- a/packages/editor/document-editor/src/ui/components/heading-component.tsx +++ b/packages/editor/document-editor/src/ui/components/heading-component.tsx @@ -29,3 +29,19 @@ export const SubheadingComp = ({ {subHeading}

); + +export const HeadingThreeComp = ({ + heading, + onClick, +}: { + heading: string; + onClick: (event: React.MouseEvent) => void; +}) => ( +

+ {heading} +

+); diff --git a/packages/editor/document-editor/src/ui/components/info-popover.tsx b/packages/editor/document-editor/src/ui/components/info-popover.tsx index d42e32a8d..41d131afb 100644 --- a/packages/editor/document-editor/src/ui/components/info-popover.tsx +++ b/packages/editor/document-editor/src/ui/components/info-popover.tsx @@ -48,7 +48,7 @@ export const InfoPopover: React.FC = (props) => { onMouseEnter={() => setIsPopoverOpen(true)} onMouseLeave={() => setIsPopoverOpen(false)} > - {isPopoverOpen && ( diff --git a/packages/editor/document-editor/src/ui/components/page-renderer.tsx b/packages/editor/document-editor/src/ui/components/page-renderer.tsx index f24ec4348..198a03b64 100644 --- a/packages/editor/document-editor/src/ui/components/page-renderer.tsx +++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx @@ -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; 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 (
-

- {documentDetails.title} -

+ {!readonly ? ( + 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} + /> + ) : ( + 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 + /> + )}
{ return ( 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: , + command: ({ editor, range }) => { + editor + .chain() + .focus() + .insertContentAt( + range, + "

#issue_

", + ) + .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 : []), + ]; +}; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/index.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/index.tsx new file mode 100644 index 000000000..54d1dec21 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/index.tsx @@ -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, + }, + }); +}; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-extension.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-extension.tsx new file mode 100644 index 000000000..fbd31c257 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-extension.tsx @@ -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, + }), + ]; + }, +}); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-items.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-items.tsx new file mode 100644 index 000000000..ae5e164a2 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-items.tsx @@ -0,0 +1,18 @@ +import { IIssueListSuggestion } from "."; + +export const getIssueSuggestionItems = ( + issueSuggestions: Array, +) => { + 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; + }; +}; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-renderer.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-renderer.tsx new file mode 100644 index 000000000..892a7f09b --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-renderer.tsx @@ -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("Backlog"); + const sections = ["Backlog", "In Progress", "Todo", "Done", "Cancelled"]; + const [displayedItems, setDisplayedItems] = useState<{ + [key: string]: IssueSuggestionProps[]; + }>({}); + const [displayedTotalLength, setDisplayedTotalLength] = useState(0); + const commandListContainer = useRef(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 ? ( +
+ {sections.map((section) => { + const sectionItems = displayedItems[section]; + return ( + sectionItems && + sectionItems.length > 0 && ( +
+
+ {section} +
+
+ {sectionItems.map( + (item: IssueSuggestionProps, index: number) => ( + + ), + )} +
+
+ ) + ); + })} +
+ ) : 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); + }, + }; +}; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/index.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/index.tsx new file mode 100644 index 000000000..bbe8ec021 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/index.tsx @@ -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, +}); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/issue-widget-card.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/issue-widget-card.tsx new file mode 100644 index 000000000..79aabcdfa --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/issue-widget-card.tsx @@ -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(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 ( + + {loading == 0 ? ( +
+
+ {issueDetails.project_detail.identifier}-{issueDetails.sequence_id} +
+

+ {issueDetails.name} +

+
+
+ +
+
+ + {issueDetails.assignee_details.map((assignee) => { + return ( + + ); + })} + +
+ {issueDetails.target_date && ( +
+ + {new Date(issueDetails.target_date).toLocaleDateString()} +
+ )} +
+
+ ) : loading == -1 ? ( +
+ + { + "This Issue embed is not found in any project. It can no longer be updated or accessed from here." + } +
+ ) : ( +
+ + +
+ + +
+
+
+ )} +
+ ); +}; + +export default IssueWidgetCard; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/issue-widget-node.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/issue-widget-node.tsx new file mode 100644 index 000000000..014197184 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/issue-widget-node.tsx @@ -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) => ( + + )); + }, + + 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)]; + }, +}); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/types.ts b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/types.ts new file mode 100644 index 000000000..9e633c0c8 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/types.ts @@ -0,0 +1,9 @@ +export interface IEmbedConfig { + issueEmbedConfig: IIssueEmbedConfig; +} + +export interface IIssueEmbedConfig { + fetchIssue: (issueId: string) => Promise; + clickAction: (issueId: string, issueTitle: string) => void; + issues: Array; +} diff --git a/packages/editor/document-editor/src/ui/hooks/use-editor-markings.tsx b/packages/editor/document-editor/src/ui/hooks/use-editor-markings.tsx index 697a7e493..e8b58a2b8 100644 --- a/packages/editor/document-editor/src/ui/hooks/use-editor-markings.tsx +++ b/packages/editor/document-editor/src/ui/hooks/use-editor-markings.tsx @@ -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, }); } }); diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index f841912aa..f2ff77455 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -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; debouncedUpdatesEnabled?: boolean; + isSubmitting: "submitting" | "submitted" | "saved"; + + // embed configuration duplicationConfig?: IDuplicationConfig; pageLockConfig?: IPageLockConfig; pageArchiveConfig?: IPageArchiveConfig; + embedConfig?: IEmbedConfig; } interface DocumentEditorProps extends IDocumentEditor { forwardedRef?: React.Ref; @@ -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("") 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} />
@@ -137,10 +173,12 @@ const DocumentEditor = ({
diff --git a/packages/editor/document-editor/src/ui/readonly/index.tsx b/packages/editor/document-editor/src/ui/readonly/index.tsx index 80ad3507e..c6e8ef025 100644 --- a/packages/editor/document-editor/src/ui/readonly/index.tsx +++ b/packages/editor/document-editor/src/ui/readonly/index.tsx @@ -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 = ({
Promise.resolve()} + readonly={true} editor={editor} editorClassNames={editorClassNames} documentDetails={documentDetails} diff --git a/packages/editor/document-editor/src/ui/utils/menu-options.ts b/packages/editor/document-editor/src/ui/utils/menu-options.ts index 7b8dac067..5df467ddf 100644 --- a/packages/editor/document-editor/src/ui/utils/menu-options.ts +++ b/packages/editor/document-editor/src/ui/utils/menu-options.ts @@ -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", + }); + }); + }, }); } diff --git a/packages/editor/extensions/src/extensions/slash-commands.tsx b/packages/editor/extensions/src/extensions/slash-commands.tsx index 399651c5a..8ca132405 100644 --- a/packages/editor/extensions/src/extensions/slash-commands.tsx +++ b/packages/editor/extensions/src/extensions/slash-commands.tsx @@ -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 ) => - ({ query }: { query: string }) => - [ - { - title: "Text", - description: "Just start typing with plain text.", - searchTerms: ["p", "paragraph"], - icon: , - 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: , + 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: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingOne(editor, range); + { + title: "Heading 1", + description: "Big section heading.", + searchTerms: ["title", "big", "large"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleHeadingOne(editor, range); + }, }, - }, - { - title: "Heading 2", - description: "Medium section heading.", - searchTerms: ["subtitle", "medium"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingTwo(editor, range); + { + title: "Heading 2", + description: "Medium section heading.", + searchTerms: ["subtitle", "medium"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleHeadingTwo(editor, range); + }, }, - }, - { - title: "Heading 3", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingThree(editor, range); + { + title: "Heading 3", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + 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: , - 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: , + command: ({ editor, range }: CommandProps) => { + toggleTaskList(editor, range); + }, }, - }, - { - title: "Bullet List", - description: "Create a simple bullet list.", - searchTerms: ["unordered", "point"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleBulletList(editor, range); + { + title: "Bullet List", + description: "Create a simple bullet list.", + searchTerms: ["unordered", "point"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleBulletList(editor, range); + }, }, - }, - { - title: "Divider", - description: "Visually divide blocks", - searchTerms: ["line", "divider", "horizontal", "rule", "separate"], - icon: , - 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: , + 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: , - command: ({ editor, range }: CommandProps) => { - insertTableCommand(editor, range); + { + title: "Table", + description: "Create a Table", + searchTerms: ["table", "cell", "db", "data", "tabular"], + icon:
, + command: ({ editor, range }: CommandProps) => { + insertTableCommand(editor, range); + }, }, - }, - { - title: "Numbered List", - description: "Create a list with numbering.", - searchTerms: ["ordered"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleOrderedList(editor, range); + { + title: "Numbered List", + description: "Create a list with numbering.", + searchTerms: ["ordered"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleOrderedList(editor, range); + }, }, - }, - { - title: "Quote", - description: "Capture a quote.", - searchTerms: ["blockquote"], - icon: , - command: ({ editor, range }: CommandProps) => - toggleBlockquote(editor, range), - }, - { - title: "Code", - description: "Capture a code snippet.", - searchTerms: ["codeblock"], - icon: , - 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: , - command: ({ editor, range }: CommandProps) => { - insertImageCommand(editor, uploadFile, setIsSubmitting, range); + { + title: "Quote", + description: "Capture a quote.", + searchTerms: ["blockquote"], + icon: , + 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: , + 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: , + 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, ) => Command.configure({ suggestion: { - items: getSuggestionItems(uploadFile, setIsSubmitting), + items: getSuggestionItems(uploadFile, setIsSubmitting, additonalOptions), render: renderItems, }, }); diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index b3b261c3b..653bbfc9a 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -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, diff --git a/packages/editor/types/package.json b/packages/editor/types/package.json index 3b947035f..9f63213b9 100644 --- a/packages/editor/types/package.json +++ b/packages/editor/types/package.json @@ -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", diff --git a/packages/editor/types/src/index.ts b/packages/editor/types/src/index.ts index 58b584977..e7c0ccc1a 100644 --- a/packages/editor/types/src/index.ts +++ b/packages/editor/types/src/index.ts @@ -5,3 +5,4 @@ export type { IMentionHighlight, IMentionSuggestion, } from "./types/mention-suggestion"; +export type { ISlashCommandItem, CommandProps } from "./types/slash-commands-suggestion" diff --git a/packages/editor/types/src/types/slash-commands-suggestion.ts b/packages/editor/types/src/types/slash-commands-suggestion.ts new file mode 100644 index 000000000..327c285cd --- /dev/null +++ b/packages/editor/types/src/types/slash-commands-suggestion.ts @@ -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; +} diff --git a/web/components/inbox/issue-activity.tsx b/web/components/inbox/issue-activity.tsx index c653653ab..92013d3de 100644 --- a/web/components/inbox/issue-activity.tsx +++ b/web/components/inbox/issue-activity.tsx @@ -22,9 +22,13 @@ const issueCommentService = new IssueCommentService(); export const InboxIssueActivity: React.FC = 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 = observer(({ issueDetails }) = const user = userStore.currentUser; const handleCommentUpdate = async (commentId: string, data: Partial) => { - 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 = observer(({ issueDetails }) = "COMMENT_ADDED", { ...res, - state: "SUCCESS" + state: "SUCCESS", }, { isGrouping: true, groupType: "Workspace_metrics", - gorupId: currentWorkspace?.id! + gorupId: currentWorkspace?.id!, } ); }) diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx index ff1fcaf19..34361108a 100644 --- a/web/components/issues/description-form.tsx +++ b/web/components/issues/description-form.tsx @@ -56,11 +56,16 @@ export const IssueDescriptionForm: FC = (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 = (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 diff --git a/web/components/issues/issue-peek-overview/issue-detail.tsx b/web/components/issues/issue-peek-overview/issue-detail.tsx index b9c3152d4..ac156180c 100644 --- a/web/components/issues/issue-peek-overview/issue-detail.tsx +++ b/web/components/issues/issue-peek-overview/issue-detail.tsx @@ -78,12 +78,15 @@ export const PeekOverviewIssueDetails: FC = (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 = (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 diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index 1f4650485..bfa806210 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -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(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(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({ - defaultValues: { name: "", description_html: "

" }, + const { handleSubmit, reset, setValue, watch, getValues, control } = useForm({ + 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) => { - 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 = { - 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) => { + 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, + dataToMutate: Partial, + formDataValues: Array, + 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) => { + 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 = { + 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 ? (
{isPageReadOnly ? ( { 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, + }, + }} /> ) : (
@@ -273,6 +409,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { control={control} render={({ field: { value, onChange } }) => ( { 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 === "" ? "

" : 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(() => { <>
)} + { + if (peekIssueId && typeof peekIssueId === "string") { + handleUpdateIssue(peekIssueId, issueToUpdate); + } + }} + />
) : ( diff --git a/web/services/page.service.ts b/web/services/page.service.ts index 5084ccd9a..83b19c926 100644 --- a/web/services/page.service.ts +++ b/web/services/page.service.ts @@ -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 { - 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 { - 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 { - 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 { - 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; diff --git a/yarn.lock b/yarn.lock index b630d9f3d..50cfe7930 100644 --- a/yarn.lock +++ b/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==