diff --git a/apiserver/plane/app/views/page.py b/apiserver/plane/app/views/page.py index b218b6687..01a0ffe98 100644 --- a/apiserver/plane/app/views/page.py +++ b/apiserver/plane/app/views/page.py @@ -136,7 +136,9 @@ class PageViewSet(BaseViewSet): ) def lock(self, request, slug, project_id, page_id): - page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id) + page = Page.objects.filter( + pk=page_id, workspace__slug=slug, project_id=project_id + ).first() # only the owner can lock the page if request.user.id != page.owned_by_id: @@ -149,7 +151,9 @@ class PageViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) def unlock(self, request, slug, project_id, page_id): - page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id) + page = Page.objects.filter( + pk=page_id, workspace__slug=slug, project_id=project_id + ).first() # only the owner can unlock the page if request.user.id != page.owned_by_id: @@ -164,66 +168,8 @@ class PageViewSet(BaseViewSet): def list(self, request, slug, project_id): queryset = self.get_queryset().filter(archived_at__isnull=True) - page_view = request.GET.get("page_view", False) - - if not page_view: - return Response( - {"error": "Page View parameter is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # All Pages - if page_view == "all": - return Response( - PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK - ) - - # Recent pages - if page_view == "recent": - current_time = date.today() - day_before = current_time - timedelta(days=1) - todays_pages = queryset.filter(updated_at__date=date.today()) - yesterdays_pages = queryset.filter(updated_at__date=day_before) - earlier_this_week = queryset.filter( - updated_at__date__range=( - (timezone.now() - timedelta(days=7)), - (timezone.now() - timedelta(days=2)), - ) - ) - return Response( - { - "today": PageSerializer(todays_pages, many=True).data, - "yesterday": PageSerializer(yesterdays_pages, many=True).data, - "earlier_this_week": PageSerializer( - earlier_this_week, many=True - ).data, - }, - status=status.HTTP_200_OK, - ) - - # Favorite Pages - if page_view == "favorite": - queryset = queryset.filter(is_favorite=True) - return Response( - PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK - ) - - # My pages - if page_view == "created_by_me": - queryset = queryset.filter(owned_by=request.user) - return Response( - PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK - ) - - # Created by other Pages - if page_view == "created_by_other": - queryset = queryset.filter(~Q(owned_by=request.user), access=0) - return Response( - PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK - ) - return Response( - {"error": "No matching view found"}, status=status.HTTP_400_BAD_REQUEST + PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK ) def archive(self, request, slug, project_id, page_id): @@ -247,29 +193,44 @@ class PageViewSet(BaseViewSet): {"error": "Only the owner of the page can unarchive a page"}, status=status.HTTP_400_BAD_REQUEST, ) - + # if parent page is archived then the page will be un archived breaking the hierarchy if page.parent_id and page.parent.archived_at: page.parent = None - page.save(update_fields=['parent']) + page.save(update_fields=["parent"]) unarchive_archive_page_and_descendants(page_id, None) return Response(status=status.HTTP_204_NO_CONTENT) def archive_list(self, request, slug, project_id): - pages = ( - Page.objects.filter( - project_id=project_id, - workspace__slug=slug, - ) - .filter(archived_at__isnull=False) - ) + pages = Page.objects.filter( + project_id=project_id, + workspace__slug=slug, + ).filter(archived_at__isnull=False) return Response( PageSerializer(pages, many=True).data, status=status.HTTP_200_OK ) + def destroy(self, request, slug, project_id, pk): + page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) + + if page.archived_at is None: + return Response( + {"error": "The page should be archived before deleting"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # remove parent from all the children + _ = Page.objects.filter( + parent_id=pk, project_id=project_id, workspace__slug=slug + ).update(parent=None) + + + page.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + class PageFavoriteViewSet(BaseViewSet): permission_classes = [ @@ -306,6 +267,7 @@ class PageFavoriteViewSet(BaseViewSet): page_favorite.delete() return Response(status=status.HTTP_204_NO_CONTENT) + class PageLogEndpoint(BaseAPIView): permission_classes = [ ProjectEntityPermission, @@ -397,4 +359,4 @@ class SubPagesEndpoint(BaseAPIView): ) return Response( SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK - ) \ No newline at end of file + ) diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index d9e1e8ef2..6a09b08ba 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -12,7 +12,7 @@ from celery import shared_task from sentry_sdk import capture_exception # Module imports -from plane.db.models import Issue, Project, State, Page +from plane.db.models import Issue, Project, State from plane.bgtasks.issue_activites_task import issue_activity @@ -20,7 +20,6 @@ from plane.bgtasks.issue_activites_task import issue_activity def archive_and_close_old_issues(): archive_old_issues() close_old_issues() - delete_archived_pages() def archive_old_issues(): @@ -166,21 +165,4 @@ def close_old_issues(): if settings.DEBUG: print(e) capture_exception(e) - return - - -def delete_archived_pages(): - try: - pages_to_delete = Page.objects.filter( - archived_at__isnull=False, - archived_at__lte=(timezone.now() - timedelta(days=30)), - ) - - pages_to_delete._raw_delete(pages_to_delete.db) - return - except Exception as e: - if settings.DEBUG: - print(e) - capture_exception(e) - return - + return \ No newline at end of file diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index da415058d..81eef5cae 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -133,6 +133,7 @@ class Issue(ProjectBaseModel): except ImportError: pass + if self._state.adding: # Get the maximum display_id value from the database last_id = IssueSequence.objects.filter(project=self.project).aggregate( diff --git a/packages/editor/core/src/ui/hooks/useEditor.tsx b/packages/editor/core/src/ui/hooks/useEditor.tsx index 966f9227d..51bf725ec 100644 --- a/packages/editor/core/src/ui/hooks/useEditor.tsx +++ b/packages/editor/core/src/ui/hooks/useEditor.tsx @@ -18,6 +18,7 @@ interface CustomEditorProps { value: string; deleteFile: DeleteImage; debouncedUpdatesEnabled?: boolean; + onStart?: (json: any, html: string) => void; onChange?: (json: any, html: string) => void; extensions?: any; editorProps?: EditorProps; @@ -34,6 +35,7 @@ export const useEditor = ({ editorProps = {}, value, extensions = [], + onStart, onChange, setIsSubmitting, forwardedRef, @@ -60,6 +62,9 @@ export const useEditor = ({ ], content: typeof value === "string" && value.trim() !== "" ? value : "

", + onCreate: async ({ editor }) => { + onStart?.(editor.getJSON(), getTrimmedHTML(editor.getHTML())) + }, onUpdate: async ({ editor }) => { // for instant feedback loop setIsSubmitting?.("submitting"); diff --git a/packages/editor/document-editor/Readme.md b/packages/editor/document-editor/Readme.md new file mode 100644 index 000000000..edbda8ea3 --- /dev/null +++ b/packages/editor/document-editor/Readme.md @@ -0,0 +1 @@ +# Document Editor \ No newline at end of file diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json new file mode 100644 index 000000000..5f82a35f7 --- /dev/null +++ b/packages/editor/document-editor/package.json @@ -0,0 +1,73 @@ +{ + "name": "@plane/document-editor", + "version": "0.0.1", + "description": "Package that powers Plane's Pages Editor", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "files": [ + "dist/**/*" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "module": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "check-types": "tsc --noEmit" + }, + "peerDependencies": { + "next": "12.3.2", + "next-themes": "^0.2.1", + "react": "^18.2.0", + "react-dom": "18.2.0" + }, + "dependencies": { + "@headlessui/react": "^1.7.17", + "@plane/ui": "*", + "@plane/editor-core": "*", + "@popperjs/core": "^2.11.8", + "@tiptap/core": "^2.1.7", + "@tiptap/extension-code-block-lowlight": "^2.1.11", + "@tiptap/extension-horizontal-rule": "^2.1.11", + "@tiptap/extension-list-item": "^2.1.11", + "@tiptap/extension-placeholder": "^2.1.11", + "@tiptap/suggestion": "^2.1.7", + "@types/node": "18.15.3", + "@types/react": "^18.2.5", + "@types/react-dom": "18.0.11", + "class-variance-authority": "^0.7.0", + "clsx": "^1.2.1", + "eslint": "8.36.0", + "eslint-config-next": "13.2.4", + "eventsource-parser": "^0.1.0", + "highlight.js": "^11.8.0", + "lowlight": "^3.0.0", + "lucide-react": "^0.244.0", + "react-markdown": "^8.0.7", + "react-popper": "^2.3.0", + "tailwind-merge": "^1.14.0", + "tippy.js": "^6.3.7", + "tiptap-markdown": "^0.8.2", + "use-debounce": "^9.0.4" + }, + "devDependencies": { + "eslint": "^7.32.0", + "postcss": "^8.4.29", + "tailwind-config-custom": "*", + "tsconfig": "*", + "tsup": "^7.2.0", + "typescript": "4.9.5" + }, + "keywords": [ + "editor", + "rich-text", + "markdown", + "nextjs", + "react" + ] +} diff --git a/packages/editor/document-editor/postcss.config.js b/packages/editor/document-editor/postcss.config.js new file mode 100644 index 000000000..419fe25d1 --- /dev/null +++ b/packages/editor/document-editor/postcss.config.js @@ -0,0 +1,9 @@ +// If you want to use other PostCSS plugins, see the following: +// https://tailwindcss.com/docs/using-with-preprocessors + +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, + }; \ No newline at end of file diff --git a/packages/editor/document-editor/src/index.ts b/packages/editor/document-editor/src/index.ts new file mode 100644 index 000000000..53ac942e4 --- /dev/null +++ b/packages/editor/document-editor/src/index.ts @@ -0,0 +1,3 @@ +export { DocumentEditor, DocumentEditorWithRef } from "./ui" +export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "./ui/readonly" +export { FixedMenu } from "./ui/menu/fixed-menu" diff --git a/packages/editor/document-editor/src/ui/components/alert-label.tsx b/packages/editor/document-editor/src/ui/components/alert-label.tsx new file mode 100644 index 000000000..7246647bc --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/alert-label.tsx @@ -0,0 +1,19 @@ +import { Icon } from "lucide-react" + +interface IAlertLabelProps { + Icon: Icon, + backgroundColor: string, + textColor?: string, + label: string, +} + +export const AlertLabel = ({ Icon, backgroundColor,textColor, label }: IAlertLabelProps) => { + + return ( +
+ + {label} +
+ ) + +} diff --git a/packages/editor/document-editor/src/ui/components/content-browser.tsx b/packages/editor/document-editor/src/ui/components/content-browser.tsx new file mode 100644 index 000000000..755d67b2d --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/content-browser.tsx @@ -0,0 +1,40 @@ +import { HeadingComp, SubheadingComp } from "./heading-component"; +import { IMarking } from ".."; +import { Editor } from "@tiptap/react"; +import { scrollSummary } from "../utils/editor-summary-utils"; + +interface ContentBrowserProps { + editor: Editor; + markings: IMarking[]; +} + +export const ContentBrowser = ({ + editor, + markings, +}: ContentBrowserProps) => ( +
+

+ Table of Contents +

+
+ {markings.length !== 0 ? ( + markings.map((marking) => + marking.level === 1 ? ( + scrollSummary(editor, marking)} + heading={marking.text} + /> + ) : ( + scrollSummary(editor, marking)} + subHeading={marking.text} + /> + ) + ) + ) : ( +

+ {"Headings will be displayed here for Navigation"} +

+ )} +
+); diff --git a/packages/editor/document-editor/src/ui/components/editor-header.tsx b/packages/editor/document-editor/src/ui/components/editor-header.tsx new file mode 100644 index 000000000..32ebe43c9 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/editor-header.tsx @@ -0,0 +1,79 @@ +import { Editor } from "@tiptap/react" +import { Lock, ArchiveIcon, MenuSquare } from "lucide-react" +import { useRef, useState } from "react" +import { usePopper } from "react-popper" +import { IMarking, UploadImage } from ".." +import { FixedMenu } from "../menu" +import { DocumentDetails } from "../types/editor-types" +import { AlertLabel } from "./alert-label" +import { ContentBrowser } from "./content-browser" +import { IVerticalDropdownItemProps, VerticalDropdownMenu } from "./vertical-dropdown-menu" + +interface IEditorHeader { + editor: Editor, + KanbanMenuOptions: IVerticalDropdownItemProps[], + sidePeakVisible: boolean, + setSidePeakVisible: (currentState: boolean) => void, + markings: IMarking[], + isLocked: boolean, + isArchived: boolean, + archivedAt?: Date, + readonly: boolean, + uploadFile?: UploadImage, + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, + documentDetails: DocumentDetails +} + +export const EditorHeader = ({ documentDetails, archivedAt, editor, sidePeakVisible, readonly, setSidePeakVisible, markings, uploadFile, setIsSubmitting, KanbanMenuOptions, isArchived, isLocked }: IEditorHeader) => { + + const summaryMenuRef = useRef(null); + const summaryButtonRef = useRef(null); + const [summaryPopoverVisible, setSummaryPopoverVisible] = useState(false); + + const { styles: summaryPopoverStyles, attributes: summaryPopoverAttributes } = usePopper(summaryButtonRef.current, summaryMenuRef.current, { + placement: "bottom-start" + }) + + return ( + +
+
+
+
setSummaryPopoverVisible(true)} + onMouseLeave={() => setSummaryPopoverVisible(false)} + > + + {summaryPopoverVisible && +
+ +
+ } +
+ {isLocked && } + {(isArchived && archivedAt) && } +
+ + {(!readonly && uploadFile) && } +
+ {!isArchived &&

{`Last updated at ${new Date(documentDetails.last_updated_at).toLocaleString()}`}

} + +
+
+
+ ) + +} diff --git a/packages/editor/document-editor/src/ui/components/heading-component.tsx b/packages/editor/document-editor/src/ui/components/heading-component.tsx new file mode 100644 index 000000000..629d3b427 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/heading-component.tsx @@ -0,0 +1,29 @@ +export const HeadingComp = ({ + heading, + onClick, +}: { + heading: string; + onClick: (event: React.MouseEvent) => void; +}) => ( +

+ {heading} +

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

+ {subHeading} +

+); diff --git a/packages/editor/document-editor/src/ui/components/page-renderer.tsx b/packages/editor/document-editor/src/ui/components/page-renderer.tsx new file mode 100644 index 000000000..aca50f3ff --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx @@ -0,0 +1,33 @@ +import { EditorContainer, EditorContentWrapper } from "@plane/editor-core" +import { Editor } from "@tiptap/react" +import { DocumentDetails } from "../types/editor-types" + +interface IPageRenderer { + sidePeakVisible: boolean, + documentDetails: DocumentDetails , + editor: Editor, + editorClassNames: string, + editorContentCustomClassNames?: string +} + +export const PageRenderer = ({ sidePeakVisible, documentDetails, editor, editorClassNames, editorContentCustomClassNames }: IPageRenderer) => { + return ( +
+
+
+

{documentDetails.title}

+
+
+
+ +
+ +
+
+
+
+
+ ) +} diff --git a/packages/editor/document-editor/src/ui/components/popover.tsx b/packages/editor/document-editor/src/ui/components/popover.tsx new file mode 100644 index 000000000..8c587b603 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/popover.tsx @@ -0,0 +1,67 @@ +import React, { Fragment, useState } from "react"; +import { usePopper } from "react-popper"; +import { Popover, Transition } from "@headlessui/react"; +import { Placement } from "@popperjs/core"; +// ui +import { Button } from "@plane/ui"; +// icons +import { ChevronUp, MenuIcon } from "lucide-react"; + +type Props = { + children: React.ReactNode; + title?: string; + placement?: Placement; +}; + +export const SummaryPopover: React.FC = (props) => { + const { children, title = "SummaryPopover", placement } = props; + + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "auto", + }); + + return ( + + {({ open }) => { + if (open) { + } + return ( + <> + + + + + +
+
{children}
+
+
+
+ + ); + }} +
+ ); +}; diff --git a/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx b/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx new file mode 100644 index 000000000..304c80018 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx @@ -0,0 +1,18 @@ +import { Editor } from "@tiptap/react" +import { IMarking } from ".." +import { ContentBrowser } from "./content-browser" + +interface ISummarySideBarProps { + editor: Editor, + markings: IMarking[], + sidePeakVisible: boolean +} + +export const SummarySideBar = ({ editor, markings, sidePeakVisible }: ISummarySideBarProps) => { + return ( + +
+ +
+ ) +} diff --git a/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx b/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx new file mode 100644 index 000000000..c28cb4d32 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx @@ -0,0 +1,50 @@ +import { Button, CustomMenu } from "@plane/ui" +import { ChevronUp, Icon, MoreVertical } from "lucide-react" + + +type TMenuItems = "archive_page" | "unarchive_page" | "lock_page" | "unlock_page" | "copy_markdown" | "close_page" | "copy_page_link" | "duplicate_page" + +export interface IVerticalDropdownItemProps { + key: number, + type: TMenuItems, + Icon: Icon, + label: string, + action: () => Promise | void +} + +export interface IVerticalDropdownMenuProps { + items: IVerticalDropdownItemProps[], +} + +const VerticalDropdownItem = ({ Icon, label, action }: IVerticalDropdownItemProps) => { + + return ( + + + + ) +} + +export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => { + + return ( + + }> + {items.map((item, index) => ( + + ))} + + ) +} diff --git a/packages/editor/document-editor/src/ui/extensions/index.tsx b/packages/editor/document-editor/src/ui/extensions/index.tsx new file mode 100644 index 000000000..cf5dc9e71 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/index.tsx @@ -0,0 +1,59 @@ +import HorizontalRule from "@tiptap/extension-horizontal-rule"; +import Placeholder from "@tiptap/extension-placeholder"; +import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; +import { common, createLowlight } from 'lowlight' +import { InputRule } from "@tiptap/core"; + +import ts from "highlight.js/lib/languages/typescript"; + +import SlashCommand from "./slash-command"; +import { UploadImage } from "../"; + +const lowlight = createLowlight(common) +lowlight.register("ts", ts); + +export const DocumentEditorExtensions = ( + uploadFile: UploadImage, + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void +) => [ + HorizontalRule.extend({ + addInputRules() { + return [ + new InputRule({ + find: /^(?:---|—-|___\s|\*\*\*\s)$/, + handler: ({ state, range, commands }) => { + commands.splitBlock(); + + const attributes = {}; + const { tr } = state; + const start = range.from; + const end = range.to; + // @ts-ignore + tr.replaceWith(start - 1, end, this.type.create(attributes)); + }, + }), + ]; + }, + }).configure({ + HTMLAttributes: { + class: "mb-6 border-t border-custom-border-300", + }, + }), + SlashCommand(uploadFile, setIsSubmitting), + CodeBlockLowlight.configure({ + lowlight, + }), + 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, + }), + ]; diff --git a/packages/editor/document-editor/src/ui/extensions/slash-command.tsx b/packages/editor/document-editor/src/ui/extensions/slash-command.tsx new file mode 100644 index 000000000..e00585dd8 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/slash-command.tsx @@ -0,0 +1,343 @@ +import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react"; +import { Editor, Range, Extension } from "@tiptap/core"; +import Suggestion from "@tiptap/suggestion"; +import { ReactRenderer } from "@tiptap/react"; +import tippy from "tippy.js"; +import { + Heading1, + Heading2, + Heading3, + List, + ListOrdered, + Text, + TextQuote, + Code, + MinusSquare, + CheckSquare, + ImageIcon, + Table, +} from "lucide-react"; +import { UploadImage } from "../"; +import { cn, insertTableCommand, toggleBlockquote, toggleBulletList, toggleOrderedList, toggleTaskList, insertImageCommand, toggleHeadingOne, toggleHeadingTwo, toggleHeadingThree } from "@plane/editor-core"; + +interface CommandItemProps { + title: string; + description: string; + icon: ReactNode; +} + +interface CommandProps { + editor: Editor; + range: Range; +} + +const Command = Extension.create({ + name: "slash-command", + addOptions() { + return { + suggestion: { + char: "/", + command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { + props.command({ editor, range }); + }, + }, + }; + }, + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + allow({ editor }) { + return !editor.isActive("table"); + }, + ...this.options.suggestion, + }), + ]; + }, +}); + +const getSuggestionItems = + ( + uploadFile: UploadImage, + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void + ) => + ({ 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(); + }, + }, + { + 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 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: "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) => { + 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: "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) => + 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); + }, + }, + ].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; + }); + +export 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 -= container.scrollTop - top + 5; + } else if (bottom > containerHeight + container.scrollTop) { + container.scrollTop += bottom - containerHeight - container.scrollTop + 5; + } +}; + +const CommandList = ({ + items, + command, +}: { + items: CommandItemProps[]; + command: any; + editor: any; + range: any; +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const selectItem = useCallback( + (index: number) => { + const item = items[index]; + if (item) { + command(item); + } + }, + [command, items] + ); + + useEffect(() => { + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; + const onKeyDown = (e: KeyboardEvent) => { + if (navigationKeys.includes(e.key)) { + e.preventDefault(); + if (e.key === "ArrowUp") { + setSelectedIndex((selectedIndex + items.length - 1) % items.length); + return true; + } + if (e.key === "ArrowDown") { + setSelectedIndex((selectedIndex + 1) % items.length); + return true; + } + if (e.key === "Enter") { + selectItem(selectedIndex); + return true; + } + return false; + } + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [items, selectedIndex, setSelectedIndex, selectItem]); + + useEffect(() => { + setSelectedIndex(0); + }, [items]); + + const commandListContainer = useRef(null); + + useLayoutEffect(() => { + const container = commandListContainer?.current; + + const item = container?.children[selectedIndex] as HTMLElement; + + if (item && container) updateScrollView(container, item); + }, [selectedIndex]); + + return items.length > 0 ? ( +
+ {items.map((item: CommandItemProps, index: number) => ( + + ))} +
+ ) : null; +}; + +const renderItems = () => { + let component: ReactRenderer | null = null; + let popup: any | null = null; + + return { + onStart: (props: { editor: Editor; clientRect: DOMRect }) => { + component = new ReactRenderer(CommandList, { + 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: "bottom-start", + }); + }, + 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: () => { + popup?.[0].destroy(); + component?.destroy(); + }, + }; +}; + +export const SlashCommand = ( + uploadFile: UploadImage, + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void +) => + Command.configure({ + suggestion: { + items: getSuggestionItems(uploadFile, setIsSubmitting), + render: renderItems, + }, + }); + +export default SlashCommand; 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 new file mode 100644 index 000000000..96bd40101 --- /dev/null +++ b/packages/editor/document-editor/src/ui/hooks/use-editor-markings.tsx @@ -0,0 +1,33 @@ +import { Editor } from "@tiptap/react"; +import { useState } from "react"; +import { IMarking } from ".."; + +export const useEditorMarkings = () => { + + const [markings, setMarkings] = useState([]) + + const updateMarkings = (json: any) => { + const nodes = json.content as any[] + const tempMarkings: IMarking[] = [] + let h1Sequence: number = 0 + let h2Sequence: number = 0 + if (nodes) { + nodes.forEach((node) => { + if (node.type === "heading" && (node.attrs.level === 1 || node.attrs.level === 2) && node.content) { + tempMarkings.push({ + type: "heading", + level: node.attrs.level, + text: node.content[0].text, + sequence: node.attrs.level === 1 ? ++h1Sequence : ++h2Sequence + }) + } + }) + } + setMarkings(tempMarkings) + } + + return { + updateMarkings, + markings, + } +} diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx new file mode 100644 index 000000000..be75ff8fb --- /dev/null +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -0,0 +1,151 @@ +"use client" +import React, { useState } from 'react'; +import { cn, getEditorClassNames, useEditor } from '@plane/editor-core'; +import { DocumentEditorExtensions } from './extensions'; +import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from './types/menu-actions'; +import { EditorHeader } from './components/editor-header'; +import { useEditorMarkings } from './hooks/use-editor-markings'; +import { SummarySideBar } from './components/summary-side-bar'; +import { DocumentDetails } from './types/editor-types'; +import { PageRenderer } from './components/page-renderer'; +import { getMenuOptions } from './utils/menu-options'; +import { useRouter } from 'next/router'; + +export type UploadImage = (file: File) => Promise; +export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; + +interface IDocumentEditor { + documentDetails: DocumentDetails, + value: string; + uploadFile: UploadImage; + deleteFile: DeleteImage; + customClassName?: string; + editorContentCustomClassNames?: string; + onChange: (json: any, html: string) => void; + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; + setShouldShowAlert?: (showAlert: boolean) => void; + forwardedRef?: any; + debouncedUpdatesEnabled?: boolean; + duplicationConfig?: IDuplicationConfig, + pageLockConfig?: IPageLockConfig, + pageArchiveConfig?: IPageArchiveConfig +} +interface DocumentEditorProps extends IDocumentEditor { + forwardedRef?: React.Ref; +} + +interface EditorHandle { + clearEditor: () => void; + setEditorValue: (content: string) => void; +} + +export interface IMarking { + type: "heading", + level: number, + text: string, + sequence: number +} + +const DocumentEditor = ({ + documentDetails, + onChange, + debouncedUpdatesEnabled, + setIsSubmitting, + setShouldShowAlert, + editorContentCustomClassNames, + value, + uploadFile, + deleteFile, + customClassName, + forwardedRef, + duplicationConfig, + pageLockConfig, + pageArchiveConfig +}: IDocumentEditor) => { + + // const [alert, setAlert] = useState("") + const { markings, updateMarkings } = useEditorMarkings() + const [sidePeakVisible, setSidePeakVisible] = useState(true) + const router = useRouter() + + const editor = useEditor({ + onChange(json, html) { + updateMarkings(json) + onChange(json, html) + }, + onStart(json) { + updateMarkings(json) + }, + debouncedUpdatesEnabled, + setIsSubmitting, + setShouldShowAlert, + value, + uploadFile, + deleteFile, + forwardedRef, + extensions: DocumentEditorExtensions(uploadFile, setIsSubmitting), + }); + + if (!editor) { + return null + } + + const KanbanMenuOptions = getMenuOptions( + { + editor: editor, + router: router, + duplicationConfig: duplicationConfig, + pageLockConfig: pageLockConfig, + pageArchiveConfig: pageArchiveConfig, + } + ) + const editorClassNames = getEditorClassNames({ noBorder: true, borderOnFocus: false, customClassName }); + + if (!editor) return null; + + return ( +
+
+ +
+
+
+ + + {/* Page Element */} +
+
+
+ ); +} + +const DocumentEditorWithRef = React.forwardRef((props, ref) => ( + +)); + +DocumentEditorWithRef.displayName = "DocumentEditorWithRef"; + +export { DocumentEditor, DocumentEditorWithRef } diff --git a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx new file mode 100644 index 000000000..2cd07ec14 --- /dev/null +++ b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx @@ -0,0 +1,142 @@ +import { Editor } from "@tiptap/react"; +import { BoldIcon, Heading1, Heading2, Heading3 } from "lucide-react"; + +import { BoldItem, BulletListItem, cn, CodeItem, ImageItem, ItalicItem, NumberedListItem, QuoteItem, StrikeThroughItem, TableItem, UnderLineItem, HeadingOneItem, HeadingTwoItem, HeadingThreeItem } from "@plane/editor-core"; +import { UploadImage } from ".."; + +export interface BubbleMenuItem { + name: string; + isActive: () => boolean; + command: () => void; + icon: typeof BoldIcon; +} + +type EditorBubbleMenuProps = { + editor: Editor; + uploadFile: UploadImage; + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; +} + +export const FixedMenu = (props: EditorBubbleMenuProps) => { + const basicMarkItems: BubbleMenuItem[] = [ + HeadingOneItem(props.editor), + HeadingTwoItem(props.editor), + HeadingThreeItem(props.editor), + BoldItem(props.editor), + ItalicItem(props.editor), + UnderLineItem(props.editor), + StrikeThroughItem(props.editor), + ]; + + const listItems: BubbleMenuItem[] = [ + BulletListItem(props.editor), + NumberedListItem(props.editor), + ]; + + const userActionItems: BubbleMenuItem[] = [ + QuoteItem(props.editor), + CodeItem(props.editor), + ]; + + const complexItems: BubbleMenuItem[] = [ + TableItem(props.editor), + ImageItem(props.editor, props.uploadFile, props.setIsSubmitting), + ]; + + // const handleAccessChange = (accessKey: string) => { + // props.commentAccessSpecifier?.onAccessChange(accessKey); + // }; + + + return ( +
+
+ {basicMarkItems.map((item, index) => ( + + ))} +
+
+ {listItems.map((item, index) => ( + + ))} +
+
+ {userActionItems.map((item, index) => ( + + ))} +
+
+ {complexItems.map((item, index) => ( + + ))} +
+
+ ); +}; diff --git a/packages/editor/document-editor/src/ui/menu/icon.tsx b/packages/editor/document-editor/src/ui/menu/icon.tsx new file mode 100644 index 000000000..c0006b3f2 --- /dev/null +++ b/packages/editor/document-editor/src/ui/menu/icon.tsx @@ -0,0 +1,13 @@ +import React from "react"; + +type Props = { + iconName: string; + className?: string; +}; + +export const Icon: React.FC = ({ iconName, className = "" }) => ( + + {iconName} + +); + diff --git a/packages/editor/document-editor/src/ui/menu/index.tsx b/packages/editor/document-editor/src/ui/menu/index.tsx new file mode 100644 index 000000000..3abc58022 --- /dev/null +++ b/packages/editor/document-editor/src/ui/menu/index.tsx @@ -0,0 +1 @@ +export { FixedMenu } from "./fixed-menu"; \ No newline at end of file diff --git a/packages/editor/document-editor/src/ui/readonly/index.tsx b/packages/editor/document-editor/src/ui/readonly/index.tsx new file mode 100644 index 000000000..d6dcb0818 --- /dev/null +++ b/packages/editor/document-editor/src/ui/readonly/index.tsx @@ -0,0 +1,121 @@ +import { cn, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core" +import { useRouter } from "next/router"; +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 { useEditorMarkings } from "../hooks/use-editor-markings"; +import { DocumentDetails } from "../types/editor-types"; +import { IPageArchiveConfig, IPageLockConfig, IDuplicationConfig } from "../types/menu-actions"; +import { getMenuOptions } from "../utils/menu-options"; + +interface IDocumentReadOnlyEditor { + value: string, + noBorder: boolean, + borderOnFocus: boolean, + customClassName: string, + documentDetails: DocumentDetails, + pageLockConfig?: IPageLockConfig, + pageArchiveConfig?: IPageArchiveConfig, + pageDuplicationConfig?: IDuplicationConfig, +} + +interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor { + forwardedRef?: React.Ref +} + +interface EditorHandle { + clearEditor: () => void; + setEditorValue: (content: string) => void; +} + +const DocumentReadOnlyEditor = ({ + noBorder, + borderOnFocus, + customClassName, + value, + documentDetails, + forwardedRef, + pageDuplicationConfig, + pageLockConfig, + pageArchiveConfig, +}: DocumentReadOnlyEditorProps) => { + + const router = useRouter() + const [sidePeakVisible, setSidePeakVisible] = useState(true) + const { markings, updateMarkings } = useEditorMarkings() + + const editor = useReadOnlyEditor({ + value, + forwardedRef, + }) + + + useEffect(() => { + if (editor) { + updateMarkings(editor.getJSON()) + } + }, [editor?.getJSON()]) + + if (!editor) { + return null + } + + const editorClassNames = getEditorClassNames({ + noBorder, + borderOnFocus, + customClassName + }) + + const KanbanMenuOptions = getMenuOptions({ + editor: editor, + router: router, + pageArchiveConfig: pageArchiveConfig, + pageLockConfig: pageLockConfig, + duplicationConfig: pageDuplicationConfig, + }) + + return ( +
+
+ +
+
+
+ + +
+
+
+ ) +} + + +const DocumentReadOnlyEditorWithRef = forwardRef< + EditorHandle, + IDocumentReadOnlyEditor +>((props, ref) => ); + +DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef"; + +export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } diff --git a/packages/editor/document-editor/src/ui/tooltip.tsx b/packages/editor/document-editor/src/ui/tooltip.tsx new file mode 100644 index 000000000..f29d8a491 --- /dev/null +++ b/packages/editor/document-editor/src/ui/tooltip.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; + +// next-themes +import { useTheme } from "next-themes"; +// tooltip2 +import { Tooltip2 } from "@blueprintjs/popover2"; + +type Props = { + tooltipHeading?: string; + tooltipContent: string | React.ReactNode; + position?: + | "top" + | "right" + | "bottom" + | "left" + | "auto" + | "auto-end" + | "auto-start" + | "bottom-left" + | "bottom-right" + | "left-bottom" + | "left-top" + | "right-bottom" + | "right-top" + | "top-left" + | "top-right"; + children: JSX.Element; + disabled?: boolean; + className?: string; + openDelay?: number; + closeDelay?: number; +}; + +export const Tooltip: React.FC = ({ + tooltipHeading, + tooltipContent, + position = "top", + children, + disabled = false, + className = "", + openDelay = 200, + closeDelay, +}) => { + const { theme } = useTheme(); + + return ( + + {tooltipHeading && ( +
+ {tooltipHeading} +
+ )} + {tooltipContent} + + } + position={position} + renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) => + React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props }) + } + /> + ); +}; diff --git a/packages/editor/document-editor/src/ui/types/editor-types.ts b/packages/editor/document-editor/src/ui/types/editor-types.ts new file mode 100644 index 000000000..4996c0e3b --- /dev/null +++ b/packages/editor/document-editor/src/ui/types/editor-types.ts @@ -0,0 +1,8 @@ + +export interface DocumentDetails { + title: string; + created_by: string; + created_on: Date; + last_updated_by: string; + last_updated_at: Date; +} diff --git a/packages/editor/document-editor/src/ui/types/menu-actions.d.ts b/packages/editor/document-editor/src/ui/types/menu-actions.d.ts new file mode 100644 index 000000000..ebb253312 --- /dev/null +++ b/packages/editor/document-editor/src/ui/types/menu-actions.d.ts @@ -0,0 +1,14 @@ + +export interface IDuplicationConfig { + action: () => Promise +} +export interface IPageLockConfig { + is_locked: boolean, + action: () => Promise + locked_by?: string, +} +export interface IPageArchiveConfig { + is_archived: boolean, + archived_at?: Date, + action: () => Promise + } diff --git a/packages/editor/document-editor/src/ui/utils/editor-summary-utils.ts b/packages/editor/document-editor/src/ui/utils/editor-summary-utils.ts new file mode 100644 index 000000000..94d0cbbbc --- /dev/null +++ b/packages/editor/document-editor/src/ui/utils/editor-summary-utils.ts @@ -0,0 +1,35 @@ +import { Editor } from "@tiptap/react"; +import { IMarking } from ".."; + +function findNthH1(editor: Editor, n: number, level: number): number { + let count = 0; + let pos = 0; + editor.state.doc.descendants((node, position) => { + if (node.type.name === 'heading' && node.attrs.level === level) { + count++; + if (count === n) { + pos = position; + return false; + } + } + }); + return pos; + } + + function scrollToNode(editor: Editor, pos: number): void { + const headingNode = editor.state.doc.nodeAt(pos); + if (headingNode) { + const headingDOM = editor.view.nodeDOM(pos); + if (headingDOM instanceof HTMLElement) { + headingDOM.scrollIntoView({ behavior: 'smooth' }); + } + } + } + + export function scrollSummary(editor: Editor, marking: IMarking) { + if (editor) { + const pos = findNthH1(editor, marking.sequence, marking.level) + scrollToNode(editor, pos) + } + } + diff --git a/packages/editor/document-editor/src/ui/utils/menu-actions.ts b/packages/editor/document-editor/src/ui/utils/menu-actions.ts new file mode 100644 index 000000000..c6fd32c21 --- /dev/null +++ b/packages/editor/document-editor/src/ui/utils/menu-actions.ts @@ -0,0 +1,12 @@ +import { Editor } from "@tiptap/core" + +export const copyMarkdownToClipboard = (editor: Editor | null) => { + const markdownOutput = editor?.storage.markdown.getMarkdown(); + navigator.clipboard.writeText(markdownOutput) +} + +export const CopyPageLink = () => { + if (window){ + navigator.clipboard.writeText(window.location.toString()) + } +} diff --git a/packages/editor/document-editor/src/ui/utils/menu-options.ts b/packages/editor/document-editor/src/ui/utils/menu-options.ts new file mode 100644 index 000000000..152ff4ac3 --- /dev/null +++ b/packages/editor/document-editor/src/ui/utils/menu-options.ts @@ -0,0 +1,75 @@ +import { Editor } from "@tiptap/react" +import { Archive, ArchiveIcon, ArchiveRestoreIcon, ClipboardIcon, Copy, Link, Lock, Unlock, XCircle } from "lucide-react" +import { NextRouter } from "next/router" +import { IVerticalDropdownItemProps } from "../components/vertical-dropdown-menu" +import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "../types/menu-actions" +import { copyMarkdownToClipboard, CopyPageLink } from "./menu-actions" + +export interface MenuOptionsProps{ + editor: Editor, + router: NextRouter, + duplicationConfig?: IDuplicationConfig, + pageLockConfig?: IPageLockConfig , + pageArchiveConfig?: IPageArchiveConfig, +} + +export const getMenuOptions = ({ editor, router, duplicationConfig, pageLockConfig, pageArchiveConfig } : MenuOptionsProps) => { + + const KanbanMenuOptions: IVerticalDropdownItemProps[] = [ + { + key: 1, + type: "copy_markdown", + Icon: ClipboardIcon, + action: () => copyMarkdownToClipboard(editor), + label: "Copy Markdown" + }, + { + key: 2, + type: "close_page", + Icon: XCircle, + action: () => router.back(), + label: "Close the page" + }, + { + key: 3, + type: "copy_page_link", + Icon: Link, + action: () => CopyPageLink(), + label: "Copy Page Link" + }, + ] + + // If duplicateConfig is given, page duplication will be allowed + if (duplicationConfig) { + KanbanMenuOptions.push({ + key: KanbanMenuOptions.length++, + type: "duplicate_page", + Icon: Copy, + action: duplicationConfig.action, + label: "Make a copy" + }) + } + // If Lock Configuration is given then, lock page option will be available in the kanban menu + if (pageLockConfig) { + KanbanMenuOptions.push({ + key: KanbanMenuOptions.length++, + 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 + }) + } + + // Archiving will be visible in the menu bar config once the pageArchiveConfig is given. + if (pageArchiveConfig) { + KanbanMenuOptions.push({ + key: KanbanMenuOptions.length++, + 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, + }) + } + + return KanbanMenuOptions +} diff --git a/packages/editor/document-editor/tailwind.config.js b/packages/editor/document-editor/tailwind.config.js new file mode 100644 index 000000000..f32063158 --- /dev/null +++ b/packages/editor/document-editor/tailwind.config.js @@ -0,0 +1,6 @@ +const sharedConfig = require("tailwind-config-custom/tailwind.config.js"); + +module.exports = { + // prefix ui lib classes to avoid conflicting with the app + ...sharedConfig, +}; diff --git a/packages/editor/document-editor/tsconfig.json b/packages/editor/document-editor/tsconfig.json new file mode 100644 index 000000000..57d0e9a74 --- /dev/null +++ b/packages/editor/document-editor/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "tsconfig/react-library.json", + "include": ["src/**/*", "index.d.ts"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/editor/document-editor/tsup.config.ts b/packages/editor/document-editor/tsup.config.ts new file mode 100644 index 000000000..5e89e04af --- /dev/null +++ b/packages/editor/document-editor/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig, Options } from "tsup"; + +export default defineConfig((options: Options) => ({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: false, + external: ["react"], + injectStyle: true, + ...options, +})); diff --git a/turbo.json b/turbo.json index 454e09f14..c732dae7b 100644 --- a/turbo.json +++ b/turbo.json @@ -31,6 +31,7 @@ "dependsOn": [ "@plane/lite-text-editor#build", "@plane/rich-text-editor#build", + "@plane/document-editor#build", "@plane/ui#build" ] }, @@ -40,6 +41,7 @@ "dependsOn": [ "@plane/lite-text-editor#build", "@plane/rich-text-editor#build", + "@plane/document-editor#build", "@plane/ui#build" ] }, @@ -48,6 +50,7 @@ "dependsOn": [ "@plane/lite-text-editor#build", "@plane/rich-text-editor#build", + "@plane/document-editor#build", "@plane/ui#build" ] }, @@ -56,6 +59,7 @@ "dependsOn": [ "@plane/lite-text-editor#build", "@plane/rich-text-editor#build", + "@plane/document-editor#build", "@plane/ui#build" ] }, @@ -67,6 +71,12 @@ "cache": true, "dependsOn": ["@plane/editor-core#build"] }, + "@plane/document-editor#build": { + "cache": true, + "dependsOn": [ + "@plane/editor-core#build" + ] + }, "test": { "dependsOn": ["^build"], "outputs": [] diff --git a/web/components/command-palette/command-pallette.tsx b/web/components/command-palette/command-pallette.tsx index 33c958b6c..0ca293475 100644 --- a/web/components/command-palette/command-pallette.tsx +++ b/web/components/command-palette/command-pallette.tsx @@ -203,8 +203,6 @@ export const CommandPalette: FC = observer(() => { toggleCreatePageModal(false)} - user={user} - workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} /> diff --git a/web/components/cycles/active-cycle-details.tsx b/web/components/cycles/active-cycle-details.tsx index 63acb8510..71a174f18 100644 --- a/web/components/cycles/active-cycle-details.tsx +++ b/web/components/cycles/active-cycle-details.tsx @@ -250,7 +250,7 @@ export const ActiveCycleDetails: React.FC = observer((props handleRemoveFromFavorites(e); }} > - + ) : ( - @@ -145,4 +125,4 @@ export const DeletePageModal: React.FC = ({ isOpen, s ); -}; +}); diff --git a/web/components/pages/index.ts b/web/components/pages/index.ts index 3d34ea8d5..76523cf08 100644 --- a/web/components/pages/index.ts +++ b/web/components/pages/index.ts @@ -1,10 +1,6 @@ export * from "./pages-list"; +export * from "./create-block"; export * from "./create-update-block-inline"; export * from "./create-update-page-modal"; export * from "./delete-page-modal"; export * from "./page-form"; -export * from "./pages-view"; -export * from "./single-page-block"; -export * from "./single-page-detailed-item"; -export * from "./single-page-list-item"; -export * from "./create-block"; diff --git a/web/components/pages/page-form.tsx b/web/components/pages/page-form.tsx index e898395c9..18366286c 100644 --- a/web/components/pages/page-form.tsx +++ b/web/components/pages/page-form.tsx @@ -1,23 +1,24 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; // ui -import { Button, Input } from "@plane/ui"; +import { Button, Input, Tooltip } from "@plane/ui"; // types import { IPage } from "types"; +import { PAGE_ACCESS_SPECIFIERS } from "constants/page"; type Props = { handleFormSubmit: (values: IPage) => Promise; handleClose: () => void; - status: boolean; data?: IPage | null; }; const defaultValues = { name: "", description: "", + access: 0, }; -export const PageForm: React.FC = ({ handleFormSubmit, handleClose, status, data }) => { +export const PageForm: React.FC = ({ handleFormSubmit, handleClose, data }) => { const { formState: { errors, isSubmitting }, handleSubmit, @@ -44,8 +45,8 @@ export const PageForm: React.FC = ({ handleFormSubmit, handleClose, statu return (
-
-

{status ? "Update" : "Create"} Page

+
+

{data ? "Update" : "Create"} Page

= ({ handleFormSubmit, handleClose, statu render={({ field: { value, onChange, ref } }) => ( )} />
-
- - +
+ ( +
+
+ {PAGE_ACCESS_SPECIFIERS.map((access) => ( + + + + ))} +
+
+ {PAGE_ACCESS_SPECIFIERS.find((access) => access.key === value)?.label} +
+
+ )} + /> +
+ + +
); diff --git a/web/components/pages/pages-list/all-pages-list.tsx b/web/components/pages/pages-list/all-pages-list.tsx index 22460f734..33e9b3198 100644 --- a/web/components/pages/pages-list/all-pages-list.tsx +++ b/web/components/pages/pages-list/all-pages-list.tsx @@ -1,29 +1,26 @@ -import { useRouter } from "next/router"; - -import useSWR from "swr"; - -// services -import { PageService } from "services/page.service"; +import { FC } from "react"; +import { observer } from "mobx-react-lite"; // components -import { PagesView } from "components/pages"; -// types -import { TPagesListProps } from "./types"; +import { PagesListView } from "components/pages/pages-list"; // fetch-keys -import { ALL_PAGES_LIST } from "constants/fetch-keys"; +import { useMobxStore } from "lib/mobx/store-provider"; +// ui +import { Loader } from "@plane/ui"; -// services -const pageService = new PageService(); +export const AllPagesList: FC = observer(() => { + // store + const { + page: { projectPages }, + } = useMobxStore(); -export const AllPagesList: React.FC = ({ viewType }) => { - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + if (!projectPages) + return ( + + + + + + ); - const { data: pages } = useSWR( - workspaceSlug && projectId ? ALL_PAGES_LIST(projectId as string) : null, - workspaceSlug && projectId - ? () => pageService.getPagesWithParams(workspaceSlug as string, projectId as string, "all") - : null - ); - - return ; -}; + return ; +}); diff --git a/web/components/pages/pages-list/archived-pages-list.tsx b/web/components/pages/pages-list/archived-pages-list.tsx new file mode 100644 index 000000000..26baa0f5c --- /dev/null +++ b/web/components/pages/pages-list/archived-pages-list.tsx @@ -0,0 +1,25 @@ +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { PagesListView } from "components/pages/pages-list"; +// hooks +import { useMobxStore } from "lib/mobx/store-provider"; +// ui +import { Loader } from "@plane/ui"; + +export const ArchivedPagesList: FC = observer(() => { + const { + page: { archivedProjectPages }, + } = useMobxStore(); + + if (!archivedProjectPages) + return ( + + + + + + ); + + return ; +}); diff --git a/web/components/pages/pages-list/favorite-pages-list.tsx b/web/components/pages/pages-list/favorite-pages-list.tsx index ef50f296a..b20ce73fc 100644 --- a/web/components/pages/pages-list/favorite-pages-list.tsx +++ b/web/components/pages/pages-list/favorite-pages-list.tsx @@ -1,29 +1,25 @@ -import { useRouter } from "next/router"; - -import useSWR from "swr"; - -// services -import { PageService } from "services/page.service"; +import { FC } from "react"; +import { observer } from "mobx-react-lite"; // components -import { PagesView } from "components/pages"; -// types -import { TPagesListProps } from "./types"; -// fetch-keys -import { FAVORITE_PAGES_LIST } from "constants/fetch-keys"; +import { PagesListView } from "components/pages/pages-list"; +// hooks +import { useMobxStore } from "lib/mobx/store-provider"; +// ui +import { Loader } from "@plane/ui"; -// services -const pageService = new PageService(); +export const FavoritePagesList: FC = observer(() => { + const { + page: { favoriteProjectPages }, + } = useMobxStore(); -export const FavoritePagesList: React.FC = ({ viewType }) => { - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + if (!favoriteProjectPages) + return ( + + + + + + ); - const { data: pages } = useSWR( - workspaceSlug && projectId ? FAVORITE_PAGES_LIST(projectId as string) : null, - workspaceSlug && projectId - ? () => pageService.getPagesWithParams(workspaceSlug as string, projectId as string, "favorite") - : null - ); - - return ; -}; + return ; +}); diff --git a/web/components/pages/pages-list/index.ts b/web/components/pages/pages-list/index.ts index 898138710..1f199296a 100644 --- a/web/components/pages/pages-list/index.ts +++ b/web/components/pages/pages-list/index.ts @@ -1,6 +1,8 @@ export * from "./all-pages-list"; +export * from "./archived-pages-list"; export * from "./favorite-pages-list"; -export * from "./my-pages-list"; -export * from "./other-pages-list"; +export * from "./private-page-list"; +export * from "./shared-pages-list"; export * from "./recent-pages-list"; export * from "./types"; +export * from "./list-view"; diff --git a/web/components/pages/pages-list/list-item.tsx b/web/components/pages/pages-list/list-item.tsx new file mode 100644 index 000000000..c4f9f9f16 --- /dev/null +++ b/web/components/pages/pages-list/list-item.tsx @@ -0,0 +1,303 @@ +import { FC, useState } from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +// icons +import { + AlertCircle, + Archive, + ArchiveRestoreIcon, + FileText, + Globe2, + LinkIcon, + Lock, + Pencil, + Star, + Trash2, +} from "lucide-react"; +// hooks +import useToast from "hooks/use-toast"; +import { useMobxStore } from "lib/mobx/store-provider"; +// helpers +import { copyUrlToClipboard } from "helpers/string.helper"; +import { renderShortDate, render24HourFormatTime, renderLongDateFormat } from "helpers/date-time.helper"; +// ui +import { CustomMenu, Tooltip } from "@plane/ui"; +// components +import { CreateUpdatePageModal, DeletePageModal } from "components/pages"; +// types +import { IPage } from "types"; + +export interface IPagesListItem { + workspaceSlug: string; + projectId: string; + page: IPage; +} + +export const PagesListItem: FC = observer((props) => { + const { workspaceSlug, projectId, page } = props; + // states + const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false); + const [deletePageModal, setDeletePageModal] = useState(false); + // store + const { + page: { archivePage, removeFromFavorites, addToFavorites, makePublic, makePrivate, restorePage }, + user: { currentProjectRole }, + projectMember: { projectMembers }, + } = useMobxStore(); + // hooks + const { setToastAlert } = useToast(); + + const handleCopyUrl = (e: any) => { + e.preventDefault(); + e.stopPropagation(); + + copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${page.id}`).then(() => { + setToastAlert({ + type: "success", + title: "Link Copied!", + message: "Page link copied to clipboard.", + }); + }); + }; + + const handleAddToFavorites = (e: any) => { + e.preventDefault(); + e.stopPropagation(); + + addToFavorites(workspaceSlug, projectId, page.id) + .then(() => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Successfully added the page to favorites.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the page to favorites. Please try again.", + }); + }); + }; + + const handleRemoveFromFavorites = (e: any) => { + e.preventDefault(); + e.stopPropagation(); + + removeFromFavorites(workspaceSlug, projectId, page.id) + .then(() => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Successfully removed the page from favorites.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't remove the page from favorites. Please try again.", + }); + }); + }; + + const handleMakePublic = (e: any) => { + e.preventDefault(); + e.stopPropagation(); + + makePublic(workspaceSlug, projectId, page.id); + }; + + const handleMakePrivate = (e: any) => { + e.preventDefault(); + e.stopPropagation(); + + makePrivate(workspaceSlug, projectId, page.id); + }; + + const handleArchivePage = (e: any) => { + e.preventDefault(); + e.stopPropagation(); + + archivePage(workspaceSlug, projectId, page.id); + }; + + const handleRestorePage = (e: any) => { + e.preventDefault(); + e.stopPropagation(); + + restorePage(workspaceSlug, projectId, page.id); + }; + + const handleDeletePage = (e: any) => { + e.preventDefault(); + e.stopPropagation(); + + setDeletePageModal(true); + }; + + const handleEditPage = (e: any) => { + e.preventDefault(); + e.stopPropagation(); + + setCreateUpdatePageModal(true); + }; + + const userCanEdit = currentProjectRole === 15 || currentProjectRole === 20; + + return ( + <> + setCreateUpdatePageModal(false)} + data={page} + projectId={projectId} + /> + setDeletePageModal(false)} data={page} /> +
  • + + +
    +
    +
    + +

    {page.name}

    + {page.label_details.length > 0 && + page.label_details.map((label) => ( +
    + + {label.name} +
    + ))} +
    +
    + {page.archived_at ? ( + +

    {render24HourFormatTime(page.archived_at)}

    +
    + ) : ( + +

    {render24HourFormatTime(page.updated_at)}

    +
    + )} + {!page.archived_at && userCanEdit && ( + + {page.is_favorite ? ( + + ) : ( + + )} + + )} + {!page.archived_at && userCanEdit && ( + + {page.access ? ( + + ) : ( + + )} + + )} + projectMember.member.id === page.created_by)?.member + .display_name ?? "" + } on ${renderLongDateFormat(`${page.created_at}`)}`} + > + + + {page.archived_at ? ( + + {userCanEdit && ( + <> + +
    + + Restore page +
    +
    + +
    + + Delete page +
    +
    + + )} + +
    + + Copy page link +
    +
    +
    + ) : ( + + {userCanEdit && ( + <> + +
    + + Edit page +
    +
    + +
    + + Archive page +
    +
    + + )} + +
    + + Copy page link +
    +
    +
    + )} +
    +
    +
    +
    + +
  • + + ); +}); diff --git a/web/components/pages/pages-list/list-view.tsx b/web/components/pages/pages-list/list-view.tsx new file mode 100644 index 000000000..71148408e --- /dev/null +++ b/web/components/pages/pages-list/list-view.tsx @@ -0,0 +1,65 @@ +import { FC } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +import { Plus } from "lucide-react"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// components +import { EmptyState } from "components/common"; +import { PagesListItem } from "./list-item"; +// ui +import { Loader } from "@plane/ui"; +// images +import emptyPage from "public/empty-state/page.svg"; +// types +import { IPage } from "types"; + +type IPagesListView = { + pages: IPage[]; +}; + +export const PagesListView: FC = observer(({ pages }) => { + // store + const { commandPalette: commandPaletteStore } = useMobxStore(); + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + return ( + <> + {pages && workspaceSlug && projectId ? ( +
    + {pages.length > 0 ? ( +
      + {pages.map((page) => ( + + ))} +
    + ) : ( + , + text: "New Page", + onClick: () => commandPaletteStore.toggleCreatePageModal(true), + }} + /> + )} +
    + ) : ( + + + + + + )} + + ); +}); diff --git a/web/components/pages/pages-list/my-pages-list.tsx b/web/components/pages/pages-list/my-pages-list.tsx deleted file mode 100644 index 889ffa3b6..000000000 --- a/web/components/pages/pages-list/my-pages-list.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useRouter } from "next/router"; - -import useSWR from "swr"; - -// services -import { PageService } from "services/page.service"; -// components -import { PagesView } from "components/pages"; -// types -import { TPagesListProps } from "./types"; -// fetch-keys -import { MY_PAGES_LIST } from "constants/fetch-keys"; - -// services -const pageService = new PageService(); - -export const MyPagesList: React.FC = ({ viewType }) => { - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - - const { data: pages } = useSWR( - workspaceSlug && projectId ? MY_PAGES_LIST(projectId as string) : null, - workspaceSlug && projectId - ? () => pageService.getPagesWithParams(workspaceSlug as string, projectId as string, "created_by_me") - : null - ); - - return ; -}; diff --git a/web/components/pages/pages-list/other-pages-list.tsx b/web/components/pages/pages-list/other-pages-list.tsx deleted file mode 100644 index 176bd6cf5..000000000 --- a/web/components/pages/pages-list/other-pages-list.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useRouter } from "next/router"; - -import useSWR from "swr"; - -// services -import { PageService } from "services/page.service"; -// components -import { PagesView } from "components/pages"; -// types -import { TPagesListProps } from "./types"; -// fetch-keys -import { OTHER_PAGES_LIST } from "constants/fetch-keys"; - -// services -const pageService = new PageService(); - -export const OtherPagesList: React.FC = ({ viewType }) => { - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - - const { data: pages } = useSWR( - workspaceSlug && projectId ? OTHER_PAGES_LIST(projectId as string) : null, - workspaceSlug && projectId - ? () => pageService.getPagesWithParams(workspaceSlug as string, projectId as string, "created_by_other") - : null - ); - - return ; -}; diff --git a/web/components/pages/pages-list/private-page-list.tsx b/web/components/pages/pages-list/private-page-list.tsx new file mode 100644 index 000000000..28a45d149 --- /dev/null +++ b/web/components/pages/pages-list/private-page-list.tsx @@ -0,0 +1,25 @@ +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { PagesListView } from "components/pages/pages-list"; +// hooks +import { useMobxStore } from "lib/mobx/store-provider"; +// ui +import { Loader } from "@plane/ui"; + +export const PrivatePagesList: FC = observer(() => { + const { + page: { privateProjectPages }, + } = useMobxStore(); + + if (!privateProjectPages) + return ( + + + + + + ); + + return ; +}); diff --git a/web/components/pages/pages-list/recent-pages-list.tsx b/web/components/pages/pages-list/recent-pages-list.tsx index 67f1fe7fc..7122fa071 100644 --- a/web/components/pages/pages-list/recent-pages-list.tsx +++ b/web/components/pages/pages-list/recent-pages-list.tsx @@ -1,14 +1,10 @@ -import React from "react"; -import { useRouter } from "next/router"; +import React, { FC } from "react"; import { observer } from "mobx-react-lite"; -import useSWR from "swr"; import { Plus } from "lucide-react"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; -// services -import { PageService } from "services/page.service"; // components -import { PagesView } from "components/pages"; +import { PagesListView } from "components/pages/pages-list"; import { EmptyState } from "components/common"; // ui import { Loader } from "@plane/ui"; @@ -16,47 +12,42 @@ import { Loader } from "@plane/ui"; import emptyPage from "public/empty-state/page.svg"; // helpers import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; -// types -import { TPagesListProps } from "./types"; -import { RecentPagesResponse } from "types"; -// fetch-keys -import { RECENT_PAGES_LIST } from "constants/fetch-keys"; -// services -const pageService = new PageService(); +export const RecentPagesList: FC = observer(() => { + // store + const { + commandPalette: commandPaletteStore, + page: { recentProjectPages }, + } = useMobxStore(); -export const RecentPagesList: React.FC = observer((props) => { - const { viewType } = props; + const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value) => value.length === 0); - const { commandPalette: commandPaletteStore } = useMobxStore(); - - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - - const { data: pages } = useSWR( - workspaceSlug && projectId ? RECENT_PAGES_LIST(projectId as string) : null, - workspaceSlug && projectId ? () => pageService.getRecentPages(workspaceSlug as string, projectId as string) : null - ); - - const isEmpty = pages && Object.keys(pages).every((key) => pages[key].length === 0); + if (!recentProjectPages) { + return ( + + + + + + ); + } return ( <> - {pages ? ( - Object.keys(pages).length > 0 && !isEmpty ? ( - Object.keys(pages).map((key) => { - if (pages[key].length === 0) return null; - + {Object.keys(recentProjectPages).length > 0 && !isEmpty ? ( + <> + {Object.keys(recentProjectPages).map((key) => { + if (recentProjectPages[key].length === 0) return null; return (
    -

    - {replaceUnderscoreIfSnakeCase(key)} -

    - +

    {replaceUnderscoreIfSnakeCase(key)}

    +
    ); - }) - ) : ( + })} + + ) : ( + <> = observer((props) => { onClick: () => commandPaletteStore.toggleCreatePageModal(true), }} /> - ) - ) : ( - - - - - + )} ); diff --git a/web/components/pages/pages-list/shared-pages-list.tsx b/web/components/pages/pages-list/shared-pages-list.tsx new file mode 100644 index 000000000..8c400695c --- /dev/null +++ b/web/components/pages/pages-list/shared-pages-list.tsx @@ -0,0 +1,25 @@ +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { PagesListView } from "components/pages/pages-list"; +// hooks +import { useMobxStore } from "lib/mobx/store-provider"; +// ui +import { Loader } from "@plane/ui"; + +export const SharedPagesList: FC = observer(() => { + const { + page: { sharedProjectPages }, + } = useMobxStore(); + + if (!sharedProjectPages) + return ( + + + + + + ); + + return ; +}); diff --git a/web/components/pages/pages-view.tsx b/web/components/pages/pages-view.tsx deleted file mode 100644 index 2c2b94713..000000000 --- a/web/components/pages/pages-view.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import { useState } from "react"; -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; -import useSWR, { mutate } from "swr"; -import { Plus } from "lucide-react"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -// services -import { PageService } from "services/page.service"; -import { ProjectMemberService } from "services/project"; -// hooks -import useToast from "hooks/use-toast"; -// components -import { CreateUpdatePageModal, DeletePageModal, SinglePageDetailedItem, SinglePageListItem } from "components/pages"; -import { EmptyState } from "components/common"; -// ui -import { Loader } from "@plane/ui"; -// images -import emptyPage from "public/empty-state/page.svg"; -// types -import { IPage, TPageViewProps } from "types"; -import { - ALL_PAGES_LIST, - FAVORITE_PAGES_LIST, - MY_PAGES_LIST, - PROJECT_MEMBERS, - RECENT_PAGES_LIST, -} from "constants/fetch-keys"; - -type Props = { - pages: IPage[] | undefined; - viewType: TPageViewProps; -}; - -// services -const pageService = new PageService(); -const projectMemberService = new ProjectMemberService(); - -export const PagesView: React.FC = observer(({ pages, viewType }) => { - // states - const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false); - const [selectedPageToUpdate, setSelectedPageToUpdate] = useState(null); - const [deletePageModal, setDeletePageModal] = useState(false); - const [selectedPageToDelete, setSelectedPageToDelete] = useState(null); - - const { user: userStore, commandPalette: commandPaletteStore } = useMobxStore(); - const user = userStore.currentUser ?? undefined; - - // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - - const { setToastAlert } = useToast(); - - const { data: people } = useSWR( - workspaceSlug && projectId ? PROJECT_MEMBERS(projectId.toString()) : null, - workspaceSlug && projectId - ? () => projectMemberService.fetchProjectMembers(workspaceSlug.toString(), projectId.toString()) - : null - ); - - const handleEditPage = (page: IPage) => { - setSelectedPageToUpdate(page); - setCreateUpdatePageModal(true); - }; - - const handleDeletePage = (page: IPage) => { - setSelectedPageToDelete(page); - setDeletePageModal(true); - }; - - const handleAddToFavorites = (page: IPage) => { - if (!workspaceSlug || !projectId) return; - - mutate( - ALL_PAGES_LIST(projectId.toString()), - (prevData) => - (prevData ?? []).map((p) => { - if (p.id === page.id) p.is_favorite = true; - - return p; - }), - false - ); - mutate( - MY_PAGES_LIST(projectId.toString()), - (prevData) => - (prevData ?? []).map((p) => { - if (p.id === page.id) p.is_favorite = true; - - return p; - }), - false - ); - mutate(FAVORITE_PAGES_LIST(projectId.toString()), (prevData) => [page, ...(prevData ?? [])], false); - - pageService - .addPageToFavorites(workspaceSlug.toString(), projectId.toString(), { - page: page.id, - }) - .then(() => { - mutate(RECENT_PAGES_LIST(projectId.toString())); - setToastAlert({ - type: "success", - title: "Success!", - message: "Successfully added the page to favorites.", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the page to favorites. Please try again.", - }); - }); - }; - - const handleRemoveFromFavorites = (page: IPage) => { - if (!workspaceSlug || !projectId) return; - - mutate( - ALL_PAGES_LIST(projectId.toString()), - (prevData) => - (prevData ?? []).map((p) => { - if (p.id === page.id) p.is_favorite = false; - - return p; - }), - false - ); - mutate( - MY_PAGES_LIST(projectId.toString()), - (prevData) => - (prevData ?? []).map((p) => { - if (p.id === page.id) p.is_favorite = false; - - return p; - }), - false - ); - mutate( - FAVORITE_PAGES_LIST(projectId.toString()), - (prevData) => (prevData ?? []).filter((p) => p.id !== page.id), - false - ); - - pageService - .removePageFromFavorites(workspaceSlug.toString(), projectId.toString(), page.id) - .then(() => { - mutate(RECENT_PAGES_LIST(projectId.toString())); - setToastAlert({ - type: "success", - title: "Success!", - message: "Successfully removed the page from favorites.", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't remove the page from favorites. Please try again.", - }); - }); - }; - - const partialUpdatePage = (page: IPage, formData: Partial) => { - if (!workspaceSlug || !projectId || !user) return; - - mutate( - ALL_PAGES_LIST(projectId.toString()), - (prevData) => (prevData ?? []).map((p) => ({ ...p, ...(p.id === page.id ? formData : {}) })), - false - ); - mutate( - MY_PAGES_LIST(projectId.toString()), - (prevData) => (prevData ?? []).map((p) => ({ ...p, ...(p.id === page.id ? formData : {}) })), - false - ); - mutate( - FAVORITE_PAGES_LIST(projectId.toString()), - (prevData) => (prevData ?? []).map((p) => ({ ...p, ...(p.id === page.id ? formData : {}) })), - false - ); - - pageService.patchPage(workspaceSlug.toString(), projectId.toString(), page.id, formData).then(() => { - mutate(RECENT_PAGES_LIST(projectId.toString())); - }); - }; - - return ( - <> - {workspaceSlug && projectId && ( - <> - setCreateUpdatePageModal(false)} - data={selectedPageToUpdate} - user={user} - workspaceSlug={workspaceSlug.toString()} - projectId={projectId.toString()} - /> - - - )} - - {pages ? ( -
    - {pages.length > 0 ? ( - viewType === "list" ? ( -
      - {pages.map((page) => ( - handleEditPage(page)} - handleDeletePage={() => handleDeletePage(page)} - handleAddToFavorites={() => handleAddToFavorites(page)} - handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)} - partialUpdatePage={partialUpdatePage} - /> - ))} -
    - ) : viewType === "detailed" ? ( -
    - {pages.map((page) => ( - handleEditPage(page)} - handleDeletePage={() => handleDeletePage(page)} - handleAddToFavorites={() => handleAddToFavorites(page)} - handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)} - partialUpdatePage={partialUpdatePage} - /> - ))} -
    - ) : ( -
    - {pages.map((page) => ( - handleEditPage(page)} - handleDeletePage={() => handleDeletePage(page)} - handleAddToFavorites={() => handleAddToFavorites(page)} - handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)} - partialUpdatePage={partialUpdatePage} - /> - ))} -
    - ) - ) : ( - , - text: "New Page", - onClick: () => commandPaletteStore.toggleCreatePageModal(true), - }} - /> - )} -
    - ) : viewType === "list" ? ( - - - - - - ) : viewType === "detailed" ? ( - - - - - ) : ( - - - - - - )} - - ); -}); diff --git a/web/components/pages/single-page-block.tsx b/web/components/pages/single-page-block.tsx deleted file mode 100644 index a03e0da65..000000000 --- a/web/components/pages/single-page-block.tsx +++ /dev/null @@ -1,432 +0,0 @@ -import React, { useEffect, useState, useRef } from "react"; -import { useRouter } from "next/router"; -import Link from "next/link"; -import { mutate } from "swr"; -import { useForm } from "react-hook-form"; -import { Draggable } from "@hello-pangea/dnd"; -// services -import { PageService } from "services/page.service"; -import { IssueService } from "services/issue/issue.service"; -import { AIService } from "services/ai.service"; -import { FileService } from "services/file.service"; -// hooks -import useToast from "hooks/use-toast"; -import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// components -import { GptAssistantModal } from "components/core"; -import { CreateUpdateBlockInline } from "components/pages"; -import { RichTextEditor } from "@plane/rich-text-editor"; -// ui -import { CustomMenu, LayersIcon, TextArea } from "@plane/ui"; -// icons -import { RefreshCw, LinkIcon, Zap, Check, MoreVertical, Pencil, Sparkle, Trash2 } from "lucide-react"; -// helpers -import { copyTextToClipboard } from "helpers/string.helper"; -// types -import { IUser, IIssue, IPageBlock, IProject } from "types"; -// fetch-keys -import { PAGE_BLOCKS_LIST } from "constants/fetch-keys"; -import useEditorSuggestions from "hooks/use-editor-suggestions"; - -type Props = { - block: IPageBlock; - projectDetails: IProject | undefined; - showBlockDetails: boolean; - index: number; - user: IUser | undefined; -}; - -const aiService = new AIService(); -const pageService = new PageService(); -const issueService = new IssueService(); -const fileService = new FileService(); - -export const SinglePageBlock: React.FC = ({ block, projectDetails, showBlockDetails, index, user }) => { - const [isSyncing, setIsSyncing] = useState(false); - const [createBlockForm, setCreateBlockForm] = useState(false); - const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); - - const [gptAssistantModal, setGptAssistantModal] = useState(false); - - const [isMenuActive, setIsMenuActive] = useState(false); - const actionSectionRef = useRef(null); - - const router = useRouter(); - const { workspaceSlug, projectId, pageId } = router.query; - - const { setToastAlert } = useToast(); - - const { handleSubmit, watch, reset, setValue } = useForm({ - defaultValues: { - name: "", - description: {}, - description_html: "

    ", - }, - }); - - const editorSuggestion = useEditorSuggestions(); - - const updatePageBlock = async (formData: Partial) => { - if (!workspaceSlug || !projectId || !pageId) return; - - if (!formData.name || formData.name.length === 0 || formData.name === "") return; - - if (block.issue && block.sync) setIsSyncing(true); - - mutate( - PAGE_BLOCKS_LIST(pageId as string), - (prevData) => - prevData?.map((p) => { - if (p.id === block.id) return { ...p, ...formData }; - - return p; - }), - false - ); - - await pageService - .patchPageBlock(workspaceSlug as string, projectId as string, pageId as string, block.id, { - name: formData.name, - description: formData.description, - description_html: formData.description_html, - }) - .then((res) => { - mutate(PAGE_BLOCKS_LIST(pageId as string)); - if (block.issue && block.sync) - issueService - .patchIssue(workspaceSlug as string, projectId as string, block.issue, { - name: res.name, - description: res.description, - description_html: res.description_html, - }) - .finally(() => setIsSyncing(false)); - }); - }; - - const pushBlockIntoIssues = async () => { - if (!workspaceSlug || !projectId || !pageId) return; - - await pageService - .convertPageBlockToIssue(workspaceSlug as string, projectId as string, pageId as string, block.id) - .then((res: IIssue) => { - mutate( - PAGE_BLOCKS_LIST(pageId as string), - (prevData) => - (prevData ?? []).map((p) => { - if (p.id === block.id) return { ...p, issue: res.id, issue_detail: res }; - - return p; - }), - false - ); - - setToastAlert({ - type: "success", - title: "Success!", - message: "Page block converted to issue successfully.", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Page block could not be converted to issue. Please try again.", - }); - }); - }; - - const deletePageBlock = async () => { - if (!workspaceSlug || !projectId || !pageId) return; - - mutate( - PAGE_BLOCKS_LIST(pageId as string), - (prevData) => (prevData ?? []).filter((p) => p.id !== block.id), - false - ); - - await pageService - .deletePageBlock(workspaceSlug as string, projectId as string, pageId as string, block.id) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Page could not be deleted. Please try again.", - }); - }); - }; - - const handleAutoGenerateDescription = async () => { - if (!workspaceSlug || !projectId) return; - - setIAmFeelingLucky(true); - - aiService - .createGptTask(workspaceSlug as string, projectId as string, { - prompt: block.name, - task: "Generate a proper description for this issue.", - }) - .then((res) => { - if (res.response === "") - setToastAlert({ - type: "error", - title: "Error!", - message: - "Block title isn't informative enough to generate the description. Please try with a different title.", - }); - else handleAiAssistance(res.response_html); - }) - .catch((err) => { - if (err.status === 429) - setToastAlert({ - type: "error", - title: "Error!", - message: "You have reached the maximum number of requests of 50 requests per month per user.", - }); - else - setToastAlert({ - type: "error", - title: "Error!", - message: "Some error occurred. Please try again.", - }); - }) - .finally(() => setIAmFeelingLucky(false)); - }; - - const handleAiAssistance = async (response: string) => { - if (!workspaceSlug || !projectId) return; - - setValue("description", {}); - setValue("description_html", `${watch("description_html")}

    ${response}

    `); - handleSubmit(updatePageBlock)() - .then(() => { - setToastAlert({ - type: "success", - title: "Success!", - message: "Block description updated successfully.", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Block description could not be updated. Please try again.", - }); - }); - }; - - const handleBlockSync = () => { - if (!workspaceSlug || !projectId || !pageId) return; - - mutate( - PAGE_BLOCKS_LIST(pageId as string), - (prevData) => - (prevData ?? []).map((p) => { - if (p.id === block.id) return { ...p, sync: !block.sync }; - - return p; - }), - false - ); - - pageService.patchPageBlock(workspaceSlug as string, projectId as string, pageId as string, block.id, { - sync: !block.sync, - }); - }; - - const handleCopyText = () => { - const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - - copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${block.issue}`).then(() => { - setToastAlert({ - type: "success", - title: "Link Copied!", - message: "Issue link copied to clipboard.", - }); - }); - }; - - useEffect(() => { - if (!block) return; - - reset({ ...block }); - }, [reset, block]); - - useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); - return ( - - {(provided, snapshot) => ( - <> - {createBlockForm ? ( -
    - setCreateBlockForm(false)} - data={block} - setIsSyncing={setIsSyncing} - focus="name" - user={user} - /> -
    - ) : ( -
    - - - } - > - {block.issue ? ( - <> - - - - Turn sync {block.sync ? "off" : "on"} - - - - - - Copy issue link - - - - ) : ( - - - - Push into issues - - - )} - - - - Delete block - - - -
    -
    -
    setCreateBlockForm(true)} - > -
    - {block.issue && ( - - )} -