From 875eb054a2bd99a766588ce6135a927c3e12b1a3 Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Tue, 13 Feb 2024 13:17:41 +0530 Subject: [PATCH 001/179] fix: stroing the transactions in page --- apiserver/plane/app/views/page.py | 37 +++++++-- .../plane/bgtasks/page_transaction_task.py | 75 +++++++++++++++++++ apiserver/plane/db/models/page.py | 2 +- 3 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 apiserver/plane/bgtasks/page_transaction_task.py diff --git a/apiserver/plane/app/views/page.py b/apiserver/plane/app/views/page.py index 1d8ff1fbb15..4a1a540b6ab 100644 --- a/apiserver/plane/app/views/page.py +++ b/apiserver/plane/app/views/page.py @@ -1,26 +1,36 @@ # Python imports -from datetime import date, datetime, timedelta +import json +from datetime import datetime # Django imports from django.db import connection from django.db.models import Exists, OuterRef, Q -from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page + # Third party imports from rest_framework import status from rest_framework.response import Response from plane.app.permissions import ProjectEntityPermission -from plane.app.serializers import (IssueLiteSerializer, PageFavoriteSerializer, - PageLogSerializer, PageSerializer, - SubPageSerializer) -from plane.db.models import (Issue, IssueActivity, IssueAssignee, Page, - PageFavorite, PageLog, ProjectMember) +from plane.app.serializers import ( + PageFavoriteSerializer, + PageLogSerializer, + PageSerializer, + SubPageSerializer, +) +from plane.db.models import ( + Page, + PageFavorite, + PageLog, + ProjectMember, +) # Module imports from .base import BaseAPIView, BaseViewSet +from plane.bgtasks.page_transaction_task import page_transaction + def unarchive_archive_page_and_descendants(page_id, archived_at): # Your SQL query @@ -81,6 +91,8 @@ def create(self, request, slug, project_id): if serializer.is_valid(): serializer.save() + # capture the page transaction + page_transaction.delay(request.data, None, serializer.data["id"]) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -117,6 +129,17 @@ def partial_update(self, request, slug, project_id, pk): serializer = PageSerializer(page, data=request.data, partial=True) if serializer.is_valid(): serializer.save() + # capture the page transaction + if request.data.get("description_html"): + page_transaction.delay( + new_value=request.data, + old_value=json.dumps( + { + "description_html": page.description_html, + } + ), + page_id=pk, + ) return Response(serializer.data, status=status.HTTP_200_OK) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST diff --git a/apiserver/plane/bgtasks/page_transaction_task.py b/apiserver/plane/bgtasks/page_transaction_task.py new file mode 100644 index 00000000000..9cf4add0536 --- /dev/null +++ b/apiserver/plane/bgtasks/page_transaction_task.py @@ -0,0 +1,75 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone + +# Third-party imports +from bs4 import BeautifulSoup + +# Module imports +from plane.db.models import Page, PageLog +from celery import shared_task + + +def extract_components(value, tag): + try: + mentions = [] + html = value.get("description_html") + soup = BeautifulSoup(html, "html.parser") + mention_tags = soup.find_all(tag) + + for mention_tag in mention_tags: + mention = { + "id": mention_tag.get("id"), + "entity_identifier": mention_tag.get("entity_identifier"), + "entity_name": mention_tag.get("entity_name"), + } + mentions.append(mention) + + return mentions + except Exception as e: + return [] + + +@shared_task +def page_transaction(new_value, old_value, page_id): + page = Page.objects.get(pk=page_id) + new_page_mention = PageLog.objects.filter(page_id=page_id).exists() + + old_value = json.loads(old_value) + + new_transactions = [] + deleted_transaction_ids = set() + + components = ["mention-component", "issue-embed-component", "img"] + for component in components: + old_mentions = extract_components(old_value, component) + new_mentions = extract_components(new_value, component) + + new_mentions_ids = {mention["id"] for mention in new_mentions} + old_mention_ids = {mention["id"] for mention in old_mentions} + deleted_transaction_ids.update(old_mention_ids - new_mentions_ids) + + new_transactions.extend( + PageLog( + transaction=mention["id"], + page_id=page_id, + entity_identifier=mention["entity_identifier"], + entity_name=mention["entity_name"], + workspace_id=page.workspace_id, + project_id=page.project_id, + created_at=timezone.now(), + updated_at=timezone.now(), + ) + for mention in new_mentions + if mention["id"] not in old_mention_ids or not new_page_mention + ) + + # Create new PageLog objects for new transactions + PageLog.objects.bulk_create(new_transactions, batch_size=10, ignore_conflicts=True) + + # Delete the removed transactions + PageLog.objects.filter( + transaction__in=deleted_transaction_ids + ).delete() diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index 6ed94798a2f..afa362b644d 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -81,7 +81,7 @@ class Meta: ordering = ("-created_at",) def __str__(self): - return f"{self.page.name} {self.type}" + return f"{self.page.name} {self.entity_name}" class PageBlock(ProjectBaseModel): From 5193e4b4ad9dc687ed38d3e6c6158271ca8ec938 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Wed, 21 Feb 2024 15:23:22 +0530 Subject: [PATCH 002/179] fix: page details changes --- web/components/pages/document.tsx | 167 +++++++++ web/components/pages/page-details.tsx | 166 +++++++++ web/components/pages/read-only-document.tsx | 96 +++++ web/hooks/store/index.ts | 3 +- .../projects/[projectId]/pages/[pageId].tsx | 339 +----------------- web/store/project-page.store.ts | 7 + 6 files changed, 449 insertions(+), 329 deletions(-) create mode 100644 web/components/pages/document.tsx create mode 100644 web/components/pages/page-details.tsx create mode 100644 web/components/pages/read-only-document.tsx diff --git a/web/components/pages/document.tsx b/web/components/pages/document.tsx new file mode 100644 index 00000000000..16ff4905b52 --- /dev/null +++ b/web/components/pages/document.tsx @@ -0,0 +1,167 @@ +import { FC } from "react"; +import { Sparkle } from "lucide-react"; +// components +import { GptAssistantPopover } from "components/core"; +// hooks +import useToast from "hooks/use-toast"; +import { usePage, useProjectPages } from "hooks/store"; + +export type PageDocumentProps = { + workspaceSlug: string; + projectId: string; + pageId: string; +}; + +export const PageDocument: FC = (props) => { + const { workspaceSlug, projectId, pageId } = props; + // hooks + const { setToastAlert } = useToast(); + const { + archivePage: archivePageAction, + restorePage: restorePageAction, + createPage: createPageAction, + projectPageMap, + projectArchivedPageMap, + fetchProjectPages, + fetchArchivedProjectPages, + cleanup, + } = useProjectPages(); + const pageStore = usePage(pageId); + const { + lockPage: lockPageAction, + unlockPage: unlockPageAction, + updateName: updateNameAction, + updateDescription: updateDescriptionAction, + id: pageIdMobx, + isSubmitting, + setIsSubmitting, + owned_by, + is_locked, + archived_at, + created_at, + created_by, + updated_at, + updated_by, + } = pageStore; + + const archivePage = async (workspaceSlug: string, projectId: string, pageId: string) => { + if (!workspaceSlug || !projectId || !pageId) return; + try { + await archivePageAction(workspaceSlug as string, projectId as string, pageId as string); + } catch (error) { + setToastAlert({ + 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; + try { + await restorePageAction(workspaceSlug as string, projectId as string, pageId as string); + } catch (error) { + setToastAlert({ + title: `Page could not be restored`, + message: `Sorry, page could not be restored, please try again later`, + type: "error", + }); + } + }; + + const lockPage = async () => { + if (!workspaceSlug || !projectId || !pageId) return; + try { + await lockPageAction(); + } catch (error) { + setToastAlert({ + title: `Page could not be locked`, + message: `Sorry, page could not be locked, please try again later`, + type: "error", + }); + } + }; + + const unlockPage = async () => { + if (!workspaceSlug || !projectId || !pageId) return; + try { + await unlockPageAction(); + } catch (error) { + setToastAlert({ + title: `Page could not be unlocked`, + message: `Sorry, page could not be unlocked, please try again later`, + type: "error", + }); + } + }; + return ( +
+ { + setShowAlert(true); + onChange(description_html); + handleSubmit(updatePage)(); + }} + duplicationConfig={userCanDuplicate ? { action: duplicate_page } : undefined} + pageArchiveConfig={ + userCanArchive + ? { + is_archived: archived_at ? true : false, + action: archived_at ? unArchivePage : archivePage, + } + : undefined + } + pageLockConfig={userCanLock ? { is_locked: false, action: lockPage } : undefined} + /> + {projectId && envConfig?.has_openai_configured && ( +
+ { + setGptModal((prevData) => !prevData); + // this is done so that the title do not reset after gpt popover closed + reset(getValues()); + }} + onResponse={(response) => { + handleAiAssistance(response); + }} + placement="top-end" + button={ + + } + className="!min-w-[38rem]" + /> +
+ )} +
+ ); +}; diff --git a/web/components/pages/page-details.tsx b/web/components/pages/page-details.tsx new file mode 100644 index 00000000000..49cd9dd9e74 --- /dev/null +++ b/web/components/pages/page-details.tsx @@ -0,0 +1,166 @@ +import { FC, useState, useRef, useEffect } from "react"; + +import { Controller, useForm } from "react-hook-form"; +// components +import { GptAssistantPopover } from "components/core"; +import { IssuePeekOverview } from "components/issues"; +// ui +import { Spinner, StateGroupIcon } from "@plane/ui"; +// hooks +import useToast from "hooks/use-toast"; +import useReloadConfirmations from "hooks/use-reload-confirmation"; +import { useApplication, usePage, useProjectPages, useUser, useWorkspace } from "hooks/store"; +// ui +import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor"; +// types +import { IPage } from "@plane/types"; +// constants +import { EUserProjectRoles } from "constants/project"; +// services +import { FileService } from "services/file.service"; +const fileService = new FileService(); + +export type PageDetailsViewProps = { + workspaceSlug: string; + projectId: string; + pageId: string; +}; + +export const PageDetailsView: FC = (props) => { + const { workspaceSlug, projectId, pageId } = props; + // states + const [gptModalOpen, setGptModal] = useState(false); + + // toast alert + const { setToastAlert } = useToast(); + // store hooks + const { + config: { envConfig }, + } = useApplication(); + const { + currentUser, + membership: { currentProjectRole }, + } = useUser(); + const { + archivePage: archivePageAction, + restorePage: restorePageAction, + createPage: createPageAction, + projectPageMap, + projectArchivedPageMap, + fetchProjectPages, + fetchArchivedProjectPages, + cleanup, + } = useProjectPages(); + const pageStore = usePage(pageId); + const { + lockPage: lockPageAction, + unlockPage: unlockPageAction, + updateName: updateNameAction, + updateDescription: updateDescriptionAction, + id: pageIdMobx, + isSubmitting, + setIsSubmitting, + owned_by, + is_locked, + archived_at, + created_at, + created_by, + updated_at, + updated_by, + } = pageStore; + // hooks + const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); + // form data + const { handleSubmit, setValue, watch, getValues, control, reset } = useForm({ + defaultValues: { name: "", description_html: "" }, + }); + // derived values + const pageTitle = pageStore?.name; + const pageDescription = pageStore?.description_html; + const isPageReadOnly = + is_locked || + archived_at || + (currentProjectRole && [EUserProjectRoles.VIEWER, EUserProjectRoles.GUEST].includes(currentProjectRole)); + const userCanDuplicate = + currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + const userCanArchive = isCurrentUserOwner || currentProjectRole === EUserProjectRoles.ADMIN; + const userCanLock = + currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + const isCurrentUserOwner = owned_by === currentUser?.id; + + useEffect( + () => () => { + cleanup && cleanup(); + }, + [cleanup] + ); + + const updatePage = async (formData: IPage) => { + if (!workspaceSlug || !projectId || !pageId) return; + await updateDescriptionAction(formData.description_html); + }; + + const handleAiAssistance = async (response: string) => { + if (!workspaceSlug || !projectId || !pageId) return; + + const newDescription = `${watch("description_html")}

${response}

`; + setValue("description_html", newDescription); + editorRef.current?.setEditorValue(newDescription); + updateDescriptionAction(newDescription); + }; + + const updatePageTitle = (title: string) => { + if (!workspaceSlug || !projectId || !pageId) return; + updateNameAction(title); + }; + + const createPage = async (payload: Partial) => { + if (!workspaceSlug || !projectId) return; + await createPageAction(workspaceSlug as string, projectId as string, payload); + }; + + const duplicate_page = async () => { + const currentPageValues = getValues(); + + if (!currentPageValues?.description_html) { + // TODO: We need to get latest data the above variable will give us stale data + currentPageValues.description_html = pageDescription as string; + } + + const formData: Partial = { + name: "Copy of " + pageTitle, + description_html: currentPageValues.description_html, + }; + + try { + await createPage(formData); + } catch (error) { + setToastAlert({ + title: `Page could not be duplicated`, + message: `Sorry, page could not be duplicated, please try again later`, + type: "error", + }); + } + }; + + if (!pageId && !workspaceSlug && !projectId) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {isPageReadOnly ? ( + + ) : ( + + )} + +
+
+ ); +}; diff --git a/web/components/pages/read-only-document.tsx b/web/components/pages/read-only-document.tsx new file mode 100644 index 00000000000..e33f918d341 --- /dev/null +++ b/web/components/pages/read-only-document.tsx @@ -0,0 +1,96 @@ +import { FC, useRef } from "react"; +// ui +import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor"; +import useToast from "hooks/use-toast"; + +export type PageReadOnlyDocumentProps = { + title: string; + description: string | undefined; + created_by: string; +}; + +export const PageReadOnlyDocument: FC = (props) => { + const { + title, + description = "", + created_by, + created_at, + updated_at, + updated_by, + userCanLock, + archived_at, + unlockPage, + canArchive, + canDuplicate, + } = props; + // refs + const editorRef = useRef(null); + // hooks + const { setToastAlert } = useToast(); + + const unArchivePage = async () => { + if (!workspaceSlug || !projectId || !pageId) return; + try { + await restorePageAction(workspaceSlug as string, projectId as string, pageId as string); + } catch (error) { + setToastAlert({ + title: `Page could not be restored`, + message: `Sorry, page could not be restored, please try again later`, + type: "error", + }); + } + }; + + const duplicate_page = async () => { + const currentPageValues = getValues(); + + if (!currentPageValues?.description_html) { + // TODO: We need to get latest data the above variable will give us stale data + currentPageValues.description_html = pageDescription as string; + } + + const formData: Partial = { + name: "Copy of " + pageTitle, + description_html: currentPageValues.description_html, + }; + + try { + await createPage(formData); + } catch (error) { + actionCompleteAlert({ + title: `Page could not be duplicated`, + message: `Sorry, page could not be duplicated, please try again later`, + type: "error", + }); + } + }; + + return ( + + ); +}; diff --git a/web/hooks/store/index.ts b/web/hooks/store/index.ts index 2349b1585a7..e10427476f2 100644 --- a/web/hooks/store/index.ts +++ b/web/hooks/store/index.ts @@ -1,5 +1,5 @@ export * from "./use-application"; -export * from "./use-event-tracker" +export * from "./use-event-tracker"; export * from "./use-calendar-view"; export * from "./use-cycle"; export * from "./use-dashboard"; @@ -22,3 +22,4 @@ export * from "./use-kanban-view"; export * from "./use-issue-detail"; export * from "./use-inbox"; export * from "./use-inbox-issues"; +export * from "./use-project-specific-pages"; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index bee4fc9c724..b5d3ec42620 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -1,370 +1,53 @@ -import { Sparkle } from "lucide-react"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; import { useRouter } from "next/router"; import { ReactElement, useEffect, useRef, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; +//components +import { PageHead } from "components/core"; // hooks - import { useApplication, usePage, useUser, useWorkspace } from "hooks/store"; -import useReloadConfirmations from "hooks/use-reload-confirmation"; -import useToast from "hooks/use-toast"; -// services -import { FileService } from "services/file.service"; + // layouts import { AppLayout } from "layouts/app-layout"; -// components -import { GptAssistantPopover, PageHead } from "components/core"; import { PageDetailsHeader } from "components/headers/page-details"; -// ui -import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor"; import { Spinner } from "@plane/ui"; // assets // helpers // types -import { IPage } from "@plane/types"; import { NextPageWithLayout } from "lib/types"; // fetch-keys -// constants -import { EUserProjectRoles } from "constants/project"; import { useProjectPages } from "hooks/store/use-project-specific-pages"; -import { IssuePeekOverview } from "components/issues"; - -// services -const fileService = new FileService(); const PageDetailsPage: NextPageWithLayout = observer(() => { - // states - const [gptModalOpen, setGptModal] = useState(false); - // refs - const editorRef = useRef(null); // router const router = useRouter(); - const { workspaceSlug, projectId, pageId } = router.query; - const workspaceStore = useWorkspace(); - const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string; - // store hooks + const { getWorkspaceBySlug } = useWorkspace(); + const workspaceId = getWorkspaceBySlug(workspaceSlug as string)?.id as string; const { config: { envConfig }, } = useApplication(); - const { - currentUser, - membership: { currentProjectRole }, - } = useUser(); - // toast alert - const { setToastAlert } = useToast(); - - const { handleSubmit, setValue, watch, getValues, control, reset } = useForm({ - defaultValues: { name: "", description_html: "" }, - }); - const { archivePage: archivePageAction, restorePage: restorePageAction, createPage: createPageAction, projectPageMap, projectArchivedPageMap, + getPageDetails, fetchProjectPages, fetchArchivedProjectPages, } = useProjectPages(); - - useSWR( - workspaceSlug && projectId ? `ALL_PAGES_LIST_${projectId}` : null, - workspaceSlug && projectId && !projectPageMap[projectId as string] && !projectArchivedPageMap[projectId as string] - ? () => fetchProjectPages(workspaceSlug.toString(), projectId.toString()) - : null - ); - // fetching archived pages from API - useSWR( - workspaceSlug && projectId ? `ALL_ARCHIVED_PAGES_LIST_${projectId}` : null, - workspaceSlug && projectId && !projectArchivedPageMap[projectId as string] && !projectPageMap[projectId as string] - ? () => fetchArchivedProjectPages(workspaceSlug.toString(), projectId.toString()) - : null - ); - - const pageStore = usePage(pageId as string); - - const { setShowAlert } = useReloadConfirmations(pageStore?.isSubmitting === "submitting"); - - useEffect( - () => () => { - if (pageStore) { - pageStore.cleanup(); - } - }, - [pageStore] - ); - - if (!pageStore) { - return ( -
- -
- ); - } - - // We need to get the values of title and description from the page store but we don't have to subscribe to those values + const pageStore = pageId ? usePage(pageId.toString()) : undefined; + // derived values const pageTitle = pageStore?.name; - const pageDescription = pageStore?.description_html; - const { - lockPage: lockPageAction, - unlockPage: unlockPageAction, - updateName: updateNameAction, - updateDescription: updateDescriptionAction, - id: pageIdMobx, - isSubmitting, - setIsSubmitting, - owned_by, - is_locked, - archived_at, - created_at, - created_by, - updated_at, - updated_by, - } = pageStore; - - const updatePage = async (formData: IPage) => { - if (!workspaceSlug || !projectId || !pageId) return; - await updateDescriptionAction(formData.description_html); - }; - - const handleAiAssistance = async (response: string) => { - if (!workspaceSlug || !projectId || !pageId) return; - - const newDescription = `${watch("description_html")}

${response}

`; - setValue("description_html", newDescription); - editorRef.current?.setEditorValue(newDescription); - updateDescriptionAction(newDescription); - }; - - const actionCompleteAlert = ({ - title, - message, - type, - }: { - title: string; - message: string; - type: "success" | "error" | "warning" | "info"; - }) => { - setToastAlert({ - title, - message, - type, - }); - }; - - const updatePageTitle = (title: string) => { - if (!workspaceSlug || !projectId || !pageId) return; - updateNameAction(title); - }; - - const createPage = async (payload: Partial) => { - if (!workspaceSlug || !projectId) return; - await createPageAction(workspaceSlug as string, projectId as string, payload); - }; - - // ================ Page Menu Actions ================== - const duplicate_page = async () => { - const currentPageValues = getValues(); - - if (!currentPageValues?.description_html) { - // TODO: We need to get latest data the above variable will give us stale data - currentPageValues.description_html = pageDescription as string; - } - - const formData: Partial = { - name: "Copy of " + pageTitle, - description_html: currentPageValues.description_html, - }; - - try { - await createPage(formData); - } catch (error) { - actionCompleteAlert({ - title: `Page could not be duplicated`, - message: `Sorry, page could not be duplicated, please try again later`, - type: "error", - }); - } - }; - - const archivePage = async () => { - if (!workspaceSlug || !projectId || !pageId) return; - try { - await archivePageAction(workspaceSlug as string, projectId as string, pageId as string); - } catch (error) { - 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; - try { - await restorePageAction(workspaceSlug as string, projectId as string, pageId as string); - } catch (error) { - actionCompleteAlert({ - title: `Page could not be restored`, - message: `Sorry, page could not be restored, please try again later`, - type: "error", - }); - } - }; - - const lockPage = async () => { - if (!workspaceSlug || !projectId || !pageId) return; - try { - await lockPageAction(); - } catch (error) { - actionCompleteAlert({ - title: `Page could not be locked`, - message: `Sorry, page could not be locked, please try again later`, - type: "error", - }); - } - }; - - const unlockPage = async () => { - if (!workspaceSlug || !projectId || !pageId) return; - try { - await unlockPageAction(); - } catch (error) { - actionCompleteAlert({ - title: `Page could not be unlocked`, - message: `Sorry, page could not be unlocked, please try again later`, - type: "error", - }); - } - }; - - const isPageReadOnly = - is_locked || - archived_at || - (currentProjectRole && [EUserProjectRoles.VIEWER, EUserProjectRoles.GUEST].includes(currentProjectRole)); - - const isCurrentUserOwner = owned_by === currentUser?.id; - - const userCanDuplicate = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - const userCanArchive = isCurrentUserOwner || currentProjectRole === EUserProjectRoles.ADMIN; - const userCanLock = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + const hasPageInStore = projectId && pageId ? getPageDetails(projectId.toString(), pageId.toString()) : undefined; + // We need to get the values of title and description from the page store but we don't have to subscribe to those values return pageIdMobx ? ( <> -
-
- {isPageReadOnly ? ( - - ) : ( -
- ( - { - setShowAlert(true); - onChange(description_html); - handleSubmit(updatePage)(); - }} - duplicationConfig={userCanDuplicate ? { action: duplicate_page } : undefined} - pageArchiveConfig={ - userCanArchive - ? { - is_archived: archived_at ? true : false, - action: archived_at ? unArchivePage : archivePage, - } - : undefined - } - pageLockConfig={userCanLock ? { is_locked: false, action: lockPage } : undefined} - /> - )} - /> - {projectId && envConfig?.has_openai_configured && ( -
- { - setGptModal((prevData) => !prevData); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - onResponse={(response) => { - handleAiAssistance(response); - }} - placement="top-end" - button={ - - } - className="!min-w-[38rem]" - /> -
- )} -
- )} - -
-
+ ) : (
diff --git a/web/store/project-page.store.ts b/web/store/project-page.store.ts index 072605bc34f..84a1e6700ab 100644 --- a/web/store/project-page.store.ts +++ b/web/store/project-page.store.ts @@ -21,6 +21,10 @@ export interface IProjectPageStore { privateProjectPageIds: string[] | undefined; publicProjectPageIds: string[] | undefined; recentProjectPages: IRecentPages | undefined; + + // page + getPageDetails: (projectId: string, pageId: string) => IPageStore | undefined; + // fetch actions fetchProjectPages: (workspaceSlug: string, projectId: string) => Promise; fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise; @@ -58,6 +62,7 @@ export class ProjectPageStore implements IProjectPageStore { // fetch actions fetchProjectPages: action, fetchArchivedProjectPages: action, + getPageDetails: action, // crud actions createPage: action, deletePage: action, @@ -67,6 +72,8 @@ export class ProjectPageStore implements IProjectPageStore { this.pageService = new PageService(); } + getPageDetails = (projectId: string, pageId: string) => this.projectPageMap[projectId][pageId]; + get projectPageIds() { const projectId = this.rootStore.app.router.projectId; if (!projectId || !this.projectPageMap?.[projectId]) return []; From 567667953040286314154a79e88d7f21a61fe0d0 Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Wed, 21 Feb 2024 16:01:28 +0530 Subject: [PATCH 003/179] chore: page response change --- apiserver/plane/app/serializers/__init__.py | 1 + apiserver/plane/app/serializers/page.py | 31 +++++++++++++++------ apiserver/plane/app/views/page.py | 26 ++++++++++++----- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 28e88106031..466ee4c4f5f 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -98,6 +98,7 @@ PageSerializer, PageLogSerializer, SubPageSerializer, + PageDetailSerializer, PageFavoriteSerializer, ) diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index a0f5986d69f..dde06e31a8c 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -19,22 +19,30 @@ class PageSerializer(BaseSerializer): is_favorite = serializers.BooleanField(read_only=True) - label_details = LabelLiteSerializer( - read_only=True, source="labels", many=True - ) labels = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), write_only=True, required=False, ) - project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer( - source="workspace", read_only=True - ) class Meta: model = Page - fields = "__all__" + fields = [ + "id", + "name", + "owned_by", + "access", + "color", + "labels", + "parent", + "is_favorite", + "workspace", + "project", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] read_only_fields = [ "workspace", "project", @@ -93,6 +101,13 @@ def update(self, instance, validated_data): return super().update(instance, validated_data) +class PageDetailSerializer(PageSerializer): + description_html = serializers.CharField() + + class Meta(PageSerializer.Meta): + fields = PageSerializer.Meta.fields + ['description_html', 'is_locked', 'archived_at'] + + class SubPageSerializer(BaseSerializer): entity_details = serializers.SerializerMethodField() diff --git a/apiserver/plane/app/views/page.py b/apiserver/plane/app/views/page.py index 1d8ff1fbb15..0f7195a7474 100644 --- a/apiserver/plane/app/views/page.py +++ b/apiserver/plane/app/views/page.py @@ -1,22 +1,30 @@ # Python imports -from datetime import date, datetime, timedelta +from datetime import datetime # Django imports from django.db import connection from django.db.models import Exists, OuterRef, Q -from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page + # Third party imports from rest_framework import status from rest_framework.response import Response from plane.app.permissions import ProjectEntityPermission -from plane.app.serializers import (IssueLiteSerializer, PageFavoriteSerializer, - PageLogSerializer, PageSerializer, - SubPageSerializer) -from plane.db.models import (Issue, IssueActivity, IssueAssignee, Page, - PageFavorite, PageLog, ProjectMember) +from plane.app.serializers import ( + PageFavoriteSerializer, + PageLogSerializer, + PageSerializer, + SubPageSerializer, + PageDetailSerializer +) +from plane.db.models import ( + Page, + PageFavorite, + PageLog, + ProjectMember, +) # Module imports from .base import BaseAPIView, BaseViewSet @@ -129,6 +137,10 @@ def partial_update(self, request, slug, project_id, pk): status=status.HTTP_400_BAD_REQUEST, ) + def retrieve(self, request, slug, project_id, pk=None): + page = self.get_queryset().filter(pk=pk).first() + return Response(PageDetailSerializer(page).data, status=status.HTTP_200_OK) + def lock(self, request, slug, project_id, page_id): page = Page.objects.filter( pk=page_id, workspace__slug=slug, project_id=project_id From a09749c62726e9bbc00a462429a544032f4b99c2 Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Wed, 21 Feb 2024 16:23:18 +0530 Subject: [PATCH 004/179] chore: removed duplicated endpoints --- apiserver/plane/app/urls/page.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/apiserver/plane/app/urls/page.py b/apiserver/plane/app/urls/page.py index 58cec2cd462..7eb4673838d 100644 --- a/apiserver/plane/app/urls/page.py +++ b/apiserver/plane/app/urls/page.py @@ -50,27 +50,6 @@ ), name="user-favorite-pages", ), - path( - "workspaces//projects//pages/", - PageViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-pages", - ), - path( - "workspaces//projects//pages//", - PageViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-pages", - ), path( "workspaces//projects//pages//archive/", PageViewSet.as_view( From d0865034491559982a68ce040f11873c83db7ec4 Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Wed, 21 Feb 2024 18:21:22 +0530 Subject: [PATCH 005/179] chore: optimised the urls --- apiserver/plane/app/serializers/page.py | 4 +- apiserver/plane/app/urls/page.py | 48 ++++++-------- apiserver/plane/app/views/page.py | 86 +++++++++++++++---------- apiserver/plane/app/views/view.py | 4 +- 4 files changed, 76 insertions(+), 66 deletions(-) diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index dde06e31a8c..acf627d03db 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -36,6 +36,8 @@ class Meta: "labels", "parent", "is_favorite", + "is_locked", + "archived_at", "workspace", "project", "created_at", @@ -105,7 +107,7 @@ class PageDetailSerializer(PageSerializer): description_html = serializers.CharField() class Meta(PageSerializer.Meta): - fields = PageSerializer.Meta.fields + ['description_html', 'is_locked', 'archived_at'] + fields = PageSerializer.Meta.fields + ['description_html'] class SubPageSerializer(BaseSerializer): diff --git a/apiserver/plane/app/urls/page.py b/apiserver/plane/app/urls/page.py index 7eb4673838d..1f5e7a78a1a 100644 --- a/apiserver/plane/app/urls/page.py +++ b/apiserver/plane/app/urls/page.py @@ -31,43 +31,27 @@ ), name="project-pages", ), + # favorite pages path( - "workspaces//projects//user-favorite-pages/", + "workspaces//projects//favorite-pages/", PageFavoriteViewSet.as_view( { "get": "list", - "post": "create", } ), name="user-favorite-pages", ), path( - "workspaces//projects//user-favorite-pages//", + "workspaces//projects//favorite-pages//", PageFavoriteViewSet.as_view( { + "post": "create", "delete": "destroy", } ), name="user-favorite-pages", ), - path( - "workspaces//projects//pages//archive/", - PageViewSet.as_view( - { - "post": "archive", - } - ), - name="project-page-archive", - ), - path( - "workspaces//projects//pages//unarchive/", - PageViewSet.as_view( - { - "post": "unarchive", - } - ), - name="project-page-unarchive", - ), + # archived pages path( "workspaces//projects//archived-pages/", PageViewSet.as_view( @@ -75,37 +59,41 @@ "get": "archive_list", } ), - name="project-pages", + name="project-pages-archived", ), path( - "workspaces//projects//pages//lock/", + "workspaces//projects//pages//archive/", PageViewSet.as_view( { - "post": "lock", + "post": "archive", + "delete": "unarchive", } ), - name="project-pages", + name="project-page-archive-unarchive", ), + # lock and unlock path( - "workspaces//projects//pages//unlock/", + "workspaces//projects//pages//lock/", PageViewSet.as_view( { - "post": "unlock", + "post": "lock", + "delete": "unlock", } ), + name="project-pages-lock-unlock", ), path( - "workspaces//projects//pages//transactions/", + "workspaces//projects//pages//transactions/", PageLogEndpoint.as_view(), name="page-transactions", ), path( - "workspaces//projects//pages//transactions//", + "workspaces//projects//pages//transactions//", PageLogEndpoint.as_view(), name="page-transactions", ), path( - "workspaces//projects//pages//sub-pages/", + "workspaces//projects//pages//sub-pages/", SubPagesEndpoint.as_view(), name="sub-page", ), diff --git a/apiserver/plane/app/views/page.py b/apiserver/plane/app/views/page.py index 0f7195a7474..64b2db7f003 100644 --- a/apiserver/plane/app/views/page.py +++ b/apiserver/plane/app/views/page.py @@ -17,12 +17,12 @@ PageLogSerializer, PageSerializer, SubPageSerializer, - PageDetailSerializer + PageDetailSerializer, ) from plane.db.models import ( Page, - PageFavorite, PageLog, + PageFavorite, ProjectMember, ) @@ -139,20 +139,22 @@ def partial_update(self, request, slug, project_id, pk): def retrieve(self, request, slug, project_id, pk=None): page = self.get_queryset().filter(pk=pk).first() - return Response(PageDetailSerializer(page).data, status=status.HTTP_200_OK) + return Response( + PageDetailSerializer(page).data, status=status.HTTP_200_OK + ) - def lock(self, request, slug, project_id, page_id): + def lock(self, request, slug, project_id, pk): page = Page.objects.filter( - pk=page_id, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, project_id=project_id ).first() page.is_locked = True page.save() return Response(status=status.HTTP_204_NO_CONTENT) - def unlock(self, request, slug, project_id, page_id): + def unlock(self, request, slug, project_id, pk): page = Page.objects.filter( - pk=page_id, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, project_id=project_id ).first() page.is_locked = False @@ -165,9 +167,9 @@ def list(self, request, slug, project_id): pages = PageSerializer(queryset, many=True).data return Response(pages, status=status.HTTP_200_OK) - def archive(self, request, slug, project_id, page_id): + def archive(self, request, slug, project_id, pk): page = Page.objects.get( - pk=page_id, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, project_id=project_id ) # only the owner or admin can archive the page @@ -185,13 +187,13 @@ def archive(self, request, slug, project_id, page_id): status=status.HTTP_400_BAD_REQUEST, ) - unarchive_archive_page_and_descendants(page_id, datetime.now()) + unarchive_archive_page_and_descendants(pk, datetime.now()) return Response(status=status.HTTP_204_NO_CONTENT) - def unarchive(self, request, slug, project_id, page_id): + def unarchive(self, request, slug, project_id, pk): page = Page.objects.get( - pk=page_id, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, project_id=project_id ) # only the owner or admin can un archive the page @@ -214,15 +216,25 @@ def unarchive(self, request, slug, project_id, page_id): page.parent = None page.save(update_fields=["parent"]) - unarchive_archive_page_and_descendants(page_id, None) + unarchive_archive_page_and_descendants(pk, 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) + subquery = PageFavorite.objects.filter( + user=self.request.user, + page_id=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) + pages = ( + Page.objects.filter( + project_id=project_id, + workspace__slug=slug, + ) + .filter(archived_at__isnull=False) + .annotate(is_favorite=Exists(subquery)) + ) pages = PageSerializer(pages, many=True).data return Response(pages, status=status.HTTP_200_OK) @@ -270,29 +282,37 @@ class PageFavoriteViewSet(BaseViewSet): serializer_class = PageFavoriteSerializer model = PageFavorite - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(archived_at__isnull=True) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(user=self.request.user) - .select_related("page", "page__owned_by") + def list(self, request, slug, project_id): + subquery = PageFavorite.objects.filter( + user=self.request.user, + page_id=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), ) + pages = Page.objects.filter( + page_favorites__user=request.user, + workspace__slug=slug, + project_id=project_id, + ).annotate(is_favorite=Exists(subquery)) - def create(self, request, slug, project_id): - serializer = PageFavoriteSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(user=request.user, project_id=project_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + PageSerializer(pages, many=True).data, status=status.HTTP_200_OK + ) + + def create(self, request, slug, project_id, pk): + _ = PageFavorite.objects.create( + project_id=project_id, + page_id=pk, + user=request.user, + ) + return Response(status=status.HTTP_204_NO_CONTENT) - def destroy(self, request, slug, project_id, page_id): + def destroy(self, request, slug, project_id, pk): page_favorite = PageFavorite.objects.get( project=project_id, user=request.user, workspace__slug=slug, - page_id=page_id, + page_id=pk, ) page_favorite.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/view.py b/apiserver/plane/app/views/view.py index 27f31f7a9ba..289d3f83623 100644 --- a/apiserver/plane/app/views/view.py +++ b/apiserver/plane/app/views/view.py @@ -277,11 +277,11 @@ def create(self, request, slug, project_id): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, slug, project_id, view_id): - view_favourite = IssueViewFavorite.objects.get( + view_favorite = IssueViewFavorite.objects.get( project=project_id, user=request.user, workspace__slug=slug, view_id=view_id, ) - view_favourite.delete() + view_favorite.delete() return Response(status=status.HTTP_204_NO_CONTENT) From 3c7ff875b00c4faf9d03953022f64e82d7e7308e Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Mon, 26 Feb 2024 14:58:56 +0530 Subject: [PATCH 006/179] chore: removed archived and favorite pages --- apiserver/plane/app/urls/page.py | 18 --------------- apiserver/plane/app/views/page.py | 38 +------------------------------ 2 files changed, 1 insertion(+), 55 deletions(-) diff --git a/apiserver/plane/app/urls/page.py b/apiserver/plane/app/urls/page.py index 1f5e7a78a1a..1a73e4ed306 100644 --- a/apiserver/plane/app/urls/page.py +++ b/apiserver/plane/app/urls/page.py @@ -32,15 +32,6 @@ name="project-pages", ), # favorite pages - path( - "workspaces//projects//favorite-pages/", - PageFavoriteViewSet.as_view( - { - "get": "list", - } - ), - name="user-favorite-pages", - ), path( "workspaces//projects//favorite-pages//", PageFavoriteViewSet.as_view( @@ -52,15 +43,6 @@ name="user-favorite-pages", ), # archived pages - path( - "workspaces//projects//archived-pages/", - PageViewSet.as_view( - { - "get": "archive_list", - } - ), - name="project-pages-archived", - ), path( "workspaces//projects//pages//archive/", PageViewSet.as_view( diff --git a/apiserver/plane/app/views/page.py b/apiserver/plane/app/views/page.py index 64b2db7f003..c96463a53ce 100644 --- a/apiserver/plane/app/views/page.py +++ b/apiserver/plane/app/views/page.py @@ -163,7 +163,7 @@ def unlock(self, request, slug, project_id, pk): return Response(status=status.HTTP_204_NO_CONTENT) def list(self, request, slug, project_id): - queryset = self.get_queryset().filter(archived_at__isnull=True) + queryset = self.get_queryset() pages = PageSerializer(queryset, many=True).data return Response(pages, status=status.HTTP_200_OK) @@ -220,25 +220,6 @@ def unarchive(self, request, slug, project_id, pk): return Response(status=status.HTTP_204_NO_CONTENT) - def archive_list(self, request, slug, project_id): - subquery = PageFavorite.objects.filter( - user=self.request.user, - page_id=OuterRef("pk"), - project_id=self.kwargs.get("project_id"), - workspace__slug=self.kwargs.get("slug"), - ) - pages = ( - Page.objects.filter( - project_id=project_id, - workspace__slug=slug, - ) - .filter(archived_at__isnull=False) - .annotate(is_favorite=Exists(subquery)) - ) - - pages = PageSerializer(pages, many=True).data - return Response(pages, 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 @@ -282,23 +263,6 @@ class PageFavoriteViewSet(BaseViewSet): serializer_class = PageFavoriteSerializer model = PageFavorite - def list(self, request, slug, project_id): - subquery = PageFavorite.objects.filter( - user=self.request.user, - page_id=OuterRef("pk"), - project_id=self.kwargs.get("project_id"), - workspace__slug=self.kwargs.get("slug"), - ) - pages = Page.objects.filter( - page_favorites__user=request.user, - workspace__slug=slug, - project_id=project_id, - ).annotate(is_favorite=Exists(subquery)) - - return Response( - PageSerializer(pages, many=True).data, status=status.HTTP_200_OK - ) - def create(self, request, slug, project_id, pk): _ = PageFavorite.objects.create( project_id=project_id, From 725997b6ec63cda8f36b46a92246af6d77cff5af Mon Sep 17 00:00:00 2001 From: gurusainath Date: Mon, 26 Feb 2024 20:00:53 +0530 Subject: [PATCH 007/179] chore: revamping pages store and components --- packages/types/src/pages.d.ts | 83 ++--- .../command-palette/command-palette.tsx | 6 +- web/components/headers/page-details.tsx | 6 +- .../pages/{ => editors}/document.tsx | 66 ++-- web/components/pages/editors/page-details.tsx | 166 ++++++++++ .../pages/editors/read-only-document.tsx | 98 ++++++ .../pages/empty-states/page-detail.tsx | 9 + web/components/pages/empty-states/page.tsx | 51 +++ web/components/pages/index.ts | 20 +- web/components/pages/layouts/page.tsx | 71 +++++ web/components/pages/list/index.ts | 1 + web/components/pages/list/search-input.tsx | 47 +++ .../pages/list/sort-filter/root.tsx | 91 ++++++ web/components/pages/loaders/page-detail.tsx | 9 + web/components/pages/loaders/page.tsx | 9 + .../{ => modals}/create-update-page-modal.tsx | 96 +++--- .../pages/{ => modals}/delete-page-modal.tsx | 80 ++--- .../pages/{ => modals}/page-form.tsx | 22 +- web/components/pages/page-detail/index.ts | 3 + web/components/pages/page-detail/loader.tsx | 118 +++++++ web/components/pages/page-detail/root.tsx | 56 ++++ web/components/pages/page-details.tsx | 166 ---------- .../pages/pages-list/all-pages-list.tsx | 24 -- .../pages/pages-list/archived-pages-list.tsx | 31 -- .../pages/pages-list/favorite-pages-list.tsx | 24 -- web/components/pages/pages-list/index.ts | 8 - web/components/pages/pages-list/list-item.tsx | 292 ------------------ web/components/pages/pages-list/list-view.tsx | 87 ------ .../pages/pages-list/private-page-list.tsx | 24 -- .../pages/pages-list/recent-pages-list.tsx | 81 ----- .../pages/pages-list/shared-pages-list.tsx | 24 -- web/components/pages/pages-list/types.ts | 5 - web/components/pages/read-only-document.tsx | 96 ------ web/constants/page.ts | 63 +--- web/hooks/store/index.ts | 6 +- web/hooks/store/pages/use-page-detail.ts | 23 ++ web/hooks/store/pages/use-page.ts | 22 ++ web/hooks/store/use-page.ts | 21 -- web/hooks/store/use-project-page.ts | 10 - web/hooks/store/use-project-specific-pages.ts | 11 - web/lib/app-provider.tsx | 5 +- .../projects/[projectId]/pages/[pageId].tsx | 50 +-- .../projects/[projectId]/pages/index.tsx | 236 +------------- web/services/page.service.ts | 138 ++------- web/store/page.store.ts | 277 ----------------- web/store/pages/page.store.ts | 173 +++++++++++ web/store/pages/project-page.store.ts | 199 ++++++++++++ web/store/project-page.store.ts | 281 ----------------- web/store/root.store.ts | 8 +- 49 files changed, 1400 insertions(+), 2093 deletions(-) rename web/components/pages/{ => editors}/document.tsx (81%) create mode 100644 web/components/pages/editors/page-details.tsx create mode 100644 web/components/pages/editors/read-only-document.tsx create mode 100644 web/components/pages/empty-states/page-detail.tsx create mode 100644 web/components/pages/empty-states/page.tsx create mode 100644 web/components/pages/layouts/page.tsx create mode 100644 web/components/pages/list/index.ts create mode 100644 web/components/pages/list/search-input.tsx create mode 100644 web/components/pages/list/sort-filter/root.tsx create mode 100644 web/components/pages/loaders/page-detail.tsx create mode 100644 web/components/pages/loaders/page.tsx rename web/components/pages/{ => modals}/create-update-page-modal.tsx (60%) rename web/components/pages/{ => modals}/delete-page-modal.tsx (78%) rename web/components/pages/{ => modals}/page-form.tsx (87%) create mode 100644 web/components/pages/page-detail/index.ts create mode 100644 web/components/pages/page-detail/loader.tsx create mode 100644 web/components/pages/page-detail/root.tsx delete mode 100644 web/components/pages/page-details.tsx delete mode 100644 web/components/pages/pages-list/all-pages-list.tsx delete mode 100644 web/components/pages/pages-list/archived-pages-list.tsx delete mode 100644 web/components/pages/pages-list/favorite-pages-list.tsx delete mode 100644 web/components/pages/pages-list/index.ts delete mode 100644 web/components/pages/pages-list/list-item.tsx delete mode 100644 web/components/pages/pages-list/list-view.tsx delete mode 100644 web/components/pages/pages-list/private-page-list.tsx delete mode 100644 web/components/pages/pages-list/recent-pages-list.tsx delete mode 100644 web/components/pages/pages-list/shared-pages-list.tsx delete mode 100644 web/components/pages/pages-list/types.ts delete mode 100644 web/components/pages/read-only-document.tsx create mode 100644 web/hooks/store/pages/use-page-detail.ts create mode 100644 web/hooks/store/pages/use-page.ts delete mode 100644 web/hooks/store/use-page.ts delete mode 100644 web/hooks/store/use-project-page.ts delete mode 100644 web/hooks/store/use-project-specific-pages.ts delete mode 100644 web/store/page.store.ts create mode 100644 web/store/pages/page.store.ts create mode 100644 web/store/pages/project-page.store.ts delete mode 100644 web/store/project-page.store.ts diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index 29552b94cf6..f6e09c0d1cd 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -1,59 +1,36 @@ -// types -import { TIssue, IIssueLabel, IWorkspaceLite, IProjectLite } from "@plane/types"; +enum EPageAccess { + PUBLIC = 0, + PRIVATE = 1, +} + +export type TPageAccess = EPageAccess.PRIVATE | EPageAccess.PUBLIC; -export interface IPage { - access: number; - archived_at: string | null; - blocks: IPageBlock[]; - color: string; - created_at: Date; - created_by: string; - description: string; - description_html: string; - description_stripped: string | null; - id: string; +export type TPage = { + id: string | undefined; + name: string | undefined; + description_html: string | undefined; + color: string | undefined; + labels: string[] | undefined; + owned_by: string | undefined; + access: TPageAccess | undefined; is_favorite: boolean; is_locked: boolean; - label_details: IIssueLabel[]; - labels: string[]; - name: string; - owned_by: string; - project: string; - project_detail: IProjectLite; - updated_at: Date; - updated_by: string; - workspace: string; - workspace_detail: IWorkspaceLite; -} + archived_at: string | undefined; + workspace: string | undefined; + project: string | undefined; + created_by: string | undefined; + updated_by: string | undefined; + created_at: Date | undefined; + updated_at: Date | undefined; +}; -export interface IRecentPages { - today: string[]; - yesterday: string[]; - this_week: string[]; - older: string[]; - [key: string]: string[]; -} +// page filters +export type TPageFiltersSortKey = "name" | "created_at" | "updated_at"; -export interface IPageBlock { - completed_at: Date | null; - created_at: Date; - created_by: string; - description: any; - description_html: any; - description_stripped: any; - id: string; - issue: string | null; - issue_detail: TIssue | null; - name: string; - page: string; - project: string; - project_detail: IProjectLite; - sort_order: number; - sync: boolean; - updated_at: Date; - updated_by: string; - workspace: string; - workspace_detail: IWorkspaceLite; -} +export type TPageFiltersSortBy = "asc" | "desc"; -export type TPageViewProps = "list" | "detailed" | "masonry"; +export type TPageFilters = { + search: string; + sortKey: TPageFiltersSortKey; + sortBy: TPageFiltersSortBy; +}; diff --git a/web/components/command-palette/command-palette.tsx b/web/components/command-palette/command-palette.tsx index 396003589a4..3c03ec69854 100644 --- a/web/components/command-palette/command-palette.tsx +++ b/web/components/command-palette/command-palette.tsx @@ -13,7 +13,7 @@ import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { CreateUpdateModuleModal } from "components/modules"; import { CreateProjectModal } from "components/project"; import { CreateUpdateProjectViewModal } from "components/views"; -import { CreateUpdatePageModal } from "components/pages"; +// import { CreateUpdatePageModal } from "components/pages"; // helpers import { copyTextToClipboard } from "helpers/string.helper"; // services @@ -206,11 +206,11 @@ export const CommandPalette: FC = observer(() => { workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} /> - toggleCreatePageModal(false)} projectId={projectId.toString()} - /> + /> */} )} diff --git a/web/components/headers/page-details.tsx b/web/components/headers/page-details.tsx index e2a427db78c..7492f779c5a 100644 --- a/web/components/headers/page-details.tsx +++ b/web/components/headers/page-details.tsx @@ -25,7 +25,7 @@ export const PageDetailsHeader: FC = observer((props) => { const { commandPalette: commandPaletteStore } = useApplication(); const { currentProjectDetails } = useProject(); - const pageDetails = usePage(pageId as string); + // const pageDetails = usePage(pageId as string); return (
@@ -74,7 +74,7 @@ export const PageDetailsHeader: FC = observer((props) => { /> } /> - = observer((props) => { icon={} /> } - /> + /> */}
diff --git a/web/components/pages/document.tsx b/web/components/pages/editors/document.tsx similarity index 81% rename from web/components/pages/document.tsx rename to web/components/pages/editors/document.tsx index 16ff4905b52..13158c8cb2e 100644 --- a/web/components/pages/document.tsx +++ b/web/components/pages/editors/document.tsx @@ -4,7 +4,7 @@ import { Sparkle } from "lucide-react"; import { GptAssistantPopover } from "components/core"; // hooks import useToast from "hooks/use-toast"; -import { usePage, useProjectPages } from "hooks/store"; +import { usePage } from "hooks/store"; export type PageDocumentProps = { workspaceSlug: string; @@ -16,38 +16,38 @@ export const PageDocument: FC = (props) => { const { workspaceSlug, projectId, pageId } = props; // hooks const { setToastAlert } = useToast(); - const { - archivePage: archivePageAction, - restorePage: restorePageAction, - createPage: createPageAction, - projectPageMap, - projectArchivedPageMap, - fetchProjectPages, - fetchArchivedProjectPages, - cleanup, - } = useProjectPages(); + // const { + // archivePage: archivePageAction, + // restorePage: restorePageAction, + // createPage: createPageAction, + // projectPageMap, + // projectArchivedPageMap, + // fetchProjectPages, + // fetchArchivedProjectPages, + // cleanup, + // } = useProjectPages(); const pageStore = usePage(pageId); - const { - lockPage: lockPageAction, - unlockPage: unlockPageAction, - updateName: updateNameAction, - updateDescription: updateDescriptionAction, - id: pageIdMobx, - isSubmitting, - setIsSubmitting, - owned_by, - is_locked, - archived_at, - created_at, - created_by, - updated_at, - updated_by, - } = pageStore; + // const { + // lockPage: lockPageAction, + // unlockPage: unlockPageAction, + // updateName: updateNameAction, + // updateDescription: updateDescriptionAction, + // id: pageIdMobx, + // isSubmitting, + // setIsSubmitting, + // owned_by, + // is_locked, + // archived_at, + // created_at, + // created_by, + // updated_at, + // updated_by, + // } = pageStore; const archivePage = async (workspaceSlug: string, projectId: string, pageId: string) => { if (!workspaceSlug || !projectId || !pageId) return; try { - await archivePageAction(workspaceSlug as string, projectId as string, pageId as string); + // await archivePageAction(workspaceSlug as string, projectId as string, pageId as string); } catch (error) { setToastAlert({ title: `Page could not be archived`, @@ -60,7 +60,7 @@ export const PageDocument: FC = (props) => { const unArchivePage = async () => { if (!workspaceSlug || !projectId || !pageId) return; try { - await restorePageAction(workspaceSlug as string, projectId as string, pageId as string); + // await restorePageAction(workspaceSlug as string, projectId as string, pageId as string); } catch (error) { setToastAlert({ title: `Page could not be restored`, @@ -73,7 +73,7 @@ export const PageDocument: FC = (props) => { const lockPage = async () => { if (!workspaceSlug || !projectId || !pageId) return; try { - await lockPageAction(); + // await lockPageAction(); } catch (error) { setToastAlert({ title: `Page could not be locked`, @@ -86,7 +86,7 @@ export const PageDocument: FC = (props) => { const unlockPage = async () => { if (!workspaceSlug || !projectId || !pageId) return; try { - await unlockPageAction(); + // await unlockPageAction(); } catch (error) { setToastAlert({ title: `Page could not be unlocked`, @@ -97,7 +97,7 @@ export const PageDocument: FC = (props) => { }; return (
- = (props) => { className="!min-w-[38rem]" />
- )} + )} */} ); }; diff --git a/web/components/pages/editors/page-details.tsx b/web/components/pages/editors/page-details.tsx new file mode 100644 index 00000000000..a0055d9fc81 --- /dev/null +++ b/web/components/pages/editors/page-details.tsx @@ -0,0 +1,166 @@ +import { FC, useState, useRef, useEffect } from "react"; + +import { Controller, useForm } from "react-hook-form"; +// components +import { GptAssistantPopover } from "components/core"; +import { IssuePeekOverview } from "components/issues"; +// ui +import { Spinner, StateGroupIcon } from "@plane/ui"; +// hooks +import useToast from "hooks/use-toast"; +import useReloadConfirmations from "hooks/use-reload-confirmation"; +import { useApplication, usePage, useUser, useWorkspace } from "hooks/store"; +// ui +import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor"; +// types +// import { IPage } from "@plane/types"; +// constants +import { EUserProjectRoles } from "constants/project"; +// services +import { FileService } from "services/file.service"; +const fileService = new FileService(); + +export type PageDetailsViewProps = { + workspaceSlug: string; + projectId: string; + pageId: string; +}; + +export const PageDetailsView: FC = (props) => { + const { workspaceSlug, projectId, pageId } = props; + // states + const [gptModalOpen, setGptModal] = useState(false); + + // toast alert + const { setToastAlert } = useToast(); + // store hooks + const { + config: { envConfig }, + } = useApplication(); + const { + currentUser, + membership: { currentProjectRole }, + } = useUser(); + // const { + // archivePage: archivePageAction, + // restorePage: restorePageAction, + // createPage: createPageAction, + // projectPageMap, + // projectArchivedPageMap, + // fetchProjectPages, + // fetchArchivedProjectPages, + // cleanup, + // } = useProjectPages(); + // const pageStore = usePage(pageId); + // const { + // lockPage: lockPageAction, + // unlockPage: unlockPageAction, + // updateName: updateNameAction, + // updateDescription: updateDescriptionAction, + // id: pageIdMobx, + // isSubmitting, + // setIsSubmitting, + // owned_by, + // is_locked, + // archived_at, + // created_at, + // created_by, + // updated_at, + // updated_by, + // } = pageStore; + // hooks + // const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); + // // form data + // const { handleSubmit, setValue, watch, getValues, control, reset } = useForm({ + // defaultValues: { name: "", description_html: "" }, + // }); + // // derived values + // const pageTitle = pageStore?.name; + // const pageDescription = pageStore?.description_html; + // const isPageReadOnly = + // is_locked || + // archived_at || + // (currentProjectRole && [EUserProjectRoles.VIEWER, EUserProjectRoles.GUEST].includes(currentProjectRole)); + // const userCanDuplicate = + // currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + // const userCanArchive = isCurrentUserOwner || currentProjectRole === EUserProjectRoles.ADMIN; + // const userCanLock = + // currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + // const isCurrentUserOwner = owned_by === currentUser?.id; + + // useEffect( + // () => () => { + // cleanup && cleanup(); + // }, + // [cleanup] + // ); + + // const updatePage = async (formData: IPage) => { + // if (!workspaceSlug || !projectId || !pageId) return; + // await updateDescriptionAction(formData.description_html); + // }; + + // const handleAiAssistance = async (response: string) => { + // if (!workspaceSlug || !projectId || !pageId) return; + + // const newDescription = `${watch("description_html")}

${response}

`; + // setValue("description_html", newDescription); + // editorRef.current?.setEditorValue(newDescription); + // updateDescriptionAction(newDescription); + // }; + + // const updatePageTitle = (title: string) => { + // if (!workspaceSlug || !projectId || !pageId) return; + // updateNameAction(title); + // }; + + // const createPage = async (payload: Partial) => { + // if (!workspaceSlug || !projectId) return; + // await createPageAction(workspaceSlug as string, projectId as string, payload); + // }; + + // const duplicate_page = async () => { + // const currentPageValues = getValues(); + + // if (!currentPageValues?.description_html) { + // // TODO: We need to get latest data the above variable will give us stale data + // currentPageValues.description_html = pageDescription as string; + // } + + // const formData: Partial = { + // name: "Copy of " + pageTitle, + // description_html: currentPageValues.description_html, + // }; + + // try { + // await createPage(formData); + // } catch (error) { + // setToastAlert({ + // title: `Page could not be duplicated`, + // message: `Sorry, page could not be duplicated, please try again later`, + // type: "error", + // }); + // } + // }; + + // if (!pageId && !workspaceSlug && !projectId) { + // return ( + //
+ // + //
+ // ); + // } + + return ( +
+ {/*
+ {isPageReadOnly ? ( + + ) : ( + + )} + +
*/} +
+ ); +}; diff --git a/web/components/pages/editors/read-only-document.tsx b/web/components/pages/editors/read-only-document.tsx new file mode 100644 index 00000000000..02869af13f5 --- /dev/null +++ b/web/components/pages/editors/read-only-document.tsx @@ -0,0 +1,98 @@ +import { FC, useRef } from "react"; +// ui +import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor"; +import useToast from "hooks/use-toast"; + +export type PageReadOnlyDocumentProps = { + title: string; + description: string | undefined; + created_by: string; +}; + +export const PageReadOnlyDocument: FC = (props) => { + const { + title, + description = "", + created_by, + // created_at, + // updated_at, + // updated_by, + // userCanLock, + // archived_at, + // unlockPage, + // canArchive, + // canDuplicate, + } = props; + // refs + // const editorRef = useRef(null); + // // hooks + // const { setToastAlert } = useToast(); + + // const unArchivePage = async () => { + // if (!workspaceSlug || !projectId || !pageId) return; + // try { + // await restorePageAction(workspaceSlug as string, projectId as string, pageId as string); + // } catch (error) { + // setToastAlert({ + // title: `Page could not be restored`, + // message: `Sorry, page could not be restored, please try again later`, + // type: "error", + // }); + // } + // }; + + // const duplicate_page = async () => { + // const currentPageValues = getValues(); + + // if (!currentPageValues?.description_html) { + // // TODO: We need to get latest data the above variable will give us stale data + // currentPageValues.description_html = pageDescription as string; + // } + + // const formData: Partial = { + // name: "Copy of " + pageTitle, + // description_html: currentPageValues.description_html, + // }; + + // try { + // await createPage(formData); + // } catch (error) { + // actionCompleteAlert({ + // title: `Page could not be duplicated`, + // message: `Sorry, page could not be duplicated, please try again later`, + // type: "error", + // }); + // } + // }; + + return ( + <> + {/* */} + + ); +}; diff --git a/web/components/pages/empty-states/page-detail.tsx b/web/components/pages/empty-states/page-detail.tsx new file mode 100644 index 00000000000..82331c1d0db --- /dev/null +++ b/web/components/pages/empty-states/page-detail.tsx @@ -0,0 +1,9 @@ +import { FC } from "react"; + +type TPageDetailEmptyState = {}; + +export const PageDetailEmptyState: FC = (props) => { + const {} = props; + + return
PageDetailEmptyState
; +}; diff --git a/web/components/pages/empty-states/page.tsx b/web/components/pages/empty-states/page.tsx new file mode 100644 index 00000000000..262c574d4d3 --- /dev/null +++ b/web/components/pages/empty-states/page.tsx @@ -0,0 +1,51 @@ +import { FC } from "react"; +import { useTheme } from "next-themes"; +// hooks +import { useEventTracker, useUser } from "hooks/store"; +// components +import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; +// constants +import { EUserWorkspaceRoles } from "constants/workspace"; +import { PAGE_EMPTY_STATE_DETAILS } from "constants/empty-state"; + +type TPageEmptyState = { + callback: () => void; +}; + +export const PageEmptyState: FC = (props) => { + const { callback } = props; + // theme + const { resolvedTheme } = useTheme(); + // hooks + const { setTrackElement } = useEventTracker(); + const { + currentUser, + membership: { currentProjectRole }, + } = useUser(); + + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "pages", isLightMode); + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; + + return ( + { + setTrackElement("Pages empty state"); + callback && callback(); + // toggleCreatePageModal(true); + }, + }} + comicBox={{ + title: PAGE_EMPTY_STATE_DETAILS["pages"].comicBox.title, + description: PAGE_EMPTY_STATE_DETAILS["pages"].comicBox.description, + }} + size="lg" + disabled={!isEditingAllowed} + /> + ); +}; diff --git a/web/components/pages/index.ts b/web/components/pages/index.ts index c24b78ff57e..ae7f9f922ca 100644 --- a/web/components/pages/index.ts +++ b/web/components/pages/index.ts @@ -1,4 +1,16 @@ -export * from "./pages-list"; -export * from "./create-update-page-modal"; -export * from "./delete-page-modal"; -export * from "./page-form"; +// empty states +export * from "./empty-states/page"; +export * from "./empty-states/page-detail"; + +// loaders +export * from "./loaders/page"; +export * from "./loaders/page-detail"; + +// layouts +export * from "./layouts/page"; + +// pages list components +export * from "./list"; + +// page detail components +export * from "./page-detail"; diff --git a/web/components/pages/layouts/page.tsx b/web/components/pages/layouts/page.tsx new file mode 100644 index 00000000000..1873e3918cc --- /dev/null +++ b/web/components/pages/layouts/page.tsx @@ -0,0 +1,71 @@ +import { FC, ReactNode } from "react"; +import Link from "next/link"; +// components +import { PageSearchInput } from "../"; +// helpers +import { cn } from "helpers/common.helper"; + +type TPageLayout = { + workspaceSlug: string; + projectId: string; + pageType?: "private" | "public"; + children: ReactNode; +}; + +export const PageLayout: FC = (props) => { + const { workspaceSlug, projectId, pageType, children } = props; + + // pages tab options + const pageTabs = [ + { + key: "private", + label: "Private", + }, + { + key: "public", + label: "Public", + }, + ]; + + // pages list loader + + // check for empty state + + return ( +
+ {/* tab header */} +
+ {pageTabs.map((tab) => ( + +
+
+ {tab.label} +
+
+
+ + ))} +
+ + {/* search and sort container */} +
+
+ +
+
Sort filter
+
+ + {/* children */} +
{children}
+
+ ); +}; diff --git a/web/components/pages/list/index.ts b/web/components/pages/list/index.ts new file mode 100644 index 00000000000..f7dd80956cf --- /dev/null +++ b/web/components/pages/list/index.ts @@ -0,0 +1 @@ +export * from "./search-input"; diff --git a/web/components/pages/list/search-input.tsx b/web/components/pages/list/search-input.tsx new file mode 100644 index 00000000000..1500460b152 --- /dev/null +++ b/web/components/pages/list/search-input.tsx @@ -0,0 +1,47 @@ +import { FC, useState, useEffect } from "react"; +import { observer } from "mobx-react-lite"; +import { Search } from "lucide-react"; +// hooks +import { usePage } from "hooks/store"; +import useDebounce from "hooks/use-debounce"; + +export type TPageSearchInput = { projectId: string }; + +export const PageSearchInput: FC = observer((props) => { + const { projectId } = props; + // hooks + const { + filters: { search }, + updateFilters, + } = usePage(projectId); + // states + const [searchElement, setSearchElement] = useState(search); + // debounce state + const debouncedValue = useDebounce(searchElement, 1000); + + useEffect(() => { + if (debouncedValue !== search) updateFilters("search", debouncedValue); + + // DO NOT Add more dependencies here. It will cause multiple requests to be sent. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedValue]); + + return ( +
+
+
+ +
+ setSearchElement(e.target.value)} + className="w-full text-sm bg-transparent focus:outline-none focus:ring-0 focus:border-0 border-0" + /> +
+ + {/* Gonna implement dropdown in future */} +
+ ); +}); diff --git a/web/components/pages/list/sort-filter/root.tsx b/web/components/pages/list/sort-filter/root.tsx new file mode 100644 index 00000000000..228e153ff4c --- /dev/null +++ b/web/components/pages/list/sort-filter/root.tsx @@ -0,0 +1,91 @@ +import { FC, Fragment, useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Menu, Transition } from "@headlessui/react"; +import { usePopper } from "react-popper"; +import { Copy, Eye, Globe2, Link2, Pencil, Trash } from "lucide-react"; +// hooks +import { usePage } from "hooks/store"; +// constants +import { pageSorting, pageSortingBy } from "constants/page"; +// types +import { TPageFiltersSortKey } from "@plane/types"; + +type TViewEditDropdown = { + projectId: string | undefined; +}; + +export const ViewEditDropdown: FC = observer((props) => { + const { projectId } = props; + // hooks + const { updateFilters } = usePage(projectId); + // refs + const dropdownRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom-end", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + { + name: "offset", + options: { + offset: [0, 10], + }, + }, + ], + }); + + const pageSortingByOptionKeys = Object.keys(pageSortingBy); + + return ( + + +
+ +
+
+ + + + {pageSorting && + pageSorting.length > 0 && + pageSorting.map((option) => ( + +
{option.label}
+
+ ))} +
+
Ascending/Descending
+ + +
+ ); +}); diff --git a/web/components/pages/loaders/page-detail.tsx b/web/components/pages/loaders/page-detail.tsx new file mode 100644 index 00000000000..9766a902104 --- /dev/null +++ b/web/components/pages/loaders/page-detail.tsx @@ -0,0 +1,9 @@ +import { FC } from "react"; + +type TPageDetailLoader = {}; + +export const PageDetailLoader: FC = (props) => { + const {} = props; + + return
PageDetailLoader
; +}; diff --git a/web/components/pages/loaders/page.tsx b/web/components/pages/loaders/page.tsx new file mode 100644 index 00000000000..a7854efa1ae --- /dev/null +++ b/web/components/pages/loaders/page.tsx @@ -0,0 +1,9 @@ +import { FC } from "react"; + +type TPageLoader = {}; + +export const PageLoader: FC = (props) => { + const {} = props; + + return
PageLoader
; +}; diff --git a/web/components/pages/create-update-page-modal.tsx b/web/components/pages/modals/create-update-page-modal.tsx similarity index 60% rename from web/components/pages/create-update-page-modal.tsx rename to web/components/pages/modals/create-update-page-modal.tsx index eea7e9d7fdd..57d3a09ef90 100644 --- a/web/components/pages/create-update-page-modal.tsx +++ b/web/components/pages/modals/create-update-page-modal.tsx @@ -6,15 +6,15 @@ import { PageForm } from "./page-form"; // hooks import { useEventTracker } from "hooks/store"; // types -import { IPage } from "@plane/types"; -import { useProjectPages } from "hooks/store/use-project-page"; -import { IPageStore } from "store/page.store"; +// import { IPage } from "@plane/types"; +// import { usePage } from "hooks/store/"; +// import { IPageStore } from "store/pages/page.store"; // constants import { PAGE_CREATED, PAGE_UPDATED } from "constants/event-tracker"; type Props = { // data?: IPage | null; - pageStore?: IPageStore; + pageStore?: any; handleClose: () => void; isOpen: boolean; projectId: string; @@ -26,55 +26,55 @@ export const CreateUpdatePageModal: FC = (props) => { const router = useRouter(); const { workspaceSlug } = router.query; // store hooks - const { createPage } = useProjectPages(); - const { capturePageEvent } = useEventTracker(); + // const { createPage } = useProjectPages(); + // const { capturePageEvent } = useEventTracker(); - const createProjectPage = async (payload: IPage) => { + const createProjectPage = async (payload: any) => { if (!workspaceSlug) return; - await createPage(workspaceSlug.toString(), projectId, payload) - .then((res) => { - capturePageEvent({ - eventName: PAGE_CREATED, - payload: { - ...res, - state: "SUCCESS", - }, - }); - }) - .catch(() => { - capturePageEvent({ - eventName: PAGE_CREATED, - payload: { - state: "FAILED", - }, - }); - }); + // await createPage(workspaceSlug.toString(), projectId, payload) + // .then((res) => { + // capturePageEvent({ + // eventName: PAGE_CREATED, + // payload: { + // ...res, + // state: "SUCCESS", + // }, + // }); + // }) + // .catch(() => { + // capturePageEvent({ + // eventName: PAGE_CREATED, + // payload: { + // state: "FAILED", + // }, + // }); + // }); }; - const handleFormSubmit = async (formData: IPage) => { + const handleFormSubmit = async (formData: any) => { if (!workspaceSlug || !projectId) return; - try { - if (pageStore) { - if (pageStore.name !== formData.name) { - await pageStore.updateName(formData.name); - } - if (pageStore.access !== formData.access) { - formData.access === 1 ? await pageStore.makePrivate() : await pageStore.makePublic(); - } - capturePageEvent({ - eventName: PAGE_UPDATED, - payload: { - ...pageStore, - state: "SUCCESS", - }, - }); - } else { - await createProjectPage(formData); - } - handleClose(); - } catch (error) { - console.log(error); - } + // try { + // if (pageStore) { + // if (pageStore.name !== formData.name) { + // await pageStore.updateName(formData.name); + // } + // if (pageStore.access !== formData.access) { + // formData.access === 1 ? await pageStore.makePrivate() : await pageStore.makePublic(); + // } + // capturePageEvent({ + // eventName: PAGE_UPDATED, + // payload: { + // ...pageStore, + // state: "SUCCESS", + // }, + // }); + // } else { + // await createProjectPage(formData); + // } + // handleClose(); + // } catch (error) { + // console.log(error); + // } }; return ( diff --git a/web/components/pages/delete-page-modal.tsx b/web/components/pages/modals/delete-page-modal.tsx similarity index 78% rename from web/components/pages/delete-page-modal.tsx rename to web/components/pages/modals/delete-page-modal.tsx index bba19b31c95..19e92a4818a 100644 --- a/web/components/pages/delete-page-modal.tsx +++ b/web/components/pages/modals/delete-page-modal.tsx @@ -8,8 +8,6 @@ import { useEventTracker, usePage } from "hooks/store"; import useToast from "hooks/use-toast"; // ui import { Button } from "@plane/ui"; -// types -import { useProjectPages } from "hooks/store/use-project-page"; // constants import { PAGE_DELETED } from "constants/event-tracker"; @@ -28,16 +26,18 @@ export const DeletePageModal: React.FC = observer((pr const router = useRouter(); const { workspaceSlug, projectId } = router.query; // store hooks - const { deletePage } = useProjectPages(); - const { capturePageEvent } = useEventTracker(); - const pageStore = usePage(pageId); + // const { deletePage } = useProjectPages(); + // const { capturePageEvent } = useEventTracker(); + // const pageStore = usePage(pageId); // toast alert const { setToastAlert } = useToast(); - if (!pageStore) return null; + // if (!pageStore) return null; - const { name } = pageStore; + // const { name } = pageStore; + + const name = undefined; const handleClose = () => { setIsDeleting(false); @@ -50,39 +50,39 @@ export const DeletePageModal: React.FC = observer((pr setIsDeleting(true); // Delete Page will only delete the page from the archive page map, at this point only archived pages can be deleted - await deletePage(workspaceSlug.toString(), projectId as string, pageId) - .then(() => { - capturePageEvent({ - eventName: PAGE_DELETED, - payload: { - ...pageStore, - state: "SUCCESS", - }, - }); - handleClose(); - setToastAlert({ - type: "success", - title: "Success!", - message: "Page deleted successfully.", - }); - }) - .catch(() => { - capturePageEvent({ - eventName: PAGE_DELETED, - payload: { - ...pageStore, - state: "FAILED", - }, - }); - setToastAlert({ - type: "error", - title: "Error!", - message: "Page could not be deleted. Please try again.", - }); - }) - .finally(() => { - setIsDeleting(false); - }); + // await deletePage(workspaceSlug.toString(), projectId as string, pageId) + // .then(() => { + // capturePageEvent({ + // eventName: PAGE_DELETED, + // payload: { + // ...pageStore, + // state: "SUCCESS", + // }, + // }); + // handleClose(); + // setToastAlert({ + // type: "success", + // title: "Success!", + // message: "Page deleted successfully.", + // }); + // }) + // .catch(() => { + // capturePageEvent({ + // eventName: PAGE_DELETED, + // payload: { + // ...pageStore, + // state: "FAILED", + // }, + // }); + // setToastAlert({ + // type: "error", + // title: "Error!", + // message: "Page could not be deleted. Please try again.", + // }); + // }) + // .finally(() => { + // setIsDeleting(false); + // }); }; return ( diff --git a/web/components/pages/page-form.tsx b/web/components/pages/modals/page-form.tsx similarity index 87% rename from web/components/pages/page-form.tsx rename to web/components/pages/modals/page-form.tsx index 4f5874e5f5e..8ed2255717c 100644 --- a/web/components/pages/page-form.tsx +++ b/web/components/pages/modals/page-form.tsx @@ -2,15 +2,15 @@ import { Controller, useForm } from "react-hook-form"; // ui import { Button, Input, Tooltip } from "@plane/ui"; // types -import { IPage } from "@plane/types"; +// import { IPage } from "@plane/types"; // constants -import { PAGE_ACCESS_SPECIFIERS } from "constants/page"; -import { IPageStore } from "store/page.store"; +// import { PAGE_ACCESS_SPECIFIERS } from "constants/page"; +// import { IPageStore } from "store/pages/page.store"; type Props = { - handleFormSubmit: (values: IPage) => Promise; + handleFormSubmit: (values: any) => Promise; handleClose: () => void; - pageStore?: IPageStore; + pageStore?: any; }; const defaultValues = { @@ -26,13 +26,13 @@ export const PageForm: React.FC = (props) => { formState: { errors, isSubmitting }, handleSubmit, control, - } = useForm({ + } = useForm({ defaultValues: pageStore ? { name: pageStore.name, description: pageStore.description, access: pageStore.access } : defaultValues, }); - const handleCreateUpdatePage = (formData: IPage) => handleFormSubmit(formData); + const handleCreateUpdatePage = (formData: any) => handleFormSubmit(formData); return (
@@ -74,7 +74,7 @@ export const PageForm: React.FC = (props) => { render={({ field: { value, onChange } }) => (
- {PAGE_ACCESS_SPECIFIERS.map((access, index) => ( + {/* {PAGE_ACCESS_SPECIFIERS.map((access, index) => ( - ))} + ))} */}
-
+ {/*
{PAGE_ACCESS_SPECIFIERS.find((access) => access.key === value)?.label} -
+ */}
)} /> diff --git a/web/components/pages/page-detail/index.ts b/web/components/pages/page-detail/index.ts new file mode 100644 index 00000000000..4fc6e5bd881 --- /dev/null +++ b/web/components/pages/page-detail/index.ts @@ -0,0 +1,3 @@ +export * from "./loader"; + +export * from "./root"; diff --git a/web/components/pages/page-detail/loader.tsx b/web/components/pages/page-detail/loader.tsx new file mode 100644 index 00000000000..68fa64abad0 --- /dev/null +++ b/web/components/pages/page-detail/loader.tsx @@ -0,0 +1,118 @@ +import { FC } from "react"; +// components/ui +import { Loader } from "@plane/ui"; + +export const PageDetailRootLoader: FC = () => ( +
+ {/* header */} +
+ {/* left options */} + + + + + {/* editor options */} +
+ + + + + + + + + + + + + + + + + + + + + +
+ + {/* right options */} + + + + + + +
+ + {/* content */} +
+ {/* table of content loader */} +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ + +
+
+
+ + {/* editor loader */} +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ + +
+ + +
+
+ +
+ +
+ +
+ +
+
+ +
+ +
+
+
+
+
+
+); diff --git a/web/components/pages/page-detail/root.tsx b/web/components/pages/page-detail/root.tsx new file mode 100644 index 00000000000..05a13b6bb2a --- /dev/null +++ b/web/components/pages/page-detail/root.tsx @@ -0,0 +1,56 @@ +import { FC, Fragment } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { usePage, usePageDetail } from "hooks/store"; +// components +import { PageHead } from "components/core"; +import { PageDetailRootLoader } from "./"; + +type TPageDetailRoot = { + projectId: string; + pageId: string; +}; + +export const PageDetailRoot: FC = observer((props) => { + const { projectId, pageId } = props; + // hooks + const { loader } = usePage(projectId); + const { + data: { id, name }, + } = usePageDetail(projectId, pageId); + + if (loader === "init-loader") return ; + + if (!id) return
No page is available.
; + + return ( + + + +
+
+ {/* header left container */} +
Icon
+ {/* header editor tool container */} +
+ Editor keys +
+ {/* header right operations container */} +
right saved
+
+ + {/* editor container for small screens */} +
+ Editor keys +
+ +
+ {/* editor table of content content container */} +
Table of content
+ {/* editor container */} +
Editor Container
+
+
+
+ ); +}); diff --git a/web/components/pages/page-details.tsx b/web/components/pages/page-details.tsx deleted file mode 100644 index 49cd9dd9e74..00000000000 --- a/web/components/pages/page-details.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { FC, useState, useRef, useEffect } from "react"; - -import { Controller, useForm } from "react-hook-form"; -// components -import { GptAssistantPopover } from "components/core"; -import { IssuePeekOverview } from "components/issues"; -// ui -import { Spinner, StateGroupIcon } from "@plane/ui"; -// hooks -import useToast from "hooks/use-toast"; -import useReloadConfirmations from "hooks/use-reload-confirmation"; -import { useApplication, usePage, useProjectPages, useUser, useWorkspace } from "hooks/store"; -// ui -import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor"; -// types -import { IPage } from "@plane/types"; -// constants -import { EUserProjectRoles } from "constants/project"; -// services -import { FileService } from "services/file.service"; -const fileService = new FileService(); - -export type PageDetailsViewProps = { - workspaceSlug: string; - projectId: string; - pageId: string; -}; - -export const PageDetailsView: FC = (props) => { - const { workspaceSlug, projectId, pageId } = props; - // states - const [gptModalOpen, setGptModal] = useState(false); - - // toast alert - const { setToastAlert } = useToast(); - // store hooks - const { - config: { envConfig }, - } = useApplication(); - const { - currentUser, - membership: { currentProjectRole }, - } = useUser(); - const { - archivePage: archivePageAction, - restorePage: restorePageAction, - createPage: createPageAction, - projectPageMap, - projectArchivedPageMap, - fetchProjectPages, - fetchArchivedProjectPages, - cleanup, - } = useProjectPages(); - const pageStore = usePage(pageId); - const { - lockPage: lockPageAction, - unlockPage: unlockPageAction, - updateName: updateNameAction, - updateDescription: updateDescriptionAction, - id: pageIdMobx, - isSubmitting, - setIsSubmitting, - owned_by, - is_locked, - archived_at, - created_at, - created_by, - updated_at, - updated_by, - } = pageStore; - // hooks - const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); - // form data - const { handleSubmit, setValue, watch, getValues, control, reset } = useForm({ - defaultValues: { name: "", description_html: "" }, - }); - // derived values - const pageTitle = pageStore?.name; - const pageDescription = pageStore?.description_html; - const isPageReadOnly = - is_locked || - archived_at || - (currentProjectRole && [EUserProjectRoles.VIEWER, EUserProjectRoles.GUEST].includes(currentProjectRole)); - const userCanDuplicate = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - const userCanArchive = isCurrentUserOwner || currentProjectRole === EUserProjectRoles.ADMIN; - const userCanLock = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - const isCurrentUserOwner = owned_by === currentUser?.id; - - useEffect( - () => () => { - cleanup && cleanup(); - }, - [cleanup] - ); - - const updatePage = async (formData: IPage) => { - if (!workspaceSlug || !projectId || !pageId) return; - await updateDescriptionAction(formData.description_html); - }; - - const handleAiAssistance = async (response: string) => { - if (!workspaceSlug || !projectId || !pageId) return; - - const newDescription = `${watch("description_html")}

${response}

`; - setValue("description_html", newDescription); - editorRef.current?.setEditorValue(newDescription); - updateDescriptionAction(newDescription); - }; - - const updatePageTitle = (title: string) => { - if (!workspaceSlug || !projectId || !pageId) return; - updateNameAction(title); - }; - - const createPage = async (payload: Partial) => { - if (!workspaceSlug || !projectId) return; - await createPageAction(workspaceSlug as string, projectId as string, payload); - }; - - const duplicate_page = async () => { - const currentPageValues = getValues(); - - if (!currentPageValues?.description_html) { - // TODO: We need to get latest data the above variable will give us stale data - currentPageValues.description_html = pageDescription as string; - } - - const formData: Partial = { - name: "Copy of " + pageTitle, - description_html: currentPageValues.description_html, - }; - - try { - await createPage(formData); - } catch (error) { - setToastAlert({ - title: `Page could not be duplicated`, - message: `Sorry, page could not be duplicated, please try again later`, - type: "error", - }); - } - }; - - if (!pageId && !workspaceSlug && !projectId) { - return ( -
- -
- ); - } - - return ( -
-
- {isPageReadOnly ? ( - - ) : ( - - )} - -
-
- ); -}; diff --git a/web/components/pages/pages-list/all-pages-list.tsx b/web/components/pages/pages-list/all-pages-list.tsx deleted file mode 100644 index 4ed759a0f4d..00000000000 --- a/web/components/pages/pages-list/all-pages-list.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// hooks -import { PagesListView } from "components/pages/pages-list"; -// ui -import { Loader } from "@plane/ui"; -import { useProjectPages } from "hooks/store/use-project-specific-pages"; - -export const AllPagesList: FC = observer(() => { - const pageStores = useProjectPages(); - // subscribing to the projectPageStore - const { projectPageIds } = pageStores; - - if (!projectPageIds) { - return ( - - - - - - ); - } - return ; -}); diff --git a/web/components/pages/pages-list/archived-pages-list.tsx b/web/components/pages/pages-list/archived-pages-list.tsx deleted file mode 100644 index eb57d755803..00000000000 --- a/web/components/pages/pages-list/archived-pages-list.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { PagesListView } from "components/pages/pages-list"; -// hooks -// ui -import { Loader, Spinner } from "@plane/ui"; -import { useProjectPages } from "hooks/store/use-project-specific-pages"; - -export const ArchivedPagesList: FC = observer(() => { - const projectPageStore = useProjectPages(); - const { archivedPageIds, archivedPageLoader } = projectPageStore; - - if (archivedPageLoader) { - return ( -
- -
- ); - } - if (!archivedPageIds) - return ( - - - - - - ); - - return ; -}); diff --git a/web/components/pages/pages-list/favorite-pages-list.tsx b/web/components/pages/pages-list/favorite-pages-list.tsx deleted file mode 100644 index 4ce301a68f0..00000000000 --- a/web/components/pages/pages-list/favorite-pages-list.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { PagesListView } from "components/pages/pages-list"; -// hooks -// ui -import { Loader } from "@plane/ui"; -import { useProjectPages } from "hooks/store/use-project-specific-pages"; - -export const FavoritePagesList: FC = observer(() => { - const projectPageStore = useProjectPages(); - const { favoriteProjectPageIds } = projectPageStore; - - if (!favoriteProjectPageIds) - return ( - - - - - - ); - - return ; -}); diff --git a/web/components/pages/pages-list/index.ts b/web/components/pages/pages-list/index.ts deleted file mode 100644 index 1f199296a5c..00000000000 --- a/web/components/pages/pages-list/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from "./all-pages-list"; -export * from "./archived-pages-list"; -export * from "./favorite-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 deleted file mode 100644 index 6b1a4793d2c..00000000000 --- a/web/components/pages/pages-list/list-item.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import { FC, useState } from "react"; -import Link from "next/link"; -import { observer } from "mobx-react-lite"; -import { - AlertCircle, - Archive, - ArchiveRestoreIcon, - FileText, - Globe2, - LinkIcon, - Lock, - Pencil, - Star, - Trash2, -} from "lucide-react"; -import { copyUrlToClipboard } from "helpers/string.helper"; -import { renderFormattedTime, renderFormattedDate } from "helpers/date-time.helper"; -// ui -import { CustomMenu, Tooltip } from "@plane/ui"; -// components -import { CreateUpdatePageModal, DeletePageModal } from "components/pages"; -// constants -import { EUserProjectRoles } from "constants/project"; -import { useRouter } from "next/router"; -import { useProjectPages } from "hooks/store/use-project-specific-pages"; -import { useMember, usePage, useUser } from "hooks/store"; -import { IIssueLabel } from "@plane/types"; - -export interface IPagesListItem { - pageId: string; - projectId: string; -} - -export const PagesListItem: FC = observer(({ pageId, projectId }: IPagesListItem) => { - const projectPageStore = useProjectPages(); - // Now, I am observing only the projectPages, out of the projectPageStore. - const { archivePage, restorePage } = projectPageStore; - - const pageStore = usePage(pageId); - - // states - const router = useRouter(); - const { workspaceSlug } = router.query; - const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false); - - const [deletePageModal, setDeletePageModal] = useState(false); - - const { - currentUser, - membership: { currentProjectRole }, - } = useUser(); - - const { - project: { getProjectMemberDetails }, - } = useMember(); - - if (!pageStore) return null; - - const { - archived_at, - label_details, - access, - is_favorite, - owned_by, - name, - created_at, - updated_at, - makePublic, - makePrivate, - addToFavorites, - removeFromFavorites, - } = pageStore; - - const handleCopyUrl = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - await copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${pageId}`); - }; - - const handleAddToFavorites = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - addToFavorites(); - }; - - const handleRemoveFromFavorites = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - removeFromFavorites(); - }; - - const handleMakePublic = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - makePublic(); - }; - - const handleMakePrivate = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - makePrivate(); - }; - - const handleArchivePage = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - await archivePage(workspaceSlug as string, projectId as string, pageId as string); - }; - - const handleRestorePage = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - await restorePage(workspaceSlug as string, projectId as string, pageId as string); - }; - - const handleDeletePage = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - setDeletePageModal(true); - }; - - const handleEditPage = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - setCreateUpdatePageModal(true); - }; - - const ownerDetails = getProjectMemberDetails(owned_by); - const isCurrentUserOwner = owned_by === currentUser?.id; - - const userCanEdit = - isCurrentUserOwner || - (currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole)); - const userCanChangeAccess = isCurrentUserOwner; - const userCanArchive = isCurrentUserOwner || currentProjectRole === EUserProjectRoles.ADMIN; - const userCanDelete = isCurrentUserOwner || currentProjectRole === EUserProjectRoles.ADMIN; - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - - return ( - <> - setCreateUpdatePageModal(false)} - projectId={projectId} - /> - setDeletePageModal(false)} pageId={pageId} /> -
  • - -
    -
    -
    - -

    {name}

    - {label_details.length > 0 && - label_details.map((label: IIssueLabel) => ( -
    - - {label.name} -
    - ))} -
    -
    - {archived_at ? ( - -

    {renderFormattedTime(archived_at)}

    -
    - ) : ( - -

    {renderFormattedTime(updated_at)}

    -
    - )} - {isEditingAllowed && ( - - {is_favorite ? ( - - ) : ( - - )} - - )} - {userCanChangeAccess && ( - - {access ? ( - - ) : ( - - )} - - )} - - - - - {archived_at ? ( - <> - {userCanArchive && ( - -
    - - Restore page -
    -
    - )} - {userCanDelete && isEditingAllowed && ( - -
    - - Delete page -
    -
    - )} - - ) : ( - <> - {userCanEdit && isEditingAllowed && ( - -
    - - Edit page -
    -
    - )} - {userCanArchive && isEditingAllowed && ( - -
    - - 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 deleted file mode 100644 index ebd1fa12898..00000000000 --- a/web/components/pages/pages-list/list-view.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { FC } from "react"; -import { useRouter } from "next/router"; -import { useTheme } from "next-themes"; -// hooks -import { useApplication, useUser } from "hooks/store"; -import useLocalStorage from "hooks/use-local-storage"; -// components -import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; -import { PagesListItem } from "./list-item"; -// ui -import { Loader } from "@plane/ui"; -// constants -import { EUserProjectRoles } from "constants/project"; -import { PAGE_EMPTY_STATE_DETAILS } from "constants/empty-state"; - -type IPagesListView = { - pageIds: string[]; -}; - -export const PagesListView: FC = (props) => { - const { pageIds: projectPageIds } = props; - // theme - const { resolvedTheme } = useTheme(); - // store hooks - const { - commandPalette: { toggleCreatePageModal }, - } = useApplication(); - const { - membership: { currentProjectRole }, - currentUser, - } = useUser(); - // local storage - const { storedValue: pageTab } = useLocalStorage("pageTab", "Recent"); - // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - - const currentPageTabDetails = pageTab - ? PAGE_EMPTY_STATE_DETAILS[pageTab as keyof typeof PAGE_EMPTY_STATE_DETAILS] - : PAGE_EMPTY_STATE_DETAILS["All"]; - - const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; - const emptyStateImage = getEmptyStateImagePath("pages", currentPageTabDetails.key, isLightMode); - - const isButtonVisible = currentPageTabDetails.key !== "archived" && currentPageTabDetails.key !== "favorites"; - - // here we are only observing the projectPageStore, so that we can re-render the component when the projectPageStore changes - - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - - return ( - <> - {projectPageIds && workspaceSlug && projectId ? ( -
    - {projectPageIds.length > 0 ? ( -
      - {projectPageIds.map((pageId: string) => ( - - ))} -
    - ) : ( - toggleCreatePageModal(true), - } - : undefined - } - disabled={!isEditingAllowed} - /> - )} -
    - ) : ( - - - - - - )} - - ); -}; diff --git a/web/components/pages/pages-list/private-page-list.tsx b/web/components/pages/pages-list/private-page-list.tsx deleted file mode 100644 index 15a577d80cd..00000000000 --- a/web/components/pages/pages-list/private-page-list.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// hooks -// components -import { PagesListView } from "components/pages/pages-list"; -// ui -import { Loader } from "@plane/ui"; -import { useProjectPages } from "hooks/store/use-project-specific-pages"; - -export const PrivatePagesList: FC = observer(() => { - const projectPageStore = useProjectPages(); - const { privateProjectPageIds } = projectPageStore; - - if (!privateProjectPageIds) - return ( - - - - - - ); - - return ; -}); diff --git a/web/components/pages/pages-list/recent-pages-list.tsx b/web/components/pages/pages-list/recent-pages-list.tsx deleted file mode 100644 index 71bbf12ace2..00000000000 --- a/web/components/pages/pages-list/recent-pages-list.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React, { FC } from "react"; -import { observer } from "mobx-react-lite"; -import { useTheme } from "next-themes"; -// hooks -import { useApplication, useUser } from "hooks/store"; -import { useProjectPages } from "hooks/store/use-project-specific-pages"; -// components -import { PagesListView } from "components/pages/pages-list"; -import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; -// ui -import { Loader } from "@plane/ui"; -// helpers -import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; -// constants -import { EUserProjectRoles } from "constants/project"; -import { PAGE_EMPTY_STATE_DETAILS } from "constants/empty-state"; - -export const RecentPagesList: FC = observer(() => { - // theme - const { resolvedTheme } = useTheme(); - // store hooks - const { commandPalette: commandPaletteStore } = useApplication(); - const { - membership: { currentProjectRole }, - currentUser, - } = useUser(); - const { recentProjectPages } = useProjectPages(); - - const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; - const EmptyStateImagePath = getEmptyStateImagePath("pages", "recent", isLightMode); - - // FIXME: replace any with proper type - const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value: any) => value.length === 0); - - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - - if (!recentProjectPages) { - return ( - - - - - - ); - } - - return ( - <> - {Object.keys(recentProjectPages).length > 0 && !isEmpty ? ( - <> - {Object.keys(recentProjectPages).map((key) => { - if (recentProjectPages[key]?.length === 0) return null; - - return ( -
    -

    - {replaceUnderscoreIfSnakeCase(key)} -

    - -
    - ); - })} - - ) : ( - <> - commandPaletteStore.toggleCreatePageModal(true), - }} - size="sm" - disabled={!isEditingAllowed} - /> - - )} - - ); -}); diff --git a/web/components/pages/pages-list/shared-pages-list.tsx b/web/components/pages/pages-list/shared-pages-list.tsx deleted file mode 100644 index d20a1350ef7..00000000000 --- a/web/components/pages/pages-list/shared-pages-list.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { PagesListView } from "components/pages/pages-list"; -// hooks -// ui -import { Loader } from "@plane/ui"; -import { useProjectPages } from "hooks/store/use-project-specific-pages"; - -export const SharedPagesList: FC = observer(() => { - const projectPageStore = useProjectPages(); - const { publicProjectPageIds } = projectPageStore; - - if (!publicProjectPageIds) - return ( - - - - - - ); - - return ; -}); diff --git a/web/components/pages/pages-list/types.ts b/web/components/pages/pages-list/types.ts deleted file mode 100644 index 148ab6aa4b5..00000000000 --- a/web/components/pages/pages-list/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { TPageViewProps } from "@plane/types"; - -export type TPagesListProps = { - viewType: TPageViewProps; -}; diff --git a/web/components/pages/read-only-document.tsx b/web/components/pages/read-only-document.tsx deleted file mode 100644 index e33f918d341..00000000000 --- a/web/components/pages/read-only-document.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { FC, useRef } from "react"; -// ui -import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor"; -import useToast from "hooks/use-toast"; - -export type PageReadOnlyDocumentProps = { - title: string; - description: string | undefined; - created_by: string; -}; - -export const PageReadOnlyDocument: FC = (props) => { - const { - title, - description = "", - created_by, - created_at, - updated_at, - updated_by, - userCanLock, - archived_at, - unlockPage, - canArchive, - canDuplicate, - } = props; - // refs - const editorRef = useRef(null); - // hooks - const { setToastAlert } = useToast(); - - const unArchivePage = async () => { - if (!workspaceSlug || !projectId || !pageId) return; - try { - await restorePageAction(workspaceSlug as string, projectId as string, pageId as string); - } catch (error) { - setToastAlert({ - title: `Page could not be restored`, - message: `Sorry, page could not be restored, please try again later`, - type: "error", - }); - } - }; - - const duplicate_page = async () => { - const currentPageValues = getValues(); - - if (!currentPageValues?.description_html) { - // TODO: We need to get latest data the above variable will give us stale data - currentPageValues.description_html = pageDescription as string; - } - - const formData: Partial = { - name: "Copy of " + pageTitle, - description_html: currentPageValues.description_html, - }; - - try { - await createPage(formData); - } catch (error) { - actionCompleteAlert({ - title: `Page could not be duplicated`, - message: `Sorry, page could not be duplicated, please try again later`, - type: "error", - }); - } - }; - - return ( - - ); -}; diff --git a/web/constants/page.ts b/web/constants/page.ts index 4b303ae73b6..dbb15f88b21 100644 --- a/web/constants/page.ts +++ b/web/constants/page.ts @@ -1,54 +1,17 @@ -import { Globe2, LayoutGrid, List, Lock } from "lucide-react"; +import { TPageFiltersSortKey, TPageFiltersSortBy } from "@plane/types"; -export const PAGE_VIEW_LAYOUTS = [ - { - key: "list", - icon: List, - title: "List layout", - }, - { - key: "detailed", - icon: LayoutGrid, - title: "Detailed layout", - }, -]; +export enum EPageAccess { + PUBLIC = 0, + PRIVATE = 1, +} -export const PAGE_TABS_LIST: { key: string; title: string }[] = [ - { - key: "recent", - title: "Recent", - }, - { - key: "all", - title: "All", - }, - { - key: "favorites", - title: "Favorites", - }, - { - key: "private", - title: "Private", - }, - { - key: "shared", - title: "Shared", - }, - { - key: "archived-pages", - title: "Archived", - }, +export const pageSorting: { key: TPageFiltersSortKey; label: string }[] = [ + { key: "name", label: "Name" }, + { key: "created_at", label: "Date Created" }, + { key: "updated_at", label: "Last Modified" }, ]; -export const PAGE_ACCESS_SPECIFIERS: { key: number; label: string; icon: any }[] = [ - { - key: 0, - label: "Public", - icon: Globe2, - }, - { - key: 1, - label: "Private", - icon: Lock, - }, -]; +export const pageSortingBy: Record = { + asc: { label: "Ascending" }, + desc: { label: "Descending" }, +}; diff --git a/web/hooks/store/index.ts b/web/hooks/store/index.ts index e10427476f2..9d87e0d68ee 100644 --- a/web/hooks/store/index.ts +++ b/web/hooks/store/index.ts @@ -9,7 +9,10 @@ export * from "./use-label"; export * from "./use-member"; export * from "./use-mention"; export * from "./use-module"; -export * from "./use-page"; + +export * from "./pages/use-page"; +export * from "./pages/use-page-detail"; + export * from "./use-project-publish"; export * from "./use-project-state"; export * from "./use-project-view"; @@ -22,4 +25,3 @@ export * from "./use-kanban-view"; export * from "./use-issue-detail"; export * from "./use-inbox"; export * from "./use-inbox-issues"; -export * from "./use-project-specific-pages"; diff --git a/web/hooks/store/pages/use-page-detail.ts b/web/hooks/store/pages/use-page-detail.ts new file mode 100644 index 00000000000..126427840c8 --- /dev/null +++ b/web/hooks/store/pages/use-page-detail.ts @@ -0,0 +1,23 @@ +import { useContext } from "react"; +import useSWR from "swr"; +// context +import { StoreContext } from "contexts/store-context"; +// hooks +import { usePage } from "./use-page"; +// mobx store +import { IPageStore } from "store/pages/page.store"; + +export const usePageDetail = (projectId: string, pageId: string): IPageStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useProjectPublish must be used within StoreProvider"); + + if (!projectId || !pageId) throw new Error("projectId, pageId must be passed as a property"); + + const { fetchById } = usePage(projectId); + + useSWR(projectId && pageId ? `PROJECT_PAGE_DETAIL_${projectId}_${pageId}` : null, async () => { + projectId && pageId && (await fetchById(pageId)); + }); + + return context.projectPage.data?.[projectId]?.[pageId] ?? {}; +}; diff --git a/web/hooks/store/pages/use-page.ts b/web/hooks/store/pages/use-page.ts new file mode 100644 index 00000000000..20372c8dcd1 --- /dev/null +++ b/web/hooks/store/pages/use-page.ts @@ -0,0 +1,22 @@ +import { useContext } from "react"; +import useSWR from "swr"; +// context +import { StoreContext } from "contexts/store-context"; +// mobx store +import { IProjectPageStore } from "store/pages/project-page.store"; + +export const usePage = (projectId: string | undefined): IProjectPageStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useProjectPublish must be used within StoreProvider"); + + if (!projectId) throw new Error("projectId must be passed as a property"); + + const projectPage = context.projectPage; + const { fetch } = projectPage; + + useSWR(projectId ? `PROJECT_PAGES_${projectId}` : null, async () => { + projectId && (await fetch()); + }); + + return context.projectPage; +}; diff --git a/web/hooks/store/use-page.ts b/web/hooks/store/use-page.ts deleted file mode 100644 index 8971acd2209..00000000000 --- a/web/hooks/store/use-page.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useContext } from "react"; -// mobx store -import { StoreContext } from "contexts/store-context"; - -export const usePage = (pageId: string) => { - const context = useContext(StoreContext); - if (context === undefined) throw new Error("usePage must be used within StoreProvider"); - - const { projectPageMap, projectArchivedPageMap } = context.projectPages; - - const { projectId, workspaceSlug } = context.app.router; - if (!projectId || !workspaceSlug) throw new Error("usePage must be used within ProjectProvider"); - - if (projectPageMap[projectId] && projectPageMap[projectId][pageId]) { - return projectPageMap[projectId][pageId]; - } else if (projectArchivedPageMap[projectId] && projectArchivedPageMap[projectId][pageId]) { - return projectArchivedPageMap[projectId][pageId]; - } else { - return; - } -}; diff --git a/web/hooks/store/use-project-page.ts b/web/hooks/store/use-project-page.ts deleted file mode 100644 index f7c25ea1757..00000000000 --- a/web/hooks/store/use-project-page.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useContext } from "react"; -// mobx store -import { StoreContext } from "contexts/store-context"; -import { IProjectPageStore } from "store/project-page.store"; - -export const useProjectPages = (): IProjectPageStore => { - const context = useContext(StoreContext); - if (context === undefined) throw new Error("useProjectPublish must be used within StoreProvider"); - return context.projectPages; -}; diff --git a/web/hooks/store/use-project-specific-pages.ts b/web/hooks/store/use-project-specific-pages.ts deleted file mode 100644 index 325c2ef1609..00000000000 --- a/web/hooks/store/use-project-specific-pages.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useContext } from "react"; -// mobx store -import { StoreContext } from "contexts/store-context"; -// types -import { IProjectPageStore } from "store/project-page.store"; - -export const useProjectPages = (): IProjectPageStore => { - const context = useContext(StoreContext); - if (context === undefined) throw new Error("usePage must be used within StoreProvider"); - return context.projectPages; -}; diff --git a/web/lib/app-provider.tsx b/web/lib/app-provider.tsx index 64d323cf0de..3c7ad95cc30 100644 --- a/web/lib/app-provider.tsx +++ b/web/lib/app-provider.tsx @@ -48,7 +48,7 @@ export const AppProvider: FC = observer((props) => { - = observer((props) => { posthogHost={envConfig?.posthog_host || null} > {children} - + */} + {children} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index b5d3ec42620..95d4a926458 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -1,58 +1,24 @@ +import { Fragment, ReactElement } from "react"; import { observer } from "mobx-react-lite"; -import useSWR from "swr"; import { useRouter } from "next/router"; -import { ReactElement, useEffect, useRef, useState } from "react"; -//components -import { PageHead } from "components/core"; -// hooks -import { useApplication, usePage, useUser, useWorkspace } from "hooks/store"; - // layouts import { AppLayout } from "layouts/app-layout"; +// components import { PageDetailsHeader } from "components/headers/page-details"; -import { Spinner } from "@plane/ui"; -// assets -// helpers +import { PageDetailRoot } from "components/pages"; // types import { NextPageWithLayout } from "lib/types"; -// fetch-keys -import { useProjectPages } from "hooks/store/use-project-specific-pages"; const PageDetailsPage: NextPageWithLayout = observer(() => { // router const router = useRouter(); const { workspaceSlug, projectId, pageId } = router.query; - // store hooks - const { getWorkspaceBySlug } = useWorkspace(); - const workspaceId = getWorkspaceBySlug(workspaceSlug as string)?.id as string; - const { - config: { envConfig }, - } = useApplication(); - const { - archivePage: archivePageAction, - restorePage: restorePageAction, - createPage: createPageAction, - projectPageMap, - projectArchivedPageMap, - getPageDetails, - fetchProjectPages, - fetchArchivedProjectPages, - } = useProjectPages(); - const pageStore = pageId ? usePage(pageId.toString()) : undefined; - // derived values - const pageTitle = pageStore?.name; - const hasPageInStore = projectId && pageId ? getPageDetails(projectId.toString(), pageId.toString()) : undefined; - // We need to get the values of title and description from the page store but we don't have to subscribe to those values - return pageIdMobx ? ( - <> - - - - ) : ( -
    - -
    + if (!workspaceSlug || !projectId || !pageId) return <>; + return ( + + + ); }); diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx index a8c85ef8dc5..177e23d72ae 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx @@ -1,242 +1,32 @@ -import { useState, Fragment, ReactElement } from "react"; +import { ReactElement } from "react"; import { useRouter } from "next/router"; -import dynamic from "next/dynamic"; -import { Tab } from "@headlessui/react"; -import useSWR from "swr"; -import { observer } from "mobx-react-lite"; -import { useTheme } from "next-themes"; -// hooks -import { useApplication, useEventTracker, useUser, useProject } from "hooks/store"; -import useLocalStorage from "hooks/use-local-storage"; -import useUserAuth from "hooks/use-user-auth"; -import useSize from "hooks/use-window-size"; // layouts import { AppLayout } from "layouts/app-layout"; // components -import { RecentPagesList, CreateUpdatePageModal } from "components/pages"; -import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; import { PagesHeader } from "components/headers"; -import { PagesLoader } from "components/ui"; +import { PageLayout } from "components/pages"; // types import { NextPageWithLayout } from "lib/types"; // constants -import { PAGE_TABS_LIST } from "constants/page"; -import { useProjectPages } from "hooks/store/use-project-page"; -import { EUserWorkspaceRoles } from "constants/workspace"; -import { PAGE_EMPTY_STATE_DETAILS } from "constants/empty-state"; -import { PageHead } from "components/core"; -const AllPagesList = dynamic(() => import("components/pages").then((a) => a.AllPagesList), { - ssr: false, -}); - -const FavoritePagesList = dynamic(() => import("components/pages").then((a) => a.FavoritePagesList), { - ssr: false, -}); - -const PrivatePagesList = dynamic(() => import("components/pages").then((a) => a.PrivatePagesList), { - ssr: false, -}); - -const ArchivedPagesList = dynamic(() => import("components/pages").then((a) => a.ArchivedPagesList), { - ssr: false, -}); - -const SharedPagesList = dynamic(() => import("components/pages").then((a) => a.SharedPagesList), { - ssr: false, -}); - -const ProjectPagesPage: NextPageWithLayout = observer(() => { +const ProjectPagesPage: NextPageWithLayout = () => { // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - // states - const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false); - // theme - const { resolvedTheme } = useTheme(); - // store hooks - const { - currentUser, - currentUserLoader, - membership: { currentProjectRole }, - } = useUser(); - const { - commandPalette: { toggleCreatePageModal }, - } = useApplication(); - const { setTrackElement } = useEventTracker(); - const { getProjectById } = useProject(); - const { fetchProjectPages, fetchArchivedProjectPages, loader, archivedPageLoader, projectPageIds, archivedPageIds } = - useProjectPages(); - // hooks - const {} = useUserAuth({ user: currentUser, isLoading: currentUserLoader }); - const [windowWidth] = useSize(); - // local storage - const { storedValue: pageTab, setValue: setPageTab } = useLocalStorage("pageTab", "Recent"); - // fetching pages from API - useSWR( - workspaceSlug && projectId ? `ALL_PAGES_LIST_${projectId}` : null, - workspaceSlug && projectId ? () => fetchProjectPages(workspaceSlug.toString(), projectId.toString()) : null - ); - // fetching archived pages from API - useSWR( - workspaceSlug && projectId ? `ALL_ARCHIVED_PAGES_LIST_${projectId}` : null, - workspaceSlug && projectId ? () => fetchArchivedProjectPages(workspaceSlug.toString(), projectId.toString()) : null - ); - - const currentTabValue = (tab: string | null) => { - switch (tab) { - case "Recent": - return 0; - case "All": - return 1; - case "Favorites": - return 2; - case "Private": - return 3; - case "Shared": - return 4; - case "Archived": - return 5; - default: - return 0; - } - }; - - // derived values - const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; - const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "pages", isLightMode); - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; - const project = projectId ? getProjectById(projectId.toString()) : undefined; - const pageTitle = project?.name ? `${project?.name} - Pages` : undefined; - - const MobileTabList = () => ( - -
    - {PAGE_TABS_LIST.map((tab) => ( - - `text-sm outline-none pb-3 ${ - selected ? "border-custom-primary-100 text-custom-primary-100 border-b" : "" - }` - } - > - {tab.title} - - ))} -
    -
    - ); - - if (loader || archivedPageLoader) return ; + const { workspaceSlug, projectId, pageType } = router.query; + if (!workspaceSlug || !projectId) return <>; return ( <> - - {projectPageIds && archivedPageIds && projectPageIds.length + archivedPageIds.length > 0 ? ( - <> - {workspaceSlug && projectId && ( - setCreateUpdatePageModal(false)} - projectId={projectId.toString()} - /> - )} -
    -
    -

    Pages

    -
    - { - switch (i) { - case 0: - return setPageTab("Recent"); - case 1: - return setPageTab("All"); - case 2: - return setPageTab("Favorites"); - case 3: - return setPageTab("Private"); - case 4: - return setPageTab("Shared"); - case 5: - return setPageTab("Archived"); - default: - return setPageTab("All"); - } - }} - > - {windowWidth < 768 ? ( - - ) : ( - -
    - {PAGE_TABS_LIST.map((tab) => ( - - `rounded-full border px-5 py-1.5 text-sm outline-none ${ - selected - ? "border-custom-primary bg-custom-primary text-white" - : "border-custom-border-200 bg-custom-background-100 hover:bg-custom-background-90" - }` - } - > - {tab.title} - - ))} -
    -
    - )} - - - - - - - - - - - - - - - - - - - - - -
    -
    - - ) : ( - { - setTrackElement("Pages empty state"); - toggleCreatePageModal(true); - }, - }} - comicBox={{ - title: PAGE_EMPTY_STATE_DETAILS["pages"].comicBox.title, - description: PAGE_EMPTY_STATE_DETAILS["pages"].comicBox.description, - }} - size="lg" - disabled={!isEditingAllowed} - /> - )} + +
    Pages Init
    +
    ); -}); +}; ProjectPagesPage.getLayout = function getLayout(page: ReactElement) { return ( diff --git a/web/services/page.service.ts b/web/services/page.service.ts index 3f0090b573e..4487c149c1f 100644 --- a/web/services/page.service.ts +++ b/web/services/page.service.ts @@ -2,55 +2,14 @@ import { API_BASE_URL } from "helpers/common.helper"; // services import { APIService } from "services/api.service"; // types -import { IPage, IPageBlock, TIssue } from "@plane/types"; +import { TPage } from "@plane/types"; export class PageService extends APIService { constructor() { super(API_BASE_URL); } - async createPage(workspaceSlug: string, projectId: string, data: Partial): Promise { - return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/`, data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async patchPage(workspaceSlug: string, projectId: string, pageId: string, data: Partial): Promise { - return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, data) - .then((response) => response?.data) - .catch((error) => { - console.error("error", error?.response?.data); - throw error?.response?.data; - }); - } - - async deletePage(workspaceSlug: string, projectId: string, pageId: string): Promise { - return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async addPageToFavorites(workspaceSlug: string, projectId: string, pageId: string): Promise { - return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-pages/`, { page: pageId }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async removePageFromFavorites(workspaceSlug: string, projectId: string, pageId: string) { - return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-pages/${pageId}`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async getProjectPages(workspaceSlug: string, projectId: string): Promise { + async fetchAll(workspaceSlug: string, projectId: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/`) .then((response) => response?.data) .catch((error) => { @@ -58,134 +17,87 @@ export class PageService extends APIService { }); } - async getPagesWithParams( - workspaceSlug: string, - projectId: string, - pageType: "all" | "favorite" | "private" | "shared" - ): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/`, { - params: { - page_view: pageType, - }, - }) + async fetchById(workspaceSlug: string, projectId: string, pageId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async getPageDetails(workspaceSlug: string, projectId: string, pageId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`) + async create(workspaceSlug: string, projectId: string, data: Partial): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/`, data) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async createPageBlock( - workspaceSlug: string, - projectId: string, - pageId: string, - data: Partial - ): Promise { - return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/page-blocks/`, data) + async update(workspaceSlug: string, projectId: string, pageId: String, data: Partial): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, data) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async getPageBlock( - workspaceSlug: string, - projectId: string, - pageId: string, - pageBlockId: string - ): Promise { - return this.get( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/page-blocks/${pageBlockId}/` - ) + async remove(workspaceSlug: string, projectId: string, pageId: String): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async patchPageBlock( - workspaceSlug: string, - projectId: string, - pageId: string, - pageBlockId: string, - data: Partial - ): Promise { - return this.patch( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/page-blocks/${pageBlockId}/`, - data - ) + async fetchFavorites(workspaceSlug: string, projectId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/favorite-pages/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async deletePageBlock(workspaceSlug: string, projectId: string, pageId: string, pageBlockId: string): Promise { - return this.delete( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/page-blocks/${pageBlockId}/` - ) + async makeFavorite(workspaceSlug: string, projectId: string, pageId: string): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/favorites/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async listPageBlocks(workspaceSlug: string, projectId: string, pageId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/page-blocks/`) + async removeFavorite(workspaceSlug: string, projectId: string, pageId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/favorites/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async convertPageBlockToIssue( - workspaceSlug: string, - projectId: string, - pageId: string, - blockId: string - ): Promise { - return this.post( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/page-blocks/${blockId}/issues/` - ) + async fetchArchived(workspaceSlug: string, projectId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/archived-pages/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - // =============== Archiving & Unarchiving Pages ================= - async archivePage(workspaceSlug: string, projectId: string, pageId: string): Promise { - return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/archive/`) + async makeArchive(workspaceSlug: string, projectId: string, pageId: string): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/archived/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async restorePage(workspaceSlug: string, projectId: string, pageId: string): Promise { - return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/unarchive/`) + async removeArchive(workspaceSlug: string, projectId: string, pageId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/archived/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async getArchivedPages(workspaceSlug: string, projectId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/archived-pages/`) - .then((response) => response?.data) - .catch((error) => { - throw error; - }); - } - // ==================== Pages Locking Services ========================== - async lockPage(workspaceSlug: string, projectId: string, pageId: string): Promise { + async lock(workspaceSlug: string, projectId: string, pageId: string): Promise { return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/lock/`) .then((response) => response?.data) .catch((error) => { @@ -193,8 +105,8 @@ export class PageService extends APIService { }); } - async unlockPage(workspaceSlug: string, projectId: string, pageId: string): Promise { - return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/unlock/`) + async unlock(workspaceSlug: string, projectId: string, pageId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/lock/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/web/store/page.store.ts b/web/store/page.store.ts deleted file mode 100644 index fa5970e49ae..00000000000 --- a/web/store/page.store.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { action, makeObservable, observable, reaction, runInAction } from "mobx"; - -import { IIssueLabel, IPage } from "@plane/types"; -import { PageService } from "services/page.service"; - -import { RootStore } from "./root.store"; - -export interface IPageStore { - // Page Properties - access: number; - archived_at: string | null; - color: string; - created_at: Date; - created_by: string; - description: string; - description_html: string; - description_stripped: string | null; - id: string; - is_favorite: boolean; - label_details: IIssueLabel[]; - is_locked: boolean; - labels: string[]; - name: string; - owned_by: string; - project: string; - updated_at: Date; - updated_by: string; - workspace: string; - - // Actions - makePublic: () => Promise; - makePrivate: () => Promise; - lockPage: () => Promise; - unlockPage: () => Promise; - addToFavorites: () => Promise; - removeFromFavorites: () => Promise; - updateName: (name: string) => Promise; - updateDescription: (description: string) => Promise; - - // Reactions - disposers: Array<() => void>; - - // Helpers - oldName: string; - cleanup: () => void; - isSubmitting: "submitting" | "submitted" | "saved"; - setIsSubmitting: (isSubmitting: "submitting" | "submitted" | "saved") => void; -} - -export class PageStore implements IPageStore { - access = 0; - isSubmitting: "submitting" | "submitted" | "saved" = "saved"; - archived_at: string | null; - color: string; - created_at: Date; - created_by: string; - description: string; - description_html = ""; - description_stripped: string | null; - id: string; - is_favorite = false; - is_locked = true; - labels: string[]; - name = ""; - owned_by: string; - project: string; - updated_at: Date; - updated_by: string; - workspace: string; - oldName = ""; - label_details: IIssueLabel[] = []; - disposers: Array<() => void> = []; - - pageService; - // root store - rootStore; - - constructor(page: IPage, _rootStore: RootStore) { - makeObservable(this, { - name: observable.ref, - description_html: observable.ref, - is_favorite: observable.ref, - is_locked: observable.ref, - isSubmitting: observable.ref, - access: observable.ref, - - makePublic: action, - makePrivate: action, - addToFavorites: action, - removeFromFavorites: action, - updateName: action, - updateDescription: action, - setIsSubmitting: action, - cleanup: action, - }); - this.created_by = page?.created_by || ""; - this.created_at = page?.created_at || new Date(); - this.color = page?.color || ""; - this.archived_at = page?.archived_at || null; - this.name = page?.name || ""; - this.description = page?.description || ""; - this.description_stripped = page?.description_stripped || ""; - this.description_html = page?.description_html || ""; - this.access = page?.access || 0; - this.workspace = page?.workspace || ""; - this.updated_by = page?.updated_by || ""; - this.updated_at = page?.updated_at || new Date(); - this.project = page?.project || ""; - this.owned_by = page?.owned_by || ""; - this.labels = page?.labels || []; - this.label_details = page?.label_details || []; - this.is_locked = page?.is_locked || false; - this.id = page?.id || ""; - this.is_favorite = page?.is_favorite || false; - this.oldName = page?.name || ""; - - this.rootStore = _rootStore; - this.pageService = new PageService(); - - const descriptionDisposer = reaction( - () => this.description_html, - (description_html) => { - //TODO: Fix reaction to only run when the data is changed, not when the page is loaded - const { projectId, workspaceSlug } = this.rootStore.app.router; - if (!projectId || !workspaceSlug) return; - this.isSubmitting = "submitting"; - this.pageService.patchPage(workspaceSlug, projectId, this.id, { description_html }).finally(() => { - runInAction(() => { - this.isSubmitting = "submitted"; - }); - }); - }, - { delay: 3000 } - ); - - const pageTitleDisposer = reaction( - () => this.name, - (name) => { - const { projectId, workspaceSlug } = this.rootStore.app.router; - if (!projectId || !workspaceSlug) return; - this.isSubmitting = "submitting"; - this.pageService - .patchPage(workspaceSlug, projectId, this.id, { name }) - .catch(() => { - runInAction(() => { - this.name = this.oldName; - }); - }) - .finally(() => { - runInAction(() => { - this.isSubmitting = "submitted"; - }); - }); - }, - { delay: 2000 } - ); - - this.disposers.push(descriptionDisposer, pageTitleDisposer); - } - - updateName = action("updateName", async (name: string) => { - const { projectId, workspaceSlug } = this.rootStore.app.router; - if (!projectId || !workspaceSlug) return; - - this.oldName = this.name; - this.name = name; - }); - - updateDescription = action("updateDescription", async (description_html: string) => { - const { projectId, workspaceSlug } = this.rootStore.app.router; - if (!projectId || !workspaceSlug) return; - - this.description_html = description_html; - }); - - cleanup = action("cleanup", () => { - this.disposers.forEach((disposer) => { - disposer(); - }); - }); - - setIsSubmitting = action("setIsSubmitting", (isSubmitting: "submitting" | "submitted" | "saved") => { - this.isSubmitting = isSubmitting; - }); - - lockPage = action("lockPage", async () => { - const { projectId, workspaceSlug } = this.rootStore.app.router; - if (!projectId || !workspaceSlug) return; - - this.is_locked = true; - - await this.pageService.lockPage(workspaceSlug, projectId, this.id).catch(() => { - runInAction(() => { - this.is_locked = false; - }); - }); - }); - - unlockPage = action("unlockPage", async () => { - const { projectId, workspaceSlug } = this.rootStore.app.router; - if (!projectId || !workspaceSlug) return; - - this.is_locked = false; - - await this.pageService.unlockPage(workspaceSlug, projectId, this.id).catch(() => { - runInAction(() => { - this.is_locked = true; - }); - }); - }); - - /** - * Add Page to users favorites list - */ - addToFavorites = action("addToFavorites", async () => { - const { projectId, workspaceSlug } = this.rootStore.app.router; - if (!projectId || !workspaceSlug) return; - - this.is_favorite = true; - - await this.pageService.addPageToFavorites(workspaceSlug, projectId, this.id).catch(() => { - runInAction(() => { - this.is_favorite = false; - }); - }); - }); - - /** - * Remove page from the users favorites list - */ - removeFromFavorites = action("removeFromFavorites", async () => { - const { projectId, workspaceSlug } = this.rootStore.app.router; - if (!projectId || !workspaceSlug) return; - - this.is_favorite = false; - - await this.pageService.removePageFromFavorites(workspaceSlug, projectId, this.id).catch(() => { - runInAction(() => { - this.is_favorite = true; - }); - }); - }); - - /** - * make a page public - * @returns - */ - makePublic = action("makePublic", async () => { - const { projectId, workspaceSlug } = this.rootStore.app.router; - if (!projectId || !workspaceSlug) return; - - this.access = 0; - - this.pageService.patchPage(workspaceSlug, projectId, this.id, { access: 0 }).catch(() => { - runInAction(() => { - this.access = 1; - }); - }); - }); - - /** - * Make a page private - * @returns - */ - makePrivate = action("makePrivate", async () => { - const { projectId, workspaceSlug } = this.rootStore.app.router; - if (!projectId || !workspaceSlug) return; - - this.access = 1; - - this.pageService.patchPage(workspaceSlug, projectId, this.id, { access: 1 }).catch(() => { - runInAction(() => { - this.access = 0; - }); - }); - }); -} diff --git a/web/store/pages/page.store.ts b/web/store/pages/page.store.ts new file mode 100644 index 00000000000..0c78bba5a85 --- /dev/null +++ b/web/store/pages/page.store.ts @@ -0,0 +1,173 @@ +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +// store +import { RootStore } from "../root.store"; +// service +import { PageService } from "services/page.service"; +// types +import { TPage } from "@plane/types"; +// constants +import { EPageAccess } from "constants/page"; + +export type TLoader = "submitting" | "submitted" | "saved" | undefined; + +export interface IPageStore { + // observables + loader: TLoader; + data: TPage; + // computed + isContentEditable: boolean; + // helper actions + updateDescription: (description: string) => void; + // actions + makePublic: () => Promise; + makePrivate: () => Promise; + lock: () => Promise; + unlock: () => Promise; + addToFavorites: () => Promise; + removeFromFavorites: () => Promise; +} + +export class PageStore implements IPageStore { + loader: TLoader = undefined; + data: TPage; + // service + pageService: PageService; + + constructor(private store: RootStore, page: TPage) { + makeObservable(this, { + // observables + loader: observable.ref, + data: observable, + // computed + isContentEditable: computed, + // helper actions + updateDescription: action, + // actions + makePublic: action, + makePrivate: action, + lock: action, + unlock: action, + addToFavorites: action, + removeFromFavorites: action, + }); + + this.data = { + id: page?.id || undefined, + name: page?.name || undefined, + description_html: page?.description_html || undefined, + color: page?.color || undefined, + labels: page?.labels || undefined, + owned_by: page?.owned_by || undefined, + access: page?.access || EPageAccess.PUBLIC, + is_favorite: page?.is_favorite || false, + is_locked: page?.is_locked || false, + archived_at: page?.archived_at || undefined, + workspace: page?.workspace || undefined, + project: page?.project || undefined, + created_by: page?.created_by || undefined, + updated_by: page?.updated_by || undefined, + created_at: page?.created_at || undefined, + updated_at: page?.updated_at || undefined, + }; + + this.pageService = new PageService(); + } + + // computed + get isContentEditable() { + const currentUserId = this.store.user.currentUser?.id; + if (!currentUserId) return false; + + const isOwner = this.data.owned_by === currentUserId; + const isPublic = this.data.access === EPageAccess.PUBLIC; + const isLocked = this.data.is_locked; + + if (isOwner) return true; + if (!isOwner && isPublic) return true; + if (!isOwner && !isLocked) return true; + + return false; + } + + updateDescription = async () => {}; + + makePublic = async () => { + const { workspaceSlug, projectId } = this.store.app.router; + if (!workspaceSlug || !projectId || !this.data.id) return undefined; + + const _access = this.data.access; + runInAction(() => (this.data.access = EPageAccess.PUBLIC)); + + await this.pageService + .update(workspaceSlug, projectId, this.data.id, { + access: EPageAccess.PUBLIC, + }) + .catch(() => { + runInAction(() => (this.data.access = _access)); + }); + }; + + makePrivate = async () => { + const { workspaceSlug, projectId } = this.store.app.router; + if (!workspaceSlug || !projectId || !this.data.id) return undefined; + + const _access = this.data.access; + runInAction(() => (this.data.access = EPageAccess.PRIVATE)); + + await this.pageService + .update(workspaceSlug, projectId, this.data.id, { + access: EPageAccess.PRIVATE, + }) + .catch(() => { + runInAction(() => (this.data.access = _access)); + }); + }; + + lock = async () => { + const { workspaceSlug, projectId } = this.store.app.router; + if (!workspaceSlug || !projectId || !this.data.id) return undefined; + + const _is_locked = this.data.is_locked; + runInAction(() => (this.data.is_locked = true)); + + await this.pageService.lock(workspaceSlug, projectId, this.data.id).catch(() => { + runInAction(() => (this.data.is_locked = _is_locked)); + }); + }; + + unlock = async () => { + const { workspaceSlug, projectId } = this.store.app.router; + if (!workspaceSlug || !projectId || !this.data.id) return undefined; + + const _is_locked = this.data.is_locked; + runInAction(() => (this.data.is_locked = false)); + + await this.pageService.unlock(workspaceSlug, projectId, this.data.id).catch(() => { + runInAction(() => (this.data.is_locked = _is_locked)); + }); + }; + + addToFavorites = async () => { + const { workspaceSlug, projectId } = this.store.app.router; + if (!workspaceSlug || !projectId || !this.data.id) return undefined; + + const _is_favorite = this.data.is_favorite; + runInAction(() => (this.data.is_favorite = true)); + + await this.pageService.makeFavorite(workspaceSlug, projectId, this.data.id).catch(() => { + runInAction(() => (this.data.is_favorite = _is_favorite)); + }); + }; + + removeFromFavorites = async () => { + const { workspaceSlug, projectId } = this.store.app.router; + if (!workspaceSlug || !projectId || !this.data.id) return undefined; + + const _is_favorite = this.data.is_favorite; + runInAction(() => (this.data.is_favorite = false)); + + await this.pageService.removeFavorite(workspaceSlug, projectId, this.data.id).catch(() => { + runInAction(() => (this.data.is_favorite = _is_favorite)); + }); + }; +} diff --git a/web/store/pages/project-page.store.ts b/web/store/pages/project-page.store.ts new file mode 100644 index 00000000000..828a9d747db --- /dev/null +++ b/web/store/pages/project-page.store.ts @@ -0,0 +1,199 @@ +import { makeObservable, observable, runInAction, action, computed } from "mobx"; +import { computedFn } from "mobx-utils"; +import set from "lodash/set"; +import unset from "lodash/unset"; +// store +import { RootStore } from "../root.store"; +import { IPageStore, PageStore } from "store/pages/page.store"; +// services +import { PageService } from "services/page.service"; +// types +import { TPage, TPageFilters } from "@plane/types"; + +type TLoader = "init-loader" | "mutation-loader" | undefined; + +type TError = { title: string; description: string }; + +export interface IProjectPageStore { + // observables + loader: TLoader; + data: Record>; // projectId => pageId => PageStore + error: TError | undefined; + filters: TPageFilters; + // computed + pageIds: string[] | undefined; + // helper actions + pageById: (pageId: string) => IPageStore | undefined; + updateFilters: (filterKey: T, filterValue: TPageFilters[T]) => void; + // actions + fetch: (_loader?: TLoader) => Promise; + fetchById: (pageId: string) => Promise; + create: (pageData: Partial) => Promise; + delete: (pageId: string) => Promise; +} + +export class ProjectPageStore implements IProjectPageStore { + // observables + loader: TLoader = "init-loader"; + data: Record> = {}; // projectId => pageId => PageStore + error: TError | undefined = undefined; + filters: TPageFilters = { + search: "", + sortKey: "name", + sortBy: "asc", + }; + // service + service: PageService; + + constructor(private store: RootStore) { + makeObservable(this, { + // observables + loader: observable.ref, + data: observable, + error: observable, + filters: observable, + // computed + pageIds: computed, + // helper actions + updateFilters: action, + // actions + fetch: action, + fetchById: action, + create: action, + delete: action, + }); + + this.service = new PageService(); + } + + get pageIds() { + const { projectId } = this.store.app.router; + if (!projectId) return undefined; + + // TODO: filter the pages based on the filter + + const pages = Object.keys(this.data?.[projectId]) || undefined; + if (!pages) return undefined; + + return pages; + } + + // helper actions + pageById = computedFn((pageId: string) => { + const { projectId } = this.store.app.router; + if (!projectId) return undefined; + + return this.data?.[projectId]?.[pageId] || undefined; + }); + + updateFilters = (filterKey: T, filterValue: TPageFilters[T]) => { + runInAction(() => { + set(this.filters, [filterKey], filterValue); + }); + }; + + // actions + fetch = async () => { + try { + const { workspaceSlug, projectId } = this.store.app.router; + if (!workspaceSlug || !projectId) return undefined; + + const currentPageIds = this.pageIds; + runInAction(() => { + this.loader = currentPageIds ? `mutation-loader` : `init-loader`; + this.error = undefined; + }); + + const _pages = await this.service.fetchAll(workspaceSlug, projectId); + runInAction(() => { + for (const page of _pages) if (page?.id) set(this.data, [projectId, page.id], new PageStore(this.store, page)); + this.loader = undefined; + }); + + return _pages; + } catch { + runInAction(() => { + this.loader = undefined; + this.error = { + title: "Failed", + description: "Failed to fetch the pages, Please try again later.", + }; + }); + } + }; + + fetchById = async (pageId: string) => { + try { + const { workspaceSlug, projectId } = this.store.app.router; + if (!workspaceSlug || !projectId || !pageId) return undefined; + + const currentPageId = this.pageById(pageId); + runInAction(() => { + this.loader = currentPageId ? `mutation-loader` : `init-loader`; + this.error = undefined; + }); + + const _page = await this.service.fetchById(workspaceSlug, projectId, pageId); + runInAction(() => { + if (_page?.id) set(this.data, [projectId, _page.id], new PageStore(this.store, _page)); + this.loader = undefined; + }); + + return _page; + } catch { + runInAction(() => { + this.loader = undefined; + this.error = { + title: "Failed", + description: "Failed to fetch the page, Please try again later.", + }; + }); + } + }; + + create = async (pageData: Partial) => { + try { + const { workspaceSlug, projectId } = this.store.app.router; + if (!workspaceSlug || !projectId) return undefined; + + runInAction(() => { + this.loader = `init-loader`; + this.error = undefined; + }); + + const _page = await this.service.create(workspaceSlug, projectId, pageData); + runInAction(() => { + if (_page?.id) set(this.data, [projectId, _page.id], new PageStore(this.store, _page)); + this.loader = undefined; + }); + + return _page; + } catch { + runInAction(() => { + this.loader = undefined; + this.error = { + title: "Failed", + description: "Failed to create a page, Please try again later.", + }; + }); + } + }; + + delete = async (pageId: string) => { + try { + const { workspaceSlug, projectId } = this.store.app.router; + if (!workspaceSlug || !projectId || !pageId) return undefined; + + await this.service.remove(workspaceSlug, projectId, pageId); + runInAction(() => unset(this.data, [projectId, pageId])); + } catch { + runInAction(() => { + this.loader = undefined; + this.error = { + title: "Failed", + description: "Failed to delete a page, Please try again later.", + }; + }); + } + }; +} diff --git a/web/store/project-page.store.ts b/web/store/project-page.store.ts deleted file mode 100644 index 84a1e6700ab..00000000000 --- a/web/store/project-page.store.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { makeObservable, observable, runInAction, action, computed } from "mobx"; -import { set } from "lodash"; -// services -import { PageService } from "services/page.service"; -// store -import { PageStore, IPageStore } from "store/page.store"; -// types -import { IPage, IRecentPages } from "@plane/types"; -import { RootStore } from "./root.store"; -import { isThisWeek, isToday, isYesterday } from "date-fns"; - -export interface IProjectPageStore { - loader: boolean; - archivedPageLoader: boolean; - projectPageMap: Record>; - projectArchivedPageMap: Record>; - - projectPageIds: string[] | undefined; - archivedPageIds: string[] | undefined; - favoriteProjectPageIds: string[] | undefined; - privateProjectPageIds: string[] | undefined; - publicProjectPageIds: string[] | undefined; - recentProjectPages: IRecentPages | undefined; - - // page - getPageDetails: (projectId: string, pageId: string) => IPageStore | undefined; - - // fetch actions - fetchProjectPages: (workspaceSlug: string, projectId: string) => Promise; - fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise; - // crud actions - createPage: (workspaceSlug: string, projectId: string, data: Partial) => Promise; - deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; -} - -export class ProjectPageStore implements IProjectPageStore { - loader: boolean = false; - archivedPageLoader: boolean = false; - projectPageMap: Record> = {}; // { projectId: [page1, page2] } - projectArchivedPageMap: Record> = {}; // { projectId: [page1, page2] } - - // root store - rootStore; - - pageService; - constructor(_rootStore: RootStore) { - makeObservable(this, { - loader: observable.ref, - archivedPageLoader: observable.ref, - projectPageMap: observable, - projectArchivedPageMap: observable, - - projectPageIds: computed, - archivedPageIds: computed, - favoriteProjectPageIds: computed, - privateProjectPageIds: computed, - publicProjectPageIds: computed, - recentProjectPages: computed, - - // fetch actions - fetchProjectPages: action, - fetchArchivedProjectPages: action, - getPageDetails: action, - // crud actions - createPage: action, - deletePage: action, - }); - this.rootStore = _rootStore; - - this.pageService = new PageService(); - } - - getPageDetails = (projectId: string, pageId: string) => this.projectPageMap[projectId][pageId]; - - get projectPageIds() { - const projectId = this.rootStore.app.router.projectId; - if (!projectId || !this.projectPageMap?.[projectId]) return []; - - const allProjectIds = Object.keys(this.projectPageMap[projectId]); - return allProjectIds.sort((a, b) => { - const dateA = new Date(this.projectPageMap[projectId][a].created_at).getTime(); - const dateB = new Date(this.projectPageMap[projectId][b].created_at).getTime(); - return dateB - dateA; - }); - } - - get archivedPageIds() { - const projectId = this.rootStore.app.router.projectId; - if (!projectId || !this.projectArchivedPageMap[projectId]) return []; - const archivedPages = Object.keys(this.projectArchivedPageMap[projectId]); - return archivedPages.sort((a, b) => { - const dateA = new Date(this.projectArchivedPageMap[projectId][a].created_at).getTime(); - const dateB = new Date(this.projectArchivedPageMap[projectId][b].created_at).getTime(); - return dateB - dateA; - }); - } - - get favoriteProjectPageIds() { - const projectId = this.rootStore.app.router.projectId; - if (!this.projectPageIds || !projectId) return []; - - const favouritePages: string[] = this.projectPageIds.filter( - (page) => this.projectPageMap[projectId][page].is_favorite - ); - return favouritePages; - } - - get privateProjectPageIds() { - const projectId = this.rootStore.app.router.projectId; - if (!this.projectPageIds || !projectId) return []; - - const privatePages: string[] = this.projectPageIds.filter( - (page) => this.projectPageMap[projectId][page].access === 1 - ); - return privatePages; - } - - get publicProjectPageIds() { - const projectId = this.rootStore.app.router.projectId; - const userId = this.rootStore.user.currentUser?.id; - if (!this.projectPageIds || !projectId || !userId) return []; - - const publicPages: string[] = this.projectPageIds.filter( - (page) => - this.projectPageMap[projectId][page].access === 0 && this.projectPageMap[projectId][page].owned_by === userId - ); - return publicPages; - } - - get recentProjectPages() { - const projectId = this.rootStore.app.router.projectId; - if (!this.projectPageIds || !projectId) return; - - const today: string[] = this.projectPageIds.filter((page) => - isToday(new Date(this.projectPageMap[projectId][page].updated_at)) - ); - - const yesterday: string[] = this.projectPageIds.filter((page) => - isYesterday(new Date(this.projectPageMap[projectId][page].updated_at)) - ); - - const this_week: string[] = this.projectPageIds.filter((page) => { - const pageUpdatedAt = this.projectPageMap[projectId][page].updated_at; - return ( - isThisWeek(new Date(pageUpdatedAt)) && - !isToday(new Date(pageUpdatedAt)) && - !isYesterday(new Date(pageUpdatedAt)) - ); - }); - - const older: string[] = this.projectPageIds.filter((page) => { - const pageUpdatedAt = this.projectPageMap[projectId][page].updated_at; - return !isThisWeek(new Date(pageUpdatedAt)) && !isYesterday(new Date(pageUpdatedAt)); - }); - - return { today, yesterday, this_week, older }; - } - - /** - * Fetching all the pages for a specific project - * @param workspaceSlug - * @param projectId - */ - fetchProjectPages = async (workspaceSlug: string, projectId: string) => { - try { - this.loader = true; - await this.pageService.getProjectPages(workspaceSlug, projectId).then((response) => { - runInAction(() => { - for (const page of response) { - set(this.projectPageMap, [projectId, page.id], new PageStore(page, this.rootStore)); - } - this.loader = false; - }); - return response; - }); - } catch (e) { - this.loader = false; - - throw e; - } - }; - - /** - * fetches all archived pages for a project. - * @param workspaceSlug - * @param projectId - * @returns Promise - */ - fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) => { - try { - this.archivedPageLoader = true; - await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => { - runInAction(() => { - for (const page of response) { - set(this.projectArchivedPageMap, [projectId, page.id], new PageStore(page, this.rootStore)); - } - this.archivedPageLoader = false; - }); - return response; - }); - } catch (e) { - this.archivedPageLoader = false; - throw e; - } - }; - - /** - * Creates a new page using the api and updated the local state in store - * @param workspaceSlug - * @param projectId - * @param data - */ - createPage = async (workspaceSlug: string, projectId: string, data: Partial) => { - const response = await this.pageService.createPage(workspaceSlug, projectId, data); - runInAction(() => { - set(this.projectPageMap, [projectId, response.id], new PageStore(response, this.rootStore)); - }); - return response; - }; - - /** - * delete a page using the api and updates the local state in store - * @param workspaceSlug - * @param projectId - * @param pageId - * @returns - */ - deletePage = async (workspaceSlug: string, projectId: string, pageId: string) => { - const response = await this.pageService.deletePage(workspaceSlug, projectId, pageId); - runInAction(() => { - delete this.projectArchivedPageMap[projectId][pageId]; - }); - return response; - }; - - /** - * Mark a page archived - * @param workspaceSlug - * @param projectId - * @param pageId - */ - archivePage = async (workspaceSlug: string, projectId: string, pageId: string) => { - runInAction(() => { - set(this.projectArchivedPageMap, [projectId, pageId], this.projectPageMap[projectId][pageId]); - set(this.projectArchivedPageMap[projectId][pageId], "archived_at", new Date().toISOString()); - delete this.projectPageMap[projectId][pageId]; - }); - const response = await this.pageService.archivePage(workspaceSlug, projectId, pageId).catch(() => { - runInAction(() => { - set(this.projectPageMap, [projectId, pageId], this.projectArchivedPageMap[projectId][pageId]); - set(this.projectPageMap[projectId][pageId], "archived_at", null); - delete this.projectArchivedPageMap[projectId][pageId]; - }); - }); - return response; - }; - - /** - * Restore a page from archived pages to pages - * @param workspaceSlug - * @param projectId - * @param pageId - */ - restorePage = async (workspaceSlug: string, projectId: string, pageId: string) => { - const pageArchivedAt = this.projectArchivedPageMap[projectId][pageId].archived_at; - runInAction(() => { - set(this.projectPageMap, [projectId, pageId], this.projectArchivedPageMap[projectId][pageId]); - set(this.projectPageMap[projectId][pageId], "archived_at", null); - delete this.projectArchivedPageMap[projectId][pageId]; - }); - await this.pageService.restorePage(workspaceSlug, projectId, pageId).catch(() => { - runInAction(() => { - set(this.projectArchivedPageMap, [projectId, pageId], this.projectPageMap[projectId][pageId]); - set(this.projectArchivedPageMap[projectId][pageId], "archived_at", pageArchivedAt); - delete this.projectPageMap[projectId][pageId]; - }); - }); - }; -} diff --git a/web/store/root.store.ts b/web/store/root.store.ts index 3e07332499b..a5e2601514d 100644 --- a/web/store/root.store.ts +++ b/web/store/root.store.ts @@ -16,7 +16,7 @@ import { IEstimateStore, EstimateStore } from "./estimate.store"; import { GlobalViewStore, IGlobalViewStore } from "./global-view.store"; import { IMentionStore, MentionStore } from "./mention.store"; import { DashboardStore, IDashboardStore } from "./dashboard.store"; -import { IProjectPageStore, ProjectPageStore } from "./project-page.store"; +import { IProjectPageStore, ProjectPageStore } from "./pages/project-page.store"; import { ILabelStore, LabelStore } from "./label.store"; enableStaticRendering(typeof window === "undefined"); @@ -39,7 +39,7 @@ export class RootStore { estimate: IEstimateStore; mention: IMentionStore; dashboard: IDashboardStore; - projectPages: IProjectPageStore; + projectPage: IProjectPageStore; constructor() { this.app = new AppRootStore(this); @@ -59,8 +59,8 @@ export class RootStore { this.label = new LabelStore(this); this.estimate = new EstimateStore(this); this.mention = new MentionStore(this); - this.projectPages = new ProjectPageStore(this); this.dashboard = new DashboardStore(this); + this.projectPage = new ProjectPageStore(this); } resetOnSignout() { @@ -78,7 +78,7 @@ export class RootStore { this.label = new LabelStore(this); this.estimate = new EstimateStore(this); this.mention = new MentionStore(this); - this.projectPages = new ProjectPageStore(this); this.dashboard = new DashboardStore(this); + this.projectPage = new ProjectPageStore(this); } } From a3033203af926b73f8b72f45028248c22e30315e Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Wed, 21 Feb 2024 18:26:49 +0530 Subject: [PATCH 008/179] mentions loading state part done --- .../core/src/types/mention-suggestion.ts | 6 + .../editor/core/src/ui/extensions/index.tsx | 142 +++++++++--------- .../editor/core/src/ui/mentions/custom.tsx | 17 +-- .../editor/core/src/ui/mentions/index.tsx | 112 +++++++++++++- .../core/src/ui/mentions/mention-list.tsx | 2 + .../src/ui/mentions/mention-node-view.tsx | 6 +- .../editor/core/src/ui/mentions/suggestion.ts | 133 ++++++++-------- .../editor/document-editor/src/ui/index.tsx | 16 +- .../document-editor/src/ui/readonly/index.tsx | 4 + .../comments/comment-create.tsx | 1 - web/hooks/store/use-mention.ts | 59 ++++++-- .../projects/[projectId]/pages/[pageId].tsx | 21 ++- web/store/mention.store.ts | 5 +- yarn.lock | 18 ++- 14 files changed, 373 insertions(+), 169 deletions(-) diff --git a/packages/editor/core/src/types/mention-suggestion.ts b/packages/editor/core/src/types/mention-suggestion.ts index dcaa3148d63..9e0b11137c8 100644 --- a/packages/editor/core/src/types/mention-suggestion.ts +++ b/packages/editor/core/src/types/mention-suggestion.ts @@ -1,3 +1,4 @@ +import { Editor, Range } from "@tiptap/react"; export type IMentionSuggestion = { id: string; type: string; @@ -7,4 +8,9 @@ export type IMentionSuggestion = { redirect_uri: string; }; +export type CommandProps = { + editor: Editor; + range: Range; +}; + export type IMentionHighlight = string; diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 5bfba3b0f55..e413aa9c16e 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -35,79 +35,81 @@ export const CoreEditorExtensions = ( deleteFile: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => any -) => [ - StarterKit.configure({ - bulletList: { +) => { + return [ + StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: "list-disc list-outside leading-3 -mt-2", + }, + }, + orderedList: { + HTMLAttributes: { + class: "list-decimal list-outside leading-3 -mt-2", + }, + }, + listItem: { + HTMLAttributes: { + class: "leading-normal -mb-2", + }, + }, + code: false, + codeBlock: false, + horizontalRule: { + HTMLAttributes: { class: "mt-4 mb-4" }, + }, + blockquote: false, + dropcursor: { + color: "rgba(var(--color-text-100))", + width: 2, + }, + }), + CustomQuoteExtension.configure({ + HTMLAttributes: { className: "border-l-4 border-custom-border-300" }, + }), + CustomKeymap, + ListKeymap, + CustomLinkExtension.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, + protocols: ["http", "https"], + validate: (url: string) => isValidHttpUrl(url), + HTMLAttributes: { + class: + "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", + }, + }), + ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({ HTMLAttributes: { - class: "list-disc list-outside leading-3 -mt-2", + class: "rounded-lg border border-custom-border-300", }, - }, - orderedList: { + }), + TiptapUnderline, + TextStyle, + Color, + TaskList.configure({ HTMLAttributes: { - class: "list-decimal list-outside leading-3 -mt-2", + class: "not-prose pl-2", }, - }, - listItem: { + }), + TaskItem.configure({ HTMLAttributes: { - class: "leading-normal -mb-2", + class: "flex items-start my-4", }, - }, - code: false, - codeBlock: false, - horizontalRule: { - HTMLAttributes: { class: "mt-4 mb-4" }, - }, - blockquote: false, - dropcursor: { - color: "rgba(var(--color-text-100))", - width: 2, - }, - }), - CustomQuoteExtension.configure({ - HTMLAttributes: { className: "border-l-4 border-custom-border-300" }, - }), - CustomKeymap, - ListKeymap, - CustomLinkExtension.configure({ - openOnClick: true, - autolink: true, - linkOnPaste: true, - protocols: ["http", "https"], - validate: (url: string) => isValidHttpUrl(url), - HTMLAttributes: { - class: - "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", - }, - }), - ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({ - HTMLAttributes: { - class: "rounded-lg border border-custom-border-300", - }, - }), - TiptapUnderline, - TextStyle, - Color, - TaskList.configure({ - HTMLAttributes: { - class: "not-prose pl-2", - }, - }), - TaskItem.configure({ - HTMLAttributes: { - class: "flex items-start my-4", - }, - nested: true, - }), - CustomCodeBlockExtension, - CustomCodeInlineExtension, - Markdown.configure({ - html: true, - transformCopiedText: true, - transformPastedText: true, - }), - Table, - TableHeader, - TableCell, - TableRow, - Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false), -]; + nested: true, + }), + CustomCodeBlockExtension, + CustomCodeInlineExtension, + Markdown.configure({ + html: true, + transformCopiedText: true, + transformPastedText: true, + }), + Table, + TableHeader, + TableCell, + TableRow, + Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false), + ]; +}; diff --git a/packages/editor/core/src/ui/mentions/custom.tsx b/packages/editor/core/src/ui/mentions/custom.tsx index e723ca0d7f9..d41b064200a 100644 --- a/packages/editor/core/src/ui/mentions/custom.tsx +++ b/packages/editor/core/src/ui/mentions/custom.tsx @@ -32,6 +32,12 @@ export const CustomMention = Mention.extend({ redirect_uri: { default: "/", }, + entity_identifier: { + default: null, + }, + entity_name: { + default: null, + }, }; }, @@ -43,17 +49,6 @@ export const CustomMention = Mention.extend({ return [ { tag: "mention-component", - getAttrs: (node: string | HTMLElement) => { - if (typeof node === "string") { - return null; - } - return { - id: node.getAttribute("data-mention-id") || "", - target: node.getAttribute("data-mention-target") || "", - label: node.innerText.slice(1) || "", - redirect_uri: node.getAttribute("redirect_uri"), - }; - }, }, ]; }, diff --git a/packages/editor/core/src/ui/mentions/index.tsx b/packages/editor/core/src/ui/mentions/index.tsx index f6d3e5b1fc5..15637e516fd 100644 --- a/packages/editor/core/src/ui/mentions/index.tsx +++ b/packages/editor/core/src/ui/mentions/index.tsx @@ -1,15 +1,115 @@ -// @ts-nocheck - -import { Suggestion } from "src/ui/mentions/suggestion"; import { CustomMention } from "src/ui/mentions/custom"; -import { IMentionHighlight } from "src/types/mention-suggestion"; +import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion"; +import { ReactRenderer } from "@tiptap/react"; +import { Editor } from "@tiptap/core"; +import tippy from "tippy.js"; + +import { v4 as uuidv4 } from "uuid"; +import { MentionList } from "src/ui/mentions/mention-list"; + +export const getSuggestionItems = + (getSuggestions: () => Promise) => + async ({ query }: { query: string }) => { + console.log("yaa"); + const suggestions = await getSuggestions(); + const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => { + const transactionId = uuidv4(); + return { + ...suggestion, + id: transactionId, + }; + }); + const filteredSuggestions = mappedSuggestions + .filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase())) + .slice(0, 5); -export const Mentions = (mentionSuggestions: IMentionSuggestion[], mentionHighlights: IMentionHighlight[], readonly) => + console.log("yoo", filteredSuggestions); + return filteredSuggestions; + }; + +export const Mentions = ( + mentionSuggestions: () => Promise, + mentionHighlights: IMentionHighlight[], + readonly: boolean +) => CustomMention.configure({ HTMLAttributes: { class: "mention", }, readonly: readonly, mentionHighlights: mentionHighlights, - suggestion: Suggestion(mentionSuggestions), + suggestion: { + items: ({ query }) => { + const suggestions = mentionSuggestions(); + const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => { + const transactionId = uuidv4(); + return { + ...suggestion, + id: transactionId, + }; + }); + const filteredSuggestions = mappedSuggestions + .filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase())) + .slice(0, 5); + + console.log("yoo", filteredSuggestions); + return filteredSuggestions; + }, + // @ts-ignore + render: () => { + let reactRenderer: ReactRenderer | null = null; + let popup: any | null = null; + + return { + onStart: (props: { editor: Editor; clientRect: DOMRect }) => { + props.editor.storage.mentionsOpen = true; + reactRenderer = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }); + // @ts-ignore + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => document.querySelector("#editor-container"), + content: reactRenderer.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + + onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { + reactRenderer?.updateProps(props); + + popup && + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + onKeyDown: (props: { event: KeyboardEvent }) => { + if (props.event.key === "Escape") { + popup?.[0].hide(); + + return true; + } + + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; + + if (navigationKeys.includes(props.event.key)) { + // @ts-ignore + reactRenderer?.ref?.onKeyDown(props); + event?.stopPropagation(); + return true; + } + return false; + }, + onExit: (props: { editor: Editor; event: KeyboardEvent }) => { + props.editor.storage.mentionsOpen = false; + popup?.[0].destroy(); + reactRenderer?.destroy(); + }, + }; + }, + }, }); diff --git a/packages/editor/core/src/ui/mentions/mention-list.tsx b/packages/editor/core/src/ui/mentions/mention-list.tsx index afbf1097021..b7ec14ecc8e 100644 --- a/packages/editor/core/src/ui/mentions/mention-list.tsx +++ b/packages/editor/core/src/ui/mentions/mention-list.tsx @@ -19,6 +19,8 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => { props.command({ id: item.id, label: item.title, + entity_identifier: item.entity_identifier, + entity_name: item.entity_name, target: "users", redirect_uri: item.redirect_uri, }); diff --git a/packages/editor/core/src/ui/mentions/mention-node-view.tsx b/packages/editor/core/src/ui/mentions/mention-node-view.tsx index 1c3755f6c66..b58ad4eb242 100644 --- a/packages/editor/core/src/ui/mentions/mention-node-view.tsx +++ b/packages/editor/core/src/ui/mentions/mention-node-view.tsx @@ -20,13 +20,13 @@ export const MentionNodeView = (props) => { @{props.node.attrs.label} diff --git a/packages/editor/core/src/ui/mentions/suggestion.ts b/packages/editor/core/src/ui/mentions/suggestion.ts index 40e75a1e381..5b66a743380 100644 --- a/packages/editor/core/src/ui/mentions/suggestion.ts +++ b/packages/editor/core/src/ui/mentions/suggestion.ts @@ -2,65 +2,80 @@ import { ReactRenderer } from "@tiptap/react"; import { Editor } from "@tiptap/core"; import tippy from "tippy.js"; -import { MentionList } from "src/ui/mentions/mention-list"; +import { v4 as uuidv4 } from "uuid"; import { IMentionSuggestion } from "src/types/mention-suggestion"; +import { MentionList } from "src/ui/mentions/mention-list"; -export const Suggestion = (suggestions: IMentionSuggestion[]) => ({ - items: ({ query }: { query: string }) => - suggestions.filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5), - render: () => { - let reactRenderer: ReactRenderer | null = null; - let popup: any | null = null; - - return { - onStart: (props: { editor: Editor; clientRect: DOMRect }) => { - props.editor.storage.mentionsOpen = true; - reactRenderer = new ReactRenderer(MentionList, { - props, - editor: props.editor, - }); - // @ts-ignore - popup = tippy("body", { - getReferenceClientRect: props.clientRect, - appendTo: () => document.querySelector("#editor-container"), - content: reactRenderer.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - }); - }, - - onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { - reactRenderer?.updateProps(props); - - popup && - popup[0].setProps({ - getReferenceClientRect: props.clientRect, - }); - }, - onKeyDown: (props: { event: KeyboardEvent }) => { - if (props.event.key === "Escape") { - popup?.[0].hide(); - - return true; - } - - const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; +export const getSuggestionItems = (suggestions: IMentionSuggestion[]) => { + return ({ query }: { query: string }) => { + const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => { + const transactionId = uuidv4(); + return { + ...suggestion, + id: transactionId, + }; + }); + return mappedSuggestions + .filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase())) + .slice(0, 5); + }; +}; - if (navigationKeys.includes(props.event.key)) { - // @ts-ignore - reactRenderer?.ref?.onKeyDown(props); - event?.stopPropagation(); - return true; - } - return false; - }, - onExit: (props: { editor: Editor; event: KeyboardEvent }) => { - props.editor.storage.mentionsOpen = false; - popup?.[0].destroy(); - reactRenderer?.destroy(); - }, - }; - }, -}); +// export const Suggestion = (suggestions: IMentionSuggestion[]) => ({ +// items: getSuggestionItems(suggestions), +// render: () => { +// let reactRenderer: ReactRenderer | null = null; +// let popup: any | null = null; +// +// return { +// onStart: (props: { editor: Editor; clientRect: DOMRect }) => { +// props.editor.storage.mentionsOpen = true; +// reactRenderer = new ReactRenderer(MentionList, { +// props, +// editor: props.editor, +// }); +// // @ts-ignore +// popup = tippy("body", { +// getReferenceClientRect: props.clientRect, +// appendTo: () => document.querySelector("#editor-container"), +// content: reactRenderer.element, +// showOnCreate: true, +// interactive: true, +// trigger: "manual", +// placement: "bottom-start", +// }); +// }, +// +// onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { +// reactRenderer?.updateProps(props); +// +// popup && +// popup[0].setProps({ +// getReferenceClientRect: props.clientRect, +// }); +// }, +// onKeyDown: (props: { event: KeyboardEvent }) => { +// if (props.event.key === "Escape") { +// popup?.[0].hide(); +// +// return true; +// } +// +// const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; +// +// if (navigationKeys.includes(props.event.key)) { +// // @ts-ignore +// reactRenderer?.ref?.onKeyDown(props); +// event?.stopPropagation(); +// return true; +// } +// return false; +// }, +// onExit: (props: { editor: Editor; event: KeyboardEvent }) => { +// props.editor.storage.mentionsOpen = false; +// popup?.[0].destroy(); +// reactRenderer?.destroy(); +// }, +// }; +// }, +// }); diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index 2491e04c7f4..c8fc8b1d9a3 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -1,6 +1,13 @@ "use client"; import React, { useState } from "react"; -import { UploadImage, DeleteImage, RestoreImage, getEditorClassNames, useEditor } from "@plane/editor-core"; +import { + UploadImage, + DeleteImage, + RestoreImage, + getEditorClassNames, + useEditor, + IMentionSuggestion, +} from "@plane/editor-core"; import { DocumentEditorExtensions } from "src/ui/extensions"; import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "src/types/menu-actions"; import { EditorHeader } from "src/ui/components/editor-header"; @@ -43,6 +50,9 @@ interface IDocumentEditor { debouncedUpdatesEnabled?: boolean; isSubmitting: "submitting" | "submitted" | "saved"; + mentionHighlights?: string[]; + mentionSuggestions?: IMentionSuggestion[]; + // embed configuration duplicationConfig?: IDuplicationConfig; pageLockConfig?: IPageLockConfig; @@ -66,6 +76,8 @@ const DocumentEditor = ({ editorContentCustomClassNames, value, uploadFile, + mentionHighlights, + mentionSuggestions, deleteFile, restoreFile, isSubmitting, @@ -109,6 +121,8 @@ const DocumentEditor = ({ cancelUploadImage, rerenderOnPropsChange, forwardedRef, + mentionSuggestions, + mentionHighlights, extensions: DocumentEditorExtensions(uploadFile, setHideDragHandleFunction, setIsSubmitting), }); diff --git a/packages/editor/document-editor/src/ui/readonly/index.tsx b/packages/editor/document-editor/src/ui/readonly/index.tsx index 7c49ffa8394..58a9ec70a3c 100644 --- a/packages/editor/document-editor/src/ui/readonly/index.tsx +++ b/packages/editor/document-editor/src/ui/readonly/index.tsx @@ -22,6 +22,8 @@ interface IDocumentReadOnlyEditor { documentDetails: DocumentDetails; pageLockConfig?: IPageLockConfig; pageArchiveConfig?: IPageArchiveConfig; + mentionHighlights?: string[]; + pageDuplicationConfig?: IDuplicationConfig; onActionCompleteHandler: (action: { title: string; @@ -44,6 +46,7 @@ const DocumentReadOnlyEditor = ({ borderOnFocus, customClassName, value, + mentionHighlights, documentDetails, forwardedRef, pageDuplicationConfig, @@ -58,6 +61,7 @@ const DocumentReadOnlyEditor = ({ const editor = useReadOnlyEditor({ value, + mentionHighlights, forwardedRef, rerenderOnPropsChange, extensions: [IssueWidgetPlaceholder()], diff --git a/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx b/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx index bb79c981769..bf5b15266f1 100644 --- a/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx +++ b/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx @@ -81,7 +81,6 @@ export const IssueCommentCreate: FC = (props) => { render={({ field: { value, onChange } }) => ( { - console.log("yo"); handleSubmit(onSubmit)(e); }} cancelUploadImage={fileService.cancelUpload} diff --git a/web/hooks/store/use-mention.ts b/web/hooks/store/use-mention.ts index bf688053cc5..270a7764e61 100644 --- a/web/hooks/store/use-mention.ts +++ b/web/hooks/store/use-mention.ts @@ -1,11 +1,50 @@ -import { useContext } from "react"; -// mobx store -import { StoreContext } from "contexts/store-context"; -// types -import { IMentionStore } from "store/mention.store"; - -export const useMention = (): IMentionStore => { - const context = useContext(StoreContext); - if (context === undefined) throw new Error("useMention must be used within StoreProvider"); - return context.mention; +import useSWR from "swr"; + +import { ProjectMemberService } from "services/project"; +import { IProjectMember } from "@plane/types"; +import { UserService } from "services/user.service"; +import { useRef, useEffect } from "react"; + +export const useMention = ({ workspaceSlug, projectId }: { workspaceSlug: string; projectId: string }) => { + const userService = new UserService(); + const projectMemberService = new ProjectMemberService(); + + const { data: projectMembers } = useSWR(["projectMembers", workspaceSlug, projectId], async () => { + const members = await projectMemberService.fetchProjectMembers(workspaceSlug, projectId); + const detailedMembers = await Promise.all( + members.map(async (member) => projectMemberService.getProjectMember(workspaceSlug, projectId, member.id)) + ); + return detailedMembers; + }); + + const projectMembersRef = useRef(); + + useEffect(() => { + if (projectMembers) { + projectMembersRef.current = projectMembers; + } + }, [projectMembers]); + + const { data: user } = useSWR("currentUser", async () => userService.currentUser()); + + const mentionHighlights = user ? [user.id] : []; + + const getMentionSuggestions = () => () => { + const mentionSuggestions = + projectMembersRef.current?.map((memberDetails) => ({ + entity_name: "user_mention", + entity_identifier: `${memberDetails?.member?.id}`, + type: "User", + title: `${memberDetails?.member?.display_name}`, + subtitle: memberDetails?.member?.email ?? "", + avatar: `${memberDetails?.member?.avatar}`, + redirect_uri: `/${workspaceSlug}/profile/${memberDetails?.member?.id}`, + })) || []; + + return mentionSuggestions; + }; + return { + getMentionSuggestions, + mentionHighlights, + }; }; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index 93a814d57ed..50a952d4b3c 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -6,7 +6,7 @@ import { ReactElement, useEffect, useRef, useState } from "react"; import { Controller, useForm } from "react-hook-form"; // hooks -import { useApplication, usePage, useUser, useWorkspace } from "hooks/store"; +import { useApplication, useMention, usePage, useUser, useWorkspace } from "hooks/store"; import useReloadConfirmations from "hooks/use-reload-confirmation"; import useToast from "hooks/use-toast"; // services @@ -29,6 +29,8 @@ import { NextPageWithLayout } from "lib/types"; import { EUserProjectRoles } from "constants/project"; import { useProjectPages } from "hooks/store/use-project-specific-pages"; import { IssuePeekOverview } from "components/issues"; +import { ProjectMemberService } from "services/project"; +import { UserService } from "services/user.service"; // services const fileService = new FileService(); @@ -84,8 +86,22 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { : null ); + const projectMemberService = new ProjectMemberService(); + + const { data: projectMembers } = useSWR(["projectMembers", workspaceSlug, projectId], async () => { + const members = await projectMemberService.fetchProjectMembers(workspaceSlug, projectId); + const detailedMembers = await Promise.all( + members.map(async (member) => projectMemberService.getProjectMember(workspaceSlug, projectId, member.id)) + ); + console.log("ye toh chal", detailedMembers); + return detailedMembers; + }); + const pageStore = usePage(pageId as string); + // store hooks + const { getMentionSuggestions, mentionHighlights, mentionSuggestions } = useMention({ workspaceSlug, projectId }); + const { setShowAlert } = useReloadConfirmations(pageStore?.isSubmitting === "submitting"); useEffect( @@ -273,6 +289,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { last_updated_at: updated_at, last_updated_by: updated_by, }} + mentionHighlights={mentionHighlights} pageLockConfig={userCanLock && !archived_at ? { action: unlockPage, is_locked: is_locked } : undefined} pageDuplicationConfig={userCanDuplicate && !archived_at ? { action: duplicate_page } : undefined} pageArchiveConfig={ @@ -300,6 +317,8 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { last_updated_at: updated_at, last_updated_by: updated_by, }} + mentionSuggestions={getMentionSuggestions(projectMembers)} + mentionHighlights={mentionHighlights} uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)} deleteFile={fileService.getDeleteImageFunction(workspaceId)} restoreFile={fileService.getRestoreImageFunction(workspaceId)} diff --git a/web/store/mention.store.ts b/web/store/mention.store.ts index 872efeb4122..48553b2cc9d 100644 --- a/web/store/mention.store.ts +++ b/web/store/mention.store.ts @@ -33,9 +33,12 @@ export class MentionStore implements IMentionStore { const suggestions = (projectMemberIds ?? [])?.map((memberId) => { const memberDetails = this.rootStore.memberRoot.project.getProjectMemberDetails(memberId); + // __AUTO_GENERATED_PRINT_VAR_START__ + console.log("MentionStore#mentionSuggestions#(anon) memberDetails are: %s", memberDetails?.member.id); // __AUTO_GENERATED_PRINT_VAR_END__ return { - id: `${memberDetails?.member?.id}`, + entity_name: "user_mention", + entity_identifier: `${memberDetails?.member?.id}`, type: "User", title: `${memberDetails?.member?.display_name}`, subtitle: memberDetails?.member?.email ?? "", diff --git a/yarn.lock b/yarn.lock index 291c710bdda..c5151b14fe5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5012,7 +5012,7 @@ fault@^2.0.0: dependencies: format "^0.2.0" -fflate@^0.4.1: +fflate@^0.4.8: version "0.4.8" resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA== @@ -7171,12 +7171,18 @@ postcss@^8.4.21, postcss@^8.4.23, postcss@^8.4.29: picocolors "^1.0.0" source-map-js "^1.0.2" -posthog-js@^1.88.4: - version "1.96.1" - resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.96.1.tgz#4f9719a24e4e14037b0e72d430194d7cdb576447" - integrity sha512-kv1vQqYMt2BV3YHS+wxsbGuP+tz+M3y1AzNhz8TfkpY1HT8W/ONT0i0eQpeRr9Y+d4x/fZ6M4cXG5GMvi9lRCA== +posthog-js@^1.105.0: + version "1.108.3" + resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.108.3.tgz#774353d7ad594b68e6f5e6cce0fe8b583562f455" + integrity sha512-Vi9lX/MhovsKIEdj2aJ5ioku9U/eMGY8/DzKf4EpyrElxPPdabAdCDRUa81eAqxC6npkOpkHskawUPLg20le4Q== dependencies: - fflate "^0.4.1" + fflate "^0.4.8" + preact "^10.19.3" + +preact@^10.19.3: + version "10.19.6" + resolved "https://registry.yarnpkg.com/preact/-/preact-10.19.6.tgz#66007b67aad4d11899f583df1b0116d94a89b8f5" + integrity sha512-gympg+T2Z1fG1unB8NH29yHJwnEaCH37Z32diPDku316OTnRPeMbiRV9kTrfZpocXjdfnWuFUl/Mj4BHaf6gnw== prebuild-install@^7.1.1: version "7.1.1" From 43fd94827feeba915e62fe485e9d38eb9ae7d8b7 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Mon, 4 Mar 2024 13:20:44 +0530 Subject: [PATCH 009/179] fixed mentions not showing in modals --- packages/editor/core/src/hooks/use-editor.tsx | 11 +- packages/editor/core/src/styles/table.css | 4 +- .../editor/core/src/ui/extensions/index.tsx | 148 +++++++++--------- .../editor/core/src/ui/mentions/custom.tsx | 2 +- .../editor/core/src/ui/mentions/index.tsx | 47 +++--- .../core/src/ui/mentions/mention-list.tsx | 1 + .../src/ui/mentions/mention-node-view.tsx | 18 ++- .../editor/document-editor/src/ui/index.tsx | 1 + .../src/extensions/slash-commands.tsx | 4 +- packages/types/src/users.d.ts | 1 - .../inbox/modals/create-issue-modal.tsx | 9 +- web/components/issues/description-form.tsx | 6 +- web/components/issues/draft-issue-form.tsx | 12 +- .../issue-activity/activity-comment-root.tsx | 4 +- .../issue-activity/comments/comment-card.tsx | 8 +- .../comments/comment-create.tsx | 8 +- .../issue-activity/comments/root.tsx | 4 +- .../issue-detail/issue-activity/root.tsx | 4 + web/components/issues/issue-modal/form.tsx | 6 +- web/hooks/store/use-mention.ts | 93 ++++++++--- .../projects/[projectId]/pages/[pageId].tsx | 26 +-- web/pages/_app.tsx | 1 + 22 files changed, 252 insertions(+), 166 deletions(-) diff --git a/packages/editor/core/src/hooks/use-editor.tsx b/packages/editor/core/src/hooks/use-editor.tsx index c2923c1e97d..cae01569a92 100644 --- a/packages/editor/core/src/hooks/use-editor.tsx +++ b/packages/editor/core/src/hooks/use-editor.tsx @@ -5,7 +5,7 @@ import { CoreEditorExtensions } from "src/ui/extensions"; import { EditorProps } from "@tiptap/pm/view"; import { getTrimmedHTML } from "src/lib/utils"; import { DeleteImage } from "src/types/delete-image"; -import { IMentionSuggestion } from "src/types/mention-suggestion"; +import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion"; import { RestoreImage } from "src/types/restore-image"; import { UploadImage } from "src/types/upload-image"; @@ -27,8 +27,8 @@ interface CustomEditorProps { extensions?: any; editorProps?: EditorProps; forwardedRef?: any; - mentionHighlights?: string[]; - mentionSuggestions?: IMentionSuggestion[]; + mentionHighlights?: () => Promise; + mentionSuggestions?: () => Promise; } export const useEditor = ({ @@ -48,6 +48,7 @@ export const useEditor = ({ mentionHighlights, mentionSuggestions, }: CustomEditorProps) => { + console.log("the mentions", mentionHighlights); const editor = useCustomEditor( { editorProps: { @@ -57,8 +58,8 @@ export const useEditor = ({ extensions: [ ...CoreEditorExtensions( { - mentionSuggestions: mentionSuggestions ?? [], - mentionHighlights: mentionHighlights ?? [], + mentionSuggestions: mentionSuggestions, + mentionHighlights: mentionHighlights, }, deleteFile, restoreFile, diff --git a/packages/editor/core/src/styles/table.css b/packages/editor/core/src/styles/table.css index 8a47a8c59fd..597384f5bc4 100644 --- a/packages/editor/core/src/styles/table.css +++ b/packages/editor/core/src/styles/table.css @@ -68,7 +68,7 @@ top: 0; bottom: -2px; width: 4px; - z-index: 99; + z-index: 5; background-color: rgba(var(--color-primary-400)); pointer-events: none; } @@ -81,7 +81,7 @@ .tableWrapper .tableControls .rowsControl { transition: opacity ease-in 100ms; position: absolute; - z-index: 99; + z-index: 5; display: flex; justify-content: center; align-items: center; diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index e413aa9c16e..ea5469b9af5 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -22,94 +22,92 @@ import { CustomKeymap } from "src/ui/extensions/keymap"; import { CustomQuoteExtension } from "src/ui/extensions/quote"; import { DeleteImage } from "src/types/delete-image"; -import { IMentionSuggestion } from "src/types/mention-suggestion"; +import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion"; import { RestoreImage } from "src/types/restore-image"; import { CustomLinkExtension } from "src/ui/extensions/custom-link"; import { CustomCodeInlineExtension } from "./code-inline"; export const CoreEditorExtensions = ( mentionConfig: { - mentionSuggestions: IMentionSuggestion[]; - mentionHighlights: string[]; + mentionSuggestions?: () => Promise; + mentionHighlights?: () => Promise; }, deleteFile: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => any -) => { - return [ - StarterKit.configure({ - bulletList: { - HTMLAttributes: { - class: "list-disc list-outside leading-3 -mt-2", - }, - }, - orderedList: { - HTMLAttributes: { - class: "list-decimal list-outside leading-3 -mt-2", - }, - }, - listItem: { - HTMLAttributes: { - class: "leading-normal -mb-2", - }, - }, - code: false, - codeBlock: false, - horizontalRule: { - HTMLAttributes: { class: "mt-4 mb-4" }, - }, - blockquote: false, - dropcursor: { - color: "rgba(var(--color-text-100))", - width: 2, - }, - }), - CustomQuoteExtension.configure({ - HTMLAttributes: { className: "border-l-4 border-custom-border-300" }, - }), - CustomKeymap, - ListKeymap, - CustomLinkExtension.configure({ - openOnClick: true, - autolink: true, - linkOnPaste: true, - protocols: ["http", "https"], - validate: (url: string) => isValidHttpUrl(url), - HTMLAttributes: { - class: - "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", - }, - }), - ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({ +) => [ + StarterKit.configure({ + bulletList: { HTMLAttributes: { - class: "rounded-lg border border-custom-border-300", + class: "list-disc list-outside leading-3 -mt-2", }, - }), - TiptapUnderline, - TextStyle, - Color, - TaskList.configure({ + }, + orderedList: { HTMLAttributes: { - class: "not-prose pl-2", + class: "list-decimal list-outside leading-3 -mt-2", }, - }), - TaskItem.configure({ + }, + listItem: { HTMLAttributes: { - class: "flex items-start my-4", + class: "leading-normal -mb-2", }, - nested: true, - }), - CustomCodeBlockExtension, - CustomCodeInlineExtension, - Markdown.configure({ - html: true, - transformCopiedText: true, - transformPastedText: true, - }), - Table, - TableHeader, - TableCell, - TableRow, - Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false), - ]; -}; + }, + code: false, + codeBlock: false, + horizontalRule: { + HTMLAttributes: { class: "mt-4 mb-4" }, + }, + blockquote: false, + dropcursor: { + color: "rgba(var(--color-text-100))", + width: 2, + }, + }), + CustomQuoteExtension.configure({ + HTMLAttributes: { className: "border-l-4 border-custom-border-300" }, + }), + CustomKeymap, + ListKeymap, + CustomLinkExtension.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, + protocols: ["http", "https"], + validate: (url: string) => isValidHttpUrl(url), + HTMLAttributes: { + class: + "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", + }, + }), + ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({ + HTMLAttributes: { + class: "rounded-lg border border-custom-border-300", + }, + }), + TiptapUnderline, + TextStyle, + Color, + TaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2", + }, + }), + TaskItem.configure({ + HTMLAttributes: { + class: "flex items-start my-4", + }, + nested: true, + }), + CustomCodeBlockExtension, + CustomCodeInlineExtension, + Markdown.configure({ + html: true, + transformCopiedText: true, + transformPastedText: true, + }), + Table, + TableHeader, + TableCell, + TableRow, + Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false), +]; diff --git a/packages/editor/core/src/ui/mentions/custom.tsx b/packages/editor/core/src/ui/mentions/custom.tsx index d41b064200a..8bab7966677 100644 --- a/packages/editor/core/src/ui/mentions/custom.tsx +++ b/packages/editor/core/src/ui/mentions/custom.tsx @@ -5,7 +5,7 @@ import { MentionNodeView } from "src/ui/mentions/mention-node-view"; import { IMentionHighlight } from "src/types/mention-suggestion"; export interface CustomMentionOptions extends MentionOptions { - mentionHighlights: IMentionHighlight[]; + mentionHighlights: () => Promise; readonly?: boolean; } diff --git a/packages/editor/core/src/ui/mentions/index.tsx b/packages/editor/core/src/ui/mentions/index.tsx index 15637e516fd..1448131ab27 100644 --- a/packages/editor/core/src/ui/mentions/index.tsx +++ b/packages/editor/core/src/ui/mentions/index.tsx @@ -7,29 +7,9 @@ import tippy from "tippy.js"; import { v4 as uuidv4 } from "uuid"; import { MentionList } from "src/ui/mentions/mention-list"; -export const getSuggestionItems = - (getSuggestions: () => Promise) => - async ({ query }: { query: string }) => { - console.log("yaa"); - const suggestions = await getSuggestions(); - const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => { - const transactionId = uuidv4(); - return { - ...suggestion, - id: transactionId, - }; - }); - const filteredSuggestions = mappedSuggestions - .filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase())) - .slice(0, 5); - - console.log("yoo", filteredSuggestions); - return filteredSuggestions; - }; - export const Mentions = ( mentionSuggestions: () => Promise, - mentionHighlights: IMentionHighlight[], + mentionHighlights: () => Promise, readonly: boolean ) => CustomMention.configure({ @@ -39,8 +19,8 @@ export const Mentions = ( readonly: readonly, mentionHighlights: mentionHighlights, suggestion: { - items: ({ query }) => { - const suggestions = mentionSuggestions(); + items: async ({ query }) => { + const suggestions = await mentionSuggestions(); const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => { const transactionId = uuidv4(); return { @@ -48,11 +28,11 @@ export const Mentions = ( id: transactionId, }; }); + const filteredSuggestions = mappedSuggestions .filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase())) .slice(0, 5); - console.log("yoo", filteredSuggestions); return filteredSuggestions; }, // @ts-ignore @@ -60,33 +40,44 @@ export const Mentions = ( let reactRenderer: ReactRenderer | null = null; let popup: any | null = null; + const hidePopup = () => { + popup?.[0].hide(); + }; return { onStart: (props: { editor: Editor; clientRect: DOMRect }) => { - props.editor.storage.mentionsOpen = true; + if (!props.clientRect) { + return; + } reactRenderer = new ReactRenderer(MentionList, { props, editor: props.editor, }); + props.editor.storage.mentionsOpen = true; // @ts-ignore popup = tippy("body", { getReferenceClientRect: props.clientRect, - appendTo: () => document.querySelector("#editor-container"), + appendTo: () => document.body, content: reactRenderer.element, showOnCreate: true, interactive: true, trigger: "manual", placement: "bottom-start", }); + // document.addEventListener("scroll", hidePopup, true); }, - onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { reactRenderer?.updateProps(props); + if (!props.clientRect) { + return; + } + popup && popup[0].setProps({ getReferenceClientRect: props.clientRect, }); }, + onKeyDown: (props: { event: KeyboardEvent }) => { if (props.event.key === "Escape") { popup?.[0].hide(); @@ -108,6 +99,8 @@ export const Mentions = ( props.editor.storage.mentionsOpen = false; popup?.[0].destroy(); reactRenderer?.destroy(); + + // document.removeEventListener("scroll", hidePopup, true); }, }; }, diff --git a/packages/editor/core/src/ui/mentions/mention-list.tsx b/packages/editor/core/src/ui/mentions/mention-list.tsx index b7ec14ecc8e..5f4c7ea6672 100644 --- a/packages/editor/core/src/ui/mentions/mention-list.tsx +++ b/packages/editor/core/src/ui/mentions/mention-list.tsx @@ -12,6 +12,7 @@ interface MentionListProps { export const MentionList = forwardRef((props: MentionListProps, ref) => { const [selectedIndex, setSelectedIndex] = useState(0); + console.log("props", props); const selectItem = (index: number) => { const item = props.items[index]; diff --git a/packages/editor/core/src/ui/mentions/mention-node-view.tsx b/packages/editor/core/src/ui/mentions/mention-node-view.tsx index b58ad4eb242..ec97bbfebf6 100644 --- a/packages/editor/core/src/ui/mentions/mention-node-view.tsx +++ b/packages/editor/core/src/ui/mentions/mention-node-view.tsx @@ -4,11 +4,22 @@ import { NodeViewWrapper } from "@tiptap/react"; import { cn } from "src/lib/utils"; import { useRouter } from "next/router"; import { IMentionHighlight } from "src/types/mention-suggestion"; +import { useEffect, useState } from "react"; // eslint-disable-next-line import/no-anonymous-default-export export const MentionNodeView = (props) => { const router = useRouter(); - const highlights = props.extension.options.mentionHighlights as IMentionHighlight[]; + // const highlights = props.extension.options.mentionHighlights as IMentionHighlight[]; + const [highlightsState, setHighlightsState] = useState(); + + useEffect(() => { + console.log("hightlights type", props.extension.options.mentionHighlights); + const hightlights = async () => { + const userId = await props.extension.options.mentionHighlights(); + setHighlightsState(userId); + }; + hightlights(); + }, []); const handleClick = () => { if (!props.extension.options.readonly) { @@ -16,12 +27,13 @@ export const MentionNodeView = (props) => { } }; + console.log("state of highlight", highlightsState); return ( { // @ts-ignore popup = tippy("body", { getReferenceClientRect: props.clientRect, - appendTo: () => document.querySelector("#editor-container"), + appendTo: () => document.body, content: component.element, showOnCreate: true, - interactive: true, + // interactive: true, trigger: "manual", placement: "bottom-start", }); diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index 81c8abcd5f0..50601b7184d 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -15,7 +15,6 @@ export interface IUser { is_email_verified: boolean; is_managed: boolean; is_onboarded: boolean; - is_password_autoset: boolean; is_tour_completed: boolean; is_password_autoset: boolean; mobile_number: string | null; diff --git a/web/components/inbox/modals/create-issue-modal.tsx b/web/components/inbox/modals/create-issue-modal.tsx index 84c4bef1ea8..88103db19ba 100644 --- a/web/components/inbox/modals/create-issue-modal.tsx +++ b/web/components/inbox/modals/create-issue-modal.tsx @@ -48,7 +48,7 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { const editorRef = useRef(null); // toast alert const { setToastAlert } = useToast(); - const { mentionHighlights, mentionSuggestions } = useMention(); + // router const router = useRouter(); const { workspaceSlug, projectId, inboxId } = router.query as { @@ -59,6 +59,13 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { const workspaceStore = useWorkspace(); const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string; + console.log("in create issue modal", workspaceSlug, projectId); + + const { mentionHighlights, mentionSuggestions } = useMention({ + workspaceSlug: workspaceSlug as string, + projectId: projectId as string, + }); + // store hooks const { issues: { createInboxIssue }, diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx index b7601ef52ed..60fb3ec1084 100644 --- a/web/components/issues/description-form.tsx +++ b/web/components/issues/description-form.tsx @@ -48,7 +48,11 @@ export const IssueDescriptionForm: FC = observer((props) => { const { setShowAlert } = useReloadConfirmations(); // store hooks - const { mentionHighlights, mentionSuggestions } = useMention(); + const { mentionHighlights, mentionSuggestions } = useMention({ + workspaceSlug: workspaceSlug as string, + projectId: projectId as string, + }); + // form info const { handleSubmit, diff --git a/web/components/issues/draft-issue-form.tsx b/web/components/issues/draft-issue-form.tsx index cfd6370fad2..da5d013bcb8 100644 --- a/web/components/issues/draft-issue-form.tsx +++ b/web/components/issues/draft-issue-form.tsx @@ -106,10 +106,6 @@ export const DraftIssueForm: FC = observer((props) => { const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); // store hooks const { areEstimatesEnabledForProject } = useEstimate(); - const { mentionHighlights, mentionSuggestions } = useMention(); - // hooks - const { setValue: setLocalStorageValue } = useLocalStorage("draftedIssue", {}); - const { setToastAlert } = useToast(); // refs const editorRef = useRef(null); // router @@ -118,6 +114,14 @@ export const DraftIssueForm: FC = observer((props) => { const workspaceStore = useWorkspace(); const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string; + // hooks + const { setValue: setLocalStorageValue } = useLocalStorage("draftedIssue", {}); + const { setToastAlert } = useToast(); + const { mentionHighlights, mentionSuggestions } = useMention({ + workspaceSlug: workspaceSlug as string, + projectId: projectId as string, + }); + // store const { config: { envConfig }, diff --git a/web/components/issues/issue-detail/issue-activity/activity-comment-root.tsx b/web/components/issues/issue-detail/issue-activity/activity-comment-root.tsx index 575e8d8414a..3d9cd2fff56 100644 --- a/web/components/issues/issue-detail/issue-activity/activity-comment-root.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity-comment-root.tsx @@ -10,13 +10,14 @@ import { TActivityOperations } from "./root"; type TIssueActivityCommentRoot = { workspaceSlug: string; + projectId: string; issueId: string; activityOperations: TActivityOperations; showAccessSpecifier?: boolean; }; export const IssueActivityCommentRoot: FC = observer((props) => { - const { workspaceSlug, issueId, activityOperations, showAccessSpecifier } = props; + const { workspaceSlug, issueId, activityOperations, showAccessSpecifier, projectId } = props; // hooks const { activity: { getActivityCommentByIssueId }, @@ -31,6 +32,7 @@ export const IssueActivityCommentRoot: FC = observer( {activityComments.map((activityComment, index) => activityComment.activity_type === "COMMENT" ? ( = (props) => { - const { workspaceSlug, commentId, activityOperations, ends, showAccessSpecifier = false } = props; + const { workspaceSlug, projectId, commentId, activityOperations, ends, showAccessSpecifier = false } = props; // hooks const { comment: { getCommentById }, } = useIssueDetail(); const { currentUser } = useUser(); - const { mentionHighlights, mentionSuggestions } = useMention(); + const { mentionHighlights, mentionSuggestions } = useMention({ + workspaceSlug: workspaceSlug as string, + projectId: projectId as string, + }); // refs const editorRef = useRef(null); const showEditorRef = useRef(null); diff --git a/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx b/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx index bf5b15266f1..fd2ca8fbb3d 100644 --- a/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx +++ b/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx @@ -15,6 +15,7 @@ import { useMention, useWorkspace } from "hooks/store"; const fileService = new FileService(); type TIssueCommentCreate = { + projectId: string; workspaceSlug: string; activityOperations: TActivityOperations; showAccessSpecifier?: boolean; @@ -39,11 +40,14 @@ const commentAccess: commentAccessType[] = [ ]; export const IssueCommentCreate: FC = (props) => { - const { workspaceSlug, activityOperations, showAccessSpecifier = false } = props; + const { workspaceSlug, projectId, activityOperations, showAccessSpecifier = false } = props; const workspaceStore = useWorkspace(); const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string; - const { mentionHighlights, mentionSuggestions } = useMention(); + const { mentionHighlights, mentionSuggestions } = useMention({ + workspaceSlug: workspaceSlug as string, + projectId: projectId as string, + }); // refs const editorRef = useRef(null); diff --git a/web/components/issues/issue-detail/issue-activity/comments/root.tsx b/web/components/issues/issue-detail/issue-activity/comments/root.tsx index 4e2775c4ae5..7c8f45cd034 100644 --- a/web/components/issues/issue-detail/issue-activity/comments/root.tsx +++ b/web/components/issues/issue-detail/issue-activity/comments/root.tsx @@ -8,6 +8,7 @@ import { IssueCommentCard } from "./comment-card"; import { TActivityOperations } from "../root"; type TIssueCommentRoot = { + projectId: string; workspaceSlug: string; issueId: string; activityOperations: TActivityOperations; @@ -15,7 +16,7 @@ type TIssueCommentRoot = { }; export const IssueCommentRoot: FC = observer((props) => { - const { workspaceSlug, issueId, activityOperations, showAccessSpecifier } = props; + const { workspaceSlug, projectId, issueId, activityOperations, showAccessSpecifier } = props; // hooks const { comment: { getCommentsByIssueId }, @@ -28,6 +29,7 @@ export const IssueCommentRoot: FC = observer((props) => {
    {commentIds.map((commentId, index) => ( = observer((props) => { {activityTab === "all" ? (
    = observer((props) => { ) : (
    = observer((props) => { } = useApplication(); const { getProjectById } = useProject(); const { areEstimatesEnabledForProject } = useEstimate(); - const { mentionHighlights, mentionSuggestions } = useMention(); + const { mentionHighlights, mentionSuggestions } = useMention({ + workspaceSlug: workspaceSlug as string, + projectId: defaultProjectId, + }); + const { issue: { getIssueById }, } = useIssueDetail(); diff --git a/web/hooks/store/use-mention.ts b/web/hooks/store/use-mention.ts index 270a7764e61..082fcd9a6c3 100644 --- a/web/hooks/store/use-mention.ts +++ b/web/hooks/store/use-mention.ts @@ -1,23 +1,27 @@ import useSWR from "swr"; - +import { useRef, useEffect } from "react"; import { ProjectMemberService } from "services/project"; -import { IProjectMember } from "@plane/types"; +import { IProjectMember, IUser } from "@plane/types"; import { UserService } from "services/user.service"; -import { useRef, useEffect } from "react"; export const useMention = ({ workspaceSlug, projectId }: { workspaceSlug: string; projectId: string }) => { const userService = new UserService(); const projectMemberService = new ProjectMemberService(); - const { data: projectMembers } = useSWR(["projectMembers", workspaceSlug, projectId], async () => { - const members = await projectMemberService.fetchProjectMembers(workspaceSlug, projectId); - const detailedMembers = await Promise.all( - members.map(async (member) => projectMemberService.getProjectMember(workspaceSlug, projectId, member.id)) - ); - return detailedMembers; - }); + const { data: projectMembers, isLoading: projectMembersLoading } = useSWR( + ["projectMembers", workspaceSlug, projectId], + async () => { + const members = await projectMemberService.fetchProjectMembers(workspaceSlug, projectId); + const detailedMembers = await Promise.all( + members.map(async (member) => projectMemberService.getProjectMember(workspaceSlug, projectId, member.id)) + ); + return detailedMembers; + } + ); + const { data: user, isLoading: userDataLoading } = useSWR("currentUser", async () => userService.currentUser()); const projectMembersRef = useRef(); + const userRef = useRef(); useEffect(() => { if (projectMembers) { @@ -25,13 +29,51 @@ export const useMention = ({ workspaceSlug, projectId }: { workspaceSlug: string } }, [projectMembers]); - const { data: user } = useSWR("currentUser", async () => userService.currentUser()); + useEffect(() => { + if (userRef) { + userRef.current = user; + } + }, [user]); + + const waitForUserDate = async () => + new Promise((resolve) => { + const checkData = () => { + if (userRef.current) { + resolve(userRef.current); + } else { + setTimeout(checkData, 100); + } + }; + checkData(); + }); + + const mentionHighlights = async () => { + console.log("isme aaya highlights"); + if (!userDataLoading && userRef.current) { + return [userRef.current.id]; + } else { + const user = await waitForUserDate(); + return [user.id]; + } + }; - const mentionHighlights = user ? [user.id] : []; + // Polling function to wait for projectMembersRef.current to be populated + const waitForData = async () => + new Promise((resolve) => { + const checkData = () => { + if (projectMembersRef.current && projectMembersRef.current.length > 0) { + resolve(projectMembersRef.current); + } else { + setTimeout(checkData, 100); // Check every 100ms + } + }; + checkData(); + }); - const getMentionSuggestions = () => () => { - const mentionSuggestions = - projectMembersRef.current?.map((memberDetails) => ({ + const mentionSuggestions = async () => { + if (!projectMembersLoading && projectMembersRef.current && projectMembersRef.current.length > 0) { + // If data is already available, return it immediately + return projectMembersRef.current.map((memberDetails) => ({ entity_name: "user_mention", entity_identifier: `${memberDetails?.member?.id}`, type: "User", @@ -39,12 +81,25 @@ export const useMention = ({ workspaceSlug, projectId }: { workspaceSlug: string subtitle: memberDetails?.member?.email ?? "", avatar: `${memberDetails?.member?.avatar}`, redirect_uri: `/${workspaceSlug}/profile/${memberDetails?.member?.id}`, - })) || []; - - return mentionSuggestions; + })); + } else { + // Wait for data to be available + const members = await waitForData(); + console.log("isme aaya", members); + return members.map((memberDetails) => ({ + entity_name: "user_mention", + entity_identifier: `${memberDetails?.member?.id}`, + type: "User", + title: `${memberDetails?.member?.display_name}`, + subtitle: memberDetails?.member?.email ?? "", + avatar: `${memberDetails?.member?.avatar}`, + redirect_uri: `/${workspaceSlug}/profile/${memberDetails?.member?.id}`, + })); + } }; + return { - getMentionSuggestions, + mentionSuggestions, mentionHighlights, }; }; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index 50a952d4b3c..674827b2021 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -1,9 +1,9 @@ import { Sparkle } from "lucide-react"; import { observer } from "mobx-react-lite"; -import useSWR from "swr"; import { useRouter } from "next/router"; import { ReactElement, useEffect, useRef, useState } from "react"; import { Controller, useForm } from "react-hook-form"; +import useSWR from "swr"; // hooks import { useApplication, useMention, usePage, useUser, useWorkspace } from "hooks/store"; @@ -26,11 +26,9 @@ import { IPage } from "@plane/types"; import { NextPageWithLayout } from "lib/types"; // fetch-keys // constants +import { IssuePeekOverview } from "components/issues"; import { EUserProjectRoles } from "constants/project"; import { useProjectPages } from "hooks/store/use-project-specific-pages"; -import { IssuePeekOverview } from "components/issues"; -import { ProjectMemberService } from "services/project"; -import { UserService } from "services/user.service"; // services const fileService = new FileService(); @@ -78,6 +76,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { ? () => fetchProjectPages(workspaceSlug.toString(), projectId.toString()) : null ); + // fetching archived pages from API useSWR( workspaceSlug && projectId ? `ALL_ARCHIVED_PAGES_LIST_${projectId}` : null, @@ -86,21 +85,12 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { : null ); - const projectMemberService = new ProjectMemberService(); - - const { data: projectMembers } = useSWR(["projectMembers", workspaceSlug, projectId], async () => { - const members = await projectMemberService.fetchProjectMembers(workspaceSlug, projectId); - const detailedMembers = await Promise.all( - members.map(async (member) => projectMemberService.getProjectMember(workspaceSlug, projectId, member.id)) - ); - console.log("ye toh chal", detailedMembers); - return detailedMembers; - }); - const pageStore = usePage(pageId as string); - // store hooks - const { getMentionSuggestions, mentionHighlights, mentionSuggestions } = useMention({ workspaceSlug, projectId }); + const { mentionHighlights, mentionSuggestions } = useMention({ + workspaceSlug: workspaceSlug as string, + projectId: projectId as string, + }); const { setShowAlert } = useReloadConfirmations(pageStore?.isSubmitting === "submitting"); @@ -317,7 +307,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { last_updated_at: updated_at, last_updated_by: updated_by, }} - mentionSuggestions={getMentionSuggestions(projectMembers)} + mentionSuggestions={mentionSuggestions} mentionHighlights={mentionHighlights} uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)} deleteFile={fileService.getDeleteImageFunction(workspaceId)} diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index a53ae80ab78..d9073f4b600 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -6,6 +6,7 @@ import "styles/globals.css"; import "styles/command-pallette.css"; import "styles/nprogress.css"; import "styles/react-datepicker.css"; + // constants import { SITE_TITLE } from "constants/seo-variables"; // mobx store provider From 26646d512e2e08633838381d940b2f7db68f1bfe Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Mon, 4 Mar 2024 13:55:40 +0530 Subject: [PATCH 010/179] removed comments and cleaned up types --- packages/editor/core/src/hooks/use-editor.tsx | 1 - .../core/src/hooks/use-read-only-editor.tsx | 10 +++--- .../core/src/types/mention-suggestion.ts | 2 ++ .../editor/core/src/ui/extensions/index.tsx | 6 +++- .../editor/core/src/ui/mentions/index.tsx | 35 +++++++++++-------- .../core/src/ui/mentions/mention-list.tsx | 11 ++++-- .../src/ui/mentions/mention-node-view.tsx | 7 ++-- .../core/src/ui/read-only/extensions.tsx | 12 ++++--- .../editor/document-editor/src/ui/index.tsx | 9 ++--- .../document-editor/src/ui/readonly/index.tsx | 5 +-- .../src/extensions/slash-commands.tsx | 2 +- .../editor/lite-text-editor/src/ui/index.tsx | 5 +-- .../src/ui/read-only/index.tsx | 10 ++++-- .../editor/rich-text-editor/src/ui/index.tsx | 5 +-- .../src/ui/read-only/index.tsx | 10 ++++-- web/hooks/store/use-mention.ts | 2 -- 16 files changed, 80 insertions(+), 52 deletions(-) diff --git a/packages/editor/core/src/hooks/use-editor.tsx b/packages/editor/core/src/hooks/use-editor.tsx index cae01569a92..64f4d4c2b1e 100644 --- a/packages/editor/core/src/hooks/use-editor.tsx +++ b/packages/editor/core/src/hooks/use-editor.tsx @@ -48,7 +48,6 @@ export const useEditor = ({ mentionHighlights, mentionSuggestions, }: CustomEditorProps) => { - console.log("the mentions", mentionHighlights); const editor = useCustomEditor( { editorProps: { diff --git a/packages/editor/core/src/hooks/use-read-only-editor.tsx b/packages/editor/core/src/hooks/use-read-only-editor.tsx index ecd49255ca6..4a8894e8dd7 100644 --- a/packages/editor/core/src/hooks/use-read-only-editor.tsx +++ b/packages/editor/core/src/hooks/use-read-only-editor.tsx @@ -3,7 +3,7 @@ import { useImperativeHandle, useRef, MutableRefObject } from "react"; import { CoreReadOnlyEditorExtensions } from "src/ui/read-only/extensions"; import { CoreReadOnlyEditorProps } from "src/ui/read-only/props"; import { EditorProps } from "@tiptap/pm/view"; -import { IMentionSuggestion } from "src/types/mention-suggestion"; +import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion"; interface CustomReadOnlyEditorProps { value: string; @@ -14,8 +14,8 @@ interface CustomReadOnlyEditorProps { id: string; description_html: string; }; - mentionHighlights?: string[]; - mentionSuggestions?: IMentionSuggestion[]; + mentionHighlights?: () => Promise; + mentionSuggestions?: () => Promise; } export const useReadOnlyEditor = ({ @@ -37,8 +37,8 @@ export const useReadOnlyEditor = ({ }, extensions: [ ...CoreReadOnlyEditorExtensions({ - mentionSuggestions: mentionSuggestions ?? [], - mentionHighlights: mentionHighlights ?? [], + mentionSuggestions: mentionSuggestions, + mentionHighlights: mentionHighlights, }), ...extensions, ], diff --git a/packages/editor/core/src/types/mention-suggestion.ts b/packages/editor/core/src/types/mention-suggestion.ts index 9e0b11137c8..aa2ad4ba29e 100644 --- a/packages/editor/core/src/types/mention-suggestion.ts +++ b/packages/editor/core/src/types/mention-suggestion.ts @@ -2,6 +2,8 @@ import { Editor, Range } from "@tiptap/react"; export type IMentionSuggestion = { id: string; type: string; + entity_name: string; + entity_identifier: string; avatar: string; title: string; subtitle: string; diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index ea5469b9af5..bee4be42c23 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -109,5 +109,9 @@ export const CoreEditorExtensions = ( TableHeader, TableCell, TableRow, - Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false), + Mentions({ + mentionSuggestions: mentionConfig.mentionSuggestions, + mentionHighlights: mentionConfig.mentionHighlights, + readonly: false, + }), ]; diff --git a/packages/editor/core/src/ui/mentions/index.tsx b/packages/editor/core/src/ui/mentions/index.tsx index 1448131ab27..c97ca03e81a 100644 --- a/packages/editor/core/src/ui/mentions/index.tsx +++ b/packages/editor/core/src/ui/mentions/index.tsx @@ -7,11 +7,15 @@ import tippy from "tippy.js"; import { v4 as uuidv4 } from "uuid"; import { MentionList } from "src/ui/mentions/mention-list"; -export const Mentions = ( - mentionSuggestions: () => Promise, - mentionHighlights: () => Promise, - readonly: boolean -) => +export const Mentions = ({ + mentionHighlights, + mentionSuggestions, + readonly, +}: { + mentionSuggestions?: () => Promise; + mentionHighlights?: () => Promise; + readonly: boolean; +}) => CustomMention.configure({ HTMLAttributes: { class: "mention", @@ -20,7 +24,10 @@ export const Mentions = ( mentionHighlights: mentionHighlights, suggestion: { items: async ({ query }) => { - const suggestions = await mentionSuggestions(); + const suggestions = await mentionSuggestions?.(); + if (!suggestions) { + return []; + } const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => { const transactionId = uuidv4(); return { @@ -37,7 +44,7 @@ export const Mentions = ( }, // @ts-ignore render: () => { - let reactRenderer: ReactRenderer | null = null; + let component: ReactRenderer | null = null; let popup: any | null = null; const hidePopup = () => { @@ -48,7 +55,7 @@ export const Mentions = ( if (!props.clientRect) { return; } - reactRenderer = new ReactRenderer(MentionList, { + component = new ReactRenderer(MentionList, { props, editor: props.editor, }); @@ -57,16 +64,16 @@ export const Mentions = ( popup = tippy("body", { getReferenceClientRect: props.clientRect, appendTo: () => document.body, - content: reactRenderer.element, + content: component.element, showOnCreate: true, interactive: true, trigger: "manual", placement: "bottom-start", }); - // document.addEventListener("scroll", hidePopup, true); + document.addEventListener("scroll", hidePopup, true); }, onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { - reactRenderer?.updateProps(props); + component?.updateProps(props); if (!props.clientRect) { return; @@ -89,7 +96,7 @@ export const Mentions = ( if (navigationKeys.includes(props.event.key)) { // @ts-ignore - reactRenderer?.ref?.onKeyDown(props); + component?.ref?.onKeyDown(props); event?.stopPropagation(); return true; } @@ -98,9 +105,9 @@ export const Mentions = ( onExit: (props: { editor: Editor; event: KeyboardEvent }) => { props.editor.storage.mentionsOpen = false; popup?.[0].destroy(); - reactRenderer?.destroy(); + component?.destroy(); - // document.removeEventListener("scroll", hidePopup, true); + document.removeEventListener("scroll", hidePopup, true); }, }; }, diff --git a/packages/editor/core/src/ui/mentions/mention-list.tsx b/packages/editor/core/src/ui/mentions/mention-list.tsx index 5f4c7ea6672..477ce6daeb7 100644 --- a/packages/editor/core/src/ui/mentions/mention-list.tsx +++ b/packages/editor/core/src/ui/mentions/mention-list.tsx @@ -4,15 +4,20 @@ import { IMentionSuggestion } from "src/types/mention-suggestion"; interface MentionListProps { items: IMentionSuggestion[]; - command: (item: { id: string; label: string; target: string; redirect_uri: string }) => void; + command: (item: { + id: string; + label: string; + entity_name: string; + entity_identifier: string; + target: string; + redirect_uri: string; + }) => void; editor: Editor; } -// eslint-disable-next-line react/display-name export const MentionList = forwardRef((props: MentionListProps, ref) => { const [selectedIndex, setSelectedIndex] = useState(0); - console.log("props", props); const selectItem = (index: number) => { const item = props.items[index]; diff --git a/packages/editor/core/src/ui/mentions/mention-node-view.tsx b/packages/editor/core/src/ui/mentions/mention-node-view.tsx index ec97bbfebf6..e4bab1f933a 100644 --- a/packages/editor/core/src/ui/mentions/mention-node-view.tsx +++ b/packages/editor/core/src/ui/mentions/mention-node-view.tsx @@ -9,17 +9,15 @@ import { useEffect, useState } from "react"; // eslint-disable-next-line import/no-anonymous-default-export export const MentionNodeView = (props) => { const router = useRouter(); - // const highlights = props.extension.options.mentionHighlights as IMentionHighlight[]; - const [highlightsState, setHighlightsState] = useState(); + const [highlightsState, setHighlightsState] = useState(); useEffect(() => { - console.log("hightlights type", props.extension.options.mentionHighlights); const hightlights = async () => { const userId = await props.extension.options.mentionHighlights(); setHighlightsState(userId); }; hightlights(); - }, []); + }, [props.extension.options]); const handleClick = () => { if (!props.extension.options.readonly) { @@ -27,7 +25,6 @@ export const MentionNodeView = (props) => { } }; - console.log("state of highlight", highlightsState); return ( Promise; + mentionSuggestions?: () => Promise; }) => [ StarterKit.configure({ bulletList: { @@ -95,5 +95,9 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { TableHeader, TableCell, TableRow, - Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true), + Mentions({ + mentionSuggestions: mentionConfig.mentionSuggestions, + mentionHighlights: mentionConfig.mentionHighlights, + readonly: true, + }), ]; diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index f87c43d7421..863c065f545 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -7,6 +7,7 @@ import { getEditorClassNames, useEditor, IMentionSuggestion, + IMentionHighlight, } from "@plane/editor-core"; import { DocumentEditorExtensions } from "src/ui/extensions"; import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "src/types/menu-actions"; @@ -50,17 +51,14 @@ interface IDocumentEditor { debouncedUpdatesEnabled?: boolean; isSubmitting: "submitting" | "submitted" | "saved"; - mentionHighlights?: string[]; - mentionSuggestions?: IMentionSuggestion[]; + mentionHighlights?: () => Promise; + mentionSuggestions?: () => Promise; // embed configuration duplicationConfig?: IDuplicationConfig; pageLockConfig?: IPageLockConfig; pageArchiveConfig?: IPageArchiveConfig; } -interface DocumentEditorProps extends IDocumentEditor { - forwardedRef?: React.Ref; -} interface EditorHandle { clearEditor: () => void; @@ -126,7 +124,6 @@ const DocumentEditor = ({ extensions: DocumentEditorExtensions(uploadFile, setHideDragHandleFunction, setIsSubmitting), }); - console.log("in document editor", mentionHighlights); if (!editor) { return null; } diff --git a/packages/editor/document-editor/src/ui/readonly/index.tsx b/packages/editor/document-editor/src/ui/readonly/index.tsx index 58a9ec70a3c..38c4e06cceb 100644 --- a/packages/editor/document-editor/src/ui/readonly/index.tsx +++ b/packages/editor/document-editor/src/ui/readonly/index.tsx @@ -1,4 +1,4 @@ -import { getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core"; +import { getEditorClassNames, IMentionHighlight, useReadOnlyEditor } from "@plane/editor-core"; import { useRouter } from "next/router"; import { useState, forwardRef, useEffect } from "react"; import { EditorHeader } from "src/ui/components/editor-header"; @@ -22,7 +22,7 @@ interface IDocumentReadOnlyEditor { documentDetails: DocumentDetails; pageLockConfig?: IPageLockConfig; pageArchiveConfig?: IPageArchiveConfig; - mentionHighlights?: string[]; + mentionHighlights?: () => Promise; pageDuplicationConfig?: IDuplicationConfig; onActionCompleteHandler: (action: { @@ -71,6 +71,7 @@ const DocumentReadOnlyEditor = ({ if (editor) { updateMarkings(editor.getJSON()); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [editor]); if (!editor) { diff --git a/packages/editor/extensions/src/extensions/slash-commands.tsx b/packages/editor/extensions/src/extensions/slash-commands.tsx index 1f0fe60977e..d488ad39576 100644 --- a/packages/editor/extensions/src/extensions/slash-commands.tsx +++ b/packages/editor/extensions/src/extensions/slash-commands.tsx @@ -330,7 +330,7 @@ const renderItems = () => { appendTo: () => document.body, content: component.element, showOnCreate: true, - // interactive: true, + interactive: true, trigger: "manual", placement: "bottom-start", }); diff --git a/packages/editor/lite-text-editor/src/ui/index.tsx b/packages/editor/lite-text-editor/src/ui/index.tsx index 57774ab5dc0..13425329d15 100644 --- a/packages/editor/lite-text-editor/src/ui/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/index.tsx @@ -8,6 +8,7 @@ import { EditorContentWrapper, getEditorClassNames, useEditor, + IMentionHighlight, } from "@plane/editor-core"; import { FixedMenu } from "src/ui/menus/fixed-menu"; import { LiteTextEditorExtensions } from "src/ui/extensions"; @@ -39,8 +40,8 @@ interface ILiteTextEditor { }; onEnterKeyPress?: (e?: any) => void; cancelUploadImage?: () => any; - mentionHighlights?: string[]; - mentionSuggestions?: IMentionSuggestion[]; + mentionHighlights?: () => Promise; + mentionSuggestions?: () => Promise; submitButton?: React.ReactNode; } diff --git a/packages/editor/lite-text-editor/src/ui/read-only/index.tsx b/packages/editor/lite-text-editor/src/ui/read-only/index.tsx index 66ce7905943..46fc87513a3 100644 --- a/packages/editor/lite-text-editor/src/ui/read-only/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/read-only/index.tsx @@ -1,5 +1,11 @@ import * as React from "react"; -import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core"; +import { + EditorContainer, + EditorContentWrapper, + getEditorClassNames, + IMentionHighlight, + useReadOnlyEditor, +} from "@plane/editor-core"; interface ICoreReadOnlyEditor { value: string; @@ -7,7 +13,7 @@ interface ICoreReadOnlyEditor { noBorder?: boolean; borderOnFocus?: boolean; customClassName?: string; - mentionHighlights: string[]; + mentionHighlights?: () => Promise; } interface EditorCoreProps extends ICoreReadOnlyEditor { diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index 43c3f8f3432..facf42303e9 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -4,6 +4,7 @@ import { EditorContainer, EditorContentWrapper, getEditorClassNames, + IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage, @@ -33,8 +34,8 @@ export type IRichTextEditor = { setShouldShowAlert?: (showAlert: boolean) => void; forwardedRef?: any; debouncedUpdatesEnabled?: boolean; - mentionHighlights?: string[]; - mentionSuggestions?: IMentionSuggestion[]; + mentionHighlights?: () => Promise; + mentionSuggestions?: () => Promise; }; export interface RichTextEditorProps extends IRichTextEditor { diff --git a/packages/editor/rich-text-editor/src/ui/read-only/index.tsx b/packages/editor/rich-text-editor/src/ui/read-only/index.tsx index 9b0f43f5791..a065d08ed0e 100644 --- a/packages/editor/rich-text-editor/src/ui/read-only/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/read-only/index.tsx @@ -1,5 +1,11 @@ "use client"; -import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core"; +import { + EditorContainer, + EditorContentWrapper, + getEditorClassNames, + IMentionHighlight, + useReadOnlyEditor, +} from "@plane/editor-core"; import * as React from "react"; interface IRichTextReadOnlyEditor { @@ -8,7 +14,7 @@ interface IRichTextReadOnlyEditor { noBorder?: boolean; borderOnFocus?: boolean; customClassName?: string; - mentionHighlights?: string[]; + mentionHighlights?: () => Promise; } interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor { diff --git a/web/hooks/store/use-mention.ts b/web/hooks/store/use-mention.ts index 082fcd9a6c3..1b35d7a213b 100644 --- a/web/hooks/store/use-mention.ts +++ b/web/hooks/store/use-mention.ts @@ -48,7 +48,6 @@ export const useMention = ({ workspaceSlug, projectId }: { workspaceSlug: string }); const mentionHighlights = async () => { - console.log("isme aaya highlights"); if (!userDataLoading && userRef.current) { return [userRef.current.id]; } else { @@ -85,7 +84,6 @@ export const useMention = ({ workspaceSlug, projectId }: { workspaceSlug: string } else { // Wait for data to be available const members = await waitForData(); - console.log("isme aaya", members); return members.map((memberDetails) => ({ entity_name: "user_mention", entity_identifier: `${memberDetails?.member?.id}`, From 5e703f660108a042e9ccb674beda8a72f32503d7 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Mon, 4 Mar 2024 14:30:04 +0530 Subject: [PATCH 011/179] removed unused types --- packages/editor/core/src/hooks/use-read-only-editor.tsx | 3 --- packages/editor/core/src/ui/read-only/extensions.tsx | 4 +--- web/components/issues/description-input.tsx | 6 +++++- .../[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx | 3 +++ 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/editor/core/src/hooks/use-read-only-editor.tsx b/packages/editor/core/src/hooks/use-read-only-editor.tsx index 4a8894e8dd7..872bc43597d 100644 --- a/packages/editor/core/src/hooks/use-read-only-editor.tsx +++ b/packages/editor/core/src/hooks/use-read-only-editor.tsx @@ -15,7 +15,6 @@ interface CustomReadOnlyEditorProps { description_html: string; }; mentionHighlights?: () => Promise; - mentionSuggestions?: () => Promise; } export const useReadOnlyEditor = ({ @@ -25,7 +24,6 @@ export const useReadOnlyEditor = ({ editorProps = {}, rerenderOnPropsChange, mentionHighlights, - mentionSuggestions, }: CustomReadOnlyEditorProps) => { const editor = useCustomEditor( { @@ -37,7 +35,6 @@ export const useReadOnlyEditor = ({ }, extensions: [ ...CoreReadOnlyEditorExtensions({ - mentionSuggestions: mentionSuggestions, mentionHighlights: mentionHighlights, }), ...extensions, diff --git a/packages/editor/core/src/ui/read-only/extensions.tsx b/packages/editor/core/src/ui/read-only/extensions.tsx index 76b61cfa8fe..93fa30a29fa 100644 --- a/packages/editor/core/src/ui/read-only/extensions.tsx +++ b/packages/editor/core/src/ui/read-only/extensions.tsx @@ -15,12 +15,11 @@ import { TableRow } from "src/ui/extensions/table/table-row/table-row"; import { ReadOnlyImageExtension } from "src/ui/extensions/image/read-only-image"; import { isValidHttpUrl } from "src/lib/utils"; import { Mentions } from "src/ui/mentions"; -import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion"; +import { IMentionHighlight } from "src/types/mention-suggestion"; import { CustomLinkExtension } from "src/ui/extensions/custom-link"; export const CoreReadOnlyEditorExtensions = (mentionConfig: { mentionHighlights?: () => Promise; - mentionSuggestions?: () => Promise; }) => [ StarterKit.configure({ bulletList: { @@ -96,7 +95,6 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { TableCell, TableRow, Mentions({ - mentionSuggestions: mentionConfig.mentionSuggestions, mentionHighlights: mentionConfig.mentionHighlights, readonly: true, }), diff --git a/web/components/issues/description-input.tsx b/web/components/issues/description-input.tsx index 79634fa84aa..22eaf64af29 100644 --- a/web/components/issues/description-input.tsx +++ b/web/components/issues/description-input.tsx @@ -28,7 +28,11 @@ export const IssueDescriptionInput: FC = (props) => // states const [descriptionHTML, setDescriptionHTML] = useState(value); // store hooks - const { mentionHighlights, mentionSuggestions } = useMention(); + const { mentionHighlights, mentionSuggestions } = useMention({ + workspaceSlug: workspaceSlug as string, + projectId: projectId as string, + }); + const { getWorkspaceBySlug } = useWorkspace(); // hooks const debouncedValue = useDebounce(descriptionHTML, 1500); diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index 1140c5b468c..efc9e617bc6 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -274,6 +274,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { customClassName={"tracking-tight w-full px-0"} borderOnFocus={false} noBorder + mentionHighlights={mentionHighlights} documentDetails={{ title: pageTitle, created_by: created_by, @@ -314,6 +315,8 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { value={pageDescription} setShouldShowAlert={setShowAlert} cancelUploadImage={fileService.cancelUpload} + mentionHighlights={mentionHighlights} + mentionSuggestions={mentionSuggestions} ref={editorRef} debouncedUpdatesEnabled={false} setIsSubmitting={setIsSubmitting} From bcf53156f09afc304ffa6fcf86c5e6a56819d51f Mon Sep 17 00:00:00 2001 From: gurusainath Date: Wed, 13 Mar 2024 14:29:24 +0530 Subject: [PATCH 012/179] reset: head --- packages/ui/src/dropdowns/custom-menu.tsx | 3 ++- packages/ui/src/dropdowns/helper.tsx | 1 + web/components/inbox/inbox-issue-actions.tsx | 2 ++ .../issue-detail/issue-activity/comments/comment-create.tsx | 2 +- .../issues/issue-layouts/quick-action-dropdowns/all-issue.tsx | 1 + .../issue-layouts/quick-action-dropdowns/archived-issue.tsx | 1 + .../issue-layouts/quick-action-dropdowns/cycle-issue.tsx | 1 + .../issue-layouts/quick-action-dropdowns/module-issue.tsx | 1 + .../issue-layouts/quick-action-dropdowns/project-issue.tsx | 1 + 9 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index cdfccbb4eda..d1623dddfdb 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -27,6 +27,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { noBorder = false, noChevron = false, optionsClassName = "", + menuItemsClassName = "", verticalEllipsis = false, portalElement, menuButtonOnClick, @@ -70,7 +71,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { useOutsideClickDetector(dropdownRef, closeDropdown); let menuItems = ( - +
    void; + menuItemsClassName?: string; onMenuClose?: () => void; closeOnSelect?: boolean; portalElement?: Element | null; diff --git a/web/components/inbox/inbox-issue-actions.tsx b/web/components/inbox/inbox-issue-actions.tsx index 8a3bb42614d..48d9157c63e 100644 --- a/web/components/inbox/inbox-issue-actions.tsx +++ b/web/components/inbox/inbox-issue-actions.tsx @@ -131,6 +131,8 @@ export const InboxIssueActionsHeader: FC = observer((p const handleInboxIssueNavigation = useCallback( (direction: "next" | "prev") => { if (!inboxIssues || !inboxIssueId) return; + const activeElement = document.activeElement as HTMLElement; + if (activeElement && (activeElement.classList.contains("tiptap") || activeElement.id === "title-input")) return; const nextIssueIndex = direction === "next" ? (currentIssueIndex + 1) % inboxIssues.length diff --git a/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx b/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx index f666b6c1de7..af47328087b 100644 --- a/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx +++ b/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx @@ -74,7 +74,7 @@ export const IssueCommentCreate: FC = (props) => { return (
    { - if (e.key === "Enter" && !e.shiftKey && !isEmpty) { + if (e.key === "Enter" && !e.shiftKey && !isEmpty && !isSubmitting) { handleSubmit(onSubmit)(e); } }} diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx index 1d0472454b8..20d21dc5f62 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx @@ -96,6 +96,7 @@ export const AllIssueQuickActions: React.FC = observer((props storeType={EIssuesStoreType.PROJECT} /> = (props) => onSubmit={handleDelete} /> = observer((pro storeType={EIssuesStoreType.CYCLE} /> = observer((pr storeType={EIssuesStoreType.MODULE} /> = observer((p isDraft={isDraftIssue} /> Date: Wed, 13 Mar 2024 17:04:45 +0530 Subject: [PATCH 013/179] chore: pages store and component updates --- packages/types/src/pages.d.ts | 4 +- ...detail.tsx => page-detail-empty-state.tsx} | 0 .../pages/empty-states/page-empty-state.tsx | 39 ++++++ web/components/pages/empty-states/page.tsx | 51 ------- web/components/pages/index.ts | 13 +- web/components/pages/layouts/page.tsx | 71 ---------- web/components/pages/list/block.tsx | 13 ++ web/components/pages/list/filters/root.tsx | 91 ++++++++++++ web/components/pages/list/index.ts | 4 + web/components/pages/list/root.tsx | 21 +++ web/components/pages/list/search-input.tsx | 4 +- .../pages/list/sort-filter/root.tsx | 4 +- web/components/pages/list/tab-navigation.tsx | 54 ++++++++ ...page-detail.tsx => page-detail-loader.tsx} | 0 web/components/pages/loaders/page-loader.tsx | 42 ++++++ web/components/pages/loaders/page.tsx | 9 -- .../pages/modals/create-update-page-modal.tsx | 71 +++++++--- web/components/pages/modals/index.ts | 4 + web/components/pages/modals/page-form.tsx | 131 ++++++------------ web/components/pages/page-detail/root.tsx | 6 +- web/components/pages/views/page-layout.tsx | 73 ++++++++++ web/constants/page.ts | 8 +- web/hooks/store/index.ts | 2 +- web/hooks/store/pages/use-page-detail.ts | 23 --- web/hooks/store/pages/use-page.ts | 17 +-- web/hooks/store/pages/use-project-page.ts | 14 ++ web/lib/posthog-provider.tsx | 15 -- .../projects/[projectId]/pages/index.tsx | 30 ++-- web/services/page.service.ts | 4 +- web/store/pages/page.store.ts | 126 +++++++++-------- web/store/pages/project-page.store.ts | 53 +++---- 31 files changed, 603 insertions(+), 394 deletions(-) rename web/components/pages/empty-states/{page-detail.tsx => page-detail-empty-state.tsx} (100%) create mode 100644 web/components/pages/empty-states/page-empty-state.tsx delete mode 100644 web/components/pages/empty-states/page.tsx delete mode 100644 web/components/pages/layouts/page.tsx create mode 100644 web/components/pages/list/block.tsx create mode 100644 web/components/pages/list/filters/root.tsx create mode 100644 web/components/pages/list/root.tsx create mode 100644 web/components/pages/list/tab-navigation.tsx rename web/components/pages/loaders/{page-detail.tsx => page-detail-loader.tsx} (100%) create mode 100644 web/components/pages/loaders/page-loader.tsx delete mode 100644 web/components/pages/loaders/page.tsx create mode 100644 web/components/pages/modals/index.ts create mode 100644 web/components/pages/views/page-layout.tsx delete mode 100644 web/hooks/store/pages/use-page-detail.ts create mode 100644 web/hooks/store/pages/use-project-page.ts diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index f6e09c0d1cd..6203b3970fc 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -25,12 +25,14 @@ export type TPage = { }; // page filters +export type TPageNavigationTabs = "public" | "private" | "archived"; + export type TPageFiltersSortKey = "name" | "created_at" | "updated_at"; export type TPageFiltersSortBy = "asc" | "desc"; export type TPageFilters = { - search: string; + searchQuery: string; sortKey: TPageFiltersSortKey; sortBy: TPageFiltersSortBy; }; diff --git a/web/components/pages/empty-states/page-detail.tsx b/web/components/pages/empty-states/page-detail-empty-state.tsx similarity index 100% rename from web/components/pages/empty-states/page-detail.tsx rename to web/components/pages/empty-states/page-detail-empty-state.tsx diff --git a/web/components/pages/empty-states/page-empty-state.tsx b/web/components/pages/empty-states/page-empty-state.tsx new file mode 100644 index 00000000000..4aa47b8bf58 --- /dev/null +++ b/web/components/pages/empty-states/page-empty-state.tsx @@ -0,0 +1,39 @@ +import { FC } from "react"; +import { useTheme } from "next-themes"; +// hooks +import { useEventTracker, useUser } from "hooks/store"; +// components +import { getEmptyStateImagePath } from "components/empty-state"; +// constants +import { EUserWorkspaceRoles } from "constants/workspace"; + +// types +import { TPageNavigationTabs } from "@plane/types"; + +type TPageEmptyState = { + pageType: TPageNavigationTabs; + title?: string; + description?: string; + callback?: () => void; +}; + +export const PageEmptyState: FC = (props) => { + const { title = "No pages", description = "No pages are available.", callback } = props; + // theme + const { resolvedTheme } = useTheme(); + // hooks + const { currentUser } = useUser(); + + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + + return ( +
    + {/* replace hello with images */} +
    Hello
    +
    +
    {title}
    +
    {description}
    +
    +
    + ); +}; diff --git a/web/components/pages/empty-states/page.tsx b/web/components/pages/empty-states/page.tsx deleted file mode 100644 index 262c574d4d3..00000000000 --- a/web/components/pages/empty-states/page.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { FC } from "react"; -import { useTheme } from "next-themes"; -// hooks -import { useEventTracker, useUser } from "hooks/store"; -// components -import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; -// constants -import { EUserWorkspaceRoles } from "constants/workspace"; -import { PAGE_EMPTY_STATE_DETAILS } from "constants/empty-state"; - -type TPageEmptyState = { - callback: () => void; -}; - -export const PageEmptyState: FC = (props) => { - const { callback } = props; - // theme - const { resolvedTheme } = useTheme(); - // hooks - const { setTrackElement } = useEventTracker(); - const { - currentUser, - membership: { currentProjectRole }, - } = useUser(); - - const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; - const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "pages", isLightMode); - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; - - return ( - { - setTrackElement("Pages empty state"); - callback && callback(); - // toggleCreatePageModal(true); - }, - }} - comicBox={{ - title: PAGE_EMPTY_STATE_DETAILS["pages"].comicBox.title, - description: PAGE_EMPTY_STATE_DETAILS["pages"].comicBox.description, - }} - size="lg" - disabled={!isEditingAllowed} - /> - ); -}; diff --git a/web/components/pages/index.ts b/web/components/pages/index.ts index ae7f9f922ca..d6ccf1cd3fc 100644 --- a/web/components/pages/index.ts +++ b/web/components/pages/index.ts @@ -1,13 +1,16 @@ // empty states -export * from "./empty-states/page"; -export * from "./empty-states/page-detail"; +export * from "./empty-states/page-empty-state"; +export * from "./empty-states/page-detail-empty-state"; // loaders -export * from "./loaders/page"; -export * from "./loaders/page-detail"; +export * from "./loaders/page-loader"; +export * from "./loaders/page-detail-loader"; // layouts -export * from "./layouts/page"; +export * from "./views/page-layout"; + +// modals +export * from "./modals"; // pages list components export * from "./list"; diff --git a/web/components/pages/layouts/page.tsx b/web/components/pages/layouts/page.tsx deleted file mode 100644 index 1873e3918cc..00000000000 --- a/web/components/pages/layouts/page.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { FC, ReactNode } from "react"; -import Link from "next/link"; -// components -import { PageSearchInput } from "../"; -// helpers -import { cn } from "helpers/common.helper"; - -type TPageLayout = { - workspaceSlug: string; - projectId: string; - pageType?: "private" | "public"; - children: ReactNode; -}; - -export const PageLayout: FC = (props) => { - const { workspaceSlug, projectId, pageType, children } = props; - - // pages tab options - const pageTabs = [ - { - key: "private", - label: "Private", - }, - { - key: "public", - label: "Public", - }, - ]; - - // pages list loader - - // check for empty state - - return ( -
    - {/* tab header */} -
    - {pageTabs.map((tab) => ( - -
    -
    - {tab.label} -
    -
    -
    - - ))} -
    - - {/* search and sort container */} -
    -
    - -
    -
    Sort filter
    -
    - - {/* children */} -
    {children}
    -
    - ); -}; diff --git a/web/components/pages/list/block.tsx b/web/components/pages/list/block.tsx new file mode 100644 index 00000000000..395d37b5859 --- /dev/null +++ b/web/components/pages/list/block.tsx @@ -0,0 +1,13 @@ +import { FC } from "react"; + +type TPageListBlock = {}; + +export const PageListBlock: FC = (props) => { + const {} = props; + + return ( +
    +
    PageListBlock
    +
    + ); +}; diff --git a/web/components/pages/list/filters/root.tsx b/web/components/pages/list/filters/root.tsx new file mode 100644 index 00000000000..c836073f0ad --- /dev/null +++ b/web/components/pages/list/filters/root.tsx @@ -0,0 +1,91 @@ +import { FC, Fragment, useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Menu, Transition } from "@headlessui/react"; +import { usePopper } from "react-popper"; +import { Copy, Eye, Globe2, Link2, Pencil, Trash } from "lucide-react"; +// hooks +import { useProjectPages } from "hooks/store"; +// constants +import { pageSorting, pageSortingBy } from "constants/page"; +// types +import { TPageFiltersSortKey } from "@plane/types"; + +type TViewEditDropdown = { + projectId: string | undefined; +}; + +export const ViewEditDropdown: FC = observer((props) => { + const { projectId } = props; + // hooks + const { updateFilters } = useProjectPages(projectId); + // refs + const dropdownRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom-end", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + { + name: "offset", + options: { + offset: [0, 10], + }, + }, + ], + }); + + const pageSortingByOptionKeys = Object.keys(pageSortingBy); + + return ( + + +
    + +
    +
    + + + + {pageSorting && + pageSorting.length > 0 && + pageSorting.map((option) => ( + +
    {option.label}
    +
    + ))} +
    +
    Ascending/Descending
    + + +
    + ); +}); diff --git a/web/components/pages/list/index.ts b/web/components/pages/list/index.ts index f7dd80956cf..aec87de0576 100644 --- a/web/components/pages/list/index.ts +++ b/web/components/pages/list/index.ts @@ -1 +1,5 @@ +export * from "./tab-navigation"; export * from "./search-input"; + +export * from "./root"; +export * from "./block"; diff --git a/web/components/pages/list/root.tsx b/web/components/pages/list/root.tsx new file mode 100644 index 00000000000..51375845ce1 --- /dev/null +++ b/web/components/pages/list/root.tsx @@ -0,0 +1,21 @@ +import { FC } from "react"; +// components +import { PageListBlock } from "./"; + +type TPagesListRoot = { + workspaceSlug: string; + projectId: string; +}; + +export const PagesListRoot: FC = (props) => { + const { workspaceSlug, projectId } = props; + + console.log("workspaceSlug", workspaceSlug); + console.log("projectId", projectId); + + return ( +
    + +
    + ); +}; diff --git a/web/components/pages/list/search-input.tsx b/web/components/pages/list/search-input.tsx index 1500460b152..f5b6b05aeb5 100644 --- a/web/components/pages/list/search-input.tsx +++ b/web/components/pages/list/search-input.tsx @@ -2,7 +2,7 @@ import { FC, useState, useEffect } from "react"; import { observer } from "mobx-react-lite"; import { Search } from "lucide-react"; // hooks -import { usePage } from "hooks/store"; +import { useProjectPages } from "hooks/store"; import useDebounce from "hooks/use-debounce"; export type TPageSearchInput = { projectId: string }; @@ -13,7 +13,7 @@ export const PageSearchInput: FC = observer((props) => { const { filters: { search }, updateFilters, - } = usePage(projectId); + } = useProjectPages(projectId); // states const [searchElement, setSearchElement] = useState(search); // debounce state diff --git a/web/components/pages/list/sort-filter/root.tsx b/web/components/pages/list/sort-filter/root.tsx index 228e153ff4c..c836073f0ad 100644 --- a/web/components/pages/list/sort-filter/root.tsx +++ b/web/components/pages/list/sort-filter/root.tsx @@ -4,7 +4,7 @@ import { Menu, Transition } from "@headlessui/react"; import { usePopper } from "react-popper"; import { Copy, Eye, Globe2, Link2, Pencil, Trash } from "lucide-react"; // hooks -import { usePage } from "hooks/store"; +import { useProjectPages } from "hooks/store"; // constants import { pageSorting, pageSortingBy } from "constants/page"; // types @@ -17,7 +17,7 @@ type TViewEditDropdown = { export const ViewEditDropdown: FC = observer((props) => { const { projectId } = props; // hooks - const { updateFilters } = usePage(projectId); + const { updateFilters } = useProjectPages(projectId); // refs const dropdownRef = useRef(null); // popper-js refs diff --git a/web/components/pages/list/tab-navigation.tsx b/web/components/pages/list/tab-navigation.tsx new file mode 100644 index 00000000000..0a2de5d1844 --- /dev/null +++ b/web/components/pages/list/tab-navigation.tsx @@ -0,0 +1,54 @@ +import { FC } from "react"; +import Link from "next/link"; +// helpers +import { cn } from "helpers/common.helper"; + +type TPageTabNavigation = { + workspaceSlug: string; + projectId: string; + pageType: "public" | "private" | "archived"; +}; + +// pages tab options +const pageTabs = [ + { + key: "public", + label: "Public", + }, + { + key: "private", + label: "Private", + }, + { + key: "archived", + label: "Archived", + }, +]; + +export const PageTabNavigation: FC = (props) => { + const { workspaceSlug, projectId, pageType } = props; + + return ( +
    + {pageTabs.map((tab) => ( + +
    +
    + {tab.label} +
    +
    +
    + + ))} +
    + ); +}; diff --git a/web/components/pages/loaders/page-detail.tsx b/web/components/pages/loaders/page-detail-loader.tsx similarity index 100% rename from web/components/pages/loaders/page-detail.tsx rename to web/components/pages/loaders/page-detail-loader.tsx diff --git a/web/components/pages/loaders/page-loader.tsx b/web/components/pages/loaders/page-loader.tsx new file mode 100644 index 00000000000..fd446681be2 --- /dev/null +++ b/web/components/pages/loaders/page-loader.tsx @@ -0,0 +1,42 @@ +import { Loader } from "@plane/ui"; +import { FC } from "react"; + +type TPageLoader = {}; + +export const PageLoader: FC = (props) => { + const {} = props; + + return ( +
    +
    + + + + + +
    +
    + + +
    + + +
    +
    +
    +
    + {Array.from(Array(10)).map((i) => ( + + +
    + + + + +
    +
    + ))} +
    +
    + ); +}; diff --git a/web/components/pages/loaders/page.tsx b/web/components/pages/loaders/page.tsx deleted file mode 100644 index a7854efa1ae..00000000000 --- a/web/components/pages/loaders/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { FC } from "react"; - -type TPageLoader = {}; - -export const PageLoader: FC = (props) => { - const {} = props; - - return
    PageLoader
    ; -}; diff --git a/web/components/pages/modals/create-update-page-modal.tsx b/web/components/pages/modals/create-update-page-modal.tsx index fcdbd45e3ce..719497e323c 100644 --- a/web/components/pages/modals/create-update-page-modal.tsx +++ b/web/components/pages/modals/create-update-page-modal.tsx @@ -1,29 +1,42 @@ -import React, { FC } from "react"; +import React, { FC, useEffect, useState } from "react"; import { useRouter } from "next/router"; import { Dialog, Transition } from "@headlessui/react"; // components +import { PageForm } from "./"; import { PAGE_CREATED, PAGE_UPDATED } from "constants/event-tracker"; import { useEventTracker } from "hooks/store"; // hooks -// types -// import { IPage } from "@plane/types"; -// import { usePage } from "hooks/store/"; // import { IPageStore } from "store/pages/page.store"; -// constants +// import { usePage } from "hooks/store/"; +// types +import { TPage } from "@plane/types"; -type Props = { - // data?: IPage | null; - pageStore?: any; - handleClose: () => void; - isOpen: boolean; +type TCreateUpdatePageModal = { + workspaceSlug: string; projectId: string; + isModalOpen: boolean; + handleModalClose: () => void; + data?: Partial | undefined; }; -export const CreateUpdatePageModal: FC = (props) => { - const { isOpen, handleClose, projectId, pageStore } = props; - // router - const router = useRouter(); - const { workspaceSlug } = router.query; +export const CreateUpdatePageModal: FC = (props) => { + const { workspaceSlug, projectId, isModalOpen, handleModalClose, data: pageData } = props; + // hooks + // states + const [pageFormData, setPageFormData] = useState>({ name: "" }); + const handlePageFormData = (key: T, value: TPage[T]) => + setPageFormData((prev) => ({ ...prev, [key]: value })); + + useEffect(() => { + if (pageData) { + setPageFormData({ + id: pageData.id || undefined, + name: pageData.name || undefined, + access: pageData.access || undefined, + }); + } + }, [pageData]); + // store hooks // const { createPage } = useProjectPages(); // const { capturePageEvent } = useEventTracker(); @@ -50,8 +63,21 @@ export const CreateUpdatePageModal: FC = (props) => { // }); }; - const handleFormSubmit = async (formData: any) => { + const handleFormSubmit = async () => { if (!workspaceSlug || !projectId) return; + + if (pageFormData.id) { + try { + } catch { + console.log("something went wrong. Please try again later"); + } + } else { + try { + } catch { + console.log("something went wrong. Please try again later"); + } + } + // try { // if (pageStore) { // if (pageStore.name !== formData.name) { @@ -70,15 +96,15 @@ export const CreateUpdatePageModal: FC = (props) => { // } else { // await createProjectPage(formData); // } - // handleClose(); + // handleModalClose(); // } catch (error) { // console.log(error); // } }; return ( - - + + = (props) => { leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    diff --git a/web/components/pages/modals/index.ts b/web/components/pages/modals/index.ts new file mode 100644 index 00000000000..2bb7d9fa6af --- /dev/null +++ b/web/components/pages/modals/index.ts @@ -0,0 +1,4 @@ +export * from "./page-form"; + +export * from "./create-update-page-modal"; +export * from "./delete-page-modal"; diff --git a/web/components/pages/modals/page-form.tsx b/web/components/pages/modals/page-form.tsx index 8ed2255717c..54d88a4dda1 100644 --- a/web/components/pages/modals/page-form.tsx +++ b/web/components/pages/modals/page-form.tsx @@ -1,6 +1,8 @@ -import { Controller, useForm } from "react-hook-form"; +import { useState } from "react"; // ui -import { Button, Input, Tooltip } from "@plane/ui"; +import { Button, Input } from "@plane/ui"; +// types +import { TPage } from "@plane/types"; // types // import { IPage } from "@plane/types"; // constants @@ -8,104 +10,61 @@ import { Button, Input, Tooltip } from "@plane/ui"; // import { IPageStore } from "store/pages/page.store"; type Props = { - handleFormSubmit: (values: any) => Promise; - handleClose: () => void; - pageStore?: any; -}; - -const defaultValues = { - name: "", - description: "", - access: 0, + formData: Partial; + handleFormData: (key: T, value: TPage[T]) => void; + handleModalClose: () => void; + handleFormSubmit: () => Promise; }; export const PageForm: React.FC = (props) => { - const { handleFormSubmit, handleClose, pageStore } = props; + const { formData, handleFormData, handleModalClose, handleFormSubmit } = props; + // state + const [isSubmitting, setIsSubmitting] = useState(false); - const { - formState: { errors, isSubmitting }, - handleSubmit, - control, - } = useForm({ - defaultValues: pageStore - ? { name: pageStore.name, description: pageStore.description, access: pageStore.access } - : defaultValues, - }); - - const handleCreateUpdatePage = (formData: any) => handleFormSubmit(formData); + const handlePageFormSubmit = async () => { + try { + setIsSubmitting(true); + await handleFormSubmit(); + setIsSubmitting(false); + } catch { + setIsSubmitting(false); + } + }; return ( - +
    -

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

    -
    -
    - ( - - )} - /> -
    +

    + {formData?.id ? "Update" : "Create"} Page +

    + +
    + handleFormData("name", e.target.value)} + placeholder="Title" + className="w-full resize-none text-lg" + tabIndex={1} + required + />
    +
    - ( -
    -
    - {/* {PAGE_ACCESS_SPECIFIERS.map((access, index) => ( - - - - ))} */} -
    - {/*
    - {PAGE_ACCESS_SPECIFIERS.find((access) => access.key === value)?.label} -
    */} -
    - )} - />
    -
    diff --git a/web/components/pages/page-detail/root.tsx b/web/components/pages/page-detail/root.tsx index 05a13b6bb2a..9026c9ecd5d 100644 --- a/web/components/pages/page-detail/root.tsx +++ b/web/components/pages/page-detail/root.tsx @@ -1,7 +1,7 @@ import { FC, Fragment } from "react"; import { observer } from "mobx-react-lite"; // hooks -import { usePage, usePageDetail } from "hooks/store"; +import { useProjectPages, usePage } from "hooks/store"; // components import { PageHead } from "components/core"; import { PageDetailRootLoader } from "./"; @@ -14,10 +14,10 @@ type TPageDetailRoot = { export const PageDetailRoot: FC = observer((props) => { const { projectId, pageId } = props; // hooks - const { loader } = usePage(projectId); + const { loader } = useProjectPages(projectId); const { data: { id, name }, - } = usePageDetail(projectId, pageId); + } = usePage(projectId, pageId); if (loader === "init-loader") return ; diff --git a/web/components/pages/views/page-layout.tsx b/web/components/pages/views/page-layout.tsx new file mode 100644 index 00000000000..98bdcfb525b --- /dev/null +++ b/web/components/pages/views/page-layout.tsx @@ -0,0 +1,73 @@ +import { FC, Fragment, ReactNode } from "react"; +import { observer } from "mobx-react-lite"; +import useSWR from "swr"; +// hooks +import { useProjectPages } from "hooks/store"; +// components +import { PageLoader, PageEmptyState, PageTabNavigation, PageSearchInput } from ".."; +import { TPageNavigationTabs } from "@plane/types"; + +type TPageView = { + workspaceSlug: string; + projectId: string; + pageType?: TPageNavigationTabs; + children: ReactNode; +}; + +export const PageView: FC = observer((props) => { + const { workspaceSlug, projectId, pageType = "public", children } = props; + // hooks + const { + loader, + getAllPages, + pageIds, + filters: { searchQuery }, + } = useProjectPages(projectId); + + // fetching pages list + useSWR(projectId && pageType ? `PROJECT_PAGES_${projectId}_${pageType}` : null, async () => { + projectId && pageType && (await getAllPages()); + }); + + // pages loader + if (loader === "init-loader") return ; + return ( +
    + {/* tab header */} +
    + + +
    + + +
    Sort filter
    + +
    Filters
    +
    +
    + + {pageIds && pageIds.length === 0 ? ( + // no filtered pages are available + + ) : pageIds && pageIds.length === 0 && searchQuery.length > 0 ? ( + // no searching pages are available + + ) : pageIds && pageIds.length === 0 ? ( + // no pages are available + + ) : ( +
    {children}
    + )} + + {/* no search elements */} +
    + ); +}); diff --git a/web/constants/page.ts b/web/constants/page.ts index dbb15f88b21..73865489f7d 100644 --- a/web/constants/page.ts +++ b/web/constants/page.ts @@ -11,7 +11,7 @@ export const pageSorting: { key: TPageFiltersSortKey; label: string }[] = [ { key: "updated_at", label: "Last Modified" }, ]; -export const pageSortingBy: Record = { - asc: { label: "Ascending" }, - desc: { label: "Descending" }, -}; +export const pageSortingBy: { key: TPageFiltersSortBy; label: string }[] = [ + { key: "asc", label: "Ascending" }, + { key: "desc", label: "Descending" }, +]; diff --git a/web/hooks/store/index.ts b/web/hooks/store/index.ts index 3882cebcf16..137c42345fa 100644 --- a/web/hooks/store/index.ts +++ b/web/hooks/store/index.ts @@ -11,8 +11,8 @@ export * from "./use-member"; export * from "./use-mention"; export * from "./use-module"; +export * from "./pages/use-project-page"; export * from "./pages/use-page"; -export * from "./pages/use-page-detail"; export * from "./use-module-filter"; export * from "./use-project-filter"; diff --git a/web/hooks/store/pages/use-page-detail.ts b/web/hooks/store/pages/use-page-detail.ts deleted file mode 100644 index 126427840c8..00000000000 --- a/web/hooks/store/pages/use-page-detail.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useContext } from "react"; -import useSWR from "swr"; -// context -import { StoreContext } from "contexts/store-context"; -// hooks -import { usePage } from "./use-page"; -// mobx store -import { IPageStore } from "store/pages/page.store"; - -export const usePageDetail = (projectId: string, pageId: string): IPageStore => { - const context = useContext(StoreContext); - if (context === undefined) throw new Error("useProjectPublish must be used within StoreProvider"); - - if (!projectId || !pageId) throw new Error("projectId, pageId must be passed as a property"); - - const { fetchById } = usePage(projectId); - - useSWR(projectId && pageId ? `PROJECT_PAGE_DETAIL_${projectId}_${pageId}` : null, async () => { - projectId && pageId && (await fetchById(pageId)); - }); - - return context.projectPage.data?.[projectId]?.[pageId] ?? {}; -}; diff --git a/web/hooks/store/pages/use-page.ts b/web/hooks/store/pages/use-page.ts index 20372c8dcd1..7959aa6513e 100644 --- a/web/hooks/store/pages/use-page.ts +++ b/web/hooks/store/pages/use-page.ts @@ -2,21 +2,22 @@ import { useContext } from "react"; import useSWR from "swr"; // context import { StoreContext } from "contexts/store-context"; +// hooks +import { useProjectPages } from "./use-project-page"; // mobx store -import { IProjectPageStore } from "store/pages/project-page.store"; +import { IPageStore } from "store/pages/page.store"; -export const usePage = (projectId: string | undefined): IProjectPageStore => { +export const usePage = (projectId: string, pageId: string): IPageStore => { const context = useContext(StoreContext); if (context === undefined) throw new Error("useProjectPublish must be used within StoreProvider"); - if (!projectId) throw new Error("projectId must be passed as a property"); + if (!projectId || !pageId) throw new Error("projectId, pageId must be passed as a property"); - const projectPage = context.projectPage; - const { fetch } = projectPage; + const { fetchById } = useProjectPages(projectId); - useSWR(projectId ? `PROJECT_PAGES_${projectId}` : null, async () => { - projectId && (await fetch()); + useSWR(projectId && pageId ? `PROJECT_PAGE_DETAIL_${projectId}_${pageId}` : null, async () => { + projectId && pageId && (await fetchById(pageId)); }); - return context.projectPage; + return context.projectPage.data?.[projectId]?.[pageId] ?? {}; }; diff --git a/web/hooks/store/pages/use-project-page.ts b/web/hooks/store/pages/use-project-page.ts new file mode 100644 index 00000000000..aa1e9e456a4 --- /dev/null +++ b/web/hooks/store/pages/use-project-page.ts @@ -0,0 +1,14 @@ +import { useContext } from "react"; +// context +import { StoreContext } from "contexts/store-context"; +// mobx store +import { IProjectPageStore } from "store/pages/project-page.store"; + +export const useProjectPages = (projectId: string | undefined): IProjectPageStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useProjectPublish must be used within StoreProvider"); + + if (!projectId) throw new Error("projectId must be passed as a property"); + + return context.projectPage; +}; diff --git a/web/lib/posthog-provider.tsx b/web/lib/posthog-provider.tsx index 80391ba95f1..e44f3b22518 100644 --- a/web/lib/posthog-provider.tsx +++ b/web/lib/posthog-provider.tsx @@ -26,21 +26,6 @@ const PostHogProvider: FC = (props) => { // router const router = useRouter(); - useEffect(() => { - if (user) { - // Identify sends an event, so you want may want to limit how often you call it - posthog?.identify(user.email, { - id: user.id, - first_name: user.first_name, - last_name: user.last_name, - email: user.email, - use_case: user.use_case, - workspace_role: workspaceRole ? getUserRole(workspaceRole) : undefined, - project_role: projectRole ? getUserRole(projectRole) : undefined, - }); - } - }, [user, workspaceRole, projectRole]); - useEffect(() => { if (posthogAPIKey && posthogHost) { posthog.init(posthogAPIKey, { diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx index 177e23d72ae..8fff709fa4b 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx @@ -1,29 +1,43 @@ -import { ReactElement } from "react"; +import { ReactElement, useState } from "react"; import { useRouter } from "next/router"; // layouts import { AppLayout } from "layouts/app-layout"; // components import { PagesHeader } from "components/headers"; -import { PageLayout } from "components/pages"; +import { PageView, PagesListRoot, CreateUpdatePageModal } from "components/pages"; // types import { NextPageWithLayout } from "lib/types"; +import { TPageNavigationTabs } from "@plane/types"; // constants const ProjectPagesPage: NextPageWithLayout = () => { // router const router = useRouter(); - const { workspaceSlug, projectId, pageType } = router.query; + const { workspaceSlug, projectId, type } = router.query; + // state + const [modalOpen, setModalOpen] = useState(false); + + const currentPageType = (): TPageNavigationTabs => { + if (!type) return "public"; + const pageType = type.toString(); + if (pageType === "private") return "private"; + if (pageType === "archived") return "archived"; + return "public"; + }; if (!workspaceSlug || !projectId) return <>; return ( <> - + + + + -
    Pages Init
    -
    + isModalOpen={modalOpen} + handleModalClose={() => setModalOpen(false)} + /> ); }; diff --git a/web/services/page.service.ts b/web/services/page.service.ts index 4487c149c1f..90b046db94a 100644 --- a/web/services/page.service.ts +++ b/web/services/page.service.ts @@ -33,7 +33,7 @@ export class PageService extends APIService { }); } - async update(workspaceSlug: string, projectId: string, pageId: String, data: Partial): Promise { + async update(workspaceSlug: string, projectId: string, pageId: string, data: Partial): Promise { return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, data) .then((response) => response?.data) .catch((error) => { @@ -41,7 +41,7 @@ export class PageService extends APIService { }); } - async remove(workspaceSlug: string, projectId: string, pageId: String): Promise { + async remove(workspaceSlug: string, projectId: string, pageId: string): Promise { return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`) .then((response) => response?.data) .catch((error) => { diff --git a/web/store/pages/page.store.ts b/web/store/pages/page.store.ts index 0c78bba5a85..65263a08666 100644 --- a/web/store/pages/page.store.ts +++ b/web/store/pages/page.store.ts @@ -4,16 +4,15 @@ import { RootStore } from "../root.store"; // service import { PageService } from "services/page.service"; // types -import { TPage } from "@plane/types"; +import { TPage, TPageAccess } from "@plane/types"; // constants import { EPageAccess } from "constants/page"; export type TLoader = "submitting" | "submitted" | "saved" | undefined; -export interface IPageStore { +export interface IPageStore extends TPage { // observables loader: TLoader; - data: TPage; // computed isContentEditable: boolean; // helper actions @@ -28,16 +27,48 @@ export interface IPageStore { } export class PageStore implements IPageStore { + id: string | undefined; + name: string | undefined; + description_html: string | undefined; + color: string | undefined; + labels: string[] | undefined; + owned_by: string | undefined; + access: TPageAccess | undefined; + is_favorite: boolean; + is_locked: boolean; + archived_at: string | undefined; + workspace: string | undefined; + project: string | undefined; + created_by: string | undefined; + updated_by: string | undefined; + created_at: Date | undefined; + updated_at: Date | undefined; + loader: TLoader = undefined; - data: TPage; // service pageService: PageService; constructor(private store: RootStore, page: TPage) { + this.id = page?.id || undefined; + this.name = page?.name || undefined; + this.description_html = page?.description_html || undefined; + this.color = page?.color || undefined; + this.labels = page?.labels || undefined; + this.owned_by = page?.owned_by || undefined; + this.access = page?.access || EPageAccess.PUBLIC; + this.is_favorite = page?.is_favorite || false; + this.is_locked = page?.is_locked || false; + this.archived_at = page?.archived_at || undefined; + this.workspace = page?.workspace || undefined; + this.project = page?.project || undefined; + this.created_by = page?.created_by || undefined; + this.updated_by = page?.updated_by || undefined; + this.created_at = page?.created_at || undefined; + this.updated_at = page?.updated_at || undefined; + makeObservable(this, { // observables loader: observable.ref, - data: observable, // computed isContentEditable: computed, // helper actions @@ -51,25 +82,6 @@ export class PageStore implements IPageStore { removeFromFavorites: action, }); - this.data = { - id: page?.id || undefined, - name: page?.name || undefined, - description_html: page?.description_html || undefined, - color: page?.color || undefined, - labels: page?.labels || undefined, - owned_by: page?.owned_by || undefined, - access: page?.access || EPageAccess.PUBLIC, - is_favorite: page?.is_favorite || false, - is_locked: page?.is_locked || false, - archived_at: page?.archived_at || undefined, - workspace: page?.workspace || undefined, - project: page?.project || undefined, - created_by: page?.created_by || undefined, - updated_by: page?.updated_by || undefined, - created_at: page?.created_at || undefined, - updated_at: page?.updated_at || undefined, - }; - this.pageService = new PageService(); } @@ -78,9 +90,9 @@ export class PageStore implements IPageStore { const currentUserId = this.store.user.currentUser?.id; if (!currentUserId) return false; - const isOwner = this.data.owned_by === currentUserId; - const isPublic = this.data.access === EPageAccess.PUBLIC; - const isLocked = this.data.is_locked; + const isOwner = this.owned_by === currentUserId; + const isPublic = this.access === EPageAccess.PUBLIC; + const isLocked = this.is_locked; if (isOwner) return true; if (!isOwner && isPublic) return true; @@ -93,81 +105,81 @@ export class PageStore implements IPageStore { makePublic = async () => { const { workspaceSlug, projectId } = this.store.app.router; - if (!workspaceSlug || !projectId || !this.data.id) return undefined; + if (!workspaceSlug || !projectId || !this.id) return undefined; - const _access = this.data.access; - runInAction(() => (this.data.access = EPageAccess.PUBLIC)); + const pageAccess = this.access; + runInAction(() => (this.access = EPageAccess.PUBLIC)); await this.pageService - .update(workspaceSlug, projectId, this.data.id, { + .update(workspaceSlug, projectId, this.id, { access: EPageAccess.PUBLIC, }) .catch(() => { - runInAction(() => (this.data.access = _access)); + runInAction(() => (this.access = pageAccess)); }); }; makePrivate = async () => { const { workspaceSlug, projectId } = this.store.app.router; - if (!workspaceSlug || !projectId || !this.data.id) return undefined; + if (!workspaceSlug || !projectId || !this.id) return undefined; - const _access = this.data.access; - runInAction(() => (this.data.access = EPageAccess.PRIVATE)); + const pageAccess = this.access; + runInAction(() => (this.access = EPageAccess.PRIVATE)); await this.pageService - .update(workspaceSlug, projectId, this.data.id, { + .update(workspaceSlug, projectId, this.id, { access: EPageAccess.PRIVATE, }) .catch(() => { - runInAction(() => (this.data.access = _access)); + runInAction(() => (this.access = pageAccess)); }); }; lock = async () => { const { workspaceSlug, projectId } = this.store.app.router; - if (!workspaceSlug || !projectId || !this.data.id) return undefined; + if (!workspaceSlug || !projectId || !this.id) return undefined; - const _is_locked = this.data.is_locked; - runInAction(() => (this.data.is_locked = true)); + const pageIsLocked = this.is_locked; + runInAction(() => (this.is_locked = true)); - await this.pageService.lock(workspaceSlug, projectId, this.data.id).catch(() => { - runInAction(() => (this.data.is_locked = _is_locked)); + await this.pageService.lock(workspaceSlug, projectId, this.id).catch(() => { + runInAction(() => (this.is_locked = pageIsLocked)); }); }; unlock = async () => { const { workspaceSlug, projectId } = this.store.app.router; - if (!workspaceSlug || !projectId || !this.data.id) return undefined; + if (!workspaceSlug || !projectId || !this.id) return undefined; - const _is_locked = this.data.is_locked; - runInAction(() => (this.data.is_locked = false)); + const pageIsLocked = this.is_locked; + runInAction(() => (this.is_locked = false)); - await this.pageService.unlock(workspaceSlug, projectId, this.data.id).catch(() => { - runInAction(() => (this.data.is_locked = _is_locked)); + await this.pageService.unlock(workspaceSlug, projectId, this.id).catch(() => { + runInAction(() => (this.is_locked = pageIsLocked)); }); }; addToFavorites = async () => { const { workspaceSlug, projectId } = this.store.app.router; - if (!workspaceSlug || !projectId || !this.data.id) return undefined; + if (!workspaceSlug || !projectId || !this.id) return undefined; - const _is_favorite = this.data.is_favorite; - runInAction(() => (this.data.is_favorite = true)); + const pageIsFavorite = this.is_favorite; + runInAction(() => (this.is_favorite = true)); - await this.pageService.makeFavorite(workspaceSlug, projectId, this.data.id).catch(() => { - runInAction(() => (this.data.is_favorite = _is_favorite)); + await this.pageService.makeFavorite(workspaceSlug, projectId, this.id).catch(() => { + runInAction(() => (this.is_favorite = pageIsFavorite)); }); }; removeFromFavorites = async () => { const { workspaceSlug, projectId } = this.store.app.router; - if (!workspaceSlug || !projectId || !this.data.id) return undefined; + if (!workspaceSlug || !projectId || !this.id) return undefined; - const _is_favorite = this.data.is_favorite; - runInAction(() => (this.data.is_favorite = false)); + const pageIsFavorite = this.is_favorite; + runInAction(() => (this.is_favorite = false)); - await this.pageService.removeFavorite(workspaceSlug, projectId, this.data.id).catch(() => { - runInAction(() => (this.data.is_favorite = _is_favorite)); + await this.pageService.removeFavorite(workspaceSlug, projectId, this.id).catch(() => { + runInAction(() => (this.is_favorite = pageIsFavorite)); }); }; } diff --git a/web/store/pages/project-page.store.ts b/web/store/pages/project-page.store.ts index 828a9d747db..f35072b7c75 100644 --- a/web/store/pages/project-page.store.ts +++ b/web/store/pages/project-page.store.ts @@ -26,10 +26,10 @@ export interface IProjectPageStore { pageById: (pageId: string) => IPageStore | undefined; updateFilters: (filterKey: T, filterValue: TPageFilters[T]) => void; // actions - fetch: (_loader?: TLoader) => Promise; - fetchById: (pageId: string) => Promise; - create: (pageData: Partial) => Promise; - delete: (pageId: string) => Promise; + getAllPages: (_loader?: TLoader) => Promise; + getPageById: (pageId: string) => Promise; + createPage: (pageData: Partial) => Promise; + removePage: (pageId: string) => Promise; } export class ProjectPageStore implements IProjectPageStore { @@ -38,7 +38,7 @@ export class ProjectPageStore implements IProjectPageStore { data: Record> = {}; // projectId => pageId => PageStore error: TError | undefined = undefined; filters: TPageFilters = { - search: "", + searchQuery: "", sortKey: "name", sortBy: "asc", }; @@ -57,10 +57,10 @@ export class ProjectPageStore implements IProjectPageStore { // helper actions updateFilters: action, // actions - fetch: action, - fetchById: action, - create: action, - delete: action, + getAllPages: action, + getPageById: action, + createPage: action, + removePage: action, }); this.service = new PageService(); @@ -70,9 +70,7 @@ export class ProjectPageStore implements IProjectPageStore { const { projectId } = this.store.app.router; if (!projectId) return undefined; - // TODO: filter the pages based on the filter - - const pages = Object.keys(this.data?.[projectId]) || undefined; + const pages = Object.keys(this?.data?.[projectId] || {}) || undefined; if (!pages) return undefined; return pages; @@ -93,24 +91,27 @@ export class ProjectPageStore implements IProjectPageStore { }; // actions - fetch = async () => { + getAllPages = async () => { try { const { workspaceSlug, projectId } = this.store.app.router; if (!workspaceSlug || !projectId) return undefined; + console.log("hello"); + const currentPageIds = this.pageIds; + console.log("currentPageIds", currentPageIds); runInAction(() => { - this.loader = currentPageIds ? `mutation-loader` : `init-loader`; + this.loader = currentPageIds && currentPageIds.length > 0 ? `mutation-loader` : `init-loader`; this.error = undefined; }); - const _pages = await this.service.fetchAll(workspaceSlug, projectId); + const pages = await this.service.fetchAll(workspaceSlug, projectId); runInAction(() => { - for (const page of _pages) if (page?.id) set(this.data, [projectId, page.id], new PageStore(this.store, page)); + for (const page of pages) if (page?.id) set(this.data, [projectId, page.id], new PageStore(this.store, page)); this.loader = undefined; }); - return _pages; + return pages; } catch { runInAction(() => { this.loader = undefined; @@ -122,7 +123,7 @@ export class ProjectPageStore implements IProjectPageStore { } }; - fetchById = async (pageId: string) => { + getPageById = async (pageId: string) => { try { const { workspaceSlug, projectId } = this.store.app.router; if (!workspaceSlug || !projectId || !pageId) return undefined; @@ -133,13 +134,13 @@ export class ProjectPageStore implements IProjectPageStore { this.error = undefined; }); - const _page = await this.service.fetchById(workspaceSlug, projectId, pageId); + const page = await this.service.fetchById(workspaceSlug, projectId, pageId); runInAction(() => { - if (_page?.id) set(this.data, [projectId, _page.id], new PageStore(this.store, _page)); + if (page?.id) set(this.data, [projectId, page.id], new PageStore(this.store, page)); this.loader = undefined; }); - return _page; + return page; } catch { runInAction(() => { this.loader = undefined; @@ -151,7 +152,7 @@ export class ProjectPageStore implements IProjectPageStore { } }; - create = async (pageData: Partial) => { + createPage = async (pageData: Partial) => { try { const { workspaceSlug, projectId } = this.store.app.router; if (!workspaceSlug || !projectId) return undefined; @@ -161,13 +162,13 @@ export class ProjectPageStore implements IProjectPageStore { this.error = undefined; }); - const _page = await this.service.create(workspaceSlug, projectId, pageData); + const page = await this.service.create(workspaceSlug, projectId, pageData); runInAction(() => { - if (_page?.id) set(this.data, [projectId, _page.id], new PageStore(this.store, _page)); + if (page?.id) set(this.data, [projectId, page.id], new PageStore(this.store, page)); this.loader = undefined; }); - return _page; + return page; } catch { runInAction(() => { this.loader = undefined; @@ -179,7 +180,7 @@ export class ProjectPageStore implements IProjectPageStore { } }; - delete = async (pageId: string) => { + removePage = async (pageId: string) => { try { const { workspaceSlug, projectId } = this.store.app.router; if (!workspaceSlug || !projectId || !pageId) return undefined; From ec9c313e3320e35e26e00e5073ad5a97aa1f4f57 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Wed, 13 Mar 2024 17:43:30 +0530 Subject: [PATCH 014/179] style: pages list item UI --- web/components/pages/dropdowns/index.ts | 1 + .../pages/dropdowns/quick-actions.tsx | 60 +++++++++++++++++++ web/components/pages/index.ts | 3 + web/components/pages/list/block.tsx | 54 +++++++++++++++-- web/components/pages/list/root.tsx | 2 +- 5 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 web/components/pages/dropdowns/index.ts create mode 100644 web/components/pages/dropdowns/quick-actions.tsx diff --git a/web/components/pages/dropdowns/index.ts b/web/components/pages/dropdowns/index.ts new file mode 100644 index 00000000000..db98841b0e4 --- /dev/null +++ b/web/components/pages/dropdowns/index.ts @@ -0,0 +1 @@ +export * from "./quick-actions"; diff --git a/web/components/pages/dropdowns/quick-actions.tsx b/web/components/pages/dropdowns/quick-actions.tsx new file mode 100644 index 00000000000..d886e76e4b8 --- /dev/null +++ b/web/components/pages/dropdowns/quick-actions.tsx @@ -0,0 +1,60 @@ +import { ExternalLink, Link, Pencil } from "lucide-react"; +// ui +import { ArchiveIcon, CustomMenu } from "@plane/ui"; + +type Props = { + pageId: string; +}; + +export const PageQuickActions: React.FC = (props) => { + const {} = props; + + return ( + + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + Copy link + + + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + Open in new tab + + + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + Edit + + + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + Archive + + + + ); +}; diff --git a/web/components/pages/index.ts b/web/components/pages/index.ts index d6ccf1cd3fc..da842cb9c9d 100644 --- a/web/components/pages/index.ts +++ b/web/components/pages/index.ts @@ -12,6 +12,9 @@ export * from "./views/page-layout"; // modals export * from "./modals"; +// dropdowns +export * from "./dropdowns"; + // pages list components export * from "./list"; diff --git a/web/components/pages/list/block.tsx b/web/components/pages/list/block.tsx index 395d37b5859..8c0b89b1a1a 100644 --- a/web/components/pages/list/block.tsx +++ b/web/components/pages/list/block.tsx @@ -1,13 +1,57 @@ import { FC } from "react"; +import Link from "next/link"; +import { Circle, Info, Star, UsersRound } from "lucide-react"; +// components +import { PageQuickActions } from "components/pages"; +import { Tooltip } from "@plane/ui"; -type TPageListBlock = {}; +type TPageListBlock = { + pageId: string; +}; export const PageListBlock: FC = (props) => { - const {} = props; + const { pageId } = props; return ( -
    -
    PageListBlock
    -
    + + {/* page title */} + +
    Page title
    +
    + {/* page properties */} +
    + {/* duration & privacy */} +
    + 10m read + + {/* */} + +
    + {/* page info */} + + {/* favorite/unfavorite */} + + {/* quick actions dropdown */} + +
    + ); }; diff --git a/web/components/pages/list/root.tsx b/web/components/pages/list/root.tsx index 51375845ce1..112d94e605a 100644 --- a/web/components/pages/list/root.tsx +++ b/web/components/pages/list/root.tsx @@ -14,7 +14,7 @@ export const PagesListRoot: FC = (props) => { console.log("projectId", projectId); return ( -
    +
    ); From 768d141729215f7b1dc32031add8ca05e479e93b Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Thu, 14 Mar 2024 13:08:24 +0530 Subject: [PATCH 015/179] fix: improved colors and drag handle width --- .../extensions/src/styles/drag-drop.css | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/editor/extensions/src/styles/drag-drop.css b/packages/editor/extensions/src/styles/drag-drop.css index d95a8654bd0..f3fdf9db3c1 100644 --- a/packages/editor/extensions/src/styles/drag-drop.css +++ b/packages/editor/extensions/src/styles/drag-drop.css @@ -2,7 +2,7 @@ position: fixed; opacity: 1; transition: opacity ease-in 0.2s; - height: 18px; + height: 20px; width: 15px; display: grid; place-items: center; @@ -12,9 +12,24 @@ background-color: rgb(var(--color-background-90)); } +.ProseMirror:not(.dragging) .ProseMirror-selectednode { + outline: none !important; + cursor: grab; + background-color: #1f2937; + transition: background-color 0.2s; + box-shadow: none; +} + .drag-handle:hover { - background-color: rgb(var(--color-background-80)); + background-color: #4d4d4d; + cursor: grab; + transition: background-color 0.2s; +} + +.drag-handle:active { + background-color: #4d4d4d; transition: background-color 0.2s; + cursor: grabbing; } .drag-handle.hidden { From 13ba7d59e08a2091f97bfed15d43c9480744b8d1 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Thu, 14 Mar 2024 13:10:27 +0530 Subject: [PATCH 016/179] fix: slash commands are no more shown in the code blocks --- .../extensions/src/extensions/slash-commands.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/editor/extensions/src/extensions/slash-commands.tsx b/packages/editor/extensions/src/extensions/slash-commands.tsx index 88e257cef6d..3f4f082972c 100644 --- a/packages/editor/extensions/src/extensions/slash-commands.tsx +++ b/packages/editor/extensions/src/extensions/slash-commands.tsx @@ -54,7 +54,20 @@ const Command = Extension.create({ props.command({ editor, range }); }, allow({ editor }: { editor: Editor }) { - return !editor.isActive("table"); + const { selection } = editor.state; + + const parentNode = selection.$from.node(selection.$from.depth); + const blockType = parentNode.type.name; + + if (blockType === "codeBlock") { + return false; + } + + if (editor.isActive("table")) { + return false; + } + + return true; }, allowSpaces: true, }, From 252c8a30bc43d06a90de0c465a6c36c98938100e Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Thu, 14 Mar 2024 13:13:29 +0530 Subject: [PATCH 017/179] fix: cleanup/hide drag handles post drop --- packages/editor/extensions/src/extensions/drag-drop.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/editor/extensions/src/extensions/drag-drop.tsx b/packages/editor/extensions/src/extensions/drag-drop.tsx index ce4088413c3..a98c810a518 100644 --- a/packages/editor/extensions/src/extensions/drag-drop.tsx +++ b/packages/editor/extensions/src/extensions/drag-drop.tsx @@ -233,6 +233,7 @@ function DragHandle(options: DragHandleOptions) { }, drop: (view) => { view.dom.classList.remove("dragging"); + hideDragHandle(); }, dragend: (view) => { view.dom.classList.remove("dragging"); From 15be7eeda32518b8d5d6890d9f0cbf28ad90f748 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Thu, 14 Mar 2024 13:14:44 +0530 Subject: [PATCH 018/179] fix: hide/cleanup drag handles post drag start --- packages/editor/extensions/src/extensions/drag-drop.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/editor/extensions/src/extensions/drag-drop.tsx b/packages/editor/extensions/src/extensions/drag-drop.tsx index a98c810a518..61b598c5d43 100644 --- a/packages/editor/extensions/src/extensions/drag-drop.tsx +++ b/packages/editor/extensions/src/extensions/drag-drop.tsx @@ -227,9 +227,9 @@ function DragHandle(options: DragHandleOptions) { wheel: () => { hideDragHandle(); }, - // dragging className is used for CSS - dragstart: (view) => { + dragenter: (view) => { view.dom.classList.add("dragging"); + hideDragHandle(); }, drop: (view) => { view.dom.classList.remove("dragging"); From 52b2fbefe1ab0485b49192afb885ae28f51240d0 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Thu, 14 Mar 2024 13:15:38 +0530 Subject: [PATCH 019/179] fix: aligning the drag handles better with the node post css changes of the length --- packages/editor/extensions/src/extensions/drag-drop.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/extensions/src/extensions/drag-drop.tsx b/packages/editor/extensions/src/extensions/drag-drop.tsx index 61b598c5d43..30699fbb9b5 100644 --- a/packages/editor/extensions/src/extensions/drag-drop.tsx +++ b/packages/editor/extensions/src/extensions/drag-drop.tsx @@ -207,7 +207,7 @@ function DragHandle(options: DragHandleOptions) { const rect = absoluteRect(node); - rect.top += (lineHeight - 24) / 2; + rect.top += (lineHeight - 20) / 2; rect.top += paddingTop; // Li markers if (node.matches("ul:not([data-type=taskList]) li, ol li")) { From 8b297b2bdacc295692b300091848d5ca56ee1f49 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Thu, 14 Mar 2024 13:16:59 +0530 Subject: [PATCH 020/179] fix: juggling back and forth of drag handles in ordered and unordered lists --- packages/editor/extensions/src/extensions/drag-drop.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/extensions/src/extensions/drag-drop.tsx b/packages/editor/extensions/src/extensions/drag-drop.tsx index 30699fbb9b5..272f5edfa1b 100644 --- a/packages/editor/extensions/src/extensions/drag-drop.tsx +++ b/packages/editor/extensions/src/extensions/drag-drop.tsx @@ -196,7 +196,7 @@ function DragHandle(options: DragHandleOptions) { y: event.clientY, }); - if (!(node instanceof Element)) { + if (!(node instanceof Element) || node.matches("ul, ol")) { hideDragHandle(); return; } From 543168a756b9497f6e2922d9f8b2a1849740330b Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Thu, 14 Mar 2024 13:17:51 +0530 Subject: [PATCH 021/179] chore: fix imports, ts errors and other things --- .../extensions/src/extensions/drag-drop.tsx | 68 ++++++------------- 1 file changed, 22 insertions(+), 46 deletions(-) diff --git a/packages/editor/extensions/src/extensions/drag-drop.tsx b/packages/editor/extensions/src/extensions/drag-drop.tsx index 272f5edfa1b..89126512b88 100644 --- a/packages/editor/extensions/src/extensions/drag-drop.tsx +++ b/packages/editor/extensions/src/extensions/drag-drop.tsx @@ -1,9 +1,13 @@ import { Extension } from "@tiptap/core"; -import { PluginKey, NodeSelection, Plugin } from "@tiptap/pm/state"; -// @ts-ignore +import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state"; +// @ts-expect-error __serializeForClipboard's is not exported import { __serializeForClipboard, EditorView } from "@tiptap/pm/view"; -import React from "react"; + +export interface DragHandleOptions { + dragHandleWidth: number; + setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; +} function createDragHandleElement(): HTMLElement { const dragHandleElement = document.createElement("div"); @@ -29,13 +33,8 @@ function createDragHandleElement(): HTMLElement { return dragHandleElement; } -export interface DragHandleOptions { - dragHandleWidth: number; - setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; -} - function absoluteRect(node: Element) { - const data = node?.getBoundingClientRect(); + const data = node.getBoundingClientRect(); return { top: data.top, @@ -51,40 +50,18 @@ function nodeDOMAtCoords(coords: { x: number; y: number }) { (elem: Element) => elem.parentElement?.matches?.(".ProseMirror") || elem.matches( - [ - "li", - "p:not(:first-child)", - "pre", - "blockquote", - "h1, h2, h3", - "[data-type=horizontalRule]", - ".tableWrapper", - ].join(", ") + ["li", "p:not(:first-child)", "pre", "blockquote", "h1, h2, h3", "table", "[data-type=horizontalRule]"].join( + ", " + ) ) ); } -function nodePosAtDOM(node: Element, view: EditorView) { - const boundingRect = node?.getBoundingClientRect(); - - if (node.nodeName === "IMG") { - return view.posAtCoords({ - left: boundingRect.left + 1, - top: boundingRect.top + 1, - })?.pos; - } - - if (node.nodeName === "PRE") { - return ( - view.posAtCoords({ - left: boundingRect.left + 1, - top: boundingRect.top + 1, - })?.pos! - 1 - ); - } +function nodePosAtDOM(node: Element, view: EditorView, options: DragHandleOptions) { + const boundingRect = node.getBoundingClientRect(); return view.posAtCoords({ - left: boundingRect.left + 1, + left: boundingRect.left + 50 + options.dragHandleWidth, top: boundingRect.top + 1, })?.inside; } @@ -96,14 +73,14 @@ function DragHandle(options: DragHandleOptions) { if (!event.dataTransfer) return; const node = nodeDOMAtCoords({ - x: event.clientX + options.dragHandleWidth + 50, + x: event.clientX + 50 + options.dragHandleWidth, y: event.clientY, }); if (!(node instanceof Element)) return; - const nodePos = nodePosAtDOM(node, view); - if (nodePos === null || nodePos === undefined || nodePos < 0) return; + const nodePos = nodePosAtDOM(node, view, options); + if (nodePos == null || nodePos < 0) return; view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos))); @@ -132,9 +109,8 @@ function DragHandle(options: DragHandleOptions) { if (!(node instanceof Element)) return; - const nodePos = nodePosAtDOM(node, view); - - if (nodePos === null || nodePos === undefined || nodePos < 0) return; + const nodePos = nodePosAtDOM(node, view, options); + if (!nodePos) return; view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos))); } @@ -192,7 +168,7 @@ function DragHandle(options: DragHandleOptions) { } const node = nodeDOMAtCoords({ - x: event.clientX + options.dragHandleWidth, + x: event.clientX + 50 + options.dragHandleWidth, y: event.clientY, }); @@ -218,13 +194,13 @@ function DragHandle(options: DragHandleOptions) { if (!dragHandleElement) return; dragHandleElement.style.left = `${rect.left - rect.width}px`; - dragHandleElement.style.top = `${rect.top + 3}px`; + dragHandleElement.style.top = `${rect.top}px`; showDragHandle(); }, keydown: () => { hideDragHandle(); }, - wheel: () => { + mousewheel: () => { hideDragHandle(); }, dragenter: (view) => { From ec70d68399c2baa651f8c846cc9f4ef759646572 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Fri, 15 Mar 2024 10:45:32 +0530 Subject: [PATCH 022/179] fix: clearing nodes to default node i.e paragraph before converting it to other types of nodes For more reference on what this does, please refer https://tiptap.dev/docs/editor/api/commands/clear-nodes --- .../editor/core/src/lib/editor-commands.ts | 44 +++++++++---------- .../src/extensions/slash-commands.tsx | 5 ++- .../src/ui/menus/bubble-menu/index.tsx | 17 ++++--- .../ui/menus/bubble-menu/link-selector.tsx | 2 +- .../ui/menus/bubble-menu/node-selector.tsx | 2 +- 5 files changed, 39 insertions(+), 31 deletions(-) diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index 6524d1ff58a..ae0bafefeb3 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -4,18 +4,18 @@ import { findTableAncestor } from "src/lib/utils"; import { UploadImage } from "src/types/upload-image"; export const toggleHeadingOne = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); - else editor.chain().focus().toggleHeading({ level: 1 }).run(); + if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 1 }).run(); + else editor.chain().focus().clearNodes().toggleHeading({ level: 1 }).run(); }; export const toggleHeadingTwo = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); - else editor.chain().focus().toggleHeading({ level: 2 }).run(); + if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 2 }).run(); + else editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run(); }; export const toggleHeadingThree = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run(); - else editor.chain().focus().toggleHeading({ level: 3 }).run(); + if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 3 }).run(); + else editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run(); }; export const toggleBold = (editor: Editor, range?: Range) => { @@ -37,10 +37,10 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => { // Check if code block is active then toggle code block if (editor.isActive("codeBlock")) { if (range) { - editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); + editor.chain().focus().clearNodes().deleteRange(range).toggleCodeBlock().run(); return; } - editor.chain().focus().toggleCodeBlock().run(); + editor.chain().focus().clearNodes().toggleCodeBlock().run(); return; } @@ -49,32 +49,32 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => { if (isSelectionEmpty) { if (range) { - editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); + editor.chain().focus().clearNodes().deleteRange(range).toggleCodeBlock().run(); return; } - editor.chain().focus().toggleCodeBlock().run(); + editor.chain().focus().clearNodes().toggleCodeBlock().run(); } else { if (range) { - editor.chain().focus().deleteRange(range).toggleCode().run(); + editor.chain().focus().clearNodes().deleteRange(range).toggleCode().run(); return; } - editor.chain().focus().toggleCode().run(); + editor.chain().focus().clearNodes().toggleCode().run(); } }; export const toggleOrderedList = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run(); - else editor.chain().focus().toggleOrderedList().run(); + if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleOrderedList().run(); + else editor.chain().focus().clearNodes().toggleOrderedList().run(); }; export const toggleBulletList = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).toggleBulletList().run(); - else editor.chain().focus().toggleBulletList().run(); + if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleBulletList().run(); + else editor.chain().focus().clearNodes().toggleBulletList().run(); }; export const toggleTaskList = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run(); - else editor.chain().focus().toggleTaskList().run(); + if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleTaskList().run(); + else editor.chain().focus().clearNodes().toggleTaskList().run(); }; export const toggleStrike = (editor: Editor, range?: Range) => { @@ -83,8 +83,8 @@ export const toggleStrike = (editor: Editor, range?: Range) => { }; export const toggleBlockquote = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).toggleBlockquote().run(); - else editor.chain().focus().toggleBlockquote().run(); + if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleBlockquote().run(); + else editor.chain().focus().clearNodes().toggleBlockquote().run(); }; export const insertTableCommand = (editor: Editor, range?: Range) => { @@ -97,8 +97,8 @@ export const insertTableCommand = (editor: Editor, range?: Range) => { } } } - if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3 }).run(); - else editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); + if (range) editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3 }).run(); + else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3 }).run(); }; export const unsetLinkEditor = (editor: Editor) => { diff --git a/packages/editor/extensions/src/extensions/slash-commands.tsx b/packages/editor/extensions/src/extensions/slash-commands.tsx index 88e257cef6d..f37d18c6838 100644 --- a/packages/editor/extensions/src/extensions/slash-commands.tsx +++ b/packages/editor/extensions/src/extensions/slash-commands.tsx @@ -85,7 +85,10 @@ const getSuggestionItems = searchTerms: ["p", "paragraph"], icon: , command: ({ editor, range }: CommandProps) => { - editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run(); + if (range) { + editor.chain().focus().deleteRange(range).clearNodes().run(); + } + editor.chain().focus().clearNodes().run(); }, }, { diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx index f96e7293eb7..ef3329b6c2b 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx @@ -25,16 +25,20 @@ type EditorBubbleMenuProps = Omit; export const EditorBubbleMenu: FC = (props: any) => { const items: BubbleMenuItem[] = [ - BoldItem(props.editor), - ItalicItem(props.editor), - UnderLineItem(props.editor), - StrikeThroughItem(props.editor), + ...(props.editor.isActive("code") + ? [] + : [ + BoldItem(props.editor), + ItalicItem(props.editor), + UnderLineItem(props.editor), + StrikeThroughItem(props.editor), + ]), CodeItem(props.editor), ]; const bubbleMenuProps: EditorBubbleMenuProps = { ...props, - shouldShow: ({ view, state, editor }) => { + shouldShow: ({ state, editor }) => { const { selection } = state; const { empty } = selection; @@ -64,6 +68,7 @@ export const EditorBubbleMenu: FC = (props: any) => { const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); const [isSelecting, setIsSelecting] = useState(false); + useEffect(() => { function handleMouseDown() { function handleMouseMove() { @@ -109,7 +114,7 @@ export const EditorBubbleMenu: FC = (props: any) => { /> )} { setIsLinkSelectorOpen(!isLinkSelectorOpen); diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/link-selector.tsx b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/link-selector.tsx index 14448114dc0..e45cfb31790 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/link-selector.tsx +++ b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/link-selector.tsx @@ -84,8 +84,8 @@ export const LinkSelector: FC = ({ editor, isOpen, setIsOpen className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90" type="button" onClick={(e) => { - e.stopPropagation(); onLinkSubmit(); + e.stopPropagation(); }} > diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx index 6bc99608573..1bb8c38bd2b 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx +++ b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx @@ -26,7 +26,7 @@ export const NodeSelector: FC = ({ editor, isOpen, setIsOpen { name: "Text", icon: TextIcon, - command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").run(), + command: () => editor.chain().focus().clearNodes().run(), isActive: () => editor.isActive("paragraph") && !editor.isActive("bulletList") && !editor.isActive("orderedList"), }, HeadingOneItem(editor), From f7700b4536ee3bd2ee7a212336faa513b8214c25 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Fri, 15 Mar 2024 11:12:42 +0530 Subject: [PATCH 023/179] chore: clearNodes after delete in case of selections being present --- packages/editor/core/src/lib/editor-commands.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index ae0bafefeb3..7c3e7f11e60 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -37,7 +37,7 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => { // Check if code block is active then toggle code block if (editor.isActive("codeBlock")) { if (range) { - editor.chain().focus().clearNodes().deleteRange(range).toggleCodeBlock().run(); + editor.chain().focus().deleteRange(range).clearNodes().toggleCodeBlock().run(); return; } editor.chain().focus().clearNodes().toggleCodeBlock().run(); @@ -49,13 +49,13 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => { if (isSelectionEmpty) { if (range) { - editor.chain().focus().clearNodes().deleteRange(range).toggleCodeBlock().run(); + editor.chain().focus().deleteRange(range).clearNodes().toggleCodeBlock().run(); return; } editor.chain().focus().clearNodes().toggleCodeBlock().run(); } else { if (range) { - editor.chain().focus().clearNodes().deleteRange(range).toggleCode().run(); + editor.chain().focus().deleteRange(range).clearNodes().toggleCode().run(); return; } editor.chain().focus().clearNodes().toggleCode().run(); From adfa9c1b1aa36eb03f47928cacc25729a7ea43db Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Fri, 15 Mar 2024 11:25:05 +0530 Subject: [PATCH 024/179] fix: hiding link selector in the bubble menu if inline code block is selected --- .../src/ui/menus/bubble-menu/index.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx index ef3329b6c2b..2dbc86cec68 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx @@ -113,14 +113,16 @@ export const EditorBubbleMenu: FC = (props: any) => { }} /> )} - { - setIsLinkSelectorOpen(!isLinkSelectorOpen); - setIsNodeSelectorOpen(false); - }} - /> + {!props.editor.isActive("code") && ( + { + setIsLinkSelectorOpen(!isLinkSelectorOpen); + setIsNodeSelectorOpen(false); + }} + /> + )}
    {items.map((item) => ( + )} + + ) : ( +

    No matches found

    + ) + ) : ( + + + + + + )} +
    + )} + + ); +}); diff --git a/web/components/pages/dropdowns/filters/index.ts b/web/components/pages/dropdowns/filters/index.ts new file mode 100644 index 00000000000..be7c679b708 --- /dev/null +++ b/web/components/pages/dropdowns/filters/index.ts @@ -0,0 +1,4 @@ +export * from "./created-at"; +export * from "./created-by"; +export * from "./labels"; +export * from "./root"; diff --git a/web/components/pages/dropdowns/filters/labels.tsx b/web/components/pages/dropdowns/filters/labels.tsx new file mode 100644 index 00000000000..639c7632b55 --- /dev/null +++ b/web/components/pages/dropdowns/filters/labels.tsx @@ -0,0 +1,94 @@ +import React, { useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import sortBy from "lodash/sortBy"; +// components +import { Loader } from "@plane/ui"; +import { FilterHeader, FilterOption } from "components/issues"; +// ui +// types +import { IIssueLabel } from "@plane/types"; + +const LabelIcons = ({ color }: { color: string }) => ( + +); + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + labels: IIssueLabel[] | undefined; + searchQuery: string; +}; + +export const FilterLabels: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, labels, searchQuery } = props; + + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const sortedOptions = useMemo(() => { + const filteredOptions = (labels || []).filter((label) => + label.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (label) => !(appliedFilters ?? []).includes(label.id), + (label) => label.name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); + + const handleViewToggle = () => { + if (!sortedOptions) return; + + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
    + {sortedOptions ? ( + sortedOptions.length > 0 ? ( + <> + {sortedOptions.slice(0, itemsToRender).map((label) => ( + handleUpdate(label?.id)} + icon={} + title={label.name} + /> + ))} + {sortedOptions.length > 5 && ( + + )} + + ) : ( +

    No matches found

    + ) + ) : ( + + + + + + )} +
    + )} + + ); +}); diff --git a/web/components/pages/dropdowns/filters/root.tsx b/web/components/pages/dropdowns/filters/root.tsx new file mode 100644 index 00000000000..7baadd3e117 --- /dev/null +++ b/web/components/pages/dropdowns/filters/root.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Search, X } from "lucide-react"; +// components +import { FilterCreatedBy, FilterCreatedDate, FilterLabels } from "components/pages"; +import { FilterOption } from "components/issues"; +// types +import { IIssueLabel, TPageFilterProps, TPageFilters } from "@plane/types"; + +type Props = { + filters: TPageFilters; + handleFiltersUpdate: (filterKey: T, filterValue: TPageFilters[T]) => void; + labels?: IIssueLabel[] | undefined; + memberIds?: string[] | undefined; +}; + +export const PageFiltersSelection: React.FC = observer((props) => { + const { filters, handleFiltersUpdate, labels, memberIds } = props; + // states + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + const handleFilters = (key: keyof TPageFilterProps, value: boolean | string | string[]) => { + const newValues = filters.filters?.[key] ?? []; + + if (typeof newValues === "boolean" && typeof value === "boolean") { + return; + } + + if (Array.isArray(newValues)) { + if (Array.isArray(value)) + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + else if (typeof value === "string") { + if (newValues?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + } + + handleFiltersUpdate("filters", { + ...filters.filters, + [key]: newValues, + }); + }; + + return ( +
    +
    +
    + + setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + + )} +
    +
    +
    +
    + + handleFiltersUpdate("filters", { + ...filters.filters, + favorites: !filters.filters?.favorites, + }) + } + title="Favorites" + /> +
    + + {/* created date */} +
    + handleFilters("created_at", val)} + searchQuery={filtersSearchQuery} + /> +
    + + {/* created by */} +
    + handleFilters("created_by", val)} + searchQuery={filtersSearchQuery} + memberIds={memberIds} + /> +
    + + {/* labels */} +
    + handleFilters("labels", val)} + searchQuery={filtersSearchQuery} + labels={labels} + /> +
    +
    +
    + ); +}); diff --git a/web/components/pages/dropdowns/index.ts b/web/components/pages/dropdowns/index.ts index db98841b0e4..fa3b1281c49 100644 --- a/web/components/pages/dropdowns/index.ts +++ b/web/components/pages/dropdowns/index.ts @@ -1 +1,3 @@ +export * from "./filters"; +export * from "./order-by"; export * from "./quick-actions"; diff --git a/web/components/pages/dropdowns/order-by.tsx b/web/components/pages/dropdowns/order-by.tsx new file mode 100644 index 00000000000..290462b942b --- /dev/null +++ b/web/components/pages/dropdowns/order-by.tsx @@ -0,0 +1,77 @@ +import { ArrowDownWideNarrow, Check, ChevronDown } from "lucide-react"; +// ui +import { CustomMenu, getButtonStyling } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TPageFiltersSortBy, TPageFiltersSortKey } from "@plane/types"; +// constants +import { PAGE_SORTING_KEY_OPTIONS } from "constants/page"; + +type Props = { + onChange: (value: { key?: TPageFiltersSortKey; order?: TPageFiltersSortBy }) => void; + sortBy: TPageFiltersSortBy; + sortKey: TPageFiltersSortKey; +}; + +export const PageOrderByDropdown: React.FC = (props) => { + const { onChange, sortBy, sortKey } = props; + + const orderByDetails = PAGE_SORTING_KEY_OPTIONS.find((option) => sortKey === option.key); + const isDescending = sortBy === "desc"; + + return ( + + + {orderByDetails?.label} + +
    + } + placement="bottom-end" + maxHeight="lg" + closeOnSelect + > + {PAGE_SORTING_KEY_OPTIONS.map((option) => ( + + onChange({ + key: option.key, + }) + } + > + {option.label} + {sortKey === option.key && } + + ))} +
    + { + if (isDescending) + onChange({ + order: "asc", + }); + }} + > + Ascending + {!isDescending && } + + { + if (!isDescending) + onChange({ + order: "desc", + }); + }} + > + Descending + {isDescending && } + + + ); +}; diff --git a/web/components/pages/list/block.tsx b/web/components/pages/list/block.tsx index 8c0b89b1a1a..9c3616b3727 100644 --- a/web/components/pages/list/block.tsx +++ b/web/components/pages/list/block.tsx @@ -1,9 +1,10 @@ import { FC } from "react"; import Link from "next/link"; -import { Circle, Info, Star, UsersRound } from "lucide-react"; +import { Circle, Info, Minus, Star, UsersRound } from "lucide-react"; // components import { PageQuickActions } from "components/pages"; -import { Tooltip } from "@plane/ui"; +// ui +import { Avatar, Tooltip } from "@plane/ui"; type TPageListBlock = { pageId: string; @@ -16,17 +17,25 @@ export const PageListBlock: FC = (props) => { {/* page title */} -
    Page title
    +
    Page title
    {/* page properties */}
    - {/* duration & privacy */} + {/* page details */}
    - 10m read + Labels - {/* */} - + + + 10m read + + + {/* */} + +
    + {/* vertical divider */} + {/* page info */} + )} +
    + setSearchElement(e.target.value)} - className="w-full text-sm bg-transparent focus:outline-none focus:ring-0 focus:border-0 border-0" + ref={inputRef} + className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none" + placeholder="Search" + value={searchQuery} + onChange={(e) => updateFilters("searchQuery", e.target.value)} + onKeyDown={handleInputKeyDown} /> + {isSearchOpen && ( + + )}
    - - {/* Gonna implement dropdown in future */} -
    + ); }); diff --git a/web/components/pages/list/sort-filter/root.tsx b/web/components/pages/list/sort-filter/root.tsx deleted file mode 100644 index c836073f0ad..00000000000 --- a/web/components/pages/list/sort-filter/root.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { FC, Fragment, useMemo, useRef, useState } from "react"; -import { observer } from "mobx-react-lite"; -import { Menu, Transition } from "@headlessui/react"; -import { usePopper } from "react-popper"; -import { Copy, Eye, Globe2, Link2, Pencil, Trash } from "lucide-react"; -// hooks -import { useProjectPages } from "hooks/store"; -// constants -import { pageSorting, pageSortingBy } from "constants/page"; -// types -import { TPageFiltersSortKey } from "@plane/types"; - -type TViewEditDropdown = { - projectId: string | undefined; -}; - -export const ViewEditDropdown: FC = observer((props) => { - const { projectId } = props; - // hooks - const { updateFilters } = useProjectPages(projectId); - // refs - const dropdownRef = useRef(null); - // popper-js refs - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - // popper-js init - const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: "bottom-end", - modifiers: [ - { - name: "preventOverflow", - options: { - padding: 12, - }, - }, - { - name: "offset", - options: { - offset: [0, 10], - }, - }, - ], - }); - - const pageSortingByOptionKeys = Object.keys(pageSortingBy); - - return ( - - -
    - -
    -
    - - - - {pageSorting && - pageSorting.length > 0 && - pageSorting.map((option) => ( - -
    {option.label}
    -
    - ))} -
    -
    Ascending/Descending
    - - -
    - ); -}); diff --git a/web/components/pages/views/page-layout.tsx b/web/components/pages/views/page-layout.tsx index 98bdcfb525b..a3270f60f27 100644 --- a/web/components/pages/views/page-layout.tsx +++ b/web/components/pages/views/page-layout.tsx @@ -1,28 +1,36 @@ -import { FC, Fragment, ReactNode } from "react"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; +import { ListFilter } from "lucide-react"; // hooks -import { useProjectPages } from "hooks/store"; +import { useLabel, useMember, useProjectPages } from "hooks/store"; // components -import { PageLoader, PageEmptyState, PageTabNavigation, PageSearchInput } from ".."; +import { + PageLoader, + PageEmptyState, + PageTabNavigation, + PageSearchInput, + PageOrderByDropdown, + PageFiltersSelection, +} from ".."; +import { FiltersDropdown } from "components/issues"; +// types import { TPageNavigationTabs } from "@plane/types"; type TPageView = { workspaceSlug: string; projectId: string; pageType?: TPageNavigationTabs; - children: ReactNode; + children: React.ReactNode; }; -export const PageView: FC = observer((props) => { +export const PageView: React.FC = observer((props) => { const { workspaceSlug, projectId, pageType = "public", children } = props; - // hooks + // store hooks + const { loader, getAllPages, pageIds, filters, updateFilters } = useProjectPages(projectId); const { - loader, - getAllPages, - pageIds, - filters: { searchQuery }, - } = useProjectPages(projectId); + workspace: { workspaceMemberIds }, + } = useMember(); + const { projectLabels } = useLabel(); // fetching pages list useSWR(projectId && pageType ? `PROJECT_PAGES_${projectId}_${pageType}` : null, async () => { @@ -34,15 +42,27 @@ export const PageView: FC = observer((props) => { return (
    {/* tab header */} -
    +
    -
    +
    - -
    Sort filter
    - -
    Filters
    + { + if (val.key) updateFilters("sortKey", val.key); + if (val.order) updateFilters("sortBy", val.order); + }} + /> + } title="Filters" placement="bottom-end"> + +
    @@ -53,7 +73,7 @@ export const PageView: FC = observer((props) => { title={`No matching pages`} description={`Remove the filters to see all pages`} /> - ) : pageIds && pageIds.length === 0 && searchQuery.length > 0 ? ( + ) : pageIds && pageIds.length === 0 && filters.searchQuery.length > 0 ? ( // no searching pages are available Date: Fri, 15 Mar 2024 15:13:00 +0530 Subject: [PATCH 026/179] chore: updated pages store and updated UI --- packages/types/src/enums.ts | 6 + packages/types/src/pages.d.ts | 9 +- .../command-palette/command-palette.tsx | 12 +- web/components/pages/dropdowns/index.ts | 2 - web/components/pages/list/block.tsx | 25 +++- .../filters/created-at.tsx | 0 .../filters/created-by.tsx | 0 .../{dropdowns => list}/filters/index.ts | 0 .../{dropdowns => list}/filters/labels.tsx | 0 .../{dropdowns => list}/filters/root.tsx | 0 web/components/pages/list/index.ts | 2 + .../pages/{dropdowns => list}/order-by.tsx | 0 web/components/pages/list/root.tsx | 17 ++- web/components/pages/list/search-input.tsx | 5 +- web/components/pages/list/tab-navigation.tsx | 16 ++- .../pages/modals/create-update-page-modal.tsx | 130 +++++++++--------- web/components/pages/modals/page-form.tsx | 61 ++++++-- web/components/pages/views/page-layout.tsx | 8 +- web/constants/page.ts | 10 ++ web/hooks/store/pages/use-page.ts | 13 +- .../projects/[projectId]/pages/index.tsx | 1 + web/store/pages/helpers.ts | 23 ++++ web/store/pages/page.store.ts | 86 +++++++++++- web/store/pages/project-page.store.ts | 26 ++-- 24 files changed, 322 insertions(+), 130 deletions(-) rename web/components/pages/{dropdowns => list}/filters/created-at.tsx (100%) rename web/components/pages/{dropdowns => list}/filters/created-by.tsx (100%) rename web/components/pages/{dropdowns => list}/filters/index.ts (100%) rename web/components/pages/{dropdowns => list}/filters/labels.tsx (100%) rename web/components/pages/{dropdowns => list}/filters/root.tsx (100%) rename web/components/pages/{dropdowns => list}/order-by.tsx (100%) create mode 100644 web/store/pages/helpers.ts diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index 259f13e9bc2..7ad6e902b14 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -4,3 +4,9 @@ export enum EUserProjectRoles { MEMBER = 15, ADMIN = 20, } + +// project pages +export enum EPageAccess { + PUBLIC = 0, + PRIVATE = 1, +} diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index 72a816182fe..ef5aaaef4c0 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -1,9 +1,4 @@ -enum EPageAccess { - PUBLIC = 0, - PRIVATE = 1, -} - -export type TPageAccess = EPageAccess.PRIVATE | EPageAccess.PUBLIC; +import { EPageAccess } from "./enums"; export type TPage = { id: string | undefined; @@ -12,7 +7,7 @@ export type TPage = { color: string | undefined; labels: string[] | undefined; owned_by: string | undefined; - access: TPageAccess | undefined; + access: EPageAccess | undefined; is_favorite: boolean; is_locked: boolean; archived_at: string | undefined; diff --git a/web/components/command-palette/command-palette.tsx b/web/components/command-palette/command-palette.tsx index 31e46c1240e..9b941807c7d 100644 --- a/web/components/command-palette/command-palette.tsx +++ b/web/components/command-palette/command-palette.tsx @@ -12,7 +12,7 @@ import { BulkDeleteIssuesModal } from "components/core"; import { CycleCreateUpdateModal } from "components/cycles"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { CreateUpdateModuleModal } from "components/modules"; -// import { CreateUpdatePageModal } from "components/pages"; +import { CreateUpdatePageModal } from "components/pages"; import { CreateProjectModal } from "components/project"; import { CreateUpdateProjectViewModal } from "components/views"; // helpers @@ -206,11 +206,13 @@ export const CommandPalette: FC = observer(() => { workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} /> - {/* toggleCreatePageModal(false)} + */} + isModalOpen={isCreatePageModalOpen} + handleModalClose={() => toggleCreatePageModal(false)} + redirectionEnabled + /> )} diff --git a/web/components/pages/dropdowns/index.ts b/web/components/pages/dropdowns/index.ts index fa3b1281c49..db98841b0e4 100644 --- a/web/components/pages/dropdowns/index.ts +++ b/web/components/pages/dropdowns/index.ts @@ -1,3 +1 @@ -export * from "./filters"; -export * from "./order-by"; export * from "./quick-actions"; diff --git a/web/components/pages/list/block.tsx b/web/components/pages/list/block.tsx index 9c3616b3727..dadb85538a7 100644 --- a/web/components/pages/list/block.tsx +++ b/web/components/pages/list/block.tsx @@ -1,24 +1,35 @@ import { FC } from "react"; import Link from "next/link"; +import { observer } from "mobx-react"; import { Circle, Info, Minus, Star, UsersRound } from "lucide-react"; +// hooks +import { usePage } from "hooks/store"; // components import { PageQuickActions } from "components/pages"; // ui import { Avatar, Tooltip } from "@plane/ui"; type TPageListBlock = { + workspaceSlug: string; + projectId: string; pageId: string; }; -export const PageListBlock: FC = (props) => { - const { pageId } = props; +export const PageListBlock: FC = observer((props) => { + const { workspaceSlug, projectId, pageId } = props; + // hooks + const { name } = usePage(projectId, pageId); return ( - + {/* page title */} -
    Page title
    +
    {name}
    + {/* page properties */}
    {/* page details */} @@ -34,8 +45,10 @@ export const PageListBlock: FC = (props) => {
    + {/* vertical divider */} + {/* page info */} + {/* favorite/unfavorite */} + {/* quick actions dropdown */}
    ); -}; +}); diff --git a/web/components/pages/dropdowns/filters/created-at.tsx b/web/components/pages/list/filters/created-at.tsx similarity index 100% rename from web/components/pages/dropdowns/filters/created-at.tsx rename to web/components/pages/list/filters/created-at.tsx diff --git a/web/components/pages/dropdowns/filters/created-by.tsx b/web/components/pages/list/filters/created-by.tsx similarity index 100% rename from web/components/pages/dropdowns/filters/created-by.tsx rename to web/components/pages/list/filters/created-by.tsx diff --git a/web/components/pages/dropdowns/filters/index.ts b/web/components/pages/list/filters/index.ts similarity index 100% rename from web/components/pages/dropdowns/filters/index.ts rename to web/components/pages/list/filters/index.ts diff --git a/web/components/pages/dropdowns/filters/labels.tsx b/web/components/pages/list/filters/labels.tsx similarity index 100% rename from web/components/pages/dropdowns/filters/labels.tsx rename to web/components/pages/list/filters/labels.tsx diff --git a/web/components/pages/dropdowns/filters/root.tsx b/web/components/pages/list/filters/root.tsx similarity index 100% rename from web/components/pages/dropdowns/filters/root.tsx rename to web/components/pages/list/filters/root.tsx diff --git a/web/components/pages/list/index.ts b/web/components/pages/list/index.ts index aec87de0576..b701a2debf2 100644 --- a/web/components/pages/list/index.ts +++ b/web/components/pages/list/index.ts @@ -1,5 +1,7 @@ export * from "./tab-navigation"; export * from "./search-input"; +export * from "./order-by"; +export * from "./filters"; export * from "./root"; export * from "./block"; diff --git a/web/components/pages/dropdowns/order-by.tsx b/web/components/pages/list/order-by.tsx similarity index 100% rename from web/components/pages/dropdowns/order-by.tsx rename to web/components/pages/list/order-by.tsx diff --git a/web/components/pages/list/root.tsx b/web/components/pages/list/root.tsx index 112d94e605a..b61f6b92b8c 100644 --- a/web/components/pages/list/root.tsx +++ b/web/components/pages/list/root.tsx @@ -1,4 +1,7 @@ import { FC } from "react"; +import { observer } from "mobx-react"; +// hooks +import { useProjectPages } from "hooks/store"; // components import { PageListBlock } from "./"; @@ -7,15 +10,17 @@ type TPagesListRoot = { projectId: string; }; -export const PagesListRoot: FC = (props) => { +export const PagesListRoot: FC = observer((props) => { const { workspaceSlug, projectId } = props; + // hooks + const { pageIds } = useProjectPages(projectId); - console.log("workspaceSlug", workspaceSlug); - console.log("projectId", projectId); - + if (!pageIds) return <>; return (
    - + {pageIds.map((pageId) => ( + + ))}
    ); -}; +}); diff --git a/web/components/pages/list/search-input.tsx b/web/components/pages/list/search-input.tsx index cf41fafe4ea..615f9d03c36 100644 --- a/web/components/pages/list/search-input.tsx +++ b/web/components/pages/list/search-input.tsx @@ -40,7 +40,7 @@ export const PageSearchInput: FC = observer((props) => { {!isSearchOpen && ( )} +
    = observer((props) => { updateFilters("searchQuery", e.target.value)} onKeyDown={handleInputKeyDown} diff --git a/web/components/pages/list/tab-navigation.tsx b/web/components/pages/list/tab-navigation.tsx index 0a2de5d1844..80afa33f58f 100644 --- a/web/components/pages/list/tab-navigation.tsx +++ b/web/components/pages/list/tab-navigation.tsx @@ -2,15 +2,17 @@ import { FC } from "react"; import Link from "next/link"; // helpers import { cn } from "helpers/common.helper"; +// types +import { TPageNavigationTabs } from "@plane/types"; type TPageTabNavigation = { workspaceSlug: string; projectId: string; - pageType: "public" | "private" | "archived"; + pageType: TPageNavigationTabs; }; // pages tab options -const pageTabs = [ +const pageTabs: { key: TPageNavigationTabs; label: string }[] = [ { key: "public", label: "Public", @@ -28,10 +30,18 @@ const pageTabs = [ export const PageTabNavigation: FC = (props) => { const { workspaceSlug, projectId, pageType } = props; + const handleTabClick = (e: React.MouseEvent, tabKey: TPageNavigationTabs) => { + if (tabKey === pageType) e.preventDefault(); + }; + return (
    {pageTabs.map((tab) => ( - + handleTabClick(e, tab.key)} + >
    void; data?: Partial | undefined; + redirectionEnabled?: boolean; }; export const CreateUpdatePageModal: FC = (props) => { - const { workspaceSlug, projectId, isModalOpen, handleModalClose, data: pageData } = props; + const { workspaceSlug, projectId, isModalOpen, handleModalClose, data: pageData, redirectionEnabled = false } = props; + const router = useRouter(); // hooks + const { createPage } = useProjectPages(projectId); + const { updatePage, asJson: storePageData } = usePage(projectId, pageData?.id || undefined); + const { capturePageEvent } = useEventTracker(); // states - const [pageFormData, setPageFormData] = useState>({ name: "" }); + const [pageFormData, setPageFormData] = useState>({ + id: undefined, + name: "", + access: EPageAccess.PUBLIC, + }); const handlePageFormData = (key: T, value: TPage[T]) => setPageFormData((prev) => ({ ...prev, [key]: value })); + const handleStateClear = () => { + setPageFormData({ id: undefined, name: "", access: EPageAccess.PUBLIC }); + handleModalClose(); + }; + useEffect(() => { if (pageData) { setPageFormData({ @@ -37,76 +51,66 @@ export const CreateUpdatePageModal: FC = (props) => { } }, [pageData]); - // store hooks - // const { createPage } = useProjectPages(); - // const { capturePageEvent } = useEventTracker(); - - const createProjectPage = async (payload: any) => { - if (!workspaceSlug) return; - // await createPage(workspaceSlug.toString(), projectId, payload) - // .then((res) => { - // capturePageEvent({ - // eventName: PAGE_CREATED, - // payload: { - // ...res, - // state: "SUCCESS", - // }, - // }); - // }) - // .catch(() => { - // capturePageEvent({ - // eventName: PAGE_CREATED, - // payload: { - // state: "FAILED", - // }, - // }); - // }); - }; - const handleFormSubmit = async () => { if (!workspaceSlug || !projectId) return; - if (pageFormData.id) { + console.log("pageFormData", pageFormData); + + if (pageFormData.id && pageFormData.id != undefined) { try { + if (pageFormData.name === storePageData?.name && pageFormData.access === storePageData?.access) { + handleStateClear(); + return; + } + const pageData = await updatePage(pageFormData); + if (pageData) { + capturePageEvent({ + eventName: PAGE_UPDATED, + payload: { + ...pageData, + state: "SUCCESS", + }, + }); + handleStateClear(); + } } catch { - console.log("something went wrong. Please try again later"); + capturePageEvent({ + eventName: PAGE_UPDATED, + payload: { + state: "FAILED", + }, + }); } } else { try { + const pageData = await createPage(pageFormData); + if (pageData) { + capturePageEvent({ + eventName: PAGE_CREATED, + payload: { + ...pageData, + state: "SUCCESS", + }, + }); + handleStateClear(); + if (redirectionEnabled) router.push(`/${workspaceSlug}/projects/${projectId}/pages/${pageData.id}`); + } } catch { - console.log("something went wrong. Please try again later"); + capturePageEvent({ + eventName: PAGE_CREATED, + payload: { + state: "FAILED", + }, + }); } } - - // try { - // if (pageStore) { - // if (pageStore.name !== formData.name) { - // await pageStore.updateName(formData.name); - // } - // if (pageStore.access !== formData.access) { - // formData.access === 1 ? await pageStore.makePrivate() : await pageStore.makePublic(); - // } - // capturePageEvent({ - // eventName: PAGE_UPDATED, - // payload: { - // ...pageStore, - // state: "SUCCESS", - // }, - // }); - // } else { - // await createProjectPage(formData); - // } - // handleModalClose(); - // } catch (error) { - // console.log(error); - // } }; return ( - + = (props) => {
    = (props) => { diff --git a/web/components/pages/modals/page-form.tsx b/web/components/pages/modals/page-form.tsx index 54d88a4dda1..51ef5f6f97f 100644 --- a/web/components/pages/modals/page-form.tsx +++ b/web/components/pages/modals/page-form.tsx @@ -1,13 +1,13 @@ -import { useState } from "react"; -// ui -import { Button, Input } from "@plane/ui"; +import { FormEvent, useState } from "react"; +import { Button, Input, Tooltip } from "@plane/ui"; +// hooks +import { usePlatformOS } from "hooks/use-platform-os"; // types import { TPage } from "@plane/types"; -// types -// import { IPage } from "@plane/types"; // constants -// import { PAGE_ACCESS_SPECIFIERS } from "constants/page"; -// import { IPageStore } from "store/pages/page.store"; +import { PAGE_ACCESS_SPECIFIERS } from "constants/page"; +// helpers +import { cn } from "helpers/common.helper"; type Props = { formData: Partial; @@ -18,10 +18,13 @@ type Props = { export const PageForm: React.FC = (props) => { const { formData, handleFormData, handleModalClose, handleFormSubmit } = props; + // hooks + const { isMobile } = usePlatformOS(); // state const [isSubmitting, setIsSubmitting] = useState(false); - const handlePageFormSubmit = async () => { + const handlePageFormSubmit = async (e: FormEvent) => { + e.preventDefault(); try { setIsSubmitting(true); await handleFormSubmit(); @@ -38,7 +41,13 @@ export const PageForm: React.FC = (props) => { {formData?.id ? "Update" : "Create"} Page -
    +
    +
    +
    Name
    +
    + Max length of the name should be less than 255 characters +
    +
    = (props) => { className="w-full resize-none text-lg" tabIndex={1} required + maxLength={255} />
    -
    -
    +
    +
    +
    + {PAGE_ACCESS_SPECIFIERS.map((access, index) => ( + + + + ))} +
    +
    + {PAGE_ACCESS_SPECIFIERS.find((access) => access.key === formData.access)?.label} +
    +
    + +
    diff --git a/web/components/pages/views/page-layout.tsx b/web/components/pages/views/page-layout.tsx index a3270f60f27..d11aeb084c1 100644 --- a/web/components/pages/views/page-layout.tsx +++ b/web/components/pages/views/page-layout.tsx @@ -19,7 +19,7 @@ import { TPageNavigationTabs } from "@plane/types"; type TPageView = { workspaceSlug: string; projectId: string; - pageType?: TPageNavigationTabs; + pageType: TPageNavigationTabs; children: React.ReactNode; }; @@ -34,7 +34,7 @@ export const PageView: React.FC = observer((props) => { // fetching pages list useSWR(projectId && pageType ? `PROJECT_PAGES_${projectId}_${pageType}` : null, async () => { - projectId && pageType && (await getAllPages()); + projectId && pageType && (await getAllPages(pageType)); }); // pages loader @@ -45,8 +45,9 @@ export const PageView: React.FC = observer((props) => {
    -
    +
    + = observer((props) => { if (val.order) updateFilters("sortBy", val.order); }} /> + } title="Filters" placement="bottom-end"> { +export const usePage = (projectId: string | undefined, pageId: string | undefined): IPageStore => { const context = useContext(StoreContext); if (context === undefined) throw new Error("useProjectPublish must be used within StoreProvider"); - if (!projectId || !pageId) throw new Error("projectId, pageId must be passed as a property"); - - const { fetchById } = useProjectPages(projectId); - - useSWR(projectId && pageId ? `PROJECT_PAGE_DETAIL_${projectId}_${pageId}` : null, async () => { - projectId && pageId && (await fetchById(pageId)); - }); + if (!projectId || !pageId) return {} as IPageStore; return context.projectPage.data?.[projectId]?.[pageId] ?? {}; }; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx index 8fff709fa4b..ededd1ed51d 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx @@ -37,6 +37,7 @@ const ProjectPagesPage: NextPageWithLayout = () => { projectId={projectId.toString()} isModalOpen={modalOpen} handleModalClose={() => setModalOpen(false)} + redirectionEnabled /> ); diff --git a/web/store/pages/helpers.ts b/web/store/pages/helpers.ts new file mode 100644 index 00000000000..a163511b64e --- /dev/null +++ b/web/store/pages/helpers.ts @@ -0,0 +1,23 @@ +// types +import { TPage, TPageNavigationTabs } from "@plane/types"; + +interface IPageHelpers { + filterPagesByPages: (pageType: TPageNavigationTabs, pages: TPage[]) => TPage[]; + isCurrentUserOwner: (page: TPage, userId: string) => boolean; +} + +export class PageHelpers implements IPageHelpers { + constructor() {} + + filterPagesByPages = (pageType: TPageNavigationTabs, pages: TPage[]) => + pages.filter((page) => { + if (pageType === "public") return page.access === 0; + if (pageType === "private") return page.access === 1; + if (pageType === "archived") return page.archived_at !== undefined; + return true; + }); + + isCurrentUserOwner(page: TPage, userId: string) { + return page?.owned_by === userId; + } +} diff --git a/web/store/pages/page.store.ts b/web/store/pages/page.store.ts index 65263a08666..fd37a1bee8c 100644 --- a/web/store/pages/page.store.ts +++ b/web/store/pages/page.store.ts @@ -1,10 +1,11 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import set from "lodash/set"; // store import { RootStore } from "../root.store"; // service import { PageService } from "services/page.service"; // types -import { TPage, TPageAccess } from "@plane/types"; +import { TPage } from "@plane/types"; // constants import { EPageAccess } from "constants/page"; @@ -14,10 +15,16 @@ export interface IPageStore extends TPage { // observables loader: TLoader; // computed - isContentEditable: boolean; + asJson: TPage | undefined; + isCurrentUserOwner: boolean; // it will give the user is the owner of the page or not + isPageEditable: boolean; // it will give the user permission to read the page or write the page + isPageDuplicated: boolean; + isPageArchived: boolean; + isPageLocked: boolean; // helper actions updateDescription: (description: string) => void; // actions + updatePage: (pageData: Partial) => Promise; makePublic: () => Promise; makePrivate: () => Promise; lock: () => Promise; @@ -33,7 +40,7 @@ export class PageStore implements IPageStore { color: string | undefined; labels: string[] | undefined; owned_by: string | undefined; - access: TPageAccess | undefined; + access: EPageAccess | undefined; is_favorite: boolean; is_locked: boolean; archived_at: string | undefined; @@ -70,10 +77,16 @@ export class PageStore implements IPageStore { // observables loader: observable.ref, // computed - isContentEditable: computed, + asJson: computed, + isCurrentUserOwner: computed, + isPageEditable: computed, + isPageDuplicated: computed, + isPageArchived: computed, + isPageLocked: computed, // helper actions updateDescription: action, // actions + updatePage: action, makePublic: action, makePrivate: action, lock: action, @@ -86,6 +99,47 @@ export class PageStore implements IPageStore { } // computed + get asJson() { + return { + id: this.id, + name: this.name, + description_html: this.description_html, + color: this.color, + labels: this.labels, + owned_by: this.owned_by, + access: this.access, + is_favorite: this.is_favorite, + is_locked: this.is_locked, + archived_at: this.archived_at, + workspace: this.workspace, + project: this.project, + created_by: this.created_by, + updated_by: this.updated_by, + created_at: this.created_at, + updated_at: this.updated_at, + }; + } + + get isCurrentUserOwner() { + const currentUserId = this.store.user.currentUser?.id; + if (!currentUserId) return false; + return this.owned_by === currentUserId ? true : false; + } + + get isPageEditable() { + // const currentUserProjectMembershipRole; + return false; + } + get isPageDuplicated() { + return false; + } + get isPageArchived() { + return false; + } + get isPageLocked() { + return false; + } + get isContentEditable() { const currentUserId = this.store.user.currentUser?.id; if (!currentUserId) return false; @@ -103,6 +157,30 @@ export class PageStore implements IPageStore { updateDescription = async () => {}; + updatePage = async (pageData: Partial) => { + const { workspaceSlug, projectId } = this.store.app.router; + if (!workspaceSlug || !projectId || !this.id) return undefined; + + const currentPage = this.asJson; + try { + const currentPageResponse = await this.pageService.update(workspaceSlug, projectId, this.id, currentPage); + if (currentPageResponse) + runInAction(() => { + Object.keys(pageData).forEach((key) => { + const currentPageKey = key as keyof TPage; + set(this, key, currentPageResponse?.[currentPageKey] || undefined); + }); + }); + } catch { + runInAction(() => { + Object.keys(pageData).forEach((key) => { + const currentPageKey = key as keyof TPage; + set(this, key, currentPage?.[currentPageKey] || undefined); + }); + }); + } + }; + makePublic = async () => { const { workspaceSlug, projectId } = this.store.app.router; if (!workspaceSlug || !projectId || !this.id) return undefined; diff --git a/web/store/pages/project-page.store.ts b/web/store/pages/project-page.store.ts index f35072b7c75..350e3cc5d95 100644 --- a/web/store/pages/project-page.store.ts +++ b/web/store/pages/project-page.store.ts @@ -8,7 +8,9 @@ import { IPageStore, PageStore } from "store/pages/page.store"; // services import { PageService } from "services/page.service"; // types -import { TPage, TPageFilters } from "@plane/types"; +import { TPage, TPageFilters, TPageNavigationTabs } from "@plane/types"; +// page helper class +import { PageHelpers } from "./helpers"; type TLoader = "init-loader" | "mutation-loader" | undefined; @@ -17,6 +19,7 @@ type TError = { title: string; description: string }; export interface IProjectPageStore { // observables loader: TLoader; + pageType: TPageNavigationTabs; data: Record>; // projectId => pageId => PageStore error: TError | undefined; filters: TPageFilters; @@ -26,15 +29,16 @@ export interface IProjectPageStore { pageById: (pageId: string) => IPageStore | undefined; updateFilters: (filterKey: T, filterValue: TPageFilters[T]) => void; // actions - getAllPages: (_loader?: TLoader) => Promise; + getAllPages: (pageType?: TPageNavigationTabs) => Promise; getPageById: (pageId: string) => Promise; createPage: (pageData: Partial) => Promise; removePage: (pageId: string) => Promise; } -export class ProjectPageStore implements IProjectPageStore { +export class ProjectPageStore extends PageHelpers implements IProjectPageStore { // observables loader: TLoader = "init-loader"; + pageType: TPageNavigationTabs = "public"; data: Record> = {}; // projectId => pageId => PageStore error: TError | undefined = undefined; filters: TPageFilters = { @@ -46,9 +50,12 @@ export class ProjectPageStore implements IProjectPageStore { service: PageService; constructor(private store: RootStore) { + super(); + makeObservable(this, { // observables loader: observable.ref, + pageType: observable.ref, data: observable, error: observable, filters: observable, @@ -70,7 +77,12 @@ export class ProjectPageStore implements IProjectPageStore { const { projectId } = this.store.app.router; if (!projectId) return undefined; - const pages = Object.keys(this?.data?.[projectId] || {}) || undefined; + // helps to filter pages based on the pageType + const filtersPages = this.filterPagesByPages(this.pageType, Object.values(this?.data?.[projectId] || {})); + + // TODO: apply user filters + + const pages = (filtersPages.map((page) => page.id) as string[]) || undefined; if (!pages) return undefined; return pages; @@ -91,16 +103,14 @@ export class ProjectPageStore implements IProjectPageStore { }; // actions - getAllPages = async () => { + getAllPages = async (pageType: TPageNavigationTabs = "public") => { try { const { workspaceSlug, projectId } = this.store.app.router; if (!workspaceSlug || !projectId) return undefined; - console.log("hello"); - const currentPageIds = this.pageIds; - console.log("currentPageIds", currentPageIds); runInAction(() => { + this.pageType = pageType; this.loader = currentPageIds && currentPageIds.length > 0 ? `mutation-loader` : `init-loader`; this.error = undefined; }); From aa82d7ba99ab582a0bd70add86e5af4368d27fd0 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Fri, 15 Mar 2024 16:36:55 +0530 Subject: [PATCH 027/179] chore: new core editor just for document editor created --- .../editor/core-document-editor/.eslintrc.js | 4 + .../core-document-editor/.prettierignore | 6 + .../editor/core-document-editor/.prettierrc | 5 + .../editor/core-document-editor/Readme.md | 116 +++++ .../editor/core-document-editor/package.json | 76 +++ .../core-document-editor/postcss.config.js | 9 + .../insert-content-at-cursor-position.ts | 17 + .../src/hooks/use-editor.tsx | 109 ++++ .../src/hooks/use-read-only-editor.tsx | 66 +++ .../editor/core-document-editor/src/index.ts | 32 ++ .../src/lib/editor-commands.ts | 130 +++++ .../core-document-editor/src/lib/utils.ts | 58 +++ .../src/styles/editor.css | 216 ++++++++ .../src/styles/github-dark.css | 85 +++ .../core-document-editor/src/styles/table.css | 240 +++++++++ .../src/styles/tailwind.css | 3 + .../src/types/delete-image.ts | 1 + .../src/types/lucide-icon.ts | 3 + .../src/types/mention-suggestion.ts | 10 + .../src/types/restore-image.ts | 1 + .../src/types/slash-commands-suggestion.ts | 16 + .../src/types/upload-image.ts | 1 + .../src/ui/components/editor-container.tsx | 61 +++ .../src/ui/components/editor-content.tsx | 28 + .../src/ui/extensions/code-inline/index.tsx | 95 ++++ .../src/ui/extensions/code/index.tsx | 101 ++++ .../custom-link/helpers/autolink.ts | 118 +++++ .../custom-link/helpers/clickHandler.ts | 46 ++ .../custom-link/helpers/pasteHandler.ts | 44 ++ .../src/ui/extensions/custom-link/index.ts | 248 +++++++++ .../ui/extensions/custom-list-keymap/index.ts | 1 + .../list-helpers/find-list-item-pos.ts | 30 ++ .../list-helpers/get-next-list-depth.ts | 16 + .../list-helpers/handle-backspace.ts | 66 +++ .../list-helpers/handle-delete.ts | 34 ++ .../list-helpers/has-list-before.ts | 15 + .../list-helpers/has-list-item-after.ts | 17 + .../list-helpers/has-list-item-before.ts | 17 + .../custom-list-keymap/list-helpers/index.ts | 9 + .../list-helpers/next-list-is-deeper.ts | 19 + .../list-helpers/next-list-is-higher.ts | 19 + .../custom-list-keymap/list-keymap.ts | 94 ++++ .../horizontal-rule/horizontal-rule.ts | 111 ++++ .../src/ui/extensions/image/image-resize.tsx | 65 +++ .../src/ui/extensions/image/index.tsx | 141 +++++ .../ui/extensions/image/read-only-image.tsx | 15 + .../utilities/insert-line-above-image.ts | 45 ++ .../utilities/insert-line-below-image.ts | 46 ++ .../src/ui/extensions/index.tsx | 117 +++++ .../src/ui/extensions/keymap.tsx | 54 ++ .../src/ui/extensions/quote/index.tsx | 25 + .../ui/extensions/table/table-cell/index.ts | 1 + .../extensions/table/table-cell/table-cell.ts | 61 +++ .../ui/extensions/table/table-header/index.ts | 1 + .../table/table-header/table-header.ts | 58 +++ .../ui/extensions/table/table-row/index.ts | 1 + .../extensions/table/table-row/table-row.ts | 44 ++ .../src/ui/extensions/table/table/icons.ts | 51 ++ .../src/ui/extensions/table/table/index.ts | 1 + .../extensions/table/table/table-controls.ts | 112 ++++ .../ui/extensions/table/table/table-view.tsx | 485 ++++++++++++++++++ .../src/ui/extensions/table/table/table.ts | 286 +++++++++++ .../table/table/utilities/create-cell.ts | 12 + .../table/table/utilities/create-table.ts | 40 ++ .../delete-table-when-all-cells-selected.ts | 34 ++ .../table/utilities/get-table-node-types.ts | 21 + .../insert-line-above-table-action.ts | 50 ++ .../insert-line-below-table-action.ts | 48 ++ .../table/utilities/is-cell-selection.ts | 5 + .../src/ui/extensions/typography/index.ts | 109 ++++ .../ui/extensions/typography/inputRules.ts | 137 +++++ .../src/ui/mentions/custom.tsx | 63 +++ .../src/ui/mentions/index.tsx | 15 + .../src/ui/mentions/mention-list.tsx | 100 ++++ .../src/ui/mentions/mention-node-view.tsx | 35 ++ .../src/ui/mentions/suggestion.ts | 66 +++ .../src/ui/menus/menu-items/index.tsx | 144 ++++++ .../src/ui/plugins/delete-image.tsx | 79 +++ .../src/ui/plugins/upload-image.tsx | 164 ++++++ .../core-document-editor/src/ui/props.tsx | 60 +++ .../src/ui/read-only/extensions.tsx | 100 ++++ .../src/ui/read-only/props.tsx | 7 + .../core-document-editor/tailwind.config.js | 6 + .../editor/core-document-editor/tsconfig.json | 15 + .../core-document-editor/tsup.config.ts | 11 + packages/editor/document-editor/package.json | 1 + .../src/ui/components/alert-label.tsx | 2 +- .../src/ui/components/editor-header.tsx | 2 +- .../ui/components/links/link-edit-view.tsx | 2 +- .../src/ui/components/page-renderer.tsx | 2 +- .../ui/components/vertical-dropdown-menu.tsx | 2 +- .../src/ui/extensions/index.tsx | 2 +- .../issue-suggestion-renderer.tsx | 2 +- .../editor/document-editor/src/ui/index.tsx | 3 +- .../src/ui/menu/fixed-menu.tsx | 14 +- .../document-editor/src/ui/readonly/index.tsx | 2 +- 96 files changed, 5339 insertions(+), 18 deletions(-) create mode 100644 packages/editor/core-document-editor/.eslintrc.js create mode 100644 packages/editor/core-document-editor/.prettierignore create mode 100644 packages/editor/core-document-editor/.prettierrc create mode 100644 packages/editor/core-document-editor/Readme.md create mode 100644 packages/editor/core-document-editor/package.json create mode 100644 packages/editor/core-document-editor/postcss.config.js create mode 100644 packages/editor/core-document-editor/src/helpers/insert-content-at-cursor-position.ts create mode 100644 packages/editor/core-document-editor/src/hooks/use-editor.tsx create mode 100644 packages/editor/core-document-editor/src/hooks/use-read-only-editor.tsx create mode 100644 packages/editor/core-document-editor/src/index.ts create mode 100644 packages/editor/core-document-editor/src/lib/editor-commands.ts create mode 100644 packages/editor/core-document-editor/src/lib/utils.ts create mode 100644 packages/editor/core-document-editor/src/styles/editor.css create mode 100644 packages/editor/core-document-editor/src/styles/github-dark.css create mode 100644 packages/editor/core-document-editor/src/styles/table.css create mode 100644 packages/editor/core-document-editor/src/styles/tailwind.css create mode 100644 packages/editor/core-document-editor/src/types/delete-image.ts create mode 100644 packages/editor/core-document-editor/src/types/lucide-icon.ts create mode 100644 packages/editor/core-document-editor/src/types/mention-suggestion.ts create mode 100644 packages/editor/core-document-editor/src/types/restore-image.ts create mode 100644 packages/editor/core-document-editor/src/types/slash-commands-suggestion.ts create mode 100644 packages/editor/core-document-editor/src/types/upload-image.ts create mode 100644 packages/editor/core-document-editor/src/ui/components/editor-container.tsx create mode 100644 packages/editor/core-document-editor/src/ui/components/editor-content.tsx create mode 100644 packages/editor/core-document-editor/src/ui/extensions/code-inline/index.tsx create mode 100644 packages/editor/core-document-editor/src/ui/extensions/code/index.tsx create mode 100644 packages/editor/core-document-editor/src/ui/extensions/custom-link/helpers/autolink.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/custom-link/helpers/clickHandler.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/custom-link/helpers/pasteHandler.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/custom-link/index.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/index.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/index.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-keymap.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/horizontal-rule/horizontal-rule.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/image/image-resize.tsx create mode 100644 packages/editor/core-document-editor/src/ui/extensions/image/index.tsx create mode 100644 packages/editor/core-document-editor/src/ui/extensions/image/read-only-image.tsx create mode 100644 packages/editor/core-document-editor/src/ui/extensions/image/utilities/insert-line-above-image.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/image/utilities/insert-line-below-image.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/index.tsx create mode 100644 packages/editor/core-document-editor/src/ui/extensions/keymap.tsx create mode 100644 packages/editor/core-document-editor/src/ui/extensions/quote/index.tsx create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table-cell/index.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table-cell/table-cell.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table-header/index.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table-header/table-header.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table-row/index.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table-row/table-row.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table/icons.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table/index.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table/table-controls.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table/table-view.tsx create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table/table.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/create-cell.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/create-table.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/get-table-node-types.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/is-cell-selection.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/typography/index.ts create mode 100644 packages/editor/core-document-editor/src/ui/extensions/typography/inputRules.ts create mode 100644 packages/editor/core-document-editor/src/ui/mentions/custom.tsx create mode 100644 packages/editor/core-document-editor/src/ui/mentions/index.tsx create mode 100644 packages/editor/core-document-editor/src/ui/mentions/mention-list.tsx create mode 100644 packages/editor/core-document-editor/src/ui/mentions/mention-node-view.tsx create mode 100644 packages/editor/core-document-editor/src/ui/mentions/suggestion.ts create mode 100644 packages/editor/core-document-editor/src/ui/menus/menu-items/index.tsx create mode 100644 packages/editor/core-document-editor/src/ui/plugins/delete-image.tsx create mode 100644 packages/editor/core-document-editor/src/ui/plugins/upload-image.tsx create mode 100644 packages/editor/core-document-editor/src/ui/props.tsx create mode 100644 packages/editor/core-document-editor/src/ui/read-only/extensions.tsx create mode 100644 packages/editor/core-document-editor/src/ui/read-only/props.tsx create mode 100644 packages/editor/core-document-editor/tailwind.config.js create mode 100644 packages/editor/core-document-editor/tsconfig.json create mode 100644 packages/editor/core-document-editor/tsup.config.ts diff --git a/packages/editor/core-document-editor/.eslintrc.js b/packages/editor/core-document-editor/.eslintrc.js new file mode 100644 index 00000000000..c8df607506c --- /dev/null +++ b/packages/editor/core-document-editor/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: ["custom"], +}; diff --git a/packages/editor/core-document-editor/.prettierignore b/packages/editor/core-document-editor/.prettierignore new file mode 100644 index 00000000000..43e8a7b8ffb --- /dev/null +++ b/packages/editor/core-document-editor/.prettierignore @@ -0,0 +1,6 @@ +.next +.vercel +.tubro +out/ +dis/ +build/ \ No newline at end of file diff --git a/packages/editor/core-document-editor/.prettierrc b/packages/editor/core-document-editor/.prettierrc new file mode 100644 index 00000000000..87d988f1b26 --- /dev/null +++ b/packages/editor/core-document-editor/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/packages/editor/core-document-editor/Readme.md b/packages/editor/core-document-editor/Readme.md new file mode 100644 index 00000000000..aafda700866 --- /dev/null +++ b/packages/editor/core-document-editor/Readme.md @@ -0,0 +1,116 @@ +# @plane/editor-core + +## Description + +The `@plane/editor-core` package serves as the foundation for our editor system. It provides the base functionality for our other editor packages, but it will not be used directly in any of the projects but only for extending other editors. + +## Utilities + +We provide a wide range of utilities for extending the core itself. + +1. Merging classes and custom styling +2. Adding new extensions +3. Adding custom props +4. Base menu items, and their commands + +This allows for extensive customization and flexibility in the Editors created using our `editor-core` package. + +### Here's a detailed overview of what's exported + +1. useEditor - A hook that you can use to extend the Plane editor. + + | Prop | Type | Description | + | ------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features | + | `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object | + | `uploadFile` | `(file: File) => Promise` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. | + | `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. | + | `value` | `html string` | The initial content of the editor. | + | `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. | + | `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. | + | `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. | + | `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert in case of content not being "saved". | + | `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component | + +2. useReadOnlyEditor - A hook that can be used to extend a Read Only instance of the core editor. + + | Prop | Type | Description | + | -------------- | ------------- | ------------------------------------------------------------------------------------------ | + | `value` | `string` | The initial content of the editor. | + | `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component | + | `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features | + | `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object | + +3. Items and Commands - H1, H2, H3, task list, quote, code block, etc's methods. + +4. UI Wrappers + +- `EditorContainer` - Wrap your Editor Container with this to apply base classes and styles. +- `EditorContentWrapper` - Use this to get Editor's Content and base menus. + +5. Extending with Custom Styles + +```ts +const customEditorClassNames = getEditorClassNames({ + noBorder, + borderOnFocus, + customClassName, +}); +``` + +## Core features + +- **Content Trimming**: The Editor’s content is now automatically trimmed of empty line breaks from the start and end before submitting it to the backend. This ensures cleaner, more consistent data. +- **Value Cleaning**: The Editor’s value is cleaned at the editor core level, eliminating the need for additional validation before sending from our app. This results in cleaner code and less potential for errors. +- **Turbo Pipeline**: Added a turbo pipeline for both dev and build tasks for projects depending on the editor package. + +```json + "web#develop": { + "cache": false, + "persistent": true, + "dependsOn": [ + "@plane/lite-text-editor#build", + "@plane/rich-text-editor#build" + ] + }, + "space#develop": { + "cache": false, + "persistent": true, + "dependsOn": [ + "@plane/lite-text-editor#build", + "@plane/rich-text-editor#build" + ] + }, + "web#build": { + "cache": true, + "dependsOn": [ + "@plane/lite-text-editor#build", + "@plane/rich-text-editor#build" + ] + }, + "space#build": { + "cache": true, + "dependsOn": [ + "@plane/lite-text-editor#build", + "@plane/rich-text-editor#build" + ] + }, + +``` + +## Base extensions included + +- BulletList +- OrderedList +- Blockquote +- Code +- Gapcursor +- Link +- Image +- Basic Marks + - Underline + - TextStyle + - Color +- TaskList +- Markdown +- Table diff --git a/packages/editor/core-document-editor/package.json b/packages/editor/core-document-editor/package.json new file mode 100644 index 00000000000..af5c582a8d7 --- /dev/null +++ b/packages/editor/core-document-editor/package.json @@ -0,0 +1,76 @@ +{ + "name": "@plane/editor-document-core", + "version": "0.16.0", + "description": "Core Editor that powers Plane", + "private": true, + "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 --minify", + "dev": "tsup --watch", + "check-types": "tsc --noEmit", + "format": "prettier --write \"**/*.{ts,tsx,md}\"" + }, + "peerDependencies": { + "next": "12.3.2", + "react": "^18.2.0", + "react-dom": "18.2.0" + }, + "dependencies": { + "@tiptap/core": "^2.1.13", + "@tiptap/extension-blockquote": "^2.1.13", + "@tiptap/extension-code-block-lowlight": "^2.1.13", + "@tiptap/extension-color": "^2.1.13", + "@tiptap/extension-image": "^2.1.13", + "@tiptap/extension-list-item": "^2.1.13", + "@tiptap/extension-mention": "^2.1.13", + "@tiptap/extension-task-item": "^2.1.13", + "@tiptap/extension-task-list": "^2.1.13", + "@tiptap/extension-text-style": "^2.1.13", + "@tiptap/extension-underline": "^2.1.13", + "@tiptap/pm": "^2.1.13", + "@tiptap/react": "^2.1.13", + "@tiptap/starter-kit": "^2.1.13", + "@tiptap/suggestion": "^2.0.13", + "class-variance-authority": "^0.7.0", + "clsx": "^1.2.1", + "highlight.js": "^11.8.0", + "jsx-dom-cjs": "^8.0.3", + "linkifyjs": "^4.1.3", + "lowlight": "^3.0.0", + "lucide-react": "^0.294.0", + "react-moveable": "^0.54.2", + "tailwind-merge": "^1.14.0", + "tippy.js": "^6.3.7", + "tiptap-markdown": "^0.8.9" + }, + "devDependencies": { + "@types/node": "18.15.3", + "@types/react": "^18.2.42", + "@types/react-dom": "^18.2.17", + "eslint-config-custom": "*", + "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/core-document-editor/postcss.config.js b/packages/editor/core-document-editor/postcss.config.js new file mode 100644 index 00000000000..07aa434b2bf --- /dev/null +++ b/packages/editor/core-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: {}, + }, +}; diff --git a/packages/editor/core-document-editor/src/helpers/insert-content-at-cursor-position.ts b/packages/editor/core-document-editor/src/helpers/insert-content-at-cursor-position.ts new file mode 100644 index 00000000000..062acafcb1b --- /dev/null +++ b/packages/editor/core-document-editor/src/helpers/insert-content-at-cursor-position.ts @@ -0,0 +1,17 @@ +import { Selection } from "@tiptap/pm/state"; +import { Editor } from "@tiptap/react"; +import { MutableRefObject } from "react"; + +export const insertContentAtSavedSelection = ( + editorRef: MutableRefObject, + content: string, + savedSelection: Selection +) => { + if (editorRef.current && savedSelection) { + editorRef.current + .chain() + .focus() + .insertContentAt(savedSelection?.anchor, content) + .run(); + } +}; diff --git a/packages/editor/core-document-editor/src/hooks/use-editor.tsx b/packages/editor/core-document-editor/src/hooks/use-editor.tsx new file mode 100644 index 00000000000..c01821a726e --- /dev/null +++ b/packages/editor/core-document-editor/src/hooks/use-editor.tsx @@ -0,0 +1,109 @@ +import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; +import { useImperativeHandle, useRef, MutableRefObject, useState } from "react"; +import { CoreEditorProps } from "src/ui/props"; +import { CoreEditorExtensions } from "src/ui/extensions"; +import { EditorProps } from "@tiptap/pm/view"; +import { getTrimmedHTML } from "src/lib/utils"; +import { DeleteImage } from "src/types/delete-image"; +import { IMentionSuggestion } from "src/types/mention-suggestion"; +import { RestoreImage } from "src/types/restore-image"; +import { UploadImage } from "src/types/upload-image"; +import { Selection } from "@tiptap/pm/state"; +import { insertContentAtSavedSelection } from "src/helpers/insert-content-at-cursor-position"; + +interface CustomEditorProps { + uploadFile: UploadImage; + restoreFile: RestoreImage; + rerenderOnPropsChange?: { + id: string; + description_html: string; + }; + deleteFile: DeleteImage; + cancelUploadImage?: () => any; + setShouldShowAlert?: (showAlert: boolean) => void; + value: string; + debouncedUpdatesEnabled?: boolean; + onStart?: (json: any, html: string) => void; + onChange?: (json: any, html: string) => void; + extensions?: any; + editorProps?: EditorProps; + forwardedRef?: any; + mentionHighlights?: string[]; + mentionSuggestions?: IMentionSuggestion[]; +} + +export const useEditor = ({ + uploadFile, + deleteFile, + cancelUploadImage, + editorProps = {}, + value, + rerenderOnPropsChange, + extensions = [], + onStart, + onChange, + forwardedRef, + restoreFile, + setShouldShowAlert, + mentionHighlights, + mentionSuggestions, +}: CustomEditorProps) => { + const editor = useCustomEditor( + { + editorProps: { + ...CoreEditorProps(uploadFile), + ...editorProps, + }, + extensions: [ + ...CoreEditorExtensions( + { + mentionSuggestions: mentionSuggestions ?? [], + mentionHighlights: mentionHighlights ?? [], + }, + deleteFile, + restoreFile, + cancelUploadImage + ), + ...extensions, + ], + content: typeof value === "string" && value.trim() !== "" ? value : "

    ", + onCreate: async ({ editor }) => { + onStart?.(editor.getJSON(), getTrimmedHTML(editor.getHTML())); + }, + onTransaction: async ({ editor }) => { + setSavedSelection(editor.state.selection); + }, + onUpdate: async ({ editor }) => { + // setIsSubmitting?.("submitting"); + setShouldShowAlert?.(true); + onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML())); + }, + }, + [rerenderOnPropsChange] + ); + + const editorRef: MutableRefObject = useRef(null); + editorRef.current = editor; + + const [savedSelection, setSavedSelection] = useState(null); + + useImperativeHandle(forwardedRef, () => ({ + clearEditor: () => { + editorRef.current?.commands.clearContent(); + }, + setEditorValue: (content: string) => { + editorRef.current?.commands.setContent(content); + }, + setEditorValueAtCursorPosition: (content: string) => { + if (savedSelection) { + insertContentAtSavedSelection(editorRef, content, savedSelection); + } + }, + })); + + if (!editor) { + return null; + } + + return editor; +}; diff --git a/packages/editor/core-document-editor/src/hooks/use-read-only-editor.tsx b/packages/editor/core-document-editor/src/hooks/use-read-only-editor.tsx new file mode 100644 index 00000000000..ecd49255ca6 --- /dev/null +++ b/packages/editor/core-document-editor/src/hooks/use-read-only-editor.tsx @@ -0,0 +1,66 @@ +import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; +import { useImperativeHandle, useRef, MutableRefObject } from "react"; +import { CoreReadOnlyEditorExtensions } from "src/ui/read-only/extensions"; +import { CoreReadOnlyEditorProps } from "src/ui/read-only/props"; +import { EditorProps } from "@tiptap/pm/view"; +import { IMentionSuggestion } from "src/types/mention-suggestion"; + +interface CustomReadOnlyEditorProps { + value: string; + forwardedRef?: any; + extensions?: any; + editorProps?: EditorProps; + rerenderOnPropsChange?: { + id: string; + description_html: string; + }; + mentionHighlights?: string[]; + mentionSuggestions?: IMentionSuggestion[]; +} + +export const useReadOnlyEditor = ({ + value, + forwardedRef, + extensions = [], + editorProps = {}, + rerenderOnPropsChange, + mentionHighlights, + mentionSuggestions, +}: CustomReadOnlyEditorProps) => { + const editor = useCustomEditor( + { + editable: false, + content: typeof value === "string" && value.trim() !== "" ? value : "

    ", + editorProps: { + ...CoreReadOnlyEditorProps, + ...editorProps, + }, + extensions: [ + ...CoreReadOnlyEditorExtensions({ + mentionSuggestions: mentionSuggestions ?? [], + mentionHighlights: mentionHighlights ?? [], + }), + ...extensions, + ], + }, + [rerenderOnPropsChange] + ); + + const editorRef: MutableRefObject = useRef(null); + editorRef.current = editor; + + useImperativeHandle(forwardedRef, () => ({ + clearEditor: () => { + editorRef.current?.commands.clearContent(); + }, + setEditorValue: (content: string) => { + editorRef.current?.commands.setContent(content); + }, + })); + + if (!editor) { + return null; + } + + return editor; +}; diff --git a/packages/editor/core-document-editor/src/index.ts b/packages/editor/core-document-editor/src/index.ts new file mode 100644 index 00000000000..c7e39d240a5 --- /dev/null +++ b/packages/editor/core-document-editor/src/index.ts @@ -0,0 +1,32 @@ +// styles +// import "./styles/tailwind.css"; +import "src/styles/editor.css"; +import "src/styles/table.css"; +import "src/styles/github-dark.css"; + +export { isCellSelection } from "src/ui/extensions/table/table/utilities/is-cell-selection"; + +// utils +export * from "src/lib/utils"; +export * from "src/ui/extensions/table/table"; +export { startImageUpload } from "src/ui/plugins/upload-image"; + +// components +export { EditorContainer } from "src/ui/components/editor-container"; +export { EditorContentWrapper } from "src/ui/components/editor-content"; + +// hooks +export { useEditor } from "src/hooks/use-editor"; +export { useReadOnlyEditor } from "src/hooks/use-read-only-editor"; + +// helper items +export * from "src/ui/menus/menu-items"; +export * from "src/lib/editor-commands"; + +// types +export type { DeleteImage } from "src/types/delete-image"; +export type { UploadImage } from "src/types/upload-image"; +export type { RestoreImage } from "src/types/restore-image"; +export type { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion"; +export type { ISlashCommandItem, CommandProps } from "src/types/slash-commands-suggestion"; +export type { LucideIconType } from "src/types/lucide-icon"; diff --git a/packages/editor/core-document-editor/src/lib/editor-commands.ts b/packages/editor/core-document-editor/src/lib/editor-commands.ts new file mode 100644 index 00000000000..6524d1ff58a --- /dev/null +++ b/packages/editor/core-document-editor/src/lib/editor-commands.ts @@ -0,0 +1,130 @@ +import { Editor, Range } from "@tiptap/core"; +import { startImageUpload } from "src/ui/plugins/upload-image"; +import { findTableAncestor } from "src/lib/utils"; +import { UploadImage } from "src/types/upload-image"; + +export const toggleHeadingOne = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); + else editor.chain().focus().toggleHeading({ level: 1 }).run(); +}; + +export const toggleHeadingTwo = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); + else editor.chain().focus().toggleHeading({ level: 2 }).run(); +}; + +export const toggleHeadingThree = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run(); + else editor.chain().focus().toggleHeading({ level: 3 }).run(); +}; + +export const toggleBold = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).toggleBold().run(); + else editor.chain().focus().toggleBold().run(); +}; + +export const toggleItalic = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).toggleItalic().run(); + else editor.chain().focus().toggleItalic().run(); +}; + +export const toggleUnderline = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).toggleUnderline().run(); + else editor.chain().focus().toggleUnderline().run(); +}; + +export const toggleCodeBlock = (editor: Editor, range?: Range) => { + // Check if code block is active then toggle code block + if (editor.isActive("codeBlock")) { + if (range) { + editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); + return; + } + editor.chain().focus().toggleCodeBlock().run(); + return; + } + + // Check if user hasn't selected any text + const isSelectionEmpty = editor.state.selection.empty; + + if (isSelectionEmpty) { + if (range) { + editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); + return; + } + editor.chain().focus().toggleCodeBlock().run(); + } else { + if (range) { + editor.chain().focus().deleteRange(range).toggleCode().run(); + return; + } + editor.chain().focus().toggleCode().run(); + } +}; + +export const toggleOrderedList = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run(); + else editor.chain().focus().toggleOrderedList().run(); +}; + +export const toggleBulletList = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).toggleBulletList().run(); + else editor.chain().focus().toggleBulletList().run(); +}; + +export const toggleTaskList = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run(); + else editor.chain().focus().toggleTaskList().run(); +}; + +export const toggleStrike = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).toggleStrike().run(); + else editor.chain().focus().toggleStrike().run(); +}; + +export const toggleBlockquote = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).toggleBlockquote().run(); + else editor.chain().focus().toggleBlockquote().run(); +}; + +export const insertTableCommand = (editor: Editor, range?: Range) => { + if (typeof window !== "undefined") { + const selection: any = window?.getSelection(); + if (selection.rangeCount !== 0) { + const range = selection.getRangeAt(0); + if (findTableAncestor(range.startContainer)) { + return; + } + } + } + if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3 }).run(); + else editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); +}; + +export const unsetLinkEditor = (editor: Editor) => { + editor.chain().focus().unsetLink().run(); +}; + +export const setLinkEditor = (editor: Editor, url: string) => { + editor.chain().focus().setLink({ href: url }).run(); +}; + +export const insertImageCommand = ( + editor: Editor, + uploadFile: UploadImage, + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, + range?: Range +) => { + if (range) editor.chain().focus().deleteRange(range).run(); + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.onchange = async () => { + if (input.files?.length) { + const file = input.files[0]; + const pos = editor.view.state.selection.from; + startImageUpload(file, editor.view, pos, uploadFile, setIsSubmitting); + } + }; + input.click(); +}; diff --git a/packages/editor/core-document-editor/src/lib/utils.ts b/packages/editor/core-document-editor/src/lib/utils.ts new file mode 100644 index 00000000000..c943d4c6048 --- /dev/null +++ b/packages/editor/core-document-editor/src/lib/utils.ts @@ -0,0 +1,58 @@ +import { Selection } from "@tiptap/pm/state"; +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; +interface EditorClassNames { + noBorder?: boolean; + borderOnFocus?: boolean; + customClassName?: string; +} + +export const getEditorClassNames = ({ noBorder, borderOnFocus, customClassName }: EditorClassNames) => + cn( + "relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md", + noBorder ? "" : "border border-custom-border-200", + borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0", + customClassName + ); + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// Helper function to find the parent node of a specific type +export function findParentNodeOfType(selection: Selection, typeName: string) { + let depth = selection.$anchor.depth; + while (depth > 0) { + const node = selection.$anchor.node(depth); + if (node.type.name === typeName) { + return { node, pos: selection.$anchor.start(depth) - 1 }; + } + depth--; + } + return null; +} + +export const findTableAncestor = (node: Node | null): HTMLTableElement | null => { + while (node !== null && node.nodeName !== "TABLE") { + node = node.parentNode; + } + return node as HTMLTableElement; +}; + +export const getTrimmedHTML = (html: string) => { + html = html.replace(/^(

    <\/p>)+/, ""); + html = html.replace(/(

    <\/p>)+$/, ""); + return html; +}; + +export const isValidHttpUrl = (string: string): boolean => { + let url: URL; + + try { + url = new URL(string); + } catch (_) { + return false; + } + + return url.protocol === "http:" || url.protocol === "https:"; +}; diff --git a/packages/editor/core-document-editor/src/styles/editor.css b/packages/editor/core-document-editor/src/styles/editor.css new file mode 100644 index 00000000000..dbbea671eba --- /dev/null +++ b/packages/editor/core-document-editor/src/styles/editor.css @@ -0,0 +1,216 @@ +.ProseMirror p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: rgb(var(--color-text-400)); + pointer-events: none; + height: 0; +} + +/* block quotes */ +.ProseMirror blockquote p::before, +.ProseMirror blockquote p::after { + display: none; +} + +.ProseMirror code::before, +.ProseMirror code::after { + display: none; +} + +.ProseMirror .is-empty::before { + content: attr(data-placeholder); + float: left; + color: rgb(var(--color-text-400)); + pointer-events: none; + height: 0; +} + +/* Custom image styles */ +.ProseMirror img { + transition: filter 0.1s ease-in-out; + margin-top: 0 !important; + margin-bottom: 0 !important; + + &:hover { + cursor: pointer; + filter: brightness(90%); + } + + &.ProseMirror-selectednode { + outline: 3px solid #5abbf7; + filter: brightness(90%); + } +} + +.ProseMirror-gapcursor:after { + border-top: 1px solid rgb(var(--color-text-100)) !important; +} + +/* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */ + +ul[data-type="taskList"] li > label { + margin-right: 0.2rem; + user-select: none; +} + +@media screen and (max-width: 768px) { + ul[data-type="taskList"] li > label { + margin-right: 0.5rem; + } +} + +ul[data-type="taskList"] li > label input[type="checkbox"] { + -webkit-appearance: none; + appearance: none; + background-color: rgb(var(--color-background-100)); + margin: 0; + cursor: pointer; + width: 0.8rem; + height: 0.8rem; + position: relative; + border: 1.5px solid rgb(var(--color-text-100)); + margin-right: 0.2rem; + margin-top: 0.15rem; + display: grid; + place-content: center; + + &:hover { + background-color: rgb(var(--color-background-80)); + } + + &:active { + background-color: rgb(var(--color-background-90)); + } + + &::before { + content: ""; + width: 0.5em; + height: 0.5em; + transform: scale(0); + transition: 120ms transform ease-in-out; + box-shadow: inset 1em 1em; + transform-origin: center; + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); + } + + &:checked::before { + transform: scale(1); + } +} + +ul[data-type="taskList"] li[data-checked="true"] > div > p { + color: rgb(var(--color-text-200)); + text-decoration: line-through; + text-decoration-thickness: 2px; +} + +/* Overwrite tippy-box original max-width */ + +.tippy-box { + max-width: 400px !important; +} + +.ProseMirror { + position: relative; + word-wrap: break-word; + white-space: pre-wrap; + -moz-tab-size: 4; + tab-size: 4; + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; + outline: none; + cursor: text; + line-height: 1.2; + font-family: inherit; + font-size: 14px; + color: inherit; + -moz-box-sizing: border-box; + box-sizing: border-box; + appearance: textfield; + -webkit-appearance: textfield; + -moz-appearance: textfield; +} + +.fadeIn { + opacity: 1; + transition: opacity 0.3s ease-in; +} + +.fadeOut { + opacity: 0; + transition: opacity 0.2s ease-out; +} + +.img-placeholder { + position: relative; + width: 35%; + margin-top: 0 !important; + margin-bottom: 0 !important; + + &:before { + content: ""; + box-sizing: border-box; + position: absolute; + top: 50%; + left: 45%; + width: 20px; + height: 20px; + border-radius: 50%; + border: 3px solid rgba(var(--color-text-200)); + border-top-color: rgba(var(--color-text-800)); + animation: spinning 0.6s linear infinite; + } +} + +@keyframes spinning { + to { + transform: rotate(360deg); + } +} + +.resize-cursor { + cursor: ew-resize; + cursor: col-resize; +} + +.ProseMirror table * p { + padding: 0px 1px; + margin: 6px 2px; +} + +.ProseMirror table * .is-empty::before { + opacity: 0; +} + +.ProseMirror pre { + background: rgba(var(--color-background-80)); + border-radius: 0.5rem; + color: rgba(var(--color-text-100)); + font-family: "JetBrainsMono", monospace; + padding: 0.75rem 1rem; +} + +.ProseMirror pre code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; +} + +div[data-type="horizontalRule"] { + line-height: 0; + padding: 0.25rem 0; + margin-top: 0; + margin-bottom: 0; + + & > div { + border-bottom: 1px solid rgb(var(--color-text-100)); + } +} + +/* image resizer */ +.moveable-control-box { + z-index: 10 !important; +} diff --git a/packages/editor/core-document-editor/src/styles/github-dark.css b/packages/editor/core-document-editor/src/styles/github-dark.css new file mode 100644 index 00000000000..9374de403f2 --- /dev/null +++ b/packages/editor/core-document-editor/src/styles/github-dark.css @@ -0,0 +1,85 @@ +pre code.hljs { + display: block; + overflow-x: auto; + padding: 1em; +} +code.hljs { + padding: 3px 5px; +} +.hljs { + color: #c9d1d9; + background: #0d1117; +} +.hljs-doctag, +.hljs-keyword, +.hljs-meta .hljs-keyword, +.hljs-template-tag, +.hljs-template-variable, +.hljs-type, +.hljs-variable.language_ { + color: #ff7b72; +} +.hljs-title, +.hljs-title.class_, +.hljs-title.class_.inherited__, +.hljs-title.function_ { + color: #d2a8ff; +} +.hljs-attr, +.hljs-attribute, +.hljs-literal, +.hljs-meta, +.hljs-number, +.hljs-operator, +.hljs-selector-attr, +.hljs-selector-class, +.hljs-selector-id, +.hljs-variable { + color: #79c0ff; +} +.hljs-meta .hljs-string, +.hljs-regexp, +.hljs-string { + color: #a5d6ff; +} +.hljs-built_in, +.hljs-symbol { + color: #ffa657; +} +.hljs-code, +.hljs-comment, +.hljs-formula { + color: #8b949e; +} +.hljs-name, +.hljs-quote, +.hljs-selector-pseudo, +.hljs-selector-tag { + color: #7ee787; +} +.hljs-subst { + color: #c9d1d9; +} +.hljs-section { + color: #1f6feb; + font-weight: 700; +} +.hljs-bullet { + color: #f2cc60; +} +.hljs-emphasis { + color: #c9d1d9; + font-style: italic; +} +.hljs-strong { + color: #c9d1d9; + font-weight: 700; +} +.hljs-addition { + color: #aff5b4; + background-color: #033a16; +} +.hljs-deletion { + color: #ffdcd7; + background-color: #67060c; +} diff --git a/packages/editor/core-document-editor/src/styles/table.css b/packages/editor/core-document-editor/src/styles/table.css new file mode 100644 index 00000000000..3ba17ee1b28 --- /dev/null +++ b/packages/editor/core-document-editor/src/styles/table.css @@ -0,0 +1,240 @@ +.tableWrapper { + overflow-x: auto; + padding: 2px; + width: fit-content; + max-width: 100%; +} + +.tableWrapper table { + border-collapse: collapse; + table-layout: fixed; + margin: 0; + margin-bottom: 1rem; + border: 2px solid rgba(var(--color-border-300)); + width: 100%; +} + +.tableWrapper table td, +.tableWrapper table th { + min-width: 1em; + border: 1px solid rgba(var(--color-border-300)); + padding: 10px 15px; + vertical-align: top; + box-sizing: border-box; + position: relative; + transition: background-color 0.3s ease; + + > * { + margin-bottom: 0; + } +} + +.tableWrapper table td > *, +.tableWrapper table th > * { + margin: 0 !important; + padding: 0.25rem 0 !important; +} + +.tableWrapper table td.has-focus, +.tableWrapper table th.has-focus { + box-shadow: rgba(var(--color-primary-300), 0.1) 0px 0px 0px 2px inset !important; +} + +.tableWrapper table th { + font-weight: bold; + text-align: left; + background-color: #d9e4ff; + color: #171717; +} + +.tableWrapper table th * { + font-weight: 600; +} + +.tableWrapper table .selectedCell:after { + z-index: 2; + position: absolute; + content: ""; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-color: rgba(var(--color-primary-300), 0.1); + pointer-events: none; +} + +.colorPicker { + display: grid; + padding: 8px 8px; + grid-template-columns: repeat(6, 1fr); + gap: 5px; +} + +.colorPickerLabel { + font-size: 0.85rem; + color: #6b7280; + padding: 8px 8px; + padding-bottom: 0px; +} + +.colorPickerItem { + margin: 2px 0px; + width: 24px; + height: 24px; + border-radius: 4px; + border: none; + cursor: pointer; +} + +.divider { + background-color: #e5e7eb; + height: 1px; + margin: 3px 0; +} + +.tableWrapper table .column-resize-handle { + position: absolute; + right: -2px; + top: 0; + bottom: -2px; + width: 4px; + z-index: 5; + background-color: #d9e4ff; + pointer-events: none; +} + +.tableWrapper .tableControls { + position: absolute; +} + +.tableWrapper .tableControls .columnsControl, +.tableWrapper .tableControls .rowsControl { + transition: opacity ease-in 100ms; + position: absolute; + z-index: 5; + display: flex; + justify-content: center; + align-items: center; +} + +.tableWrapper .tableControls .columnsControl { + height: 20px; + transform: translateY(-50%); +} + +.tableWrapper .tableControls .columnsControl .columnsControlDiv { + color: white; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E"); + width: 30px; + height: 15px; +} + +.tableWrapper .tableControls .rowsControl { + width: 20px; + transform: translateX(-50%); +} + +.tableWrapper .tableControls .rowsControl .rowsControlDiv { + color: white; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E"); + height: 30px; + width: 15px; +} + +.tableWrapper .tableControls .rowsControlDiv { + background-color: #d9e4ff; + border: 1px solid rgba(var(--color-border-200)); + border-radius: 2px; + background-size: 1.25rem; + background-repeat: no-repeat; + background-position: center; + transition: + transform ease-out 100ms, + background-color ease-out 100ms; + outline: none; + box-shadow: #000 0px 2px 4px; + cursor: pointer; +} + +.tableWrapper .tableControls .columnsControlDiv { + background-color: #d9e4ff; + border: 1px solid rgba(var(--color-border-200)); + border-radius: 2px; + background-size: 1.25rem; + background-repeat: no-repeat; + background-position: center; + transition: + transform ease-out 100ms, + background-color ease-out 100ms; + outline: none; + box-shadow: #000 0px 2px 4px; + cursor: pointer; +} +.tableWrapper .tableControls .tableToolbox, +.tableWrapper .tableControls .tableColorPickerToolbox { + border: 1px solid rgba(var(--color-border-300)); + background-color: rgba(var(--color-background-100)); + border-radius: 5px; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); + padding: 0.25rem; + display: flex; + flex-direction: column; + width: max-content; + gap: 0.25rem; +} + +.tableWrapper .tableControls .tableToolbox .toolboxItem, +.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem { + background-color: rgba(var(--color-background-100)); + display: flex; + align-items: center; + gap: 0.5rem; + border: none; + padding: 0.3rem 0.5rem 0.1rem 0.1rem; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.tableWrapper .tableControls .tableToolbox .toolboxItem:hover, +.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem:hover { + background-color: rgba(var(--color-background-80), 0.6); +} + +.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer, +.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer, +.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer, +.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer { + padding: 4px 0px; + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; +} + +.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer svg, +.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg, +.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg, +.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg { + width: 1rem; + height: 1rem; +} + +.tableToolbox { + background-color: rgba(var(--color-background-100)); +} + +.tableWrapper .tableControls .tableToolbox .toolboxItem .label, +.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .label { + font-size: 0.85rem; + color: rgba(var(--color-text-300)); +} + +.resize-cursor .tableWrapper .tableControls .rowsControl, +.tableWrapper.controls--disabled .tableControls .rowsControl, +.resize-cursor .tableWrapper .tableControls .columnsControl, +.tableWrapper.controls--disabled .tableControls .columnsControl { + opacity: 0; + pointer-events: none; +} diff --git a/packages/editor/core-document-editor/src/styles/tailwind.css b/packages/editor/core-document-editor/src/styles/tailwind.css new file mode 100644 index 00000000000..b5c61c95671 --- /dev/null +++ b/packages/editor/core-document-editor/src/styles/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/packages/editor/core-document-editor/src/types/delete-image.ts b/packages/editor/core-document-editor/src/types/delete-image.ts new file mode 100644 index 00000000000..40bfffe2fd3 --- /dev/null +++ b/packages/editor/core-document-editor/src/types/delete-image.ts @@ -0,0 +1 @@ +export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; diff --git a/packages/editor/core-document-editor/src/types/lucide-icon.ts b/packages/editor/core-document-editor/src/types/lucide-icon.ts new file mode 100644 index 00000000000..2211c18e8f0 --- /dev/null +++ b/packages/editor/core-document-editor/src/types/lucide-icon.ts @@ -0,0 +1,3 @@ +import { Smile } from "lucide-react"; + +export type LucideIconType = typeof Smile; diff --git a/packages/editor/core-document-editor/src/types/mention-suggestion.ts b/packages/editor/core-document-editor/src/types/mention-suggestion.ts new file mode 100644 index 00000000000..dcaa3148d63 --- /dev/null +++ b/packages/editor/core-document-editor/src/types/mention-suggestion.ts @@ -0,0 +1,10 @@ +export type IMentionSuggestion = { + id: string; + type: string; + avatar: string; + title: string; + subtitle: string; + redirect_uri: string; +}; + +export type IMentionHighlight = string; diff --git a/packages/editor/core-document-editor/src/types/restore-image.ts b/packages/editor/core-document-editor/src/types/restore-image.ts new file mode 100644 index 00000000000..9b33177b787 --- /dev/null +++ b/packages/editor/core-document-editor/src/types/restore-image.ts @@ -0,0 +1 @@ +export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; diff --git a/packages/editor/core-document-editor/src/types/slash-commands-suggestion.ts b/packages/editor/core-document-editor/src/types/slash-commands-suggestion.ts new file mode 100644 index 00000000000..34e451098f1 --- /dev/null +++ b/packages/editor/core-document-editor/src/types/slash-commands-suggestion.ts @@ -0,0 +1,16 @@ +import { ReactNode } from "react"; +import { Editor, Range } from "@tiptap/core"; + +export type CommandProps = { + editor: Editor; + range: Range; +}; + +export type ISlashCommandItem = { + key: string; + title: string; + description: string; + searchTerms: string[]; + icon: ReactNode; + command: ({ editor, range }: CommandProps) => void; +}; diff --git a/packages/editor/core-document-editor/src/types/upload-image.ts b/packages/editor/core-document-editor/src/types/upload-image.ts new file mode 100644 index 00000000000..3cf1408d22b --- /dev/null +++ b/packages/editor/core-document-editor/src/types/upload-image.ts @@ -0,0 +1 @@ +export type UploadImage = (file: File) => Promise; diff --git a/packages/editor/core-document-editor/src/ui/components/editor-container.tsx b/packages/editor/core-document-editor/src/ui/components/editor-container.tsx new file mode 100644 index 00000000000..1b2504b5834 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/components/editor-container.tsx @@ -0,0 +1,61 @@ +import { Editor } from "@tiptap/react"; +import { FC, ReactNode } from "react"; + +interface EditorContainerProps { + editor: Editor | null; + editorClassNames: string; + children: ReactNode; + hideDragHandle?: () => void; +} + +export const EditorContainer: FC = (props) => { + const { editor, editorClassNames, hideDragHandle, children } = props; + + const handleContainerClick = () => { + if (!editor) return; + if (!editor.isEditable) return; + if (editor.isFocused) return; // If editor is already focused, do nothing + + const { selection } = editor.state; + const currentNode = selection.$from.node(); + + editor?.chain().focus("end", { scrollIntoView: false }).run(); // Focus the editor at the end + + if ( + currentNode.content.size === 0 && // Check if the current node is empty + !( + editor.isActive("orderedList") || + editor.isActive("bulletList") || + editor.isActive("taskItem") || + editor.isActive("table") || + editor.isActive("blockquote") || + editor.isActive("codeBlock") + ) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block + ) { + return; + } + + // Insert a new paragraph at the end of the document + const endPosition = editor?.state.doc.content.size; + editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run(); + + // Focus the newly added paragraph for immediate editing + editor + .chain() + .setTextSelection(endPosition + 1) + .run(); + }; + + return ( +

    { + hideDragHandle?.(); + }} + className={`cursor-text ${editorClassNames}`} + > + {children} +
    + ); +}; diff --git a/packages/editor/core-document-editor/src/ui/components/editor-content.tsx b/packages/editor/core-document-editor/src/ui/components/editor-content.tsx new file mode 100644 index 00000000000..7a6ce30f770 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/components/editor-content.tsx @@ -0,0 +1,28 @@ +import { Editor, EditorContent } from "@tiptap/react"; +import { FC, ReactNode } from "react"; +import { ImageResizer } from "src/ui/extensions/image/image-resize"; + +interface EditorContentProps { + editor: Editor | null; + editorContentCustomClassNames: string | undefined; + children?: ReactNode; + tabIndex?: number; +} + +export const EditorContentWrapper: FC = (props) => { + const { editor, editorContentCustomClassNames = "", tabIndex, children } = props; + + return ( +
    { + editor?.chain().focus(undefined, { scrollIntoView: false }).run(); + }} + > + + {editor?.isActive("image") && editor?.isEditable && } + {children} +
    + ); +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/code-inline/index.tsx b/packages/editor/core-document-editor/src/ui/extensions/code-inline/index.tsx new file mode 100644 index 00000000000..1c5d341090b --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/code-inline/index.tsx @@ -0,0 +1,95 @@ +import { Mark, markInputRule, markPasteRule, mergeAttributes } from "@tiptap/core"; + +export interface CodeOptions { + HTMLAttributes: Record; +} + +declare module "@tiptap/core" { + interface Commands { + code: { + /** + * Set a code mark + */ + setCode: () => ReturnType; + /** + * Toggle inline code + */ + toggleCode: () => ReturnType; + /** + * Unset a code mark + */ + unsetCode: () => ReturnType; + }; + } +} + +export const inputRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))$/; +export const pasteRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))/g; + +export const CustomCodeInlineExtension = Mark.create({ + name: "code", + + addOptions() { + return { + HTMLAttributes: { + class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-[2px] font-mono font-medium text-custom-text-1000", + spellcheck: "false", + }, + }; + }, + + excludes: "_", + + code: true, + + exitable: true, + + parseHTML() { + return [{ tag: "code" }]; + }, + + renderHTML({ HTMLAttributes }) { + return ["code", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + }, + + addCommands() { + return { + setCode: + () => + ({ commands }) => + commands.setMark(this.name), + toggleCode: + () => + ({ commands }) => + commands.toggleMark(this.name), + unsetCode: + () => + ({ commands }) => + commands.unsetMark(this.name), + }; + }, + + addKeyboardShortcuts() { + return { + "Mod-e": () => this.editor.commands.toggleCode(), + }; + }, + + addInputRules() { + return [ + markInputRule({ + find: inputRegex, + type: this.type, + }), + ]; + }, + + addPasteRules() { + return [ + markPasteRule({ + find: pasteRegex, + type: this.type, + }), + ]; + }, +}); diff --git a/packages/editor/core-document-editor/src/ui/extensions/code/index.tsx b/packages/editor/core-document-editor/src/ui/extensions/code/index.tsx new file mode 100644 index 00000000000..64a1740cb29 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/code/index.tsx @@ -0,0 +1,101 @@ +import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; + +import { common, createLowlight } from "lowlight"; +import ts from "highlight.js/lib/languages/typescript"; + +const lowlight = createLowlight(common); +lowlight.register("ts", ts); + +import { Selection } from "@tiptap/pm/state"; + +export const CustomCodeBlockExtension = CodeBlockLowlight.extend({ + addKeyboardShortcuts() { + return { + Tab: ({ editor }) => { + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + // Use ProseMirror's insertText transaction to insert the tab character + const tr = state.tr.insertText("\t", $from.pos, $from.pos); + editor.view.dispatch(tr); + + return true; + }, + ArrowUp: ({ editor }) => { + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + const isAtStart = $from.parentOffset === 0; + + if (!isAtStart) { + return false; + } + + // Check if codeBlock is the first node + const isFirstNode = $from.depth === 1 && $from.index($from.depth - 1) === 0; + + if (isFirstNode) { + // Insert a new paragraph at the start of the document and move the cursor to it + return editor.commands.command(({ tr }) => { + const node = editor.schema.nodes.paragraph.create(); + tr.insert(0, node); + tr.setSelection(Selection.near(tr.doc.resolve(1))); + return true; + }); + } + + return false; + }, + ArrowDown: ({ editor }) => { + if (!this.options.exitOnArrowDown) { + return false; + } + + const { state } = editor; + const { selection, doc } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2; + + if (!isAtEnd) { + return false; + } + + const after = $from.after(); + + if (after === undefined) { + return false; + } + + const nodeAfter = doc.nodeAt(after); + + if (nodeAfter) { + return editor.commands.command(({ tr }) => { + tr.setSelection(Selection.near(doc.resolve(after))); + return true; + }); + } + + return editor.commands.exitCode(); + }, + }; + }, +}).configure({ + lowlight, + defaultLanguage: "plaintext", + exitOnTripleEnter: false, +}); diff --git a/packages/editor/core-document-editor/src/ui/extensions/custom-link/helpers/autolink.ts b/packages/editor/core-document-editor/src/ui/extensions/custom-link/helpers/autolink.ts new file mode 100644 index 00000000000..cf67e13d90b --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/custom-link/helpers/autolink.ts @@ -0,0 +1,118 @@ +import { + combineTransactionSteps, + findChildrenInRange, + getChangedRanges, + getMarksBetween, + NodeWithPos, +} from "@tiptap/core"; +import { MarkType } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { find } from "linkifyjs"; + +type AutolinkOptions = { + type: MarkType; + validate?: (url: string) => boolean; +}; + +export function autolink(options: AutolinkOptions): Plugin { + return new Plugin({ + key: new PluginKey("autolink"), + appendTransaction: (transactions, oldState, newState) => { + const docChanges = transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc); + const preventAutolink = transactions.some((transaction) => transaction.getMeta("preventAutolink")); + + if (!docChanges || preventAutolink) { + return; + } + + const { tr } = newState; + const transform = combineTransactionSteps(oldState.doc, [...transactions]); + const changes = getChangedRanges(transform); + + changes.forEach(({ newRange }) => { + // Now let’s see if we can add new links. + const nodesInChangedRanges = findChildrenInRange(newState.doc, newRange, (node) => node.isTextblock); + + let textBlock: NodeWithPos | undefined; + let textBeforeWhitespace: string | undefined; + + if (nodesInChangedRanges.length > 1) { + // Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter). + textBlock = nodesInChangedRanges[0]; + textBeforeWhitespace = newState.doc.textBetween( + textBlock.pos, + textBlock.pos + textBlock.node.nodeSize, + undefined, + " " + ); + } else if ( + nodesInChangedRanges.length && + // We want to make sure to include the block seperator argument to treat hard breaks like spaces. + newState.doc.textBetween(newRange.from, newRange.to, " ", " ").endsWith(" ") + ) { + textBlock = nodesInChangedRanges[0]; + textBeforeWhitespace = newState.doc.textBetween(textBlock.pos, newRange.to, undefined, " "); + } + + if (textBlock && textBeforeWhitespace) { + const wordsBeforeWhitespace = textBeforeWhitespace.split(" ").filter((s) => s !== ""); + + if (wordsBeforeWhitespace.length <= 0) { + return false; + } + + const lastWordBeforeSpace = wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1]; + const lastWordAndBlockOffset = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace); + + if (!lastWordBeforeSpace) { + return false; + } + + find(lastWordBeforeSpace) + .filter((link) => link.isLink) + // Calculate link position. + .map((link) => ({ + ...link, + from: lastWordAndBlockOffset + link.start + 1, + to: lastWordAndBlockOffset + link.end + 1, + })) + // ignore link inside code mark + .filter((link) => { + if (!newState.schema.marks.code) { + return true; + } + + return !newState.doc.rangeHasMark(link.from, link.to, newState.schema.marks.code); + }) + // validate link + .filter((link) => { + if (options.validate) { + return options.validate(link.value); + } + return true; + }) + // Add link mark. + .forEach((link) => { + if (getMarksBetween(link.from, link.to, newState.doc).some((item) => item.mark.type === options.type)) { + return; + } + + tr.addMark( + link.from, + link.to, + options.type.create({ + href: link.href, + }) + ); + }); + } + }); + + if (!tr.steps.length) { + return; + } + + return tr; + }, + }); +} diff --git a/packages/editor/core-document-editor/src/ui/extensions/custom-link/helpers/clickHandler.ts b/packages/editor/core-document-editor/src/ui/extensions/custom-link/helpers/clickHandler.ts new file mode 100644 index 00000000000..ec6c540dacc --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/custom-link/helpers/clickHandler.ts @@ -0,0 +1,46 @@ +import { getAttributes } from "@tiptap/core"; +import { MarkType } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +type ClickHandlerOptions = { + type: MarkType; +}; + +export function clickHandler(options: ClickHandlerOptions): Plugin { + return new Plugin({ + key: new PluginKey("handleClickLink"), + props: { + handleClick: (view, pos, event) => { + if (event.button !== 0) { + return false; + } + + let a = event.target as HTMLElement; + const els = []; + + while (a.nodeName !== "DIV") { + els.push(a); + a = a.parentNode as HTMLElement; + } + + if (!els.find((value) => value.nodeName === "A")) { + return false; + } + + const attrs = getAttributes(view.state, options.type.name); + const link = event.target as HTMLLinkElement; + + const href = link?.href ?? attrs.href; + const target = link?.target ?? attrs.target; + + if (link && href) { + window.open(href, target); + + return true; + } + + return false; + }, + }, + }); +} diff --git a/packages/editor/core-document-editor/src/ui/extensions/custom-link/helpers/pasteHandler.ts b/packages/editor/core-document-editor/src/ui/extensions/custom-link/helpers/pasteHandler.ts new file mode 100644 index 00000000000..475bf28d94b --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/custom-link/helpers/pasteHandler.ts @@ -0,0 +1,44 @@ +import { Editor } from "@tiptap/core"; +import { MarkType } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { find } from "linkifyjs"; + +type PasteHandlerOptions = { + editor: Editor; + type: MarkType; +}; + +export function pasteHandler(options: PasteHandlerOptions): Plugin { + return new Plugin({ + key: new PluginKey("handlePasteLink"), + props: { + handlePaste: (view, event, slice) => { + const { state } = view; + const { selection } = state; + const { empty } = selection; + + if (empty) { + return false; + } + + let textContent = ""; + + slice.content.forEach((node) => { + textContent += node.textContent; + }); + + const link = find(textContent).find((item) => item.isLink && item.value === textContent); + + if (!textContent || !link) { + return false; + } + + options.editor.commands.setMark(options.type, { + href: link.href, + }); + + return true; + }, + }, + }); +} diff --git a/packages/editor/core-document-editor/src/ui/extensions/custom-link/index.ts b/packages/editor/core-document-editor/src/ui/extensions/custom-link/index.ts new file mode 100644 index 00000000000..88e7abfe57c --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/custom-link/index.ts @@ -0,0 +1,248 @@ +import { Mark, markPasteRule, mergeAttributes, PasteRuleMatch } from "@tiptap/core"; +import { Plugin } from "@tiptap/pm/state"; +import { find, registerCustomProtocol, reset } from "linkifyjs"; +import { autolink } from "./helpers/autolink"; +import { clickHandler } from "./helpers/clickHandler"; +import { pasteHandler } from "./helpers/pasteHandler"; + +export interface LinkProtocolOptions { + scheme: string; + optionalSlashes?: boolean; +} + +export const pasteRegex = + /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)/gi; + +export interface LinkOptions { + /** + * If enabled, it adds links as you type. + */ + autolink: boolean; + /** + * An array of custom protocols to be registered with linkifyjs. + */ + protocols: Array; + /** + * If enabled, links will be opened on click. + */ + openOnClick: boolean; + /** + * If enabled, links will be inclusive i.e. if you move your cursor to the + * link text, and start typing, it'll be a part of the link itself. + */ + inclusive: boolean; + /** + * Adds a link to the current selection if the pasted content only contains an url. + */ + linkOnPaste: boolean; + /** + * A list of HTML attributes to be rendered. + */ + HTMLAttributes: Record; + /** + * A validation function that modifies link verification for the auto linker. + * @param url - The url to be validated. + * @returns - True if the url is valid, false otherwise. + */ + validate?: (url: string) => boolean; +} + +declare module "@tiptap/core" { + interface Commands { + link: { + /** + * Set a link mark + */ + setLink: (attributes: { + href: string; + target?: string | null; + rel?: string | null; + class?: string | null; + }) => ReturnType; + /** + * Toggle a link mark + */ + toggleLink: (attributes: { + href: string; + target?: string | null; + rel?: string | null; + class?: string | null; + }) => ReturnType; + /** + * Unset a link mark + */ + unsetLink: () => ReturnType; + }; + } +} + +export const CustomLinkExtension = Mark.create({ + name: "link", + + priority: 1000, + + keepOnSplit: false, + + onCreate() { + this.options.protocols.forEach((protocol) => { + if (typeof protocol === "string") { + registerCustomProtocol(protocol); + return; + } + registerCustomProtocol(protocol.scheme, protocol.optionalSlashes); + }); + }, + + onDestroy() { + reset(); + }, + + inclusive() { + return this.options.inclusive; + }, + + addOptions() { + return { + openOnClick: true, + linkOnPaste: true, + autolink: true, + inclusive: false, + protocols: [], + HTMLAttributes: { + target: "_blank", + rel: "noopener noreferrer nofollow", + class: null, + }, + validate: undefined, + }; + }, + + addAttributes() { + return { + href: { + default: null, + }, + target: { + default: this.options.HTMLAttributes.target, + }, + rel: { + default: this.options.HTMLAttributes.rel, + }, + class: { + default: this.options.HTMLAttributes.class, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "a[href]", + getAttrs: (node) => { + if (typeof node === "string" || !(node instanceof HTMLElement)) { + return null; + } + const href = node.getAttribute("href")?.toLowerCase() || ""; + if (href.startsWith("javascript:") || href.startsWith("data:") || href.startsWith("vbscript:")) { + return false; + } + return {}; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + const href = HTMLAttributes.href?.toLowerCase() || ""; + if (href.startsWith("javascript:") || href.startsWith("data:") || href.startsWith("vbscript:")) { + return ["a", mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: "" }), 0]; + } + return ["a", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + }, + + addCommands() { + return { + setLink: + (attributes) => + ({ chain }) => + chain().setMark(this.name, attributes).setMeta("preventAutolink", true).run(), + + toggleLink: + (attributes) => + ({ chain }) => + chain() + .toggleMark(this.name, attributes, { extendEmptyMarkRange: true }) + .setMeta("preventAutolink", true) + .run(), + + unsetLink: + () => + ({ chain }) => + chain().unsetMark(this.name, { extendEmptyMarkRange: true }).setMeta("preventAutolink", true).run(), + }; + }, + + addPasteRules() { + return [ + markPasteRule({ + find: (text) => { + const foundLinks: PasteRuleMatch[] = []; + + if (text) { + const links = find(text).filter((item) => item.isLink); + + if (links.length) { + links.forEach((link) => + foundLinks.push({ + text: link.value, + data: { + href: link.href, + }, + index: link.start, + }) + ); + } + } + + return foundLinks; + }, + type: this.type, + getAttributes: (match) => ({ + href: match.data?.href, + }), + }), + ]; + }, + + addProseMirrorPlugins() { + const plugins: Plugin[] = []; + + if (this.options.autolink) { + plugins.push( + autolink({ + type: this.type, + validate: this.options.validate, + }) + ); + } + + if (this.options.openOnClick) { + plugins.push( + clickHandler({ + type: this.type, + }) + ); + } + + if (this.options.linkOnPaste) { + plugins.push( + pasteHandler({ + editor: this.editor, + type: this.type, + }) + ); + } + + return plugins; + }, +}); diff --git a/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/index.ts b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/index.ts new file mode 100644 index 00000000000..b91209e9292 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/index.ts @@ -0,0 +1 @@ +export * from "./list-keymap"; diff --git a/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts new file mode 100644 index 00000000000..3bbfd9c9370 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts @@ -0,0 +1,30 @@ +import { getNodeType } from "@tiptap/core"; +import { NodeType } from "@tiptap/pm/model"; +import { EditorState } from "@tiptap/pm/state"; + +export const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => { + const { $from } = state.selection; + const nodeType = getNodeType(typeOrName, state.schema); + + let currentNode = null; + let currentDepth = $from.depth; + let currentPos = $from.pos; + let targetDepth: number | null = null; + + while (currentDepth > 0 && targetDepth === null) { + currentNode = $from.node(currentDepth); + + if (currentNode.type === nodeType) { + targetDepth = currentDepth; + } else { + currentDepth -= 1; + currentPos -= 1; + } + } + + if (targetDepth === null) { + return null; + } + + return { $pos: state.doc.resolve(currentPos), depth: targetDepth }; +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts new file mode 100644 index 00000000000..2e4f5fbaa5e --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts @@ -0,0 +1,16 @@ +import { getNodeAtPosition } from "@tiptap/core"; +import { EditorState } from "@tiptap/pm/state"; + +import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos"; + +export const getNextListDepth = (typeOrName: string, state: EditorState) => { + const listItemPos = findListItemPos(typeOrName, state); + + if (!listItemPos) { + return false; + } + + const [, depth] = getNodeAtPosition(state, typeOrName, listItemPos.$pos.pos + 4); + + return depth; +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts new file mode 100644 index 00000000000..a4f2d5db943 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts @@ -0,0 +1,66 @@ +import { Editor, isAtStartOfNode, isNodeActive } from "@tiptap/core"; +import { Node } from "@tiptap/pm/model"; + +import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos"; +import { hasListBefore } from "src/ui/extensions/custom-list-keymap/list-helpers/has-list-before"; + +export const handleBackspace = (editor: Editor, name: string, parentListTypes: string[]) => { + // this is required to still handle the undo handling + if (editor.commands.undoInputRule()) { + return true; + } + + // if the cursor is not at the start of a node + // do nothing and proceed + if (!isAtStartOfNode(editor.state)) { + return false; + } + + // if the current item is NOT inside a list item & + // the previous item is a list (orderedList or bulletList) + // move the cursor into the list and delete the current item + if (!isNodeActive(editor.state, name) && hasListBefore(editor.state, name, parentListTypes)) { + const { $anchor } = editor.state.selection; + + const $listPos = editor.state.doc.resolve($anchor.before() - 1); + + const listDescendants: Array<{ node: Node; pos: number }> = []; + + $listPos.node().descendants((node, pos) => { + if (node.type.name === name) { + listDescendants.push({ node, pos }); + } + }); + + const lastItem = listDescendants.at(-1); + + if (!lastItem) { + return false; + } + + const $lastItemPos = editor.state.doc.resolve($listPos.start() + lastItem.pos + 1); + + return editor + .chain() + .cut({ from: $anchor.start() - 1, to: $anchor.end() + 1 }, $lastItemPos.end()) + .joinForward() + .run(); + } + + // if the cursor is not inside the current node type + // do nothing and proceed + if (!isNodeActive(editor.state, name)) { + return false; + } + + const listItemPos = findListItemPos(name, editor.state); + + if (!listItemPos) { + return false; + } + + // if current node is a list item and cursor it at start of a list node, + // simply lift the list item i.e. remove it as a list item (task/bullet/ordered) + // irrespective of above node being a list or not + return editor.chain().liftListItem(name).run(); +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts new file mode 100644 index 00000000000..9179e0f2016 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts @@ -0,0 +1,34 @@ +import { Editor, isAtEndOfNode, isNodeActive } from "@tiptap/core"; + +import { nextListIsDeeper } from "src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper"; +import { nextListIsHigher } from "src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher"; + +export const handleDelete = (editor: Editor, name: string) => { + // if the cursor is not inside the current node type + // do nothing and proceed + if (!isNodeActive(editor.state, name)) { + return false; + } + + // if the cursor is not at the end of a node + // do nothing and proceed + if (!isAtEndOfNode(editor.state, name)) { + return false; + } + + // check if the next node is a list with a deeper depth + if (nextListIsDeeper(name, editor.state)) { + return editor + .chain() + .focus(editor.state.selection.from + 4) + .lift(name) + .joinBackward() + .run(); + } + + if (nextListIsHigher(name, editor.state)) { + return editor.chain().joinForward().joinBackward().run(); + } + + return editor.commands.joinItemForward(); +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts new file mode 100644 index 00000000000..fb6b95b6a74 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts @@ -0,0 +1,15 @@ +import { EditorState } from "@tiptap/pm/state"; + +export const hasListBefore = (editorState: EditorState, name: string, parentListTypes: string[]) => { + const { $anchor } = editorState.selection; + + const previousNodePos = Math.max(0, $anchor.pos - 2); + + const previousNode = editorState.doc.resolve(previousNodePos).node(); + + if (!previousNode || !parentListTypes.includes(previousNode.type.name)) { + return false; + } + + return true; +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts new file mode 100644 index 00000000000..4e538ac47fe --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts @@ -0,0 +1,17 @@ +import { EditorState } from "@tiptap/pm/state"; + +export const hasListItemAfter = (typeOrName: string, state: EditorState): boolean => { + const { $anchor } = state.selection; + + const $targetPos = state.doc.resolve($anchor.pos - $anchor.parentOffset - 2); + + if ($targetPos.index() === $targetPos.parent.childCount - 1) { + return false; + } + + if ($targetPos.nodeAfter?.type.name !== typeOrName) { + return false; + } + + return true; +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts new file mode 100644 index 00000000000..91fda9bf4cd --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts @@ -0,0 +1,17 @@ +import { EditorState } from "@tiptap/pm/state"; + +export const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => { + const { $anchor } = state.selection; + + const $targetPos = state.doc.resolve($anchor.pos - 2); + + if ($targetPos.index() === 0) { + return false; + } + + if ($targetPos.nodeBefore?.type.name !== typeOrName) { + return false; + } + + return true; +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/index.ts b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/index.ts new file mode 100644 index 00000000000..644953b92b4 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/index.ts @@ -0,0 +1,9 @@ +export * from "./find-list-item-pos"; +export * from "./get-next-list-depth"; +export * from "./handle-backspace"; +export * from "./handle-delete"; +export * from "./has-list-before"; +export * from "./has-list-item-after"; +export * from "./has-list-item-before"; +export * from "./next-list-is-deeper"; +export * from "./next-list-is-higher"; diff --git a/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts new file mode 100644 index 00000000000..7cd1a63f7ef --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts @@ -0,0 +1,19 @@ +import { EditorState } from "@tiptap/pm/state"; + +import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos"; +import { getNextListDepth } from "src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth"; + +export const nextListIsDeeper = (typeOrName: string, state: EditorState) => { + const listDepth = getNextListDepth(typeOrName, state); + const listItemPos = findListItemPos(typeOrName, state); + + if (!listItemPos || !listDepth) { + return false; + } + + if (listDepth > listItemPos.depth) { + return true; + } + + return false; +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts new file mode 100644 index 00000000000..3364c3b87c9 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts @@ -0,0 +1,19 @@ +import { EditorState } from "@tiptap/pm/state"; + +import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos"; +import { getNextListDepth } from "src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth"; + +export const nextListIsHigher = (typeOrName: string, state: EditorState) => { + const listDepth = getNextListDepth(typeOrName, state); + const listItemPos = findListItemPos(typeOrName, state); + + if (!listItemPos || !listDepth) { + return false; + } + + if (listDepth < listItemPos.depth) { + return true; + } + + return false; +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-keymap.ts b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-keymap.ts new file mode 100644 index 00000000000..aabd836d2ec --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/custom-list-keymap/list-keymap.ts @@ -0,0 +1,94 @@ +import { Extension } from "@tiptap/core"; + +import { handleBackspace, handleDelete } from "src/ui/extensions/custom-list-keymap/list-helpers"; + +export type ListKeymapOptions = { + listTypes: Array<{ + itemName: string; + wrapperNames: string[]; + }>; +}; + +export const ListKeymap = Extension.create({ + name: "listKeymap", + + addOptions() { + return { + listTypes: [ + { + itemName: "listItem", + wrapperNames: ["bulletList", "orderedList"], + }, + { + itemName: "taskItem", + wrapperNames: ["taskList"], + }, + ], + }; + }, + + addKeyboardShortcuts() { + return { + Delete: ({ editor }) => { + let handled = false; + + this.options.listTypes.forEach(({ itemName }) => { + if (editor.state.schema.nodes[itemName] === undefined) { + return; + } + + if (handleDelete(editor, itemName)) { + handled = true; + } + }); + + return handled; + }, + "Mod-Delete": ({ editor }) => { + let handled = false; + + this.options.listTypes.forEach(({ itemName }) => { + if (editor.state.schema.nodes[itemName] === undefined) { + return; + } + + if (handleDelete(editor, itemName)) { + handled = true; + } + }); + + return handled; + }, + Backspace: ({ editor }) => { + let handled = false; + + this.options.listTypes.forEach(({ itemName, wrapperNames }) => { + if (editor.state.schema.nodes[itemName] === undefined) { + return; + } + + if (handleBackspace(editor, itemName, wrapperNames)) { + handled = true; + } + }); + + return handled; + }, + "Mod-Backspace": ({ editor }) => { + let handled = false; + + this.options.listTypes.forEach(({ itemName, wrapperNames }) => { + if (editor.state.schema.nodes[itemName] === undefined) { + return; + } + + if (handleBackspace(editor, itemName, wrapperNames)) { + handled = true; + } + }); + + return handled; + }, + }; + }, +}); diff --git a/packages/editor/core-document-editor/src/ui/extensions/horizontal-rule/horizontal-rule.ts b/packages/editor/core-document-editor/src/ui/extensions/horizontal-rule/horizontal-rule.ts new file mode 100644 index 00000000000..2af845b7a08 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/horizontal-rule/horizontal-rule.ts @@ -0,0 +1,111 @@ +import { isNodeSelection, mergeAttributes, Node, nodeInputRule } from "@tiptap/core"; +import { NodeSelection, TextSelection } from "@tiptap/pm/state"; + +export interface HorizontalRuleOptions { + HTMLAttributes: Record; +} + +declare module "@tiptap/core" { + interface Commands { + horizontalRule: { + /** + * Add a horizontal rule + */ + setHorizontalRule: () => ReturnType; + }; + } +} + +export const CustomHorizontalRule = Node.create({ + name: "horizontalRule", + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + group: "block", + + parseHTML() { + return [{ tag: "hr" }]; + }, + + renderHTML({ HTMLAttributes }) { + return ["hr", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; + }, + + addCommands() { + return { + setHorizontalRule: + () => + ({ chain, state }) => { + const { selection } = state; + const { $from: $originFrom, $to: $originTo } = selection; + + const currentChain = chain(); + + if ($originFrom.parentOffset === 0) { + currentChain.insertContentAt( + { + from: Math.max($originFrom.pos - 1, 0), + to: $originTo.pos, + }, + { + type: this.name, + } + ); + } else if (isNodeSelection(selection)) { + currentChain.insertContentAt($originTo.pos, { + type: this.name, + }); + } else { + currentChain.insertContent({ type: this.name }); + } + + return ( + currentChain + // set cursor after horizontal rule + .command(({ tr, dispatch }) => { + if (dispatch) { + const { $to } = tr.selection; + const posAfter = $to.end(); + + if ($to.nodeAfter) { + if ($to.nodeAfter.isTextblock) { + tr.setSelection(TextSelection.create(tr.doc, $to.pos + 1)); + } else if ($to.nodeAfter.isBlock) { + tr.setSelection(NodeSelection.create(tr.doc, $to.pos)); + } else { + tr.setSelection(TextSelection.create(tr.doc, $to.pos)); + } + } else { + // add node after horizontal rule if it’s the end of the document + const node = $to.parent.type.contentMatch.defaultType?.create(); + + if (node) { + tr.insert(posAfter, node); + tr.setSelection(TextSelection.create(tr.doc, posAfter + 1)); + } + } + + tr.scrollIntoView(); + } + + return true; + }) + .run() + ); + }, + }; + }, + + addInputRules() { + return [ + nodeInputRule({ + find: /^(?:---|—-|___\s|\*\*\*\s)$/, + type: this.type, + }), + ]; + }, +}); diff --git a/packages/editor/core-document-editor/src/ui/extensions/image/image-resize.tsx b/packages/editor/core-document-editor/src/ui/extensions/image/image-resize.tsx new file mode 100644 index 00000000000..400938785bc --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/image/image-resize.tsx @@ -0,0 +1,65 @@ +import { Editor } from "@tiptap/react"; +import { useState } from "react"; +import Moveable from "react-moveable"; + +export const ImageResizer = ({ editor }: { editor: Editor }) => { + const updateMediaSize = () => { + const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement; + if (imageInfo) { + const selection = editor.state.selection; + editor.commands.setImage({ + src: imageInfo.src, + width: Number(imageInfo.style.width.replace("px", "")), + height: Number(imageInfo.style.height.replace("px", "")), + } as any); + editor.commands.setNodeSelection(selection.from); + } + }; + + const [aspectRatio, setAspectRatio] = useState(1); + + return ( + <> + { + const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement; + if (imageInfo) { + const originalWidth = Number(imageInfo.width); + const originalHeight = Number(imageInfo.height); + setAspectRatio(originalWidth / originalHeight); + } + }} + onResize={({ target, width, height, delta }: any) => { + if (delta[0]) { + const newWidth = Math.max(width, 100); + const newHeight = newWidth / aspectRatio; + target!.style.width = `${newWidth}px`; + target!.style.height = `${newHeight}px`; + } + if (delta[1]) { + const newHeight = Math.max(height, 100); + const newWidth = newHeight * aspectRatio; + target!.style.height = `${newHeight}px`; + target!.style.width = `${newWidth}px`; + } + }} + onResizeEnd={() => { + updateMediaSize(); + }} + scalable + renderDirections={["w", "e"]} + onScale={({ target, transform }: any) => { + target!.style.transform = transform; + }} + /> + + ); +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/image/index.tsx b/packages/editor/core-document-editor/src/ui/extensions/image/index.tsx new file mode 100644 index 00000000000..1431b77558a --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/image/index.tsx @@ -0,0 +1,141 @@ +import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { UploadImagesPlugin } from "src/ui/plugins/upload-image"; +import ImageExt from "@tiptap/extension-image"; +import { onNodeDeleted, onNodeRestored } from "src/ui/plugins/delete-image"; +import { DeleteImage } from "src/types/delete-image"; +import { RestoreImage } from "src/types/restore-image"; +import { insertLineBelowImageAction } from "./utilities/insert-line-below-image"; +import { insertLineAboveImageAction } from "./utilities/insert-line-above-image"; + +interface ImageNode extends ProseMirrorNode { + attrs: { + src: string; + id: string; + }; +} + +const deleteKey = new PluginKey("delete-image"); +const IMAGE_NODE_TYPE = "image"; + +export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => any) => + ImageExt.extend({ + addKeyboardShortcuts() { + return { + ArrowDown: insertLineBelowImageAction, + ArrowUp: insertLineAboveImageAction, + }; + }, + addProseMirrorPlugins() { + return [ + UploadImagesPlugin(cancelUploadImage), + new Plugin({ + key: deleteKey, + appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { + const newImageSources = new Set(); + newState.doc.descendants((node) => { + if (node.type.name === IMAGE_NODE_TYPE) { + newImageSources.add(node.attrs.src); + } + }); + + transactions.forEach((transaction) => { + // transaction could be a selection + if (!transaction.docChanged) return; + + const removedImages: ImageNode[] = []; + + // iterate through all the nodes in the old state + oldState.doc.descendants((oldNode, oldPos) => { + // if the node is not an image, then return as no point in checking + if (oldNode.type.name !== IMAGE_NODE_TYPE) return; + + // Check if the node has been deleted or replaced + if (!newImageSources.has(oldNode.attrs.src)) { + removedImages.push(oldNode as ImageNode); + } + }); + + removedImages.forEach(async (node) => { + const src = node.attrs.src; + this.storage.images.set(src, true); + await onNodeDeleted(src, deleteImage); + }); + }); + + return null; + }, + }), + new Plugin({ + key: new PluginKey("imageRestoration"), + appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { + const oldImageSources = new Set(); + oldState.doc.descendants((node) => { + if (node.type.name === IMAGE_NODE_TYPE) { + oldImageSources.add(node.attrs.src); + } + }); + + transactions.forEach((transaction) => { + if (!transaction.docChanged) return; + + const addedImages: ImageNode[] = []; + + newState.doc.descendants((node, pos) => { + if (node.type.name !== IMAGE_NODE_TYPE) return; + if (pos < 0 || pos > newState.doc.content.size) return; + if (oldImageSources.has(node.attrs.src)) return; + addedImages.push(node as ImageNode); + }); + + addedImages.forEach(async (image) => { + const wasDeleted = this.storage.images.get(image.attrs.src); + if (wasDeleted === undefined) { + this.storage.images.set(image.attrs.src, false); + } else if (wasDeleted === true) { + await onNodeRestored(image.attrs.src, restoreFile); + } + }); + }); + return null; + }, + }), + ]; + }, + + onCreate(this) { + const imageSources = new Set(); + this.editor.state.doc.descendants((node) => { + if (node.type.name === IMAGE_NODE_TYPE) { + imageSources.add(node.attrs.src); + } + }); + imageSources.forEach(async (src) => { + try { + const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); + await restoreFile(assetUrlWithWorkspaceId); + } catch (error) { + console.error("Error restoring image: ", error); + } + }); + }, + + // storage to keep track of image states Map + addStorage() { + return { + images: new Map(), + }; + }, + + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + height: { + default: null, + }, + }; + }, + }); diff --git a/packages/editor/core-document-editor/src/ui/extensions/image/read-only-image.tsx b/packages/editor/core-document-editor/src/ui/extensions/image/read-only-image.tsx new file mode 100644 index 00000000000..8112eba4ec5 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/image/read-only-image.tsx @@ -0,0 +1,15 @@ +import Image from "@tiptap/extension-image"; + +export const ReadOnlyImageExtension = Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + height: { + default: null, + }, + }; + }, +}); diff --git a/packages/editor/core-document-editor/src/ui/extensions/image/utilities/insert-line-above-image.ts b/packages/editor/core-document-editor/src/ui/extensions/image/utilities/insert-line-above-image.ts new file mode 100644 index 00000000000..a18576b4627 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/image/utilities/insert-line-above-image.ts @@ -0,0 +1,45 @@ +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { KeyboardShortcutCommand } from "@tiptap/core"; + +export const insertLineAboveImageAction: KeyboardShortcutCommand = ({ editor }) => { + const { selection, doc } = editor.state; + const { $from, $to } = selection; + + let imageNode: ProseMirrorNode | null = null; + let imagePos: number | null = null; + + // Check if the selection itself is an image node + doc.nodesBetween($from.pos, $to.pos, (node, pos) => { + if (node.type.name === "image") { + imageNode = node; + imagePos = pos; + return false; // Stop iterating once an image node is found + } + return true; + }); + + if (imageNode === null || imagePos === null) return false; + + // Since we want to insert above the image, we use the imagePos directly + const insertPos = imagePos; + + if (insertPos < 0) return false; + + // Check for an existing node immediately before the image + if (insertPos === 0) { + // If the previous node doesn't exist or isn't a paragraph, create and insert a new empty node there + editor.chain().insertContentAt(insertPos, { type: "paragraph" }).run(); + editor.chain().setTextSelection(insertPos).run(); + } else { + const prevNode = doc.nodeAt(insertPos); + + if (prevNode && prevNode.type.name === "paragraph") { + // If the previous node is a paragraph, move the cursor there + editor.chain().setTextSelection(insertPos).run(); + } else { + return false; + } + } + + return true; +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/image/utilities/insert-line-below-image.ts b/packages/editor/core-document-editor/src/ui/extensions/image/utilities/insert-line-below-image.ts new file mode 100644 index 00000000000..e998c728b93 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/image/utilities/insert-line-below-image.ts @@ -0,0 +1,46 @@ +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { KeyboardShortcutCommand } from "@tiptap/core"; + +export const insertLineBelowImageAction: KeyboardShortcutCommand = ({ editor }) => { + const { selection, doc } = editor.state; + const { $from, $to } = selection; + + let imageNode: ProseMirrorNode | null = null; + let imagePos: number | null = null; + + // Check if the selection itself is an image node + doc.nodesBetween($from.pos, $to.pos, (node, pos) => { + if (node.type.name === "image") { + imageNode = node; + imagePos = pos; + return false; // Stop iterating once an image node is found + } + return true; + }); + + if (imageNode === null || imagePos === null) return false; + + const guaranteedImageNode: ProseMirrorNode = imageNode; + const nextNodePos = imagePos + guaranteedImageNode.nodeSize; + + // Check for an existing node immediately after the image + const nextNode = doc.nodeAt(nextNodePos); + + if (nextNode && nextNode.type.name === "paragraph") { + // If the next node is a paragraph, move the cursor there + const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1; + editor.chain().setTextSelection(endOfParagraphPos).run(); + } else if (!nextNode) { + // If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there + editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run(); + editor + .chain() + .setTextSelection(nextNodePos + 1) + .run(); + } else { + // If the next node is not a paragraph, do not proceed + return false; + } + + return true; +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/index.tsx b/packages/editor/core-document-editor/src/ui/extensions/index.tsx new file mode 100644 index 00000000000..1a932d6d51c --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/index.tsx @@ -0,0 +1,117 @@ +import { Color } from "@tiptap/extension-color"; +import TaskItem from "@tiptap/extension-task-item"; +import TaskList from "@tiptap/extension-task-list"; +import TextStyle from "@tiptap/extension-text-style"; +import TiptapUnderline from "@tiptap/extension-underline"; +import StarterKit from "@tiptap/starter-kit"; +import { Markdown } from "tiptap-markdown"; + +import { Table } from "src/ui/extensions/table/table"; +import { TableCell } from "src/ui/extensions/table/table-cell/table-cell"; +import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; +import { TableRow } from "src/ui/extensions/table/table-row/table-row"; + +import { ImageExtension } from "src/ui/extensions/image"; + +import { isValidHttpUrl } from "src/lib/utils"; +import { Mentions } from "src/ui/mentions"; + +import { CustomCodeBlockExtension } from "src/ui/extensions/code"; +import { ListKeymap } from "src/ui/extensions/custom-list-keymap"; +import { CustomKeymap } from "src/ui/extensions/keymap"; +import { CustomQuoteExtension } from "src/ui/extensions/quote"; + +import { DeleteImage } from "src/types/delete-image"; +import { IMentionSuggestion } from "src/types/mention-suggestion"; +import { RestoreImage } from "src/types/restore-image"; +import { CustomLinkExtension } from "src/ui/extensions/custom-link"; +import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline"; +import { CustomTypographyExtension } from "src/ui/extensions/typography"; +import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule"; + +export const CoreEditorExtensions = ( + mentionConfig: { + mentionSuggestions: IMentionSuggestion[]; + mentionHighlights: string[]; + }, + deleteFile: DeleteImage, + restoreFile: RestoreImage, + cancelUploadImage?: () => any +) => [ + StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: "list-disc list-outside leading-3 -mt-2", + }, + }, + orderedList: { + HTMLAttributes: { + class: "list-decimal list-outside leading-3 -mt-2", + }, + }, + listItem: { + HTMLAttributes: { + class: "leading-normal -mb-2", + }, + }, + code: false, + codeBlock: false, + horizontalRule: false, + blockquote: false, + dropcursor: { + color: "rgba(var(--color-text-100))", + width: 2, + }, + }), + CustomQuoteExtension.configure({ + HTMLAttributes: { className: "border-l-4 border-custom-border-300" }, + }), + CustomHorizontalRule.configure({ + HTMLAttributes: { class: "mt-4 mb-4" }, + }), + CustomKeymap, + ListKeymap, + CustomLinkExtension.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, + protocols: ["http", "https"], + validate: (url: string) => isValidHttpUrl(url), + HTMLAttributes: { + class: + "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", + }, + }), + CustomTypographyExtension, + ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({ + HTMLAttributes: { + class: "rounded-lg border border-custom-border-300", + }, + }), + TiptapUnderline, + TextStyle, + Color, + TaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2", + }, + }), + TaskItem.configure({ + HTMLAttributes: { + class: "flex items-start my-4", + }, + nested: true, + }), + CustomCodeBlockExtension, + CustomCodeInlineExtension, + Markdown.configure({ + html: true, + transformCopiedText: true, + transformPastedText: true, + }), + Table, + TableHeader, + TableCell, + TableRow, + Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false), +]; diff --git a/packages/editor/core-document-editor/src/ui/extensions/keymap.tsx b/packages/editor/core-document-editor/src/ui/extensions/keymap.tsx new file mode 100644 index 00000000000..0caa194cd47 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/keymap.tsx @@ -0,0 +1,54 @@ +import { Extension } from "@tiptap/core"; + +declare module "@tiptap/core" { + // eslint-disable-next-line no-unused-vars + interface Commands { + customkeymap: { + /** + * Select text between node boundaries + */ + selectTextWithinNodeBoundaries: () => ReturnType; + }; + } +} + +export const CustomKeymap = Extension.create({ + name: "CustomKeymap", + + addCommands() { + return { + selectTextWithinNodeBoundaries: + () => + ({ editor, commands }) => { + const { state } = editor; + const { tr } = state; + const startNodePos = tr.selection.$from.start(); + const endNodePos = tr.selection.$to.end(); + return commands.setTextSelection({ + from: startNodePos, + to: endNodePos, + }); + }, + }; + }, + + addKeyboardShortcuts() { + return { + "Mod-a": ({ editor }) => { + const { state } = editor; + const { tr } = state; + const startSelectionPos = tr.selection.from; + const endSelectionPos = tr.selection.to; + const startNodePos = tr.selection.$from.start(); + const endNodePos = tr.selection.$to.end(); + const isCurrentTextSelectionNotExtendedToNodeBoundaries = + startSelectionPos > startNodePos || endSelectionPos < endNodePos; + if (isCurrentTextSelectionNotExtendedToNodeBoundaries) { + editor.chain().selectTextWithinNodeBoundaries().run(); + return true; + } + return false; + }, + }; + }, +}); diff --git a/packages/editor/core-document-editor/src/ui/extensions/quote/index.tsx b/packages/editor/core-document-editor/src/ui/extensions/quote/index.tsx new file mode 100644 index 00000000000..9dcae6ad7a5 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/quote/index.tsx @@ -0,0 +1,25 @@ +import Blockquote from "@tiptap/extension-blockquote"; + +export const CustomQuoteExtension = Blockquote.extend({ + addKeyboardShortcuts() { + return { + Enter: () => { + const { $from, $to, $head } = this.editor.state.selection; + const parent = $head.node(-1); + + if (!parent) return false; + + if (parent.type.name !== "blockquote") { + return false; + } + if ($from.pos !== $to.pos) return false; + // if ($head.parentOffset < $head.parent.content.size) return false; + + // this.editor.commands.insertContentAt(parent.ne); + this.editor.chain().splitBlock().lift(this.name).run(); + + return true; + }, + }; + }, +}); diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table-cell/index.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table-cell/index.ts new file mode 100644 index 00000000000..68a25a9c3de --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table-cell/index.ts @@ -0,0 +1 @@ +export { TableCell } from "./table-cell"; diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table-cell/table-cell.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table-cell/table-cell.ts new file mode 100644 index 00000000000..403bd3f02c7 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table-cell/table-cell.ts @@ -0,0 +1,61 @@ +import { mergeAttributes, Node } from "@tiptap/core"; + +export interface TableCellOptions { + HTMLAttributes: Record; +} + +export const TableCell = Node.create({ + name: "tableCell", + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + content: "block+", + + addAttributes() { + return { + colspan: { + default: 1, + }, + rowspan: { + default: 1, + }, + colwidth: { + default: null, + parseHTML: (element) => { + const colwidth = element.getAttribute("colwidth"); + const value = colwidth ? [parseInt(colwidth, 10)] : null; + + return value; + }, + }, + background: { + default: null, + }, + textColor: { + default: null, + }, + }; + }, + + tableRole: "cell", + + isolating: true, + + parseHTML() { + return [{ tag: "td" }]; + }, + + renderHTML({ node, HTMLAttributes }) { + return [ + "td", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor}`, + }), + 0, + ]; + }, +}); diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table-header/index.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table-header/index.ts new file mode 100644 index 00000000000..290f37d0b78 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table-header/index.ts @@ -0,0 +1 @@ +export { TableHeader } from "./table-header"; diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table-header/table-header.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table-header/table-header.ts new file mode 100644 index 00000000000..bd994f467d5 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table-header/table-header.ts @@ -0,0 +1,58 @@ +import { mergeAttributes, Node } from "@tiptap/core"; + +export interface TableHeaderOptions { + HTMLAttributes: Record; +} + +export const TableHeader = Node.create({ + name: "tableHeader", + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + content: "paragraph+", + + addAttributes() { + return { + colspan: { + default: 1, + }, + rowspan: { + default: 1, + }, + colwidth: { + default: null, + parseHTML: (element) => { + const colwidth = element.getAttribute("colwidth"); + const value = colwidth ? [parseInt(colwidth, 10)] : null; + + return value; + }, + }, + background: { + default: "none", + }, + }; + }, + + tableRole: "header_cell", + + isolating: true, + + parseHTML() { + return [{ tag: "th" }]; + }, + + renderHTML({ node, HTMLAttributes }) { + return [ + "th", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + style: `background-color: ${node.attrs.background}`, + }), + 0, + ]; + }, +}); diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table-row/index.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table-row/index.ts new file mode 100644 index 00000000000..24dafb7e012 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table-row/index.ts @@ -0,0 +1 @@ +export { TableRow } from "./table-row"; diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table-row/table-row.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table-row/table-row.ts new file mode 100644 index 00000000000..f961c058246 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table-row/table-row.ts @@ -0,0 +1,44 @@ +import { mergeAttributes, Node } from "@tiptap/core"; + +export interface TableRowOptions { + HTMLAttributes: Record; +} + +export const TableRow = Node.create({ + name: "tableRow", + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + addAttributes() { + return { + background: { + default: null, + }, + textColor: { + default: null, + }, + }; + }, + + content: "(tableCell | tableHeader)*", + + tableRole: "row", + + parseHTML() { + return [{ tag: "tr" }]; + }, + + renderHTML({ HTMLAttributes }) { + const style = HTMLAttributes.background + ? `background-color: ${HTMLAttributes.background}; color: ${HTMLAttributes.textColor}` + : ""; + + const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { style }); + + return ["tr", attributes, 0]; + }, +}); diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table/icons.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table/icons.ts new file mode 100644 index 00000000000..f73c55c09f4 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table/icons.ts @@ -0,0 +1,51 @@ +export const icons = { + colorPicker: ``, + deleteColumn: ``, + deleteRow: ``, + insertLeftTableIcon: ` + + +`, + insertRightTableIcon: ` + + +`, + insertTopTableIcon: ` + + +`, + toggleColumnHeader: ``, + toggleRowHeader: ``, + insertBottomTableIcon: ` + + +`, +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table/index.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table/index.ts new file mode 100644 index 00000000000..8efc4312099 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table/index.ts @@ -0,0 +1 @@ +export { Table } from "./table"; diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table/table-controls.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table/table-controls.ts new file mode 100644 index 00000000000..9311d4c990a --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table/table-controls.ts @@ -0,0 +1,112 @@ +import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; +import { findParentNode } from "@tiptap/core"; +import { DecorationSet, Decoration } from "@tiptap/pm/view"; + +const key = new PluginKey("tableControls"); + +export function tableControls() { + return new Plugin({ + key, + state: { + init() { + return new TableControlsState(); + }, + apply(tr, prev) { + return prev.apply(tr); + }, + }, + props: { + handleDOMEvents: { + mousemove: (view, event) => { + const pluginState = key.getState(view.state); + + if (!(event.target as HTMLElement).closest(".tableWrapper") && pluginState.values.hoveredTable) { + return view.dispatch( + view.state.tr.setMeta(key, { + setHoveredTable: null, + setHoveredCell: null, + }) + ); + } + + const pos = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + + if (!pos) return; + + const table = findParentNode((node) => node.type.name === "table")( + TextSelection.create(view.state.doc, pos.pos) + ); + const cell = findParentNode((node) => node.type.name === "tableCell" || node.type.name === "tableHeader")( + TextSelection.create(view.state.doc, pos.pos) + ); + + if (!table || !cell) return; + + if (pluginState.values.hoveredCell?.pos !== cell.pos) { + return view.dispatch( + view.state.tr.setMeta(key, { + setHoveredTable: table, + setHoveredCell: cell, + }) + ); + } + }, + }, + decorations: (state) => { + const pluginState = key.getState(state); + if (!pluginState) { + return null; + } + + const { hoveredTable, hoveredCell } = pluginState.values; + const docSize = state.doc.content.size; + if (hoveredTable && hoveredCell && hoveredTable.pos < docSize && hoveredCell.pos < docSize) { + const decorations = [ + Decoration.node( + hoveredTable.pos, + hoveredTable.pos + hoveredTable.node.nodeSize, + {}, + { + hoveredTable, + hoveredCell, + } + ), + ]; + + return DecorationSet.create(state.doc, decorations); + } + + return null; + }, + }, + }); +} + +class TableControlsState { + values; + + constructor(props = {}) { + this.values = { + hoveredTable: null, + hoveredCell: null, + ...props, + }; + } + + apply(tr: any) { + const actions = tr.getMeta(key); + + if (actions?.setHoveredTable !== undefined) { + this.values.hoveredTable = actions.setHoveredTable; + } + + if (actions?.setHoveredCell !== undefined) { + this.values.hoveredCell = actions.setHoveredCell; + } + + return this; + } +} diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table/table-view.tsx b/packages/editor/core-document-editor/src/ui/extensions/table/table/table-view.tsx new file mode 100644 index 00000000000..2941179c7c5 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table/table-view.tsx @@ -0,0 +1,485 @@ +import { h } from "jsx-dom-cjs"; +import { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model"; +import { Decoration, NodeView } from "@tiptap/pm/view"; +import tippy, { Instance, Props } from "tippy.js"; + +import { Editor } from "@tiptap/core"; +import { CellSelection, TableMap, updateColumnsOnResize } from "@tiptap/pm/tables"; + +import { icons } from "src/ui/extensions/table/table/icons"; + +type ToolboxItem = { + label: string; + icon: string; + action: (args: any) => void; +}; + +export function updateColumns( + node: ProseMirrorNode, + colgroup: HTMLElement, + table: HTMLElement, + cellMinWidth: number, + overrideCol?: number, + overrideValue?: any +) { + let totalWidth = 0; + let fixedWidth = true; + let nextDOM = colgroup.firstChild as HTMLElement; + const row = node.firstChild; + + if (!row) return; + + for (let i = 0, col = 0; i < row.childCount; i += 1) { + const { colspan, colwidth } = row.child(i).attrs; + + for (let j = 0; j < colspan; j += 1, col += 1) { + const hasWidth = overrideCol === col ? overrideValue : colwidth && colwidth[j]; + const cssWidth = hasWidth ? `${hasWidth}px` : ""; + + totalWidth += hasWidth || cellMinWidth; + + if (!hasWidth) { + fixedWidth = false; + } + + if (!nextDOM) { + colgroup.appendChild(document.createElement("col")).style.width = cssWidth; + } else { + if (nextDOM.style.width !== cssWidth) { + nextDOM.style.width = cssWidth; + } + + nextDOM = nextDOM.nextSibling as HTMLElement; + } + } + } + + while (nextDOM) { + const after = nextDOM.nextSibling; + + nextDOM.parentNode?.removeChild(nextDOM); + nextDOM = after as HTMLElement; + } + + if (fixedWidth) { + table.style.width = `${totalWidth}px`; + table.style.minWidth = ""; + } else { + table.style.width = ""; + table.style.minWidth = `${totalWidth}px`; + } +} + +const defaultTippyOptions: Partial = { + allowHTML: true, + arrow: false, + trigger: "click", + animation: "scale-subtle", + theme: "light-border no-padding", + interactive: true, + hideOnClick: true, + placement: "right", +}; + +function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) { + return editor + .chain() + .focus() + .updateAttributes("tableCell", { + background: color.backgroundColor, + textColor: color.textColor, + }) + .run(); +} + +function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) { + const { state, dispatch } = editor.view; + const { selection } = state; + if (!(selection instanceof CellSelection)) { + return false; + } + + // Get the position of the hovered cell in the selection to determine the row. + const hoveredCell = selection.$headCell || selection.$anchorCell; + + // Find the depth of the table row node + let rowDepth = hoveredCell.depth; + while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== "tableRow") { + rowDepth--; + } + + // If we couldn't find a tableRow node, we can't set the background color + if (hoveredCell.node(rowDepth).type.name !== "tableRow") { + return false; + } + + // Get the position where the table row starts + const rowStartPos = hoveredCell.start(rowDepth); + + // Create a transaction that sets the background color on the tableRow node. + const tr = state.tr.setNodeMarkup(rowStartPos - 1, null, { + ...hoveredCell.node(rowDepth).attrs, + background: color.backgroundColor, + textColor: color.textColor, + }); + + dispatch(tr); + return true; +} + +const columnsToolboxItems: ToolboxItem[] = [ + { + label: "Toggle column header", + icon: icons.toggleColumnHeader, + action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderColumn().run(), + }, + { + label: "Add column before", + icon: icons.insertLeftTableIcon, + action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run(), + }, + { + label: "Add column after", + icon: icons.insertRightTableIcon, + action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run(), + }, + { + label: "Pick color", + icon: "", // No icon needed for color picker + action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox` + }, + { + label: "Delete column", + icon: icons.deleteColumn, + action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run(), + }, +]; + +const rowsToolboxItems: ToolboxItem[] = [ + { + label: "Toggle row header", + icon: icons.toggleRowHeader, + action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderRow().run(), + }, + { + label: "Add row above", + icon: icons.insertTopTableIcon, + action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run(), + }, + { + label: "Add row below", + icon: icons.insertBottomTableIcon, + action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run(), + }, + { + label: "Pick color", + icon: "", + action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox` + }, + { + label: "Delete Row", + icon: icons.deleteRow, + action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteRow().run(), + }, +]; + +function createToolbox({ + triggerButton, + items, + tippyOptions, + onSelectColor, + onClickItem, + colors, +}: { + triggerButton: Element | null; + items: ToolboxItem[]; + tippyOptions: any; + onClickItem: (item: ToolboxItem) => void; + onSelectColor: (color: { backgroundColor: string; textColor: string }) => void; + colors: { [key: string]: { backgroundColor: string; textColor: string; icon?: string } }; +}): Instance { + // @ts-expect-error + const toolbox = tippy(triggerButton, { + content: h( + "div", + { className: "tableToolbox" }, + items.map((item, index) => { + if (item.label === "Pick color") { + return h("div", { className: "flex flex-col" }, [ + h("div", { className: "divider" }), + h("div", { className: "colorPickerLabel" }, item.label), + h( + "div", + { className: "colorPicker grid" }, + Object.entries(colors).map(([colorName, colorValue]) => + h("div", { + className: "colorPickerItem flex items-center justify-center", + style: `background-color: ${colorValue.backgroundColor}; + color: ${colorValue.textColor || "inherit"};`, + innerHTML: + colorValue.icon ?? `A`, + onClick: () => onSelectColor(colorValue), + }) + ) + ), + h("div", { className: "divider" }), + ]); + } else { + return h( + "div", + { + className: "toolboxItem", + itemType: "div", + onClick: () => onClickItem(item), + }, + [ + h("div", { className: "iconContainer", innerHTML: item.icon }), + h("div", { className: "label" }, item.label), + ] + ); + } + }) + ), + ...tippyOptions, + }); + + return Array.isArray(toolbox) ? toolbox[0] : toolbox; +} + +export class TableView implements NodeView { + node: ProseMirrorNode; + cellMinWidth: number; + decorations: Decoration[]; + editor: Editor; + getPos: () => number; + hoveredCell: ResolvedPos | null = null; + map: TableMap; + root: HTMLElement; + table: HTMLTableElement; + colgroup: HTMLTableColElement; + tbody: HTMLElement; + rowsControl?: HTMLElement | null; + columnsControl?: HTMLElement | null; + columnsToolbox?: Instance; + rowsToolbox?: Instance; + controls?: HTMLElement; + + get dom() { + return this.root; + } + + get contentDOM() { + return this.tbody; + } + + constructor( + node: ProseMirrorNode, + cellMinWidth: number, + decorations: Decoration[], + editor: Editor, + getPos: () => number + ) { + this.node = node; + this.cellMinWidth = cellMinWidth; + this.decorations = decorations; + this.editor = editor; + this.getPos = getPos; + this.hoveredCell = null; + this.map = TableMap.get(node); + + if (editor.isEditable) { + this.rowsControl = h( + "div", + { className: "rowsControl" }, + h("div", { + itemType: "button", + className: "rowsControlDiv", + onClick: () => this.selectRow(), + }) + ); + + this.columnsControl = h( + "div", + { className: "columnsControl" }, + h("div", { + itemType: "button", + className: "columnsControlDiv", + onClick: () => this.selectColumn(), + }) + ); + + this.controls = h( + "div", + { className: "tableControls", contentEditable: "false" }, + this.rowsControl, + this.columnsControl + ); + const columnColors = { + Blue: { backgroundColor: "#D9E4FF", textColor: "#171717" }, + Orange: { backgroundColor: "#FFEDD5", textColor: "#171717" }, + Grey: { backgroundColor: "#F1F1F1", textColor: "#171717" }, + Yellow: { backgroundColor: "#FEF3C7", textColor: "#171717" }, + Green: { backgroundColor: "#DCFCE7", textColor: "#171717" }, + Red: { backgroundColor: "#FFDDDD", textColor: "#171717" }, + Pink: { backgroundColor: "#FFE8FA", textColor: "#171717" }, + Purple: { backgroundColor: "#E8DAFB", textColor: "#171717" }, + None: { + backgroundColor: "none", + textColor: "none", + icon: ``, + }, + }; + + this.columnsToolbox = createToolbox({ + triggerButton: this.columnsControl.querySelector(".columnsControlDiv"), + items: columnsToolboxItems, + colors: columnColors, + onSelectColor: (color) => setCellsBackgroundColor(this.editor, color), + tippyOptions: { + ...defaultTippyOptions, + appendTo: this.controls, + }, + onClickItem: (item) => { + item.action({ + editor: this.editor, + triggerButton: this.columnsControl?.firstElementChild, + controlsContainer: this.controls, + }); + this.columnsToolbox?.hide(); + }, + }); + + this.rowsToolbox = createToolbox({ + triggerButton: this.rowsControl.firstElementChild, + items: rowsToolboxItems, + colors: columnColors, + tippyOptions: { + ...defaultTippyOptions, + appendTo: this.controls, + }, + onSelectColor: (color) => setTableRowBackgroundColor(editor, color), + onClickItem: (item) => { + item.action({ + editor: this.editor, + triggerButton: this.rowsControl?.firstElementChild, + controlsContainer: this.controls, + }); + this.rowsToolbox?.hide(); + }, + }); + } + + this.colgroup = h( + "colgroup", + null, + Array.from({ length: this.map.width }, () => 1).map(() => h("col")) + ); + this.tbody = h("tbody"); + this.table = h("table", null, this.colgroup, this.tbody); + + this.root = h( + "div", + { + className: "tableWrapper controls--disabled", + }, + this.controls, + this.table + ); + + this.render(); + } + + update(node: ProseMirrorNode, decorations: readonly Decoration[]) { + if (node.type !== this.node.type) { + return false; + } + + this.node = node; + this.decorations = [...decorations]; + this.map = TableMap.get(this.node); + + if (this.editor.isEditable) { + this.updateControls(); + } + + this.render(); + + return true; + } + + render() { + if (this.colgroup.children.length !== this.map.width) { + const cols = Array.from({ length: this.map.width }, () => 1).map(() => h("col")); + this.colgroup.replaceChildren(...cols); + } + + updateColumnsOnResize(this.node, this.colgroup, this.table, this.cellMinWidth); + } + + ignoreMutation() { + return true; + } + + updateControls() { + const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce( + (acc, curr) => { + if (curr.spec.hoveredCell !== undefined) { + acc["hoveredCell"] = curr.spec.hoveredCell; + } + + if (curr.spec.hoveredTable !== undefined) { + acc["hoveredTable"] = curr.spec.hoveredTable; + } + return acc; + }, + {} as Record + ) as any; + + if (table === undefined || cell === undefined) { + return this.root.classList.add("controls--disabled"); + } + + this.root.classList.remove("controls--disabled"); + this.hoveredCell = cell; + + const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement; + + if (!this.table || !cellDom) { + return; + } + + const tableRect = this.table?.getBoundingClientRect(); + const cellRect = cellDom?.getBoundingClientRect(); + + if (this.columnsControl) { + this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`; + this.columnsControl.style.width = `${cellRect.width}px`; + } + if (this.rowsControl) { + this.rowsControl.style.top = `${cellRect.top - tableRect.top}px`; + this.rowsControl.style.height = `${cellRect.height}px`; + } + } + + selectColumn() { + if (!this.hoveredCell) return; + + const colIndex = this.map.colCount(this.hoveredCell.pos - (this.getPos() + 1)); + const anchorCellPos = this.hoveredCell.pos; + const headCellPos = this.map.map[colIndex + this.map.width * (this.map.height - 1)] + (this.getPos() + 1); + + const cellSelection = CellSelection.create(this.editor.view.state.doc, anchorCellPos, headCellPos); + this.editor.view.dispatch(this.editor.state.tr.setSelection(cellSelection)); + } + + selectRow() { + if (!this.hoveredCell) return; + + const anchorCellPos = this.hoveredCell.pos; + const anchorCellIndex = this.map.map.indexOf(anchorCellPos - (this.getPos() + 1)); + const headCellPos = this.map.map[anchorCellIndex + (this.map.width - 1)] + (this.getPos() + 1); + + const cellSelection = CellSelection.create(this.editor.state.doc, anchorCellPos, headCellPos); + this.editor.view.dispatch(this.editor.view.state.tr.setSelection(cellSelection)); + } +} diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table/table.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table/table.ts new file mode 100644 index 00000000000..5fd06caf6af --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table/table.ts @@ -0,0 +1,286 @@ +import { TextSelection } from "@tiptap/pm/state"; + +import { callOrReturn, getExtensionField, mergeAttributes, Node, ParentConfig } from "@tiptap/core"; +import { + addColumnAfter, + addColumnBefore, + addRowAfter, + addRowBefore, + CellSelection, + columnResizing, + deleteColumn, + deleteRow, + deleteTable, + fixTables, + goToNextCell, + mergeCells, + setCellAttr, + splitCell, + tableEditing, + toggleHeader, + toggleHeaderCell, +} from "@tiptap/pm/tables"; + +import { tableControls } from "src/ui/extensions/table/table/table-controls"; +import { TableView } from "src/ui/extensions/table/table/table-view"; +import { createTable } from "src/ui/extensions/table/table/utilities/create-table"; +import { deleteTableWhenAllCellsSelected } from "src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected"; +import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action"; +import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action"; + +export interface TableOptions { + HTMLAttributes: Record; + resizable: boolean; + handleWidth: number; + cellMinWidth: number; + lastColumnResizable: boolean; + allowTableNodeSelection: boolean; +} + +declare module "@tiptap/core" { + interface Commands { + table: { + insertTable: (options?: { rows?: number; cols?: number; withHeaderRow?: boolean }) => ReturnType; + addColumnBefore: () => ReturnType; + addColumnAfter: () => ReturnType; + deleteColumn: () => ReturnType; + addRowBefore: () => ReturnType; + addRowAfter: () => ReturnType; + deleteRow: () => ReturnType; + deleteTable: () => ReturnType; + mergeCells: () => ReturnType; + splitCell: () => ReturnType; + toggleHeaderColumn: () => ReturnType; + toggleHeaderRow: () => ReturnType; + toggleHeaderCell: () => ReturnType; + mergeOrSplit: () => ReturnType; + setCellAttribute: (name: string, value: any) => ReturnType; + goToNextCell: () => ReturnType; + goToPreviousCell: () => ReturnType; + fixTables: () => ReturnType; + setCellSelection: (position: { anchorCell: number; headCell?: number }) => ReturnType; + }; + } + + interface NodeConfig { + tableRole?: + | string + | ((this: { + name: string; + options: Options; + storage: Storage; + parent: ParentConfig>["tableRole"]; + }) => string); + } +} + +export const Table = Node.create({ + name: "table", + + addOptions() { + return { + HTMLAttributes: {}, + resizable: true, + handleWidth: 5, + cellMinWidth: 100, + lastColumnResizable: true, + allowTableNodeSelection: true, + }; + }, + + content: "tableRow+", + + tableRole: "table", + + isolating: true, + + group: "block", + + allowGapCursor: false, + + parseHTML() { + return [{ tag: "table" }]; + }, + + renderHTML({ HTMLAttributes }) { + return ["table", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), ["tbody", 0]]; + }, + + addCommands() { + return { + insertTable: + ({ rows = 3, cols = 3, withHeaderRow = false } = {}) => + ({ tr, dispatch, editor }) => { + const node = createTable(editor.schema, rows, cols, withHeaderRow); + if (dispatch) { + const offset = tr.selection.anchor + 1; + + tr.replaceSelectionWith(node) + .scrollIntoView() + .setSelection(TextSelection.near(tr.doc.resolve(offset))); + } + + return true; + }, + addColumnBefore: + () => + ({ state, dispatch }) => + addColumnBefore(state, dispatch), + addColumnAfter: + () => + ({ state, dispatch }) => + addColumnAfter(state, dispatch), + deleteColumn: + () => + ({ state, dispatch }) => + deleteColumn(state, dispatch), + addRowBefore: + () => + ({ state, dispatch }) => + addRowBefore(state, dispatch), + addRowAfter: + () => + ({ state, dispatch }) => + addRowAfter(state, dispatch), + deleteRow: + () => + ({ state, dispatch }) => + deleteRow(state, dispatch), + deleteTable: + () => + ({ state, dispatch }) => + deleteTable(state, dispatch), + mergeCells: + () => + ({ state, dispatch }) => + mergeCells(state, dispatch), + splitCell: + () => + ({ state, dispatch }) => + splitCell(state, dispatch), + toggleHeaderColumn: + () => + ({ state, dispatch }) => + toggleHeader("column")(state, dispatch), + toggleHeaderRow: + () => + ({ state, dispatch }) => + toggleHeader("row")(state, dispatch), + toggleHeaderCell: + () => + ({ state, dispatch }) => + toggleHeaderCell(state, dispatch), + mergeOrSplit: + () => + ({ state, dispatch }) => { + if (mergeCells(state, dispatch)) { + return true; + } + + return splitCell(state, dispatch); + }, + setCellAttribute: + (name, value) => + ({ state, dispatch }) => + setCellAttr(name, value)(state, dispatch), + goToNextCell: + () => + ({ state, dispatch }) => + goToNextCell(1)(state, dispatch), + goToPreviousCell: + () => + ({ state, dispatch }) => + goToNextCell(-1)(state, dispatch), + fixTables: + () => + ({ state, dispatch }) => { + if (dispatch) { + fixTables(state); + } + + return true; + }, + setCellSelection: + (position) => + ({ tr, dispatch }) => { + if (dispatch) { + const selection = CellSelection.create(tr.doc, position.anchorCell, position.headCell); + + // @ts-ignore + tr.setSelection(selection); + } + + return true; + }, + }; + }, + + addKeyboardShortcuts() { + return { + Tab: () => { + if (this.editor.commands.goToNextCell()) { + return true; + } + + if (!this.editor.can().addRowAfter()) { + return false; + } + + return this.editor.chain().addRowAfter().goToNextCell().run(); + }, + "Shift-Tab": () => this.editor.commands.goToPreviousCell(), + Backspace: deleteTableWhenAllCellsSelected, + "Mod-Backspace": deleteTableWhenAllCellsSelected, + Delete: deleteTableWhenAllCellsSelected, + "Mod-Delete": deleteTableWhenAllCellsSelected, + ArrowDown: insertLineBelowTableAction, + ArrowUp: insertLineAboveTableAction, + }; + }, + + addNodeView() { + return ({ editor, getPos, node, decorations }) => { + const { cellMinWidth } = this.options; + + return new TableView(node, cellMinWidth, decorations, editor, getPos as () => number); + }; + }, + + addProseMirrorPlugins() { + const isResizable = this.options.resizable && this.editor.isEditable; + + const plugins = [ + tableEditing({ + allowTableNodeSelection: this.options.allowTableNodeSelection, + }), + tableControls(), + ]; + + if (isResizable) { + plugins.unshift( + columnResizing({ + handleWidth: this.options.handleWidth, + cellMinWidth: this.options.cellMinWidth, + // View: TableView, + + // @ts-ignore + lastColumnResizable: this.options.lastColumnResizable, + }) + ); + } + + return plugins; + }, + + extendNodeSchema(extension) { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage, + }; + + return { + tableRole: callOrReturn(getExtensionField(extension, "tableRole", context)), + }; + }, +}); diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/create-cell.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/create-cell.ts new file mode 100644 index 00000000000..5fc2b146d06 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/create-cell.ts @@ -0,0 +1,12 @@ +import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model"; + +export function createCell( + cellType: NodeType, + cellContent?: Fragment | ProsemirrorNode | Array +): ProsemirrorNode | null | undefined { + if (cellContent) { + return cellType.createChecked(null, cellContent); + } + + return cellType.createAndFill(); +} diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/create-table.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/create-table.ts new file mode 100644 index 00000000000..7299dd442d6 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/create-table.ts @@ -0,0 +1,40 @@ +import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model"; + +import { createCell } from "src/ui/extensions/table/table/utilities/create-cell"; +import { getTableNodeTypes } from "src/ui/extensions/table/table/utilities/get-table-node-types"; + +export function createTable( + schema: Schema, + rowsCount: number, + colsCount: number, + withHeaderRow: boolean, + cellContent?: Fragment | ProsemirrorNode | Array +): ProsemirrorNode { + const types = getTableNodeTypes(schema); + const headerCells: ProsemirrorNode[] = []; + const cells: ProsemirrorNode[] = []; + + for (let index = 0; index < colsCount; index += 1) { + const cell = createCell(types.cell, cellContent); + + if (cell) { + cells.push(cell); + } + + if (withHeaderRow) { + const headerCell = createCell(types.header_cell, cellContent); + + if (headerCell) { + headerCells.push(headerCell); + } + } + } + + const rows: ProsemirrorNode[] = []; + + for (let index = 0; index < rowsCount; index += 1) { + rows.push(types.row.createChecked(null, withHeaderRow && index === 0 ? headerCells : cells)); + } + + return types.table.createChecked(null, rows); +} diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts new file mode 100644 index 00000000000..c08228a002e --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts @@ -0,0 +1,34 @@ +import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core"; + +import { isCellSelection } from "src/ui/extensions/table/table/utilities/is-cell-selection"; + +export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ editor }) => { + const { selection } = editor.state; + + if (!isCellSelection(selection)) { + return false; + } + + let cellCount = 0; + const table = findParentNodeClosestToPos(selection.ranges[0].$from, (node) => node.type.name === "table"); + + table?.node.descendants((node) => { + if (node.type.name === "table") { + return false; + } + + if (["tableCell", "tableHeader"].includes(node.type.name)) { + cellCount += 1; + } + }); + + const allCellsSelected = cellCount === selection.ranges.length; + + if (!allCellsSelected) { + return false; + } + + editor.commands.deleteTable(); + + return true; +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/get-table-node-types.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/get-table-node-types.ts new file mode 100644 index 00000000000..28c322a1f1f --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/get-table-node-types.ts @@ -0,0 +1,21 @@ +import { NodeType, Schema } from "prosemirror-model"; + +export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } { + if (schema.cached.tableNodeTypes) { + return schema.cached.tableNodeTypes; + } + + const roles: { [key: string]: NodeType } = {}; + + Object.keys(schema.nodes).forEach((type) => { + const nodeType = schema.nodes[type]; + + if (nodeType.spec.tableRole) { + roles[nodeType.spec.tableRole] = nodeType; + } + }); + + schema.cached.tableNodeTypes = roles; + + return roles; +} diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts new file mode 100644 index 00000000000..d61d21c5b39 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts @@ -0,0 +1,50 @@ +import { KeyboardShortcutCommand } from "@tiptap/core"; +import { findParentNodeOfType } from "src/lib/utils"; + +export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) => { + // Check if the current selection or the closest node is a table + if (!editor.isActive("table")) return false; + + // Get the current selection + const { selection } = editor.state; + + // Find the table node and its position + const tableNode = findParentNodeOfType(selection, "table"); + if (!tableNode) return false; + + const tablePos = tableNode.pos; + + // Determine if the selection is in the first row of the table + const firstRow = tableNode.node.child(0); + const selectionPath = (selection.$anchor as any).path; + const selectionInFirstRow = selectionPath.includes(firstRow); + + if (!selectionInFirstRow) return false; + + // Check if the table is at the very start of the document or its parent node + if (tablePos === 0) { + // The table is at the start, so just insert a paragraph at the current position + editor.chain().insertContentAt(tablePos, { type: "paragraph" }).run(); + editor + .chain() + .setTextSelection(tablePos + 1) + .run(); + } else { + // The table is not at the start, check for the node immediately before the table + const prevNodePos = tablePos - 1; + + if (prevNodePos <= 0) return false; + + const prevNode = editor.state.doc.nodeAt(prevNodePos - 1); + + if (prevNode && prevNode.type.name === "paragraph") { + // If there's a paragraph before the table, move the cursor to the end of that paragraph + const endOfParagraphPos = tablePos - prevNode.nodeSize; + editor.chain().setTextSelection(endOfParagraphPos).run(); + } else { + return false; + } + } + + return true; +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts new file mode 100644 index 00000000000..28b46084aba --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts @@ -0,0 +1,48 @@ +import { KeyboardShortcutCommand } from "@tiptap/core"; +import { findParentNodeOfType } from "src/lib/utils"; + +export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) => { + // Check if the current selection or the closest node is a table + if (!editor.isActive("table")) return false; + + // Get the current selection + const { selection } = editor.state; + + // Find the table node and its position + const tableNode = findParentNodeOfType(selection, "table"); + if (!tableNode) return false; + + const tablePos = tableNode.pos; + const table = tableNode.node; + + // Determine if the selection is in the last row of the table + const rowCount = table.childCount; + const lastRow = table.child(rowCount - 1); + const selectionPath = (selection.$anchor as any).path; + const selectionInLastRow = selectionPath.includes(lastRow); + + if (!selectionInLastRow) return false; + + // Calculate the position immediately after the table + const nextNodePos = tablePos + table.nodeSize; + + // Check for an existing node immediately after the table + const nextNode = editor.state.doc.nodeAt(nextNodePos); + + if (nextNode && nextNode.type.name === "paragraph") { + // If the next node is an paragraph, move the cursor there + const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1; + editor.chain().setTextSelection(endOfParagraphPos).run(); + } else if (!nextNode) { + // If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there + editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run(); + editor + .chain() + .setTextSelection(nextNodePos + 1) + .run(); + } else { + return false; + } + + return true; +}; diff --git a/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/is-cell-selection.ts b/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/is-cell-selection.ts new file mode 100644 index 00000000000..42ea5759c9b --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/table/table/utilities/is-cell-selection.ts @@ -0,0 +1,5 @@ +import { CellSelection } from "@tiptap/pm/tables"; + +export function isCellSelection(value: unknown): value is CellSelection { + return value instanceof CellSelection; +} diff --git a/packages/editor/core-document-editor/src/ui/extensions/typography/index.ts b/packages/editor/core-document-editor/src/ui/extensions/typography/index.ts new file mode 100644 index 00000000000..78af3c46e2c --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/typography/index.ts @@ -0,0 +1,109 @@ +import { Extension } from "@tiptap/core"; +import { + TypographyOptions, + emDash, + ellipsis, + leftArrow, + rightArrow, + copyright, + trademark, + servicemark, + registeredTrademark, + oneHalf, + plusMinus, + notEqual, + laquo, + raquo, + multiplication, + superscriptTwo, + superscriptThree, + oneQuarter, + threeQuarters, + impliesArrowRight, +} from "src/ui/extensions/typography/inputRules"; + +export const CustomTypographyExtension = Extension.create({ + name: "typography", + + addInputRules() { + const rules = []; + + if (this.options.emDash !== false) { + rules.push(emDash(this.options.emDash)); + } + + if (this.options.impliesArrowRight !== false) { + rules.push(impliesArrowRight(this.options.impliesArrowRight)); + } + + if (this.options.ellipsis !== false) { + rules.push(ellipsis(this.options.ellipsis)); + } + + if (this.options.leftArrow !== false) { + rules.push(leftArrow(this.options.leftArrow)); + } + + if (this.options.rightArrow !== false) { + rules.push(rightArrow(this.options.rightArrow)); + } + + if (this.options.copyright !== false) { + rules.push(copyright(this.options.copyright)); + } + + if (this.options.trademark !== false) { + rules.push(trademark(this.options.trademark)); + } + + if (this.options.servicemark !== false) { + rules.push(servicemark(this.options.servicemark)); + } + + if (this.options.registeredTrademark !== false) { + rules.push(registeredTrademark(this.options.registeredTrademark)); + } + + if (this.options.oneHalf !== false) { + rules.push(oneHalf(this.options.oneHalf)); + } + + if (this.options.plusMinus !== false) { + rules.push(plusMinus(this.options.plusMinus)); + } + + if (this.options.notEqual !== false) { + rules.push(notEqual(this.options.notEqual)); + } + + if (this.options.laquo !== false) { + rules.push(laquo(this.options.laquo)); + } + + if (this.options.raquo !== false) { + rules.push(raquo(this.options.raquo)); + } + + if (this.options.multiplication !== false) { + rules.push(multiplication(this.options.multiplication)); + } + + if (this.options.superscriptTwo !== false) { + rules.push(superscriptTwo(this.options.superscriptTwo)); + } + + if (this.options.superscriptThree !== false) { + rules.push(superscriptThree(this.options.superscriptThree)); + } + + if (this.options.oneQuarter !== false) { + rules.push(oneQuarter(this.options.oneQuarter)); + } + + if (this.options.threeQuarters !== false) { + rules.push(threeQuarters(this.options.threeQuarters)); + } + + return rules; + }, +}); diff --git a/packages/editor/core-document-editor/src/ui/extensions/typography/inputRules.ts b/packages/editor/core-document-editor/src/ui/extensions/typography/inputRules.ts new file mode 100644 index 00000000000..f528e92426d --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/extensions/typography/inputRules.ts @@ -0,0 +1,137 @@ +import { textInputRule } from "@tiptap/core"; + +export interface TypographyOptions { + emDash: false | string; + ellipsis: false | string; + leftArrow: false | string; + rightArrow: false | string; + copyright: false | string; + trademark: false | string; + servicemark: false | string; + registeredTrademark: false | string; + oneHalf: false | string; + plusMinus: false | string; + notEqual: false | string; + laquo: false | string; + raquo: false | string; + multiplication: false | string; + superscriptTwo: false | string; + superscriptThree: false | string; + oneQuarter: false | string; + threeQuarters: false | string; + impliesArrowRight: false | string; +} + +export const emDash = (override?: string) => + textInputRule({ + find: /--$/, + replace: override ?? "—", + }); + +export const impliesArrowRight = (override?: string) => + textInputRule({ + find: /=>$/, + replace: override ?? "⇒", + }); + +export const leftArrow = (override?: string) => + textInputRule({ + find: /<-$/, + replace: override ?? "←", + }); + +export const rightArrow = (override?: string) => + textInputRule({ + find: /->$/, + replace: override ?? "→", + }); + +export const ellipsis = (override?: string) => + textInputRule({ + find: /\.\.\.$/, + replace: override ?? "…", + }); + +export const copyright = (override?: string) => + textInputRule({ + find: /\(c\)$/, + replace: override ?? "©", + }); + +export const trademark = (override?: string) => + textInputRule({ + find: /\(tm\)$/, + replace: override ?? "™", + }); + +export const servicemark = (override?: string) => + textInputRule({ + find: /\(sm\)$/, + replace: override ?? "℠", + }); + +export const registeredTrademark = (override?: string) => + textInputRule({ + find: /\(r\)$/, + replace: override ?? "®", + }); + +export const oneHalf = (override?: string) => + textInputRule({ + find: /(?:^|\s)(1\/2)\s$/, + replace: override ?? "½", + }); + +export const plusMinus = (override?: string) => + textInputRule({ + find: /\+\/-$/, + replace: override ?? "±", + }); + +export const notEqual = (override?: string) => + textInputRule({ + find: /!=$/, + replace: override ?? "≠", + }); + +export const laquo = (override?: string) => + textInputRule({ + find: /<<$/, + replace: override ?? "«", + }); + +export const raquo = (override?: string) => + textInputRule({ + find: />>$/, + replace: override ?? "»", + }); + +export const multiplication = (override?: string) => + textInputRule({ + find: /\d+\s?([*x])\s?\d+$/, + replace: override ?? "×", + }); + +export const superscriptTwo = (override?: string) => + textInputRule({ + find: /\^2$/, + replace: override ?? "²", + }); + +export const superscriptThree = (override?: string) => + textInputRule({ + find: /\^3$/, + replace: override ?? "³", + }); + +export const oneQuarter = (override?: string) => + textInputRule({ + find: /(?:^|\s)(1\/4)\s$/, + replace: override ?? "¼", + }); + +export const threeQuarters = (override?: string) => + textInputRule({ + find: /(?:^|\s)(3\/4)\s$/, + replace: override ?? "¾", + }); diff --git a/packages/editor/core-document-editor/src/ui/mentions/custom.tsx b/packages/editor/core-document-editor/src/ui/mentions/custom.tsx new file mode 100644 index 00000000000..e723ca0d7f9 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/mentions/custom.tsx @@ -0,0 +1,63 @@ +import { Mention, MentionOptions } from "@tiptap/extension-mention"; +import { mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { MentionNodeView } from "src/ui/mentions/mention-node-view"; +import { IMentionHighlight } from "src/types/mention-suggestion"; + +export interface CustomMentionOptions extends MentionOptions { + mentionHighlights: IMentionHighlight[]; + readonly?: boolean; +} + +export const CustomMention = Mention.extend({ + addStorage(this) { + return { + mentionsOpen: false, + }; + }, + addAttributes() { + return { + id: { + default: null, + }, + label: { + default: null, + }, + target: { + default: null, + }, + self: { + default: false, + }, + redirect_uri: { + default: "/", + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(MentionNodeView); + }, + + parseHTML() { + return [ + { + tag: "mention-component", + getAttrs: (node: string | HTMLElement) => { + if (typeof node === "string") { + return null; + } + return { + id: node.getAttribute("data-mention-id") || "", + target: node.getAttribute("data-mention-target") || "", + label: node.innerText.slice(1) || "", + redirect_uri: node.getAttribute("redirect_uri"), + }; + }, + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + return ["mention-component", mergeAttributes(HTMLAttributes)]; + }, +}); diff --git a/packages/editor/core-document-editor/src/ui/mentions/index.tsx b/packages/editor/core-document-editor/src/ui/mentions/index.tsx new file mode 100644 index 00000000000..f6d3e5b1fc5 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/mentions/index.tsx @@ -0,0 +1,15 @@ +// @ts-nocheck + +import { Suggestion } from "src/ui/mentions/suggestion"; +import { CustomMention } from "src/ui/mentions/custom"; +import { IMentionHighlight } from "src/types/mention-suggestion"; + +export const Mentions = (mentionSuggestions: IMentionSuggestion[], mentionHighlights: IMentionHighlight[], readonly) => + CustomMention.configure({ + HTMLAttributes: { + class: "mention", + }, + readonly: readonly, + mentionHighlights: mentionHighlights, + suggestion: Suggestion(mentionSuggestions), + }); diff --git a/packages/editor/core-document-editor/src/ui/mentions/mention-list.tsx b/packages/editor/core-document-editor/src/ui/mentions/mention-list.tsx new file mode 100644 index 00000000000..afbf1097021 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/mentions/mention-list.tsx @@ -0,0 +1,100 @@ +import { Editor } from "@tiptap/react"; +import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; +import { IMentionSuggestion } from "src/types/mention-suggestion"; + +interface MentionListProps { + items: IMentionSuggestion[]; + command: (item: { id: string; label: string; target: string; redirect_uri: string }) => void; + editor: Editor; +} + +// eslint-disable-next-line react/display-name +export const MentionList = forwardRef((props: MentionListProps, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const selectItem = (index: number) => { + const item = props.items[index]; + + if (item) { + props.command({ + id: item.id, + label: item.title, + target: "users", + redirect_uri: item.redirect_uri, + }); + } + }; + + const upHandler = () => { + setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length); + }; + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items.length); + }; + + const enterHandler = () => { + selectItem(selectedIndex); + }; + + useEffect(() => { + setSelectedIndex(0); + }, [props.items]); + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: { event: KeyboardEvent }) => { + if (event.key === "ArrowUp") { + upHandler(); + return true; + } + + if (event.key === "ArrowDown") { + downHandler(); + return true; + } + + if (event.key === "Enter") { + enterHandler(); + return true; + } + + return false; + }, + })); + + return props.items && props.items.length !== 0 ? ( +
    + {props.items.length ? ( + props.items.map((item, index) => ( +
    selectItem(index)} + > +
    + {item.avatar && item.avatar.trim() !== "" ? ( + {item.title} + ) : ( +
    + {item.title[0]} +
    + )} +
    +
    +

    {item.title}

    + {/*

    {item.subtitle}

    */} +
    +
    + )) + ) : ( +
    No result
    + )} +
    + ) : ( + <> + ); +}); + +MentionList.displayName = "MentionList"; diff --git a/packages/editor/core-document-editor/src/ui/mentions/mention-node-view.tsx b/packages/editor/core-document-editor/src/ui/mentions/mention-node-view.tsx new file mode 100644 index 00000000000..1c3755f6c66 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/mentions/mention-node-view.tsx @@ -0,0 +1,35 @@ +/* eslint-disable react/display-name */ +// @ts-nocheck +import { NodeViewWrapper } from "@tiptap/react"; +import { cn } from "src/lib/utils"; +import { useRouter } from "next/router"; +import { IMentionHighlight } from "src/types/mention-suggestion"; + +// eslint-disable-next-line import/no-anonymous-default-export +export const MentionNodeView = (props) => { + const router = useRouter(); + const highlights = props.extension.options.mentionHighlights as IMentionHighlight[]; + + const handleClick = () => { + if (!props.extension.options.readonly) { + router.push(props.node.attrs.redirect_uri); + } + }; + + return ( + + + @{props.node.attrs.label} + + + ); +}; diff --git a/packages/editor/core-document-editor/src/ui/mentions/suggestion.ts b/packages/editor/core-document-editor/src/ui/mentions/suggestion.ts new file mode 100644 index 00000000000..40e75a1e381 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/mentions/suggestion.ts @@ -0,0 +1,66 @@ +import { ReactRenderer } from "@tiptap/react"; +import { Editor } from "@tiptap/core"; +import tippy from "tippy.js"; + +import { MentionList } from "src/ui/mentions/mention-list"; +import { IMentionSuggestion } from "src/types/mention-suggestion"; + +export const Suggestion = (suggestions: IMentionSuggestion[]) => ({ + items: ({ query }: { query: string }) => + suggestions.filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5), + render: () => { + let reactRenderer: ReactRenderer | null = null; + let popup: any | null = null; + + return { + onStart: (props: { editor: Editor; clientRect: DOMRect }) => { + props.editor.storage.mentionsOpen = true; + reactRenderer = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }); + // @ts-ignore + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => document.querySelector("#editor-container"), + content: reactRenderer.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + + onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { + reactRenderer?.updateProps(props); + + popup && + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + onKeyDown: (props: { event: KeyboardEvent }) => { + if (props.event.key === "Escape") { + popup?.[0].hide(); + + return true; + } + + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; + + if (navigationKeys.includes(props.event.key)) { + // @ts-ignore + reactRenderer?.ref?.onKeyDown(props); + event?.stopPropagation(); + return true; + } + return false; + }, + onExit: (props: { editor: Editor; event: KeyboardEvent }) => { + props.editor.storage.mentionsOpen = false; + popup?.[0].destroy(); + reactRenderer?.destroy(); + }, + }; + }, +}); diff --git a/packages/editor/core-document-editor/src/ui/menus/menu-items/index.tsx b/packages/editor/core-document-editor/src/ui/menus/menu-items/index.tsx new file mode 100644 index 00000000000..f60febc59d6 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/menus/menu-items/index.tsx @@ -0,0 +1,144 @@ +import { + BoldIcon, + Heading1, + CheckSquare, + Heading2, + Heading3, + QuoteIcon, + ImageIcon, + TableIcon, + ListIcon, + ListOrderedIcon, + ItalicIcon, + UnderlineIcon, + StrikethroughIcon, + CodeIcon, +} from "lucide-react"; +import { Editor } from "@tiptap/react"; +import { + insertImageCommand, + insertTableCommand, + toggleBlockquote, + toggleBold, + toggleBulletList, + toggleCodeBlock, + toggleHeadingOne, + toggleHeadingThree, + toggleHeadingTwo, + toggleItalic, + toggleOrderedList, + toggleStrike, + toggleTaskList, + toggleUnderline, +} from "src/lib/editor-commands"; +import { LucideIconType } from "src/types/lucide-icon"; +import { UploadImage } from "src/types/upload-image"; + +export interface EditorMenuItem { + name: string; + isActive: () => boolean; + command: () => void; + icon: LucideIconType; +} + +export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({ + name: "H1", + isActive: () => editor.isActive("heading", { level: 1 }), + command: () => toggleHeadingOne(editor), + icon: Heading1, +}); + +export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({ + name: "H2", + isActive: () => editor.isActive("heading", { level: 2 }), + command: () => toggleHeadingTwo(editor), + icon: Heading2, +}); + +export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({ + name: "H3", + isActive: () => editor.isActive("heading", { level: 3 }), + command: () => toggleHeadingThree(editor), + icon: Heading3, +}); + +export const BoldItem = (editor: Editor): EditorMenuItem => ({ + name: "bold", + isActive: () => editor?.isActive("bold"), + command: () => toggleBold(editor), + icon: BoldIcon, +}); + +export const ItalicItem = (editor: Editor): EditorMenuItem => ({ + name: "italic", + isActive: () => editor?.isActive("italic"), + command: () => toggleItalic(editor), + icon: ItalicIcon, +}); + +export const UnderLineItem = (editor: Editor): EditorMenuItem => ({ + name: "underline", + isActive: () => editor?.isActive("underline"), + command: () => toggleUnderline(editor), + icon: UnderlineIcon, +}); + +export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({ + name: "strike", + isActive: () => editor?.isActive("strike"), + command: () => toggleStrike(editor), + icon: StrikethroughIcon, +}); + +export const BulletListItem = (editor: Editor): EditorMenuItem => ({ + name: "bullet-list", + isActive: () => editor?.isActive("bulletList"), + command: () => toggleBulletList(editor), + icon: ListIcon, +}); + +export const TodoListItem = (editor: Editor): EditorMenuItem => ({ + name: "To-do List", + isActive: () => editor.isActive("taskItem"), + command: () => toggleTaskList(editor), + icon: CheckSquare, +}); + +export const CodeItem = (editor: Editor): EditorMenuItem => ({ + name: "code", + isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"), + command: () => toggleCodeBlock(editor), + icon: CodeIcon, +}); + +export const NumberedListItem = (editor: Editor): EditorMenuItem => ({ + name: "ordered-list", + isActive: () => editor?.isActive("orderedList"), + command: () => toggleOrderedList(editor), + icon: ListOrderedIcon, +}); + +export const QuoteItem = (editor: Editor): EditorMenuItem => ({ + name: "quote", + isActive: () => editor?.isActive("blockquote"), + command: () => toggleBlockquote(editor), + icon: QuoteIcon, +}); + +export const TableItem = (editor: Editor): EditorMenuItem => ({ + name: "table", + isActive: () => editor?.isActive("table"), + command: () => insertTableCommand(editor), + icon: TableIcon, +}); + +export const ImageItem = ( + editor: Editor, + uploadFile: UploadImage, + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void +): EditorMenuItem => ({ + name: "image", + isActive: () => editor?.isActive("image"), + command: () => insertImageCommand(editor, uploadFile, setIsSubmitting), + icon: ImageIcon, +}); diff --git a/packages/editor/core-document-editor/src/ui/plugins/delete-image.tsx b/packages/editor/core-document-editor/src/ui/plugins/delete-image.tsx new file mode 100644 index 00000000000..afe13730aed --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/plugins/delete-image.tsx @@ -0,0 +1,79 @@ +import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { DeleteImage } from "src/types/delete-image"; +import { RestoreImage } from "src/types/restore-image"; + +const deleteKey = new PluginKey("delete-image"); +const IMAGE_NODE_TYPE = "image"; + +interface ImageNode extends ProseMirrorNode { + attrs: { + src: string; + id: string; + }; +} + +export const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin => + new Plugin({ + key: deleteKey, + appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { + const newImageSources = new Set(); + newState.doc.descendants((node) => { + if (node.type.name === IMAGE_NODE_TYPE) { + newImageSources.add(node.attrs.src); + } + }); + + transactions.forEach((transaction) => { + if (!transaction.docChanged) return; + + const removedImages: ImageNode[] = []; + + oldState.doc.descendants((oldNode, oldPos) => { + if (oldNode.type.name !== IMAGE_NODE_TYPE) return; + if (oldPos < 0 || oldPos > newState.doc.content.size) return; + if (!newState.doc.resolve(oldPos).parent) return; + + const newNode = newState.doc.nodeAt(oldPos); + + // Check if the node has been deleted or replaced + if (!newNode || newNode.type.name !== IMAGE_NODE_TYPE) { + if (!newImageSources.has(oldNode.attrs.src)) { + removedImages.push(oldNode as ImageNode); + } + } + }); + + removedImages.forEach(async (node) => { + const src = node.attrs.src; + await onNodeDeleted(src, deleteImage); + }); + }); + + return null; + }, + }); + +export async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise { + try { + const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); + const resStatus = await deleteImage(assetUrlWithWorkspaceId); + if (resStatus === 204) { + console.log("Image deleted successfully"); + } + } catch (error) { + console.error("Error deleting image: ", error); + } +} + +export async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise { + try { + const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); + const resStatus = await restoreImage(assetUrlWithWorkspaceId); + if (resStatus === 204) { + console.log("Image restored successfully"); + } + } catch (error) { + console.error("Error restoring image: ", error); + } +} diff --git a/packages/editor/core-document-editor/src/ui/plugins/upload-image.tsx b/packages/editor/core-document-editor/src/ui/plugins/upload-image.tsx new file mode 100644 index 00000000000..738653d7139 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/plugins/upload-image.tsx @@ -0,0 +1,164 @@ +import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; +import { UploadImage } from "src/types/upload-image"; + +const uploadKey = new PluginKey("upload-image"); + +export const UploadImagesPlugin = (cancelUploadImage?: () => any) => + new Plugin({ + key: uploadKey, + state: { + init() { + return DecorationSet.empty; + }, + apply(tr, set) { + set = set.map(tr.mapping, tr.doc); + // See if the transaction adds or removes any placeholders + const action = tr.getMeta(uploadKey); + if (action && action.add) { + const { id, pos, src } = action.add; + + const placeholder = document.createElement("div"); + placeholder.setAttribute("class", "img-placeholder"); + const image = document.createElement("img"); + image.setAttribute("class", "opacity-10 rounded-lg border border-custom-border-300"); + image.src = src; + placeholder.appendChild(image); + + // Create cancel button + const cancelButton = document.createElement("button"); + cancelButton.style.position = "absolute"; + cancelButton.style.right = "3px"; + cancelButton.style.top = "3px"; + cancelButton.setAttribute("class", "opacity-90 rounded-lg"); + + cancelButton.onclick = () => { + cancelUploadImage?.(); + }; + + // Create an SVG element from the SVG string + const svgString = ``; + const parser = new DOMParser(); + const svgElement = parser.parseFromString(svgString, "image/svg+xml").documentElement; + + cancelButton.appendChild(svgElement); + placeholder.appendChild(cancelButton); + const deco = Decoration.widget(pos, placeholder, { + id, + }); + set = set.add(tr.doc, [deco]); + } else if (action && action.remove) { + set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id)); + } + return set; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + }); + +function findPlaceholder(state: EditorState, id: {}) { + const decos = uploadKey.getState(state); + const found = decos.find(undefined, undefined, (spec: { id: number | undefined }) => spec.id == id); + return found.length ? found[0].from : null; +} + +const removePlaceholder = (view: EditorView, id: {}) => { + const removePlaceholderTr = view.state.tr.setMeta(uploadKey, { + remove: { id }, + }); + view.dispatch(removePlaceholderTr); +}; + +export async function startImageUpload( + file: File, + view: EditorView, + pos: number, + uploadFile: UploadImage, + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void +) { + if (!file) { + alert("No file selected. Please select a file to upload."); + return; + } + + if (!file.type.includes("image/")) { + alert("Invalid file type. Please select an image file."); + return; + } + + if (file.size > 5 * 1024 * 1024) { + alert("File size too large. Please select a file smaller than 5MB."); + return; + } + + const id = {}; + + const tr = view.state.tr; + if (!tr.selection.empty) tr.deleteSelection(); + + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + tr.setMeta(uploadKey, { + add: { + id, + pos, + src: reader.result, + }, + }); + view.dispatch(tr); + }; + + // Handle FileReader errors + reader.onerror = (error) => { + console.error("FileReader error: ", error); + removePlaceholder(view, id); + return; + }; + + setIsSubmitting?.("submitting"); + + try { + const src = await UploadImageHandler(file, uploadFile); + const { schema } = view.state; + pos = findPlaceholder(view.state, id); + + if (pos == null) return; + const imageSrc = typeof src === "object" ? reader.result : src; + + const node = schema.nodes.image.create({ src: imageSrc }); + const transaction = view.state.tr.insert(pos - 1, node).setMeta(uploadKey, { remove: { id } }); + + view.dispatch(transaction); + } catch (error) { + console.error("Upload error: ", error); + removePlaceholder(view, id); + } +} + +const UploadImageHandler = (file: File, uploadFile: UploadImage): Promise => { + try { + return new Promise(async (resolve, reject) => { + try { + const imageUrl = await uploadFile(file); + + const image = new Image(); + image.src = imageUrl; + image.onload = () => { + resolve(imageUrl); + }; + } catch (error) { + if (error instanceof Error) { + console.log(error.message); + } + reject(error); + } + }); + } catch (error) { + return Promise.reject(error); + } +}; diff --git a/packages/editor/core-document-editor/src/ui/props.tsx b/packages/editor/core-document-editor/src/ui/props.tsx new file mode 100644 index 00000000000..c02ad324adc --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/props.tsx @@ -0,0 +1,60 @@ +import { EditorProps } from "@tiptap/pm/view"; +import { findTableAncestor } from "src/lib/utils"; +import { UploadImage } from "src/types/upload-image"; +import { startImageUpload } from "src/ui/plugins/upload-image"; + +export function CoreEditorProps(uploadFile: UploadImage): EditorProps { + return { + attributes: { + class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`, + }, + handleDOMEvents: { + keydown: (_view, event) => { + // prevent default event listeners from firing when slash command is active + if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { + const slashCommand = document.querySelector("#slash-command"); + if (slashCommand) { + return true; + } + } + }, + }, + handlePaste: (view, event) => { + if (typeof window !== "undefined") { + const selection: any = window?.getSelection(); + if (selection.rangeCount !== 0) { + const range = selection.getRangeAt(0); + if (findTableAncestor(range.startContainer)) { + return; + } + } + } + if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) { + event.preventDefault(); + const file = event.clipboardData.files[0]; + const pos = view.state.selection.from; + startImageUpload(file, view, pos, uploadFile, setIsSubmitting); + return true; + } + return false; + }, + handleDrop: (view, event, _slice, moved) => { + if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { + event.preventDefault(); + const file = event.dataTransfer.files[0]; + const coordinates = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + if (coordinates) { + startImageUpload(file, view, coordinates.pos - 1, uploadFile, setIsSubmitting); + } + return true; + } + return false; + }, + transformPastedHTML(html) { + return html.replace(//g, ""); + }, + }; +} diff --git a/packages/editor/core-document-editor/src/ui/read-only/extensions.tsx b/packages/editor/core-document-editor/src/ui/read-only/extensions.tsx new file mode 100644 index 00000000000..93e1b388722 --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/read-only/extensions.tsx @@ -0,0 +1,100 @@ +import StarterKit from "@tiptap/starter-kit"; +import TiptapUnderline from "@tiptap/extension-underline"; +import TextStyle from "@tiptap/extension-text-style"; +import { Color } from "@tiptap/extension-color"; +import TaskItem from "@tiptap/extension-task-item"; +import TaskList from "@tiptap/extension-task-list"; +import { Markdown } from "tiptap-markdown"; + +import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; +import { Table } from "src/ui/extensions/table/table"; +import { TableCell } from "src/ui/extensions/table/table-cell/table-cell"; +import { TableRow } from "src/ui/extensions/table/table-row/table-row"; + +import { ReadOnlyImageExtension } from "src/ui/extensions/image/read-only-image"; +import { isValidHttpUrl } from "src/lib/utils"; +import { Mentions } from "src/ui/mentions"; +import { IMentionSuggestion } from "src/types/mention-suggestion"; +import { CustomLinkExtension } from "src/ui/extensions/custom-link"; +import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule"; +import { CustomQuoteExtension } from "src/ui/extensions/quote"; +import { CustomTypographyExtension } from "src/ui/extensions/typography"; +import { CustomCodeBlockExtension } from "src/ui/extensions/code"; +import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline"; + +export const CoreReadOnlyEditorExtensions = (mentionConfig: { + mentionSuggestions: IMentionSuggestion[]; + mentionHighlights: string[]; +}) => [ + StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: "list-disc list-outside leading-3 -mt-2", + }, + }, + orderedList: { + HTMLAttributes: { + class: "list-decimal list-outside leading-3 -mt-2", + }, + }, + listItem: { + HTMLAttributes: { + class: "leading-normal -mb-2", + }, + }, + code: false, + codeBlock: false, + horizontalRule: false, + blockquote: false, + dropcursor: false, + gapcursor: false, + }), + CustomQuoteExtension.configure({ + HTMLAttributes: { className: "border-l-4 border-custom-border-300" }, + }), + CustomHorizontalRule.configure({ + HTMLAttributes: { class: "mt-4 mb-4" }, + }), + CustomLinkExtension.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, + protocols: ["http", "https"], + validate: (url: string) => isValidHttpUrl(url), + HTMLAttributes: { + class: + "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", + }, + }), + CustomTypographyExtension, + ReadOnlyImageExtension.configure({ + HTMLAttributes: { + class: "rounded-lg border border-custom-border-300", + }, + }), + TiptapUnderline, + TextStyle, + Color, + TaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2", + }, + }), + TaskItem.configure({ + HTMLAttributes: { + class: "flex items-start my-4", + }, + nested: true, + }), + CustomCodeBlockExtension, + CustomCodeInlineExtension, + Markdown.configure({ + html: true, + transformCopiedText: true, + }), + Table, + TableHeader, + TableCell, + TableRow, + Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true), +]; diff --git a/packages/editor/core-document-editor/src/ui/read-only/props.tsx b/packages/editor/core-document-editor/src/ui/read-only/props.tsx new file mode 100644 index 00000000000..79f9fcb0dbb --- /dev/null +++ b/packages/editor/core-document-editor/src/ui/read-only/props.tsx @@ -0,0 +1,7 @@ +import { EditorProps } from "@tiptap/pm/view"; + +export const CoreReadOnlyEditorProps: EditorProps = { + attributes: { + class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`, + }, +}; diff --git a/packages/editor/core-document-editor/tailwind.config.js b/packages/editor/core-document-editor/tailwind.config.js new file mode 100644 index 00000000000..f3206315889 --- /dev/null +++ b/packages/editor/core-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/core-document-editor/tsconfig.json b/packages/editor/core-document-editor/tsconfig.json new file mode 100644 index 00000000000..c15534037ac --- /dev/null +++ b/packages/editor/core-document-editor/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "tsconfig/react-library.json", + "include": [ + "src/**/*", + "index.d.ts" + ], + "exclude": [ + "dist", + "build", + "node_modules" + ], + "compilerOptions": { + "baseUrl": "." + } +} diff --git a/packages/editor/core-document-editor/tsup.config.ts b/packages/editor/core-document-editor/tsup.config.ts new file mode 100644 index 00000000000..5e89e04afad --- /dev/null +++ b/packages/editor/core-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/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json index 870d5edd9e1..e4d0ff73f4a 100644 --- a/packages/editor/document-editor/package.json +++ b/packages/editor/document-editor/package.json @@ -30,6 +30,7 @@ "dependencies": { "@floating-ui/react": "^0.26.4", "@plane/editor-core": "*", + "@plane/editor-document-core": "*", "@plane/editor-extensions": "*", "@plane/ui": "*", "@tippyjs/react": "^4.2.6", diff --git a/packages/editor/document-editor/src/ui/components/alert-label.tsx b/packages/editor/document-editor/src/ui/components/alert-label.tsx index 69b6dd02d0b..be4c0a2a36f 100644 --- a/packages/editor/document-editor/src/ui/components/alert-label.tsx +++ b/packages/editor/document-editor/src/ui/components/alert-label.tsx @@ -1,4 +1,4 @@ -import { LucideIconType } from "@plane/editor-core"; +import { LucideIconType } from "@plane/editor-document-core"; interface IAlertLabelProps { Icon?: LucideIconType; 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 a322ddddc5b..8441fc199d8 100644 --- a/packages/editor/document-editor/src/ui/components/editor-header.tsx +++ b/packages/editor/document-editor/src/ui/components/editor-header.tsx @@ -2,7 +2,7 @@ import { Editor } from "@tiptap/react"; import { Archive, RefreshCw, Lock } from "lucide-react"; import { IMarking, DocumentDetails } from "src/types/editor-types"; import { FixedMenu } from "src/ui/menu"; -import { UploadImage } from "@plane/editor-core"; +import { UploadImage } from "@plane/editor-document-core"; import { AlertLabel } from "src/ui/components/alert-label"; import { IVerticalDropdownItemProps, VerticalDropdownMenu } from "src/ui/components/vertical-dropdown-menu"; import { SummaryPopover } from "src/ui/components/summary-popover"; diff --git a/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx b/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx index 97191543991..85d7bf6dbc5 100644 --- a/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx +++ b/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx @@ -1,4 +1,4 @@ -import { isValidHttpUrl } from "@plane/editor-core"; +import { isValidHttpUrl } from "@plane/editor-document-core"; import { Node } from "@tiptap/pm/model"; import { Link2Off } from "lucide-react"; import { useEffect, useRef, useState } from "react"; 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 7c2717e807e..22e5fd058eb 100644 --- a/packages/editor/document-editor/src/ui/components/page-renderer.tsx +++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx @@ -1,4 +1,4 @@ -import { EditorContainer, EditorContentWrapper } from "@plane/editor-core"; +import { EditorContainer, EditorContentWrapper } from "@plane/editor-document-core"; import { Node } from "@tiptap/pm/model"; import { EditorView } from "@tiptap/pm/view"; import { Editor, ReactRenderer } from "@tiptap/react"; 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 index 43843e50714..ce0b1abef6e 100644 --- a/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx +++ b/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx @@ -1,4 +1,4 @@ -import { LucideIconType } from "@plane/editor-core"; +import { LucideIconType } from "@plane/editor-document-core"; import { CustomMenu } from "@plane/ui"; import { MoreVertical } from "lucide-react"; diff --git a/packages/editor/document-editor/src/ui/extensions/index.tsx b/packages/editor/document-editor/src/ui/extensions/index.tsx index cedc3ed8098..b174f0a8ea5 100644 --- a/packages/editor/document-editor/src/ui/extensions/index.tsx +++ b/packages/editor/document-editor/src/ui/extensions/index.tsx @@ -2,7 +2,7 @@ import Placeholder from "@tiptap/extension-placeholder"; import { IssueWidgetPlaceholder } from "src/ui/extensions/widgets/issue-embed-widget"; import { SlashCommand, DragAndDrop } from "@plane/editor-extensions"; -import { UploadImage } from "@plane/editor-core"; +import { UploadImage } from "@plane/editor-document-core"; export const DocumentEditorExtensions = ( uploadFile: UploadImage, diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx index e586bfd80cc..dccea958b68 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx @@ -1,4 +1,4 @@ -import { cn } from "@plane/editor-core"; +import { cn } from "@plane/editor-document-core"; import { Editor } from "@tiptap/core"; import tippy from "tippy.js"; import { ReactRenderer } from "@tiptap/react"; diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index eb54a204bde..ac71c60f2ab 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -1,6 +1,6 @@ "use client"; import React, { useState } from "react"; -import { UploadImage, DeleteImage, RestoreImage, getEditorClassNames, useEditor } from "@plane/editor-core"; +import { UploadImage, DeleteImage, RestoreImage, getEditorClassNames, useEditor } from "@plane/editor-document-core"; import { DocumentEditorExtensions } from "src/ui/extensions"; import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "src/types/menu-actions"; import { EditorHeader } from "src/ui/components/editor-header"; @@ -64,7 +64,6 @@ const DocumentEditor = ({ documentDetails, onChange, debouncedUpdatesEnabled, - setIsSubmitting, setShouldShowAlert, editorContentCustomClassNames, value, diff --git a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx index 397e8c576dd..3969a7009b0 100644 --- a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx +++ b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx @@ -1,10 +1,12 @@ -import { Editor } from "@tiptap/react"; import { BoldItem, BulletListItem, - isCellSelection, cn, CodeItem, + EditorMenuItem, + HeadingOneItem, + HeadingThreeItem, + HeadingTwoItem, ImageItem, ItalicItem, NumberedListItem, @@ -12,13 +14,9 @@ import { StrikeThroughItem, TableItem, UnderLineItem, - HeadingOneItem, - HeadingTwoItem, - HeadingThreeItem, - findTableAncestor, - EditorMenuItem, UploadImage, -} from "@plane/editor-core"; +} from "@plane/editor-document-core"; +import { Editor } from "@tiptap/react"; export type BubbleMenuItem = EditorMenuItem; diff --git a/packages/editor/document-editor/src/ui/readonly/index.tsx b/packages/editor/document-editor/src/ui/readonly/index.tsx index 22099281ec8..a93df3cbce1 100644 --- a/packages/editor/document-editor/src/ui/readonly/index.tsx +++ b/packages/editor/document-editor/src/ui/readonly/index.tsx @@ -1,4 +1,4 @@ -import { getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core"; +import { getEditorClassNames, useReadOnlyEditor } from "@plane/editor-document-core"; import { useRouter } from "next/router"; import { useState, forwardRef, useEffect } from "react"; import { EditorHeader } from "src/ui/components/editor-header"; From 48d83974cb3ce2ae0ca26e7f7210bb8f6a39da40 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Fri, 15 Mar 2024 16:50:40 +0530 Subject: [PATCH 028/179] chore: removed setIsSubmitting prop in doc editor --- .../core-document-editor/src/lib/editor-commands.ts | 9 ++------- .../src/ui/menus/menu-items/index.tsx | 8 ++------ .../src/ui/plugins/upload-image.tsx | 10 ++-------- packages/editor/core-document-editor/src/ui/props.tsx | 4 ++-- .../src/ui/components/editor-header.tsx | 6 +----- .../editor/document-editor/src/ui/extensions/index.tsx | 5 ++--- packages/editor/document-editor/src/ui/index.tsx | 6 ++---- .../editor/document-editor/src/ui/menu/fixed-menu.tsx | 5 ++--- .../projects/[projectId]/pages/[pageId].tsx | 2 +- 9 files changed, 16 insertions(+), 39 deletions(-) diff --git a/packages/editor/core-document-editor/src/lib/editor-commands.ts b/packages/editor/core-document-editor/src/lib/editor-commands.ts index 6524d1ff58a..52ce49b73ee 100644 --- a/packages/editor/core-document-editor/src/lib/editor-commands.ts +++ b/packages/editor/core-document-editor/src/lib/editor-commands.ts @@ -109,12 +109,7 @@ export const setLinkEditor = (editor: Editor, url: string) => { editor.chain().focus().setLink({ href: url }).run(); }; -export const insertImageCommand = ( - editor: Editor, - uploadFile: UploadImage, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, - range?: Range -) => { +export const insertImageCommand = (editor: Editor, uploadFile: UploadImage, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).run(); const input = document.createElement("input"); input.type = "file"; @@ -123,7 +118,7 @@ export const insertImageCommand = ( if (input.files?.length) { const file = input.files[0]; const pos = editor.view.state.selection.from; - startImageUpload(file, editor.view, pos, uploadFile, setIsSubmitting); + startImageUpload(file, editor.view, pos, uploadFile); } }; input.click(); diff --git a/packages/editor/core-document-editor/src/ui/menus/menu-items/index.tsx b/packages/editor/core-document-editor/src/ui/menus/menu-items/index.tsx index f60febc59d6..2b5e724d5fc 100644 --- a/packages/editor/core-document-editor/src/ui/menus/menu-items/index.tsx +++ b/packages/editor/core-document-editor/src/ui/menus/menu-items/index.tsx @@ -132,13 +132,9 @@ export const TableItem = (editor: Editor): EditorMenuItem => ({ icon: TableIcon, }); -export const ImageItem = ( - editor: Editor, - uploadFile: UploadImage, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void -): EditorMenuItem => ({ +export const ImageItem = (editor: Editor, uploadFile: UploadImage): EditorMenuItem => ({ name: "image", isActive: () => editor?.isActive("image"), - command: () => insertImageCommand(editor, uploadFile, setIsSubmitting), + command: () => insertImageCommand(editor, uploadFile), icon: ImageIcon, }); diff --git a/packages/editor/core-document-editor/src/ui/plugins/upload-image.tsx b/packages/editor/core-document-editor/src/ui/plugins/upload-image.tsx index 738653d7139..fe485b1b8a9 100644 --- a/packages/editor/core-document-editor/src/ui/plugins/upload-image.tsx +++ b/packages/editor/core-document-editor/src/ui/plugins/upload-image.tsx @@ -73,13 +73,7 @@ const removePlaceholder = (view: EditorView, id: {}) => { view.dispatch(removePlaceholderTr); }; -export async function startImageUpload( - file: File, - view: EditorView, - pos: number, - uploadFile: UploadImage, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void -) { +export async function startImageUpload(file: File, view: EditorView, pos: number, uploadFile: UploadImage) { if (!file) { alert("No file selected. Please select a file to upload."); return; @@ -120,7 +114,7 @@ export async function startImageUpload( return; }; - setIsSubmitting?.("submitting"); + // setIsSubmitting?.("submitting"); try { const src = await UploadImageHandler(file, uploadFile); diff --git a/packages/editor/core-document-editor/src/ui/props.tsx b/packages/editor/core-document-editor/src/ui/props.tsx index c02ad324adc..82b3a599223 100644 --- a/packages/editor/core-document-editor/src/ui/props.tsx +++ b/packages/editor/core-document-editor/src/ui/props.tsx @@ -33,7 +33,7 @@ export function CoreEditorProps(uploadFile: UploadImage): EditorProps { event.preventDefault(); const file = event.clipboardData.files[0]; const pos = view.state.selection.from; - startImageUpload(file, view, pos, uploadFile, setIsSubmitting); + startImageUpload(file, view, pos, uploadFile); return true; } return false; @@ -47,7 +47,7 @@ export function CoreEditorProps(uploadFile: UploadImage): EditorProps { top: event.clientY, }); if (coordinates) { - startImageUpload(file, view, coordinates.pos - 1, uploadFile, setIsSubmitting); + startImageUpload(file, view, coordinates.pos - 1, uploadFile); } return true; } 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 8441fc199d8..fbdd7c453c7 100644 --- a/packages/editor/document-editor/src/ui/components/editor-header.tsx +++ b/packages/editor/document-editor/src/ui/components/editor-header.tsx @@ -19,7 +19,6 @@ interface IEditorHeader { archivedAt?: Date; readonly: boolean; uploadFile?: UploadImage; - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; documentDetails: DocumentDetails; isSubmitting?: "submitting" | "submitted" | "saved"; } @@ -34,7 +33,6 @@ export const EditorHeader = (props: IEditorHeader) => { setSidePeekVisible, markings, uploadFile, - setIsSubmitting, KanbanMenuOptions, isArchived, isLocked, @@ -53,9 +51,7 @@ export const EditorHeader = (props: IEditorHeader) => {
    - {!readonly && uploadFile && ( - - )} + {!readonly && uploadFile && }
    diff --git a/packages/editor/document-editor/src/ui/extensions/index.tsx b/packages/editor/document-editor/src/ui/extensions/index.tsx index b174f0a8ea5..315160a2a85 100644 --- a/packages/editor/document-editor/src/ui/extensions/index.tsx +++ b/packages/editor/document-editor/src/ui/extensions/index.tsx @@ -6,10 +6,9 @@ import { UploadImage } from "@plane/editor-document-core"; export const DocumentEditorExtensions = ( uploadFile: UploadImage, - setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void + setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void ) => [ - SlashCommand(uploadFile, setIsSubmitting), + SlashCommand(uploadFile), DragAndDrop(setHideDragHandle), Placeholder.configure({ placeholder: ({ node }) => { diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index ac71c60f2ab..c49abf075dc 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -104,7 +104,6 @@ const DocumentEditor = ({ }, debouncedUpdatesEnabled, restoreFile, - setIsSubmitting, setShouldShowAlert, value, uploadFile, @@ -112,7 +111,7 @@ const DocumentEditor = ({ cancelUploadImage, rerenderOnPropsChange, forwardedRef, - extensions: DocumentEditorExtensions(uploadFile, setHideDragHandleFunction, setIsSubmitting), + extensions: DocumentEditorExtensions(uploadFile, setHideDragHandleFunction), }); if (!editor) { @@ -146,7 +145,6 @@ const DocumentEditor = ({ setSidePeekVisible={(val) => setSidePeekVisible(val)} markings={markings} uploadFile={uploadFile} - setIsSubmitting={setIsSubmitting} isLocked={!pageLockConfig ? false : pageLockConfig.is_locked} isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived} archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at} @@ -154,7 +152,7 @@ const DocumentEditor = ({ isSubmitting={isSubmitting} />
    - {uploadFile && } + {uploadFile && }
    diff --git a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx index 3969a7009b0..467d5b4da66 100644 --- a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx +++ b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx @@ -23,11 +23,10 @@ export type BubbleMenuItem = EditorMenuItem; type EditorBubbleMenuProps = { editor: Editor; uploadFile: UploadImage; - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; }; export const FixedMenu = (props: EditorBubbleMenuProps) => { - const { editor, uploadFile, setIsSubmitting } = props; + const { editor, uploadFile } = props; const basicMarkItems: BubbleMenuItem[] = [ HeadingOneItem(editor), @@ -46,7 +45,7 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => { function getComplexItems(): BubbleMenuItem[] { const items: BubbleMenuItem[] = [TableItem(editor)]; - items.push(ImageItem(editor, uploadFile, setIsSubmitting)); + items.push(ImageItem(editor, uploadFile)); return items; } diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index 16dba79b39c..c807533dfe7 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -304,11 +304,11 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { cancelUploadImage={fileService.cancelUpload} ref={editorRef} debouncedUpdatesEnabled={false} - setIsSubmitting={setIsSubmitting} updatePageTitle={updatePageTitle} onActionCompleteHandler={actionCompleteAlert} customClassName="tracking-tight self-center h-full w-full right-[0.675rem]" onChange={(_description_json: any, description_html: string) => { + setIsSubmitting?.("submitting"); setShowAlert(true); onChange(description_html); handleSubmit(updatePage)(); From 0f26ab7d4870c1aff5e1bd7a4028fb7c2b23b642 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Fri, 15 Mar 2024 19:16:30 +0530 Subject: [PATCH 029/179] fix: fixed submitting state for image uploads --- .../projects/[projectId]/pages/[pageId].tsx | 2 +- web/services/file.service.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index c807533dfe7..aa3b464fb9d 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -296,7 +296,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { last_updated_at: updated_at, last_updated_by: updated_by, }} - uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)} + uploadFile={fileService.getUploadFileFunction(workspaceSlug as string, setIsSubmitting)} deleteFile={fileService.getDeleteImageFunction(workspaceId)} restoreFile={fileService.getRestoreImageFunction(workspaceId)} value={pageDescription} diff --git a/web/services/file.service.ts b/web/services/file.service.ts index 0818bc99212..a61366e30c9 100644 --- a/web/services/file.service.ts +++ b/web/services/file.service.ts @@ -63,13 +63,20 @@ export class FileService extends APIService { this.cancelSource.cancel("Upload cancelled"); } - getUploadFileFunction(workspaceSlug: string): (file: File) => Promise { + getUploadFileFunction( + workspaceSlug: string, + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void + ): (file: File) => Promise { return async (file: File) => { try { const formData = new FormData(); formData.append("asset", file); formData.append("attributes", JSON.stringify({})); + // the submitted state will be resolved by the page rendering the editor + // once the patch request of saving the editor contents is resolved + setIsSubmitting?.("submitting"); + const data = await this.uploadFile(workspaceSlug, formData); return data.asset; } catch (e) { From 0cca35588ae102ae48504970001399d53d1cccf5 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Mon, 18 Mar 2024 11:29:40 +0530 Subject: [PATCH 030/179] refactor: setShouldShowAlert removed --- packages/editor/core-document-editor/src/hooks/use-editor.tsx | 4 +--- packages/editor/document-editor/src/ui/index.tsx | 4 ---- .../[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx | 4 ++-- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/editor/core-document-editor/src/hooks/use-editor.tsx b/packages/editor/core-document-editor/src/hooks/use-editor.tsx index c01821a726e..97eed5fe0b9 100644 --- a/packages/editor/core-document-editor/src/hooks/use-editor.tsx +++ b/packages/editor/core-document-editor/src/hooks/use-editor.tsx @@ -20,7 +20,6 @@ interface CustomEditorProps { }; deleteFile: DeleteImage; cancelUploadImage?: () => any; - setShouldShowAlert?: (showAlert: boolean) => void; value: string; debouncedUpdatesEnabled?: boolean; onStart?: (json: any, html: string) => void; @@ -44,7 +43,6 @@ export const useEditor = ({ onChange, forwardedRef, restoreFile, - setShouldShowAlert, mentionHighlights, mentionSuggestions, }: CustomEditorProps) => { @@ -75,7 +73,7 @@ export const useEditor = ({ }, onUpdate: async ({ editor }) => { // setIsSubmitting?.("submitting"); - setShouldShowAlert?.(true); + // setShouldShowAlert?.(true); onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML())); }, }, diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index c49abf075dc..83a5cef4d7d 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -36,8 +36,6 @@ interface IDocumentEditor { customClassName?: string; editorContentCustomClassNames?: string; onChange: (json: any, html: string) => void; - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; - setShouldShowAlert?: (showAlert: boolean) => void; forwardedRef?: any; updatePageTitle: (title: string) => void; debouncedUpdatesEnabled?: boolean; @@ -64,7 +62,6 @@ const DocumentEditor = ({ documentDetails, onChange, debouncedUpdatesEnabled, - setShouldShowAlert, editorContentCustomClassNames, value, uploadFile, @@ -104,7 +101,6 @@ const DocumentEditor = ({ }, debouncedUpdatesEnabled, restoreFile, - setShouldShowAlert, value, uploadFile, deleteFile, diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index aa3b464fb9d..1f4fb6b4cfb 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -88,6 +88,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { useEffect( () => () => { if (pageStore) { + console.log("ran cleanup"); pageStore.cleanup(); } }, @@ -300,7 +301,6 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { deleteFile={fileService.getDeleteImageFunction(workspaceId)} restoreFile={fileService.getRestoreImageFunction(workspaceId)} value={pageDescription} - setShouldShowAlert={setShowAlert} cancelUploadImage={fileService.cancelUpload} ref={editorRef} debouncedUpdatesEnabled={false} @@ -308,7 +308,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { onActionCompleteHandler={actionCompleteAlert} customClassName="tracking-tight self-center h-full w-full right-[0.675rem]" onChange={(_description_json: any, description_html: string) => { - setIsSubmitting?.("submitting"); + setIsSubmitting("submitting"); setShowAlert(true); onChange(description_html); handleSubmit(updatePage)(); From e819179dc85e9cc5aeac293582bd7125357e52a0 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Mon, 18 Mar 2024 11:48:28 +0530 Subject: [PATCH 031/179] refactor: rerenderOnPropsChange prop removed --- packages/editor/document-editor/src/ui/index.tsx | 10 +--------- .../editor/document-editor/src/ui/readonly/index.tsx | 6 ------ 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index 83a5cef4d7d..11b25618255 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -16,10 +16,6 @@ interface IDocumentEditor { // document info documentDetails: DocumentDetails; value: string; - rerenderOnPropsChange?: { - id: string; - description_html: string; - }; // file operations uploadFile: UploadImage; @@ -40,13 +36,11 @@ interface IDocumentEditor { updatePageTitle: (title: string) => void; debouncedUpdatesEnabled?: boolean; isSubmitting: "submitting" | "submitted" | "saved"; - + tabIndex?: number; // embed configuration duplicationConfig?: IDuplicationConfig; pageLockConfig?: IPageLockConfig; pageArchiveConfig?: IPageArchiveConfig; - - tabIndex?: number; } interface DocumentEditorProps extends IDocumentEditor { forwardedRef?: React.Ref; @@ -76,7 +70,6 @@ const DocumentEditor = ({ updatePageTitle, cancelUploadImage, onActionCompleteHandler, - rerenderOnPropsChange, tabIndex, }: IDocumentEditor) => { const { markings, updateMarkings } = useEditorMarkings(); @@ -105,7 +98,6 @@ const DocumentEditor = ({ uploadFile, deleteFile, cancelUploadImage, - rerenderOnPropsChange, forwardedRef, extensions: DocumentEditorExtensions(uploadFile, setHideDragHandleFunction), }); diff --git a/packages/editor/document-editor/src/ui/readonly/index.tsx b/packages/editor/document-editor/src/ui/readonly/index.tsx index a93df3cbce1..b783743298e 100644 --- a/packages/editor/document-editor/src/ui/readonly/index.tsx +++ b/packages/editor/document-editor/src/ui/readonly/index.tsx @@ -12,10 +12,6 @@ import { IssueWidgetPlaceholder } from "../extensions/widgets/issue-embed-widget interface IDocumentReadOnlyEditor { value: string; - rerenderOnPropsChange?: { - id: string; - description_html: string; - }; noBorder: boolean; borderOnFocus: boolean; customClassName: string; @@ -50,7 +46,6 @@ const DocumentReadOnlyEditor = ({ pageDuplicationConfig, pageLockConfig, pageArchiveConfig, - rerenderOnPropsChange, onActionCompleteHandler, tabIndex, }: DocumentReadOnlyEditorProps) => { @@ -61,7 +56,6 @@ const DocumentReadOnlyEditor = ({ const editor = useReadOnlyEditor({ value, forwardedRef, - rerenderOnPropsChange, extensions: [IssueWidgetPlaceholder()], }); From 8f828742829084d85606b84da8dc5032c1519961 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Mon, 18 Mar 2024 13:33:49 +0530 Subject: [PATCH 032/179] chore: type inference magic in ref to expose an api for controlling editor menu items from outside --- .../src/hooks/use-editor.tsx | 48 ++-- .../editor/core-document-editor/src/index.ts | 1 + .../src/types/editor-ref-api.ts | 9 + .../src/ui/menus/menu-items/index.tsx | 233 ++++++++++-------- packages/editor/document-editor/src/index.ts | 2 + .../projects/[projectId]/pages/[pageId].tsx | 4 +- 6 files changed, 184 insertions(+), 113 deletions(-) create mode 100644 packages/editor/core-document-editor/src/types/editor-ref-api.ts diff --git a/packages/editor/core-document-editor/src/hooks/use-editor.tsx b/packages/editor/core-document-editor/src/hooks/use-editor.tsx index 97eed5fe0b9..ecb845f5a29 100644 --- a/packages/editor/core-document-editor/src/hooks/use-editor.tsx +++ b/packages/editor/core-document-editor/src/hooks/use-editor.tsx @@ -10,6 +10,8 @@ import { RestoreImage } from "src/types/restore-image"; import { UploadImage } from "src/types/upload-image"; import { Selection } from "@tiptap/pm/state"; import { insertContentAtSavedSelection } from "src/helpers/insert-content-at-cursor-position"; +import { EditorMenuItemNames, getEditorMenuItems } from "src/ui/menus/menu-items"; +import { EditorRefApi } from "src/types/editor-ref-api"; interface CustomEditorProps { uploadFile: UploadImage; @@ -26,7 +28,7 @@ interface CustomEditorProps { onChange?: (json: any, html: string) => void; extensions?: any; editorProps?: EditorProps; - forwardedRef?: any; + forwardedRef?: MutableRefObject; mentionHighlights?: string[]; mentionSuggestions?: IMentionSuggestion[]; } @@ -85,19 +87,37 @@ export const useEditor = ({ const [savedSelection, setSavedSelection] = useState(null); - useImperativeHandle(forwardedRef, () => ({ - clearEditor: () => { - editorRef.current?.commands.clearContent(); - }, - setEditorValue: (content: string) => { - editorRef.current?.commands.setContent(content); - }, - setEditorValueAtCursorPosition: (content: string) => { - if (savedSelection) { - insertContentAtSavedSelection(editorRef, content, savedSelection); - } - }, - })); + useImperativeHandle(forwardedRef, () => { + const editorItems = getEditorMenuItems(editorRef.current!, uploadFile); + + const getEditorItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName); + + return { + clearEditor: () => { + editorRef.current?.commands.clearContent(); + }, + setEditorValue: (content: string) => { + editorRef.current?.commands.setContent(content); + }, + setEditorValueAtCursorPosition: (content: string) => { + if (savedSelection) { + insertContentAtSavedSelection(editorRef, content, savedSelection); + } + }, + executeCommand: (itemName: EditorMenuItemNames) => { + const item = getEditorItem(itemName); + if (item) { + item.command(); + } else { + console.warn(`No command found for item: ${itemName}`); + } + }, + isItemActive: (itemName: EditorMenuItemNames): boolean => { + const item = getEditorItem(itemName); + return item ? item.isActive() : false; + }, + }; + }); if (!editor) { return null; diff --git a/packages/editor/core-document-editor/src/index.ts b/packages/editor/core-document-editor/src/index.ts index c7e39d240a5..9a3b3d66647 100644 --- a/packages/editor/core-document-editor/src/index.ts +++ b/packages/editor/core-document-editor/src/index.ts @@ -26,6 +26,7 @@ export * from "src/lib/editor-commands"; // types export type { DeleteImage } from "src/types/delete-image"; export type { UploadImage } from "src/types/upload-image"; +export type { EditorRefApi } from "src/types/editor-ref-api"; export type { RestoreImage } from "src/types/restore-image"; export type { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion"; export type { ISlashCommandItem, CommandProps } from "src/types/slash-commands-suggestion"; diff --git a/packages/editor/core-document-editor/src/types/editor-ref-api.ts b/packages/editor/core-document-editor/src/types/editor-ref-api.ts new file mode 100644 index 00000000000..61152ff59b5 --- /dev/null +++ b/packages/editor/core-document-editor/src/types/editor-ref-api.ts @@ -0,0 +1,9 @@ +import { EditorMenuItemNames } from "src/ui/menus/menu-items"; + +export interface EditorRefApi { + clearEditor: () => void; + setEditorValue: (content: string) => void; + setEditorValueAtCursorPosition: (content: string) => void; + executeCommand: (itemName: EditorMenuItemNames) => void; + isItemActive: (itemName: EditorMenuItemNames) => boolean; +} diff --git a/packages/editor/core-document-editor/src/ui/menus/menu-items/index.tsx b/packages/editor/core-document-editor/src/ui/menus/menu-items/index.tsx index 2b5e724d5fc..5be6be07953 100644 --- a/packages/editor/core-document-editor/src/ui/menus/menu-items/index.tsx +++ b/packages/editor/core-document-editor/src/ui/menus/menu-items/index.tsx @@ -41,100 +41,139 @@ export interface EditorMenuItem { icon: LucideIconType; } -export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({ - name: "H1", - isActive: () => editor.isActive("heading", { level: 1 }), - command: () => toggleHeadingOne(editor), - icon: Heading1, -}); - -export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({ - name: "H2", - isActive: () => editor.isActive("heading", { level: 2 }), - command: () => toggleHeadingTwo(editor), - icon: Heading2, -}); - -export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({ - name: "H3", - isActive: () => editor.isActive("heading", { level: 3 }), - command: () => toggleHeadingThree(editor), - icon: Heading3, -}); - -export const BoldItem = (editor: Editor): EditorMenuItem => ({ - name: "bold", - isActive: () => editor?.isActive("bold"), - command: () => toggleBold(editor), - icon: BoldIcon, -}); - -export const ItalicItem = (editor: Editor): EditorMenuItem => ({ - name: "italic", - isActive: () => editor?.isActive("italic"), - command: () => toggleItalic(editor), - icon: ItalicIcon, -}); - -export const UnderLineItem = (editor: Editor): EditorMenuItem => ({ - name: "underline", - isActive: () => editor?.isActive("underline"), - command: () => toggleUnderline(editor), - icon: UnderlineIcon, -}); - -export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({ - name: "strike", - isActive: () => editor?.isActive("strike"), - command: () => toggleStrike(editor), - icon: StrikethroughIcon, -}); - -export const BulletListItem = (editor: Editor): EditorMenuItem => ({ - name: "bullet-list", - isActive: () => editor?.isActive("bulletList"), - command: () => toggleBulletList(editor), - icon: ListIcon, -}); - -export const TodoListItem = (editor: Editor): EditorMenuItem => ({ - name: "To-do List", - isActive: () => editor.isActive("taskItem"), - command: () => toggleTaskList(editor), - icon: CheckSquare, -}); - -export const CodeItem = (editor: Editor): EditorMenuItem => ({ - name: "code", - isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"), - command: () => toggleCodeBlock(editor), - icon: CodeIcon, -}); - -export const NumberedListItem = (editor: Editor): EditorMenuItem => ({ - name: "ordered-list", - isActive: () => editor?.isActive("orderedList"), - command: () => toggleOrderedList(editor), - icon: ListOrderedIcon, -}); - -export const QuoteItem = (editor: Editor): EditorMenuItem => ({ - name: "quote", - isActive: () => editor?.isActive("blockquote"), - command: () => toggleBlockquote(editor), - icon: QuoteIcon, -}); - -export const TableItem = (editor: Editor): EditorMenuItem => ({ - name: "table", - isActive: () => editor?.isActive("table"), - command: () => insertTableCommand(editor), - icon: TableIcon, -}); - -export const ImageItem = (editor: Editor, uploadFile: UploadImage): EditorMenuItem => ({ - name: "image", - isActive: () => editor?.isActive("image"), - command: () => insertImageCommand(editor, uploadFile), - icon: ImageIcon, -}); +export const HeadingOneItem = (editor: Editor) => + ({ + name: "H1", + isActive: () => editor.isActive("heading", { level: 1 }), + command: () => toggleHeadingOne(editor), + icon: Heading1, + }) as const satisfies EditorMenuItem; + +export const HeadingTwoItem = (editor: Editor) => + ({ + name: "H2", + isActive: () => editor.isActive("heading", { level: 2 }), + command: () => toggleHeadingTwo(editor), + icon: Heading2, + }) as const satisfies EditorMenuItem; + +export const HeadingThreeItem = (editor: Editor) => + ({ + name: "H3", + isActive: () => editor.isActive("heading", { level: 3 }), + command: () => toggleHeadingThree(editor), + icon: Heading3, + }) as const satisfies EditorMenuItem; + +export const BoldItem = (editor: Editor) => + ({ + name: "bold", + isActive: () => editor?.isActive("bold"), + command: () => toggleBold(editor), + icon: BoldIcon, + }) as const satisfies EditorMenuItem; + +export const ItalicItem = (editor: Editor) => + ({ + name: "italic", + isActive: () => editor?.isActive("italic"), + command: () => toggleItalic(editor), + icon: ItalicIcon, + }) as const satisfies EditorMenuItem; + +export const UnderLineItem = (editor: Editor) => + ({ + name: "underline", + isActive: () => editor?.isActive("underline"), + command: () => toggleUnderline(editor), + icon: UnderlineIcon, + }) as const satisfies EditorMenuItem; + +export const StrikeThroughItem = (editor: Editor) => + ({ + name: "strike", + isActive: () => editor?.isActive("strike"), + command: () => toggleStrike(editor), + icon: StrikethroughIcon, + }) as const satisfies EditorMenuItem; + +export const BulletListItem = (editor: Editor) => + ({ + name: "bullet-list", + isActive: () => editor?.isActive("bulletList"), + command: () => toggleBulletList(editor), + icon: ListIcon, + }) as const satisfies EditorMenuItem; + +export const TodoListItem = (editor: Editor) => + ({ + name: "To-do List", + isActive: () => editor.isActive("taskItem"), + command: () => toggleTaskList(editor), + icon: CheckSquare, + }) as const satisfies EditorMenuItem; + +export const CodeItem = (editor: Editor) => + ({ + name: "code", + isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"), + command: () => toggleCodeBlock(editor), + icon: CodeIcon, + }) as const satisfies EditorMenuItem; + +export const NumberedListItem = (editor: Editor) => + ({ + name: "ordered-list", + isActive: () => editor?.isActive("orderedList"), + command: () => toggleOrderedList(editor), + icon: ListOrderedIcon, + }) as const satisfies EditorMenuItem; + +export const QuoteItem = (editor: Editor) => + ({ + name: "quote", + isActive: () => editor?.isActive("blockquote"), + command: () => toggleBlockquote(editor), + icon: QuoteIcon, + }) as const satisfies EditorMenuItem; + +export const TableItem = (editor: Editor) => + ({ + name: "table", + isActive: () => editor?.isActive("table"), + command: () => insertTableCommand(editor), + icon: TableIcon, + }) as const satisfies EditorMenuItem; + +export const ImageItem = (editor: Editor, uploadFile: UploadImage) => + ({ + name: "image", + isActive: () => editor?.isActive("image"), + command: () => insertImageCommand(editor, uploadFile), + icon: ImageIcon, + }) as const satisfies EditorMenuItem; + +export function getEditorMenuItems(editor: Editor, uploadFile: UploadImage) { + return [ + HeadingOneItem(editor), + HeadingTwoItem(editor), + HeadingThreeItem(editor), + BoldItem(editor), + ItalicItem(editor), + UnderLineItem(editor), + StrikeThroughItem(editor), + BulletListItem(editor), + TodoListItem(editor), + CodeItem(editor), + NumberedListItem(editor), + QuoteItem(editor), + TableItem(editor), + ImageItem(editor, uploadFile), + ]; +} + +export type EditorMenuItemNames = ReturnType extends (infer U)[] + ? U extends { name: infer N } + ? N + : never + : never; diff --git a/packages/editor/document-editor/src/index.ts b/packages/editor/document-editor/src/index.ts index c074009f49c..09ccf700079 100644 --- a/packages/editor/document-editor/src/index.ts +++ b/packages/editor/document-editor/src/index.ts @@ -1,3 +1,5 @@ export { DocumentEditor, DocumentEditorWithRef } from "src/ui"; export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "src/ui/readonly"; export { FixedMenu } from "src/ui/menu/fixed-menu"; + +export type { EditorRefApi } from "@plane/editor-document-core"; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index 1f4fb6b4cfb..9dd41db688d 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -1,5 +1,5 @@ import { ReactElement, useEffect, useRef, useState } from "react"; -import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor"; +import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef, EditorRefApi } from "@plane/document-editor"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; @@ -36,7 +36,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { // states const [gptModalOpen, setGptModal] = useState(false); // refs - const editorRef = useRef(null); + const editorRef = useRef(null); // router const router = useRouter(); From ecb9dbe10541ed7e0a3aec04b212aefe06d5da78 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Mon, 18 Mar 2024 13:37:34 +0530 Subject: [PATCH 033/179] fix: naming imports --- .../projects/[projectId]/pages/[pageId].tsx | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index 9dd41db688d..91f3a590636 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -1,33 +1,38 @@ import { ReactElement, useEffect, useRef, useState } from "react"; -import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef, EditorRefApi } from "@plane/document-editor"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; import useSWR from "swr"; + +// assets import { Sparkle } from "lucide-react"; -// hooks +// ui import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; -import { GptAssistantPopover, PageHead } from "components/core"; -import { PageDetailsHeader } from "components/headers/page-details"; -import { IssuePeekOverview } from "components/issues"; -import { EUserProjectRoles } from "constants/project"; + +// hooks import { useApplication, usePage, useUser, useWorkspace } from "hooks/store"; import { useProjectPages } from "hooks/store/use-project-specific-pages"; import useReloadConfirmations from "hooks/use-reload-confirmation"; -// services + +// layouts import { AppLayout } from "layouts/app-layout"; import { NextPageWithLayout } from "lib/types"; + +// services import { FileService } from "services/file.service"; -// layouts + // components -// ui -// assets -// helpers +import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef, EditorRefApi } from "@plane/document-editor"; +import { PageDetailsHeader } from "components/headers/page-details"; +import { IssuePeekOverview } from "components/issues"; +import { GptAssistantPopover, PageHead } from "components/core"; + +// constants +import { EUserProjectRoles } from "constants/project"; + // types import { IPage } from "@plane/types"; -// fetch-keys -// constants // services const fileService = new FileService(); From c8291a76d718f3a335596da8e4fed39cc5cd1d7c Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Mon, 18 Mar 2024 13:40:07 +0530 Subject: [PATCH 034/179] chore: change names of the exposed functions and removing old types --- .../src/hooks/use-editor.tsx | 10 +++++----- .../src/types/editor-ref-api.ts | 4 ++-- packages/editor/document-editor/package.json | 1 - .../editor/document-editor/src/ui/index.tsx | 20 +++++++++---------- 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/packages/editor/core-document-editor/src/hooks/use-editor.tsx b/packages/editor/core-document-editor/src/hooks/use-editor.tsx index ecb845f5a29..2439a583237 100644 --- a/packages/editor/core-document-editor/src/hooks/use-editor.tsx +++ b/packages/editor/core-document-editor/src/hooks/use-editor.tsx @@ -90,7 +90,7 @@ export const useEditor = ({ useImperativeHandle(forwardedRef, () => { const editorItems = getEditorMenuItems(editorRef.current!, uploadFile); - const getEditorItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName); + const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName); return { clearEditor: () => { @@ -104,16 +104,16 @@ export const useEditor = ({ insertContentAtSavedSelection(editorRef, content, savedSelection); } }, - executeCommand: (itemName: EditorMenuItemNames) => { - const item = getEditorItem(itemName); + executeMenuItemCommand: (itemName: EditorMenuItemNames) => { + const item = getEditorMenuItem(itemName); if (item) { item.command(); } else { console.warn(`No command found for item: ${itemName}`); } }, - isItemActive: (itemName: EditorMenuItemNames): boolean => { - const item = getEditorItem(itemName); + isMenuItemActive: (itemName: EditorMenuItemNames): boolean => { + const item = getEditorMenuItem(itemName); return item ? item.isActive() : false; }, }; diff --git a/packages/editor/core-document-editor/src/types/editor-ref-api.ts b/packages/editor/core-document-editor/src/types/editor-ref-api.ts index 61152ff59b5..02b94f4f62d 100644 --- a/packages/editor/core-document-editor/src/types/editor-ref-api.ts +++ b/packages/editor/core-document-editor/src/types/editor-ref-api.ts @@ -4,6 +4,6 @@ export interface EditorRefApi { clearEditor: () => void; setEditorValue: (content: string) => void; setEditorValueAtCursorPosition: (content: string) => void; - executeCommand: (itemName: EditorMenuItemNames) => void; - isItemActive: (itemName: EditorMenuItemNames) => boolean; + executeMenuItemCommand: (itemName: EditorMenuItemNames) => void; + isMenuItemActive: (itemName: EditorMenuItemNames) => boolean; } diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json index e4d0ff73f4a..8205b563760 100644 --- a/packages/editor/document-editor/package.json +++ b/packages/editor/document-editor/package.json @@ -29,7 +29,6 @@ }, "dependencies": { "@floating-ui/react": "^0.26.4", - "@plane/editor-core": "*", "@plane/editor-document-core": "*", "@plane/editor-extensions": "*", "@plane/ui": "*", diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index 11b25618255..aafc0c15e34 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -1,6 +1,13 @@ "use client"; import React, { useState } from "react"; -import { UploadImage, DeleteImage, RestoreImage, getEditorClassNames, useEditor } from "@plane/editor-document-core"; +import { + UploadImage, + DeleteImage, + RestoreImage, + getEditorClassNames, + useEditor, + EditorRefApi, +} from "@plane/editor-document-core"; import { DocumentEditorExtensions } from "src/ui/extensions"; import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "src/types/menu-actions"; import { EditorHeader } from "src/ui/components/editor-header"; @@ -42,15 +49,6 @@ interface IDocumentEditor { pageLockConfig?: IPageLockConfig; pageArchiveConfig?: IPageArchiveConfig; } -interface DocumentEditorProps extends IDocumentEditor { - forwardedRef?: React.Ref; -} - -interface EditorHandle { - clearEditor: () => void; - setEditorValue: (content: string) => void; - setEditorValueAtCursorPosition: (content: string) => void; -} const DocumentEditor = ({ documentDetails, @@ -165,7 +163,7 @@ const DocumentEditor = ({ ); }; -const DocumentEditorWithRef = React.forwardRef((props, ref) => ( +const DocumentEditorWithRef = React.forwardRef((props, ref) => ( )); From a057fb539994f992b4d2371e3e46775141b90a08 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Mon, 18 Mar 2024 13:43:18 +0530 Subject: [PATCH 035/179] refactor: remove debouncedUpdatesEnabled prop; --- .../core-document-editor/src/hooks/use-editor.tsx | 5 ++--- packages/editor/document-editor/src/ui/index.tsx | 10 ++++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/editor/core-document-editor/src/hooks/use-editor.tsx b/packages/editor/core-document-editor/src/hooks/use-editor.tsx index 2439a583237..599f2600c7e 100644 --- a/packages/editor/core-document-editor/src/hooks/use-editor.tsx +++ b/packages/editor/core-document-editor/src/hooks/use-editor.tsx @@ -23,9 +23,8 @@ interface CustomEditorProps { deleteFile: DeleteImage; cancelUploadImage?: () => any; value: string; - debouncedUpdatesEnabled?: boolean; - onStart?: (json: any, html: string) => void; - onChange?: (json: any, html: string) => void; + onStart?: (json: object, html: string) => void; + onChange?: (json: object, html: string) => void; extensions?: any; editorProps?: EditorProps; forwardedRef?: MutableRefObject; diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index aafc0c15e34..d3a95606664 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -38,12 +38,12 @@ interface IDocumentEditor { }) => void; customClassName?: string; editorContentCustomClassNames?: string; - onChange: (json: any, html: string) => void; - forwardedRef?: any; + onChange: (json: object, html: string) => void; + forwardedRef?: React.MutableRefObject; updatePageTitle: (title: string) => void; - debouncedUpdatesEnabled?: boolean; isSubmitting: "submitting" | "submitted" | "saved"; tabIndex?: number; + // embed configuration duplicationConfig?: IDuplicationConfig; pageLockConfig?: IPageLockConfig; @@ -53,7 +53,6 @@ interface IDocumentEditor { const DocumentEditor = ({ documentDetails, onChange, - debouncedUpdatesEnabled, editorContentCustomClassNames, value, uploadFile, @@ -90,7 +89,6 @@ const DocumentEditor = ({ onStart(json) { updateMarkings(json); }, - debouncedUpdatesEnabled, restoreFile, value, uploadFile, @@ -164,7 +162,7 @@ const DocumentEditor = ({ }; const DocumentEditorWithRef = React.forwardRef((props, ref) => ( - + } /> )); DocumentEditorWithRef.displayName = "DocumentEditorWithRef"; From bdbec34ae470f73f445ebc699c3dbdbf41dfc061 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Mon, 18 Mar 2024 15:42:57 +0530 Subject: [PATCH 036/179] refactor: editor heading markings now parsed using html --- .../src/hooks/use-editor-markings.tsx | 31 +++++++++---------- packages/editor/document-editor/src/index.ts | 3 ++ .../editor/document-editor/src/ui/index.tsx | 6 ++-- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/editor/document-editor/src/hooks/use-editor-markings.tsx b/packages/editor/document-editor/src/hooks/use-editor-markings.tsx index 1eb72eaab56..90e02737070 100644 --- a/packages/editor/document-editor/src/hooks/use-editor-markings.tsx +++ b/packages/editor/document-editor/src/hooks/use-editor-markings.tsx @@ -4,28 +4,25 @@ import { IMarking } from "src/types/editor-types"; export const useEditorMarkings = () => { const [markings, setMarkings] = useState([]); - const updateMarkings = (json: any) => { - const nodes = json.content as any[]; + const updateMarkings = (html: string) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + const headings = doc.querySelectorAll("h1, h2, h3"); 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 === 3) && - node.content - ) { - tempMarkings.push({ - type: "heading", - level: node.attrs.level, - text: node.content[0].text, - sequence: node.attrs.level === 1 ? ++h1Sequence : node.attrs.level === 2 ? ++h2Sequence : ++h3Sequence, - }); - } + + headings.forEach((heading) => { + const level = parseInt(heading.tagName[1]); // Extract the number from h1, h2, h3 + tempMarkings.push({ + type: "heading", + level: level, + text: heading.textContent || "", + sequence: level === 1 ? ++h1Sequence : level === 2 ? ++h2Sequence : ++h3Sequence, }); - } + }); + setMarkings(tempMarkings); }; diff --git a/packages/editor/document-editor/src/index.ts b/packages/editor/document-editor/src/index.ts index 09ccf700079..d1592484d6d 100644 --- a/packages/editor/document-editor/src/index.ts +++ b/packages/editor/document-editor/src/index.ts @@ -2,4 +2,7 @@ export { DocumentEditor, DocumentEditorWithRef } from "src/ui"; export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "src/ui/readonly"; export { FixedMenu } from "src/ui/menu/fixed-menu"; +// hooks +export { useEditorMarkings } from "src/hooks/use-editor-markings"; + export type { EditorRefApi } from "@plane/editor-document-core"; diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index d3a95606664..bee60afe5cb 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -83,11 +83,11 @@ const DocumentEditor = ({ const editor = useEditor({ onChange(json, html) { - updateMarkings(json); + updateMarkings(html); onChange(json, html); }, - onStart(json) { - updateMarkings(json); + onStart(_json, html) { + updateMarkings(html); }, restoreFile, value, From 53f3dccf28efdecda5d905fd7df5168bf2303880 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Tue, 19 Mar 2024 13:28:21 +0530 Subject: [PATCH 037/179] chore: removed unrelated components from the document editor --- packages/editor/document-editor/src/index.ts | 2 + .../src/ui/components/alert-label.tsx | 20 -- .../src/ui/components/content-browser.tsx | 40 --- .../src/ui/components/editor-header.tsx | 94 ----- .../src/ui/components/index.ts | 8 - .../src/ui/components/page-renderer.tsx | 28 +- .../src/ui/components/summary-side-bar.tsx | 19 - .../ui/components/vertical-dropdown-menu.tsx | 46 --- .../editor/document-editor/src/ui/index.tsx | 71 +--- .../document-editor/src/ui/readonly/index.tsx | 90 ++--- packages/ui/src/form-fields/textarea.tsx | 20 +- web/components/pages/header/index.ts | 4 + .../components/pages/header}/info-popover.tsx | 30 +- .../pages/header/options-dropdown.tsx | 188 ++++++++++ web/components/pages/header/root.tsx | 86 +++++ web/components/pages/header/toolbar.tsx | 179 ++++++++++ web/components/pages/index.ts | 2 + .../pages/summary/content-browser.tsx | 51 +++ .../pages/summary/heading-components.tsx | 0 web/components/pages/summary/index.ts | 2 + .../components/pages/summary/popover.tsx | 15 +- .../projects/[projectId]/pages/[pageId].tsx | 337 +++++------------- 22 files changed, 682 insertions(+), 650 deletions(-) delete mode 100644 packages/editor/document-editor/src/ui/components/alert-label.tsx delete mode 100644 packages/editor/document-editor/src/ui/components/content-browser.tsx delete mode 100644 packages/editor/document-editor/src/ui/components/editor-header.tsx delete mode 100644 packages/editor/document-editor/src/ui/components/summary-side-bar.tsx delete mode 100644 packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx create mode 100644 web/components/pages/header/index.ts rename {packages/editor/document-editor/src/ui/components => web/components/pages/header}/info-popover.tsx (69%) create mode 100644 web/components/pages/header/options-dropdown.tsx create mode 100644 web/components/pages/header/root.tsx create mode 100644 web/components/pages/header/toolbar.tsx create mode 100644 web/components/pages/summary/content-browser.tsx rename packages/editor/document-editor/src/ui/components/heading-component.tsx => web/components/pages/summary/heading-components.tsx (100%) create mode 100644 web/components/pages/summary/index.ts rename packages/editor/document-editor/src/ui/components/summary-popover.tsx => web/components/pages/summary/popover.tsx (80%) diff --git a/packages/editor/document-editor/src/index.ts b/packages/editor/document-editor/src/index.ts index d1592484d6d..9722be44141 100644 --- a/packages/editor/document-editor/src/index.ts +++ b/packages/editor/document-editor/src/index.ts @@ -6,3 +6,5 @@ export { FixedMenu } from "src/ui/menu/fixed-menu"; export { useEditorMarkings } from "src/hooks/use-editor-markings"; export type { EditorRefApi } from "@plane/editor-document-core"; + +export type { IMarking } from "src/types/editor-types"; diff --git a/packages/editor/document-editor/src/ui/components/alert-label.tsx b/packages/editor/document-editor/src/ui/components/alert-label.tsx deleted file mode 100644 index be4c0a2a36f..00000000000 --- a/packages/editor/document-editor/src/ui/components/alert-label.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { LucideIconType } from "@plane/editor-document-core"; - -interface IAlertLabelProps { - Icon?: LucideIconType; - backgroundColor: string; - textColor?: string; - label: string; -} -export const AlertLabel = (props: IAlertLabelProps) => { - const { Icon, backgroundColor, textColor, label } = props; - - return ( -
    - {Icon && } - {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 deleted file mode 100644 index 926d9a53deb..00000000000 --- a/packages/editor/document-editor/src/ui/components/content-browser.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { HeadingComp, HeadingThreeComp, SubheadingComp } from "src/ui/components/heading-component"; -import { IMarking } from "src/types/editor-types"; -import { Editor } from "@tiptap/react"; -import { scrollSummary } from "src/utils/editor-summary-utils"; - -interface ContentBrowserProps { - editor: Editor; - markings: IMarking[]; - setSidePeekVisible?: (sidePeekState: boolean) => void; -} - -export const ContentBrowser = (props: ContentBrowserProps) => { - const { editor, markings, setSidePeekVisible } = props; - - const handleOnClick = (marking: IMarking) => { - scrollSummary(editor, marking); - if (setSidePeekVisible) setSidePeekVisible(false); - }; - - return ( -
    -

    Outline

    -
    - {markings.length !== 0 ? ( - markings.map((marking) => - marking.level === 1 ? ( - handleOnClick(marking)} heading={marking.text} /> - ) : marking.level === 2 ? ( - handleOnClick(marking)} subHeading={marking.text} /> - ) : ( - handleOnClick(marking)} /> - ) - ) - ) : ( -

    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 deleted file mode 100644 index fbdd7c453c7..00000000000 --- a/packages/editor/document-editor/src/ui/components/editor-header.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { Editor } from "@tiptap/react"; -import { Archive, RefreshCw, Lock } from "lucide-react"; -import { IMarking, DocumentDetails } from "src/types/editor-types"; -import { FixedMenu } from "src/ui/menu"; -import { UploadImage } from "@plane/editor-document-core"; -import { AlertLabel } from "src/ui/components/alert-label"; -import { IVerticalDropdownItemProps, VerticalDropdownMenu } from "src/ui/components/vertical-dropdown-menu"; -import { SummaryPopover } from "src/ui/components/summary-popover"; -import { InfoPopover } from "src/ui/components/info-popover"; - -interface IEditorHeader { - editor: Editor; - KanbanMenuOptions: IVerticalDropdownItemProps[]; - sidePeekVisible: boolean; - setSidePeekVisible: (sidePeekState: boolean) => void; - markings: IMarking[]; - isLocked: boolean; - isArchived: boolean; - archivedAt?: Date; - readonly: boolean; - uploadFile?: UploadImage; - documentDetails: DocumentDetails; - isSubmitting?: "submitting" | "submitted" | "saved"; -} - -export const EditorHeader = (props: IEditorHeader) => { - const { - documentDetails, - archivedAt, - editor, - sidePeekVisible, - readonly, - setSidePeekVisible, - markings, - uploadFile, - KanbanMenuOptions, - isArchived, - isLocked, - isSubmitting, - } = props; - - return ( -
    -
    - -
    - -
    - {!readonly && uploadFile && } -
    - -
    - {isLocked && ( - - )} - {isArchived && archivedAt && ( - - )} - - {!isLocked && !isArchived ? ( -
    - {isSubmitting !== "submitted" && isSubmitting !== "saved" && ( - - )} - - {isSubmitting === "submitting" ? "Saving..." : "Saved"} - -
    - ) : null} - {!isArchived && } - -
    -
    - ); -}; diff --git a/packages/editor/document-editor/src/ui/components/index.ts b/packages/editor/document-editor/src/ui/components/index.ts index 1496a3cf4f7..4d2d76baac4 100644 --- a/packages/editor/document-editor/src/ui/components/index.ts +++ b/packages/editor/document-editor/src/ui/components/index.ts @@ -1,9 +1 @@ -export * from "./alert-label"; -export * from "./content-browser"; -export * from "./editor-header"; -export * from "./heading-component"; -export * from "./info-popover"; export * from "./page-renderer"; -export * from "./summary-popover"; -export * from "./summary-side-bar"; -export * from "./vertical-dropdown-menu"; 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 22e5fd058eb..3ce32e1968a 100644 --- a/packages/editor/document-editor/src/ui/components/page-renderer.tsx +++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx @@ -1,9 +1,8 @@ +import { useCallback, useRef, useState } from "react"; import { EditorContainer, EditorContentWrapper } from "@plane/editor-document-core"; import { Node } from "@tiptap/pm/model"; import { EditorView } from "@tiptap/pm/view"; import { Editor, ReactRenderer } from "@tiptap/react"; -import { useCallback, useRef, useState } from "react"; -import { DocumentDetails } from "src/types/editor-types"; import { LinkView, LinkViewProps } from "./links/link-view"; import { autoUpdate, @@ -15,6 +14,10 @@ import { useFloating, useInteractions, } from "@floating-ui/react"; +// ui +import { TextArea } from "@plane/ui"; +// types +import { DocumentDetails } from "src/types/editor-types"; type IPageRenderer = { documentDetails: DocumentDetails; @@ -44,7 +47,7 @@ export const PageRenderer = (props: IPageRenderer) => { hideDragHandle, } = props; - const [pageTitle, setPagetitle] = useState(documentDetails.title); + const [pageTitle, setPageTitle] = useState(documentDetails.title); const [linkViewProps, setLinkViewProps] = useState(); const [isOpen, setIsOpen] = useState(false); @@ -64,11 +67,11 @@ export const PageRenderer = (props: IPageRenderer) => { const { getFloatingProps } = useInteractions([dismiss]); const handlePageTitleChange = (title: string) => { - setPagetitle(title); + setPageTitle(title); updatePageTitle(title); }; - const [cleanup, setcleanup] = useState(() => () => {}); + const [cleanup, setCleanup] = useState(() => () => {}); const floatingElementRef = useRef(null); @@ -148,25 +151,20 @@ export const PageRenderer = (props: IPageRenderer) => { }); }); - setcleanup(cleanupFunc); + setCleanup(cleanupFunc); }, [editor, cleanup] ); return (
    - {!readonly ? ( - handlePageTitleChange(e.target.value)} - className="-mt-2 w-full break-words border-none bg-custom-background pr-5 text-4xl font-bold outline-none" - value={pageTitle} - /> + {readonly ? ( +
    {pageTitle}
    ) : ( - handlePageTitleChange(e.target.value)} - className="-mt-2 w-full overflow-x-clip break-words border-none bg-custom-background pr-5 text-4xl font-bold outline-none" + className="-mt-2 w-full bg-custom-background text-4xl font-bold outline-none p-0 border-none resize-none" value={pageTitle} - disabled /> )}
    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 deleted file mode 100644 index 44ede3e8d56..00000000000 --- a/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Editor } from "@tiptap/react"; -import { IMarking } from "src/types/editor-types"; -import { ContentBrowser } from "src/ui/components/content-browser"; - -interface ISummarySideBarProps { - editor: Editor; - markings: IMarking[]; - sidePeekVisible: boolean; -} - -export const SummarySideBar = ({ editor, markings, sidePeekVisible }: ISummarySideBarProps) => ( -
    - -
    -); 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 deleted file mode 100644 index ce0b1abef6e..00000000000 --- a/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { LucideIconType } from "@plane/editor-document-core"; -import { CustomMenu } from "@plane/ui"; -import { 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: LucideIconType; - label: string; - action: () => Promise | void; -} - -export interface IVerticalDropdownMenuProps { - items: IVerticalDropdownItemProps[]; -} - -const VerticalDropdownItem = ({ Icon, label, action }: IVerticalDropdownItemProps) => ( - - -
    {label}
    -
    -); - -export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => ( - } - > - {items.map((item) => ( - - ))} - -); diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index bee60afe5cb..76b31f2fc23 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useState } from "react"; +import React from "react"; import { UploadImage, DeleteImage, @@ -9,15 +9,9 @@ import { EditorRefApi, } from "@plane/editor-document-core"; import { DocumentEditorExtensions } from "src/ui/extensions"; -import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "src/types/menu-actions"; -import { EditorHeader } from "src/ui/components/editor-header"; import { useEditorMarkings } from "src/hooks/use-editor-markings"; -import { SummarySideBar } from "src/ui/components/summary-side-bar"; import { DocumentDetails } from "src/types/editor-types"; import { PageRenderer } from "src/ui/components/page-renderer"; -import { getMenuOptions } from "src/utils/menu-options"; -import { useRouter } from "next/router"; -import { FixedMenu } from "src"; interface IDocumentEditor { // document info @@ -43,11 +37,6 @@ interface IDocumentEditor { updatePageTitle: (title: string) => void; isSubmitting: "submitting" | "submitted" | "saved"; tabIndex?: number; - - // embed configuration - duplicationConfig?: IDuplicationConfig; - pageLockConfig?: IPageLockConfig; - pageArchiveConfig?: IPageArchiveConfig; } const DocumentEditor = ({ @@ -58,20 +47,14 @@ const DocumentEditor = ({ uploadFile, deleteFile, restoreFile, - isSubmitting, customClassName, forwardedRef, - duplicationConfig, - pageLockConfig, - pageArchiveConfig, updatePageTitle, cancelUploadImage, onActionCompleteHandler, tabIndex, }: IDocumentEditor) => { - const { markings, updateMarkings } = useEditorMarkings(); - const [sidePeekVisible, setSidePeekVisible] = useState(true); - const router = useRouter(); + const { updateMarkings } = useEditorMarkings(); const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {}); @@ -102,15 +85,6 @@ const DocumentEditor = ({ return null; } - const KanbanMenuOptions = getMenuOptions({ - editor: editor, - router: router, - duplicationConfig: duplicationConfig, - pageLockConfig: pageLockConfig, - pageArchiveConfig: pageArchiveConfig, - onActionCompleteHandler, - }); - const editorClassNames = getEditorClassNames({ noBorder: true, borderOnFocus: false, @@ -120,43 +94,18 @@ const DocumentEditor = ({ if (!editor) return null; return ( -
    - + setSidePeekVisible(val)} - markings={markings} - uploadFile={uploadFile} - isLocked={!pageLockConfig ? false : pageLockConfig.is_locked} - isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived} - archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at} + editorContentCustomClassNames={editorContentCustomClassNames} + editorClassNames={editorClassNames} documentDetails={documentDetails} - isSubmitting={isSubmitting} + updatePageTitle={updatePageTitle} /> -
    - {uploadFile && } -
    -
    -
    - -
    -
    - -
    -
    -
    ); }; diff --git a/packages/editor/document-editor/src/ui/readonly/index.tsx b/packages/editor/document-editor/src/ui/readonly/index.tsx index b783743298e..5c557e4f202 100644 --- a/packages/editor/document-editor/src/ui/readonly/index.tsx +++ b/packages/editor/document-editor/src/ui/readonly/index.tsx @@ -1,30 +1,25 @@ +import { forwardRef, useEffect } from "react"; +// hooks +import { useEditorMarkings } from "src/hooks/use-editor-markings"; import { getEditorClassNames, useReadOnlyEditor } from "@plane/editor-document-core"; -import { useRouter } from "next/router"; -import { useState, forwardRef, useEffect } from "react"; -import { EditorHeader } from "src/ui/components/editor-header"; +// components import { PageRenderer } from "src/ui/components/page-renderer"; -import { SummarySideBar } from "src/ui/components/summary-side-bar"; -import { useEditorMarkings } from "src/hooks/use-editor-markings"; -import { DocumentDetails } from "src/types/editor-types"; -import { IPageArchiveConfig, IPageLockConfig, IDuplicationConfig } from "src/types/menu-actions"; -import { getMenuOptions } from "src/utils/menu-options"; import { IssueWidgetPlaceholder } from "../extensions/widgets/issue-embed-widget"; +// types +import { DocumentDetails } from "src/types/editor-types"; interface IDocumentReadOnlyEditor { value: string; noBorder: boolean; borderOnFocus: boolean; customClassName: string; - documentDetails: DocumentDetails; - pageLockConfig?: IPageLockConfig; - pageArchiveConfig?: IPageArchiveConfig; - pageDuplicationConfig?: IDuplicationConfig; onActionCompleteHandler: (action: { title: string; message: string; type: "success" | "error" | "warning" | "info"; }) => void; tabIndex?: number; + documentDetails: DocumentDetails; } interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor { @@ -36,22 +31,18 @@ interface EditorHandle { setEditorValue: (content: string) => void; } -const DocumentReadOnlyEditor = ({ - noBorder, - borderOnFocus, - customClassName, - value, - documentDetails, - forwardedRef, - pageDuplicationConfig, - pageLockConfig, - pageArchiveConfig, - onActionCompleteHandler, - tabIndex, -}: DocumentReadOnlyEditorProps) => { - const router = useRouter(); - const [sidePeekVisible, setSidePeekVisible] = useState(true); - const { markings, updateMarkings } = useEditorMarkings(); +const DocumentReadOnlyEditor = (props: DocumentReadOnlyEditorProps) => { + const { + noBorder, + borderOnFocus, + customClassName, + value, + documentDetails, + forwardedRef, + onActionCompleteHandler, + tabIndex, + } = props; + const { updateMarkings } = useEditorMarkings(); const editor = useReadOnlyEditor({ value, @@ -61,7 +52,7 @@ const DocumentReadOnlyEditor = ({ useEffect(() => { if (editor) { - updateMarkings(editor.getJSON()); + updateMarkings(editor.getHTML()); } }, [editor]); @@ -75,46 +66,17 @@ const DocumentReadOnlyEditor = ({ customClassName, }); - const KanbanMenuOptions = getMenuOptions({ - editor: editor, - router: router, - pageArchiveConfig: pageArchiveConfig, - pageLockConfig: pageLockConfig, - duplicationConfig: pageDuplicationConfig, - onActionCompleteHandler, - }); - return ( -
    - + Promise.resolve()} readonly editor={editor} - sidePeekVisible={sidePeekVisible} - setSidePeekVisible={setSidePeekVisible} - KanbanMenuOptions={KanbanMenuOptions} - markings={markings} + editorClassNames={editorClassNames} documentDetails={documentDetails} - archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at} /> -
    -
    - -
    -
    - Promise.resolve()} - readonly - editor={editor} - editorClassNames={editorClassNames} - documentDetails={documentDetails} - /> -
    -
    -
    ); }; diff --git a/packages/ui/src/form-fields/textarea.tsx b/packages/ui/src/form-fields/textarea.tsx index b93c1aba8c5..271b76d83ab 100644 --- a/packages/ui/src/form-fields/textarea.tsx +++ b/packages/ui/src/form-fields/textarea.tsx @@ -1,4 +1,6 @@ import * as React from "react"; +// helpers +import { cn } from "../../helpers"; export interface TextAreaProps extends React.TextareaHTMLAttributes { mode?: "primary" | "transparent"; @@ -46,13 +48,17 @@ const TextArea = React.forwardRef((props, re value={value} rows={rows} cols={cols} - className={`no-scrollbar w-full bg-transparent px-3 py-2 placeholder-custom-text-400 outline-none ${ - mode === "primary" - ? "rounded-md border-[0.5px] border-custom-border-200" - : mode === "transparent" - ? "focus:ring-theme rounded border-none bg-transparent ring-0 transition-all focus:ring-1" - : "" - } ${hasError ? "border-red-500" : ""} ${hasError && mode === "primary" ? "bg-red-100" : ""} ${className}`} + className={cn( + "no-scrollbar w-full bg-transparent px-3 py-2 placeholder-custom-text-400 outline-none", + { + "rounded-md border-[0.5px] border-custom-border-200": mode === "primary", + "focus:ring-theme rounded border-none bg-transparent ring-0 transition-all focus:ring-1": + mode === "transparent", + "border-red-500": hasError, + "bg-red-100": hasError && mode === "primary", + }, + className + )} {...rest} /> ); diff --git a/web/components/pages/header/index.ts b/web/components/pages/header/index.ts new file mode 100644 index 00000000000..7582d6736db --- /dev/null +++ b/web/components/pages/header/index.ts @@ -0,0 +1,4 @@ +export * from "./info-popover"; +export * from "./options-dropdown"; +export * from "./root"; +export * from "./toolbar"; diff --git a/packages/editor/document-editor/src/ui/components/info-popover.tsx b/web/components/pages/header/info-popover.tsx similarity index 69% rename from packages/editor/document-editor/src/ui/components/info-popover.tsx rename to web/components/pages/header/info-popover.tsx index f78dd347372..908b6c34daf 100644 --- a/packages/editor/document-editor/src/ui/components/info-popover.tsx +++ b/web/components/pages/header/info-popover.tsx @@ -1,31 +1,17 @@ import { useState } from "react"; import { usePopper } from "react-popper"; import { Calendar, History, Info } from "lucide-react"; +// helpers +import { renderFormattedDate } from "helpers/date-time.helper"; // types -import { DocumentDetails } from "src/types/editor-types"; +import { IPageStore } from "store/page.store"; type Props = { - documentDetails: DocumentDetails; + pageStore: IPageStore; }; -// function to render a Date in the format- 25 May 2023 at 2:53PM -const renderDate = (date: Date): string => { - const options: Intl.DateTimeFormatOptions = { - day: "numeric", - month: "long", - year: "numeric", - hour: "numeric", - minute: "numeric", - hour12: true, - }; - - const formattedDate: string = new Intl.DateTimeFormat("en-US", options).format(date); - - return formattedDate; -}; - -export const InfoPopover: React.FC = (props) => { - const { documentDetails } = props; +export const PageInfoPopover: React.FC = (props) => { + const { pageStore } = props; const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -52,14 +38,14 @@ export const InfoPopover: React.FC = (props) => {
    Last updated on
    - {renderDate(new Date(documentDetails.last_updated_at))} + {renderFormattedDate(new Date(pageStore.updated_at))}
    Created on
    - {renderDate(new Date(documentDetails.created_on))} + {renderFormattedDate(new Date(pageStore.created_at))}
    diff --git a/web/components/pages/header/options-dropdown.tsx b/web/components/pages/header/options-dropdown.tsx new file mode 100644 index 00000000000..0bd219ea9d8 --- /dev/null +++ b/web/components/pages/header/options-dropdown.tsx @@ -0,0 +1,188 @@ +import { observer } from "mobx-react"; +import { Clipboard, Copy, Link, Lock } from "lucide-react"; +// hooks +import { useApplication, useUser } from "hooks/store"; +import { useProjectPages } from "hooks/store/use-project-specific-pages"; +// ui +import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +// types +import { IPageStore } from "store/page.store"; +import { IPage } from "@plane/types"; +// constants +import { EUserProjectRoles } from "constants/project"; + +type Props = { + pageStore: IPageStore; +}; + +export const PageOptionsDropdown: React.FC = observer((props) => { + const { pageStore } = props; + // store values + const { lockPage, unlockPage, owned_by } = pageStore; + // store hooks + const { + router: { workspaceSlug, projectId }, + } = useApplication(); + const { + currentUser, + membership: { currentProjectRole }, + } = useUser(); + const { archivePage, createPage, restorePage } = useProjectPages(); + + const handleCreatePage = async (payload: Partial) => { + if (!workspaceSlug || !projectId) return; + await createPage(workspaceSlug.toString(), projectId.toString(), payload); + }; + + const handleDuplicatePage = async () => { + const currentPageValues = getValues(); + + if (!currentPageValues?.description_html) { + // TODO: We need to get latest data the above variable will give us stale data + currentPageValues.description_html = pageStore.description_html; + } + + const formData: Partial = { + name: "Copy of " + pageStore.name, + description_html: currentPageValues.description_html, + }; + + try { + await handleCreatePage(formData); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Page could not be duplicated. Please try again later.", + }); + } + }; + + const handleArchivePage = async () => { + if (!workspaceSlug || !projectId) return; + try { + await archivePage(workspaceSlug.toString(), projectId.toString(), pageStore.id); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Page could not be archived. Please try again later.", + }); + } + }; + + const handleRestorePage = async () => { + if (!workspaceSlug || !projectId) return; + try { + await restorePage(workspaceSlug.toString(), projectId.toString(), pageStore.id); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Page could not be restored. Please try again later.", + }); + } + }; + + const handleLockPage = async () => { + try { + await lockPage(); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Page could not be locked. Please try again later.", + }); + } + }; + + const handleUnlockPage = async () => { + try { + await unlockPage(); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Page could not be unlocked. Please try again later.", + }); + } + }; + + // auth + const isCurrentUserOwner = owned_by === currentUser?.id; + const canUserDuplicate = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + const canUserArchive = isCurrentUserOwner || currentProjectRole === EUserProjectRoles.ADMIN; + const canUserLock = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + // menu items list + const MENU_ITEMS: { + key: string; + action: () => void; + label: string; + icon: React.FC; + shouldRender: boolean; + }[] = [ + { + key: "copy-markdown", + action: () => {}, + label: "Copy markdown", + icon: Clipboard, + shouldRender: true, + }, + { + key: "copy-page-;ink", + action: () => {}, + label: "Copy page link", + icon: Link, + shouldRender: true, + }, + { + key: "make-a-copy", + action: handleDuplicatePage, + label: "Make a copy", + icon: Copy, + shouldRender: canUserDuplicate, + }, + { + key: "lock-page", + action: handleLockPage, + label: "Lock page", + icon: Lock, + shouldRender: !pageStore.is_locked && canUserLock, + }, + { + key: "unlock-page", + action: handleUnlockPage, + label: "Unlock page", + icon: Lock, + shouldRender: pageStore.is_locked && canUserLock, + }, + { + key: "archive-page", + action: handleArchivePage, + label: "Archive page", + icon: ArchiveIcon, + shouldRender: !pageStore.archived_at && canUserArchive, + }, + { + key: "restore-page", + action: handleRestorePage, + label: "Restore page", + icon: ArchiveIcon, + shouldRender: !!pageStore.archived_at && canUserArchive, + }, + ]; + + return ( + + {MENU_ITEMS.map((item) => { + if (!item.shouldRender) return null; + return ( + + +
    {item.label}
    +
    + ); + })} +
    + ); +}); diff --git a/web/components/pages/header/root.tsx b/web/components/pages/header/root.tsx new file mode 100644 index 00000000000..42db3879c27 --- /dev/null +++ b/web/components/pages/header/root.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +import { Lock, Sparkle } from "lucide-react"; +// hooks +import { useApplication } from "hooks/store"; +// components +import { GptAssistantPopover } from "components/core"; +import { PageInfoPopover, PageOptionsDropdown, PageSummaryPopover, PageToolbar } from "components/pages"; +// ui +import { ArchiveIcon } from "@plane/ui"; +// helpers +import { renderFormattedDate } from "helpers/date-time.helper"; +// types +import { EditorRefApi } from "@plane/document-editor"; +import { IPageStore } from "store/page.store"; + +type Props = { + editorRef: EditorRefApi; + pageStore: IPageStore; + projectId: string; +}; + +export const PageEditorHeaderRoot: React.FC = observer((props) => { + const { editorRef, pageStore, projectId } = props; + // states + const [gptModalOpen, setGptModal] = useState(false); + // store hooks + const { + config: { envConfig }, + router: { workspaceSlug }, + } = useApplication(); + + const handleAiAssistance = async (response: string) => { + if (!workspaceSlug || !projectId) return; + editorRef.setEditorValueAtCursorPosition(response); + }; + + return ( +
    +
    + +
    + +
    + {pageStore.is_locked && ( +
    + + Locked +
    + )} + {pageStore.archived_at && ( +
    + + Archived at {renderFormattedDate(pageStore.archived_at)} +
    + )} + {envConfig?.has_openai_configured && ( + { + setGptModal((prevData) => !prevData); + // this is done so that the title do not reset after gpt popover closed + // reset(getValues()); + }} + onResponse={handleAiAssistance} + placement="top-end" + button={ + + } + className="!min-w-[38rem]" + /> + )} + + +
    +
    + ); +}); diff --git a/web/components/pages/header/toolbar.tsx b/web/components/pages/header/toolbar.tsx new file mode 100644 index 00000000000..1ca1d616f05 --- /dev/null +++ b/web/components/pages/header/toolbar.tsx @@ -0,0 +1,179 @@ +import { + Bold, + Code, + Heading1, + Heading2, + Heading3, + Italic, + List, + ListOrdered, + LucideIcon, + Quote, + Strikethrough, + Table, + Underline, +} from "lucide-react"; +// editor +import { EditorRefApi } from "@plane/document-editor"; +import { EditorMenuItemNames } from "@plane/editor-document-core"; +// helpers +import { cn } from "helpers/common.helper"; + +type Props = { + editorRef: EditorRefApi; +}; + +type MenuItem = { + name: EditorMenuItemNames; + icon: LucideIcon; +}; + +const BASIC_MARK_ITEMS: MenuItem[] = [ + { + name: "H1", + icon: Heading1, + }, + { + name: "H2", + icon: Heading2, + }, + { + name: "H3", + icon: Heading3, + }, + { + name: "bold", + icon: Bold, + }, + { + name: "italic", + icon: Italic, + }, + { + name: "underline", + icon: Underline, + }, + { + name: "strike", + icon: Strikethrough, + }, +]; +const LIST_ITEMS: MenuItem[] = [ + { + name: "bullet-list", + icon: List, + }, + { + name: "ordered-list", + icon: ListOrdered, + }, +]; +const USER_ACTION_ITEMS: MenuItem[] = [ + { + name: "quote", + icon: Quote, + }, + { + name: "code", + icon: Code, + }, +]; +const COMPLEX_ITEMS: MenuItem[] = [ + { + name: "table", + icon: Table, + }, +]; + +export const PageToolbar: React.FC = (props) => { + const { editorRef } = props; + + return ( +
    +
    + {BASIC_MARK_ITEMS.map((item) => ( + + ))} +
    +
    + {LIST_ITEMS.map((item) => ( + + ))} +
    +
    + {USER_ACTION_ITEMS.map((item) => ( + + ))} +
    +
    + {COMPLEX_ITEMS.map((item) => ( + + ))} +
    +
    + ); +}; diff --git a/web/components/pages/index.ts b/web/components/pages/index.ts index c24b78ff57e..bff0159d96f 100644 --- a/web/components/pages/index.ts +++ b/web/components/pages/index.ts @@ -1,4 +1,6 @@ +export * from "./header"; export * from "./pages-list"; +export * from "./summary"; export * from "./create-update-page-modal"; export * from "./delete-page-modal"; export * from "./page-form"; diff --git a/web/components/pages/summary/content-browser.tsx b/web/components/pages/summary/content-browser.tsx new file mode 100644 index 00000000000..1c4e6cc61ad --- /dev/null +++ b/web/components/pages/summary/content-browser.tsx @@ -0,0 +1,51 @@ +// types +import { EditorRefApi, IMarking } from "@plane/document-editor"; +import { HeadingComp, HeadingThreeComp, SubheadingComp } from "./heading-components"; + +type Props = { + editorRef: EditorRefApi; + markings: IMarking[]; + setSidePeekVisible?: (sidePeekState: boolean) => void; +}; + +export const PageContentBrowser: React.FC = (props) => { + const { editorRef, markings, setSidePeekVisible } = props; + + const handleOnClick = (marking: IMarking) => { + // scrollSummary(editor, marking); + if (setSidePeekVisible) setSidePeekVisible(false); + }; + + return ( +
    +

    Outline

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

    Headings will be displayed here for navigation

    + )} +
    +
    + ); +}; diff --git a/packages/editor/document-editor/src/ui/components/heading-component.tsx b/web/components/pages/summary/heading-components.tsx similarity index 100% rename from packages/editor/document-editor/src/ui/components/heading-component.tsx rename to web/components/pages/summary/heading-components.tsx diff --git a/web/components/pages/summary/index.ts b/web/components/pages/summary/index.ts new file mode 100644 index 00000000000..3c4afb4d8af --- /dev/null +++ b/web/components/pages/summary/index.ts @@ -0,0 +1,2 @@ +export * from "./content-browser"; +export * from "./popover"; diff --git a/packages/editor/document-editor/src/ui/components/summary-popover.tsx b/web/components/pages/summary/popover.tsx similarity index 80% rename from packages/editor/document-editor/src/ui/components/summary-popover.tsx rename to web/components/pages/summary/popover.tsx index 41056c6ad26..f16c886dc15 100644 --- a/packages/editor/document-editor/src/ui/components/summary-popover.tsx +++ b/web/components/pages/summary/popover.tsx @@ -1,21 +1,20 @@ import { useState } from "react"; -import { Editor } from "@tiptap/react"; import { usePopper } from "react-popper"; import { List } from "lucide-react"; // components -import { ContentBrowser } from "src/ui/components/content-browser"; +import { PageContentBrowser } from "./content-browser"; // types -import { IMarking } from "src/types/editor-types"; +import { EditorRefApi, IMarking } from "@plane/document-editor"; type Props = { - editor: Editor; + editorRef: EditorRefApi; markings: IMarking[]; sidePeekVisible: boolean; setSidePeekVisible: (sidePeekState: boolean) => void; }; -export const SummaryPopover: React.FC = (props) => { - const { editor, markings, sidePeekVisible, setSidePeekVisible } = props; +export const PageSummaryPopover: React.FC = (props) => { + const { editorRef, markings, sidePeekVisible, setSidePeekVisible } = props; const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -48,7 +47,7 @@ export const SummaryPopover: React.FC = (props) => { style={summaryPopoverStyles.popper} {...summaryPopoverAttributes.popper} > - +
    )}
    @@ -60,7 +59,7 @@ export const SummaryPopover: React.FC = (props) => { style={summaryPopoverStyles.popper} {...summaryPopoverAttributes.popper} > - +
    )}
    diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index 91f3a590636..74abc3f7e75 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -1,77 +1,60 @@ -import { ReactElement, useEffect, useRef, useState } from "react"; +import { ReactElement, useEffect, useRef } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; import useSWR from "swr"; - -// assets -import { Sparkle } from "lucide-react"; - -// ui -import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; - // hooks -import { useApplication, usePage, useUser, useWorkspace } from "hooks/store"; +import { usePage, useUser, useWorkspace } from "hooks/store"; import { useProjectPages } from "hooks/store/use-project-specific-pages"; import useReloadConfirmations from "hooks/use-reload-confirmation"; - +// services +import { FileService } from "services/file.service"; // layouts import { AppLayout } from "layouts/app-layout"; import { NextPageWithLayout } from "lib/types"; - -// services -import { FileService } from "services/file.service"; - // components -import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef, EditorRefApi } from "@plane/document-editor"; +import { + DocumentEditorWithRef, + DocumentReadOnlyEditorWithRef, + EditorRefApi, + useEditorMarkings, +} from "@plane/document-editor"; import { PageDetailsHeader } from "components/headers/page-details"; import { IssuePeekOverview } from "components/issues"; -import { GptAssistantPopover, PageHead } from "components/core"; - -// constants -import { EUserProjectRoles } from "constants/project"; - +// ui +import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; +import { PageHead } from "components/core"; // types import { IPage } from "@plane/types"; +// constants +import { EUserProjectRoles } from "constants/project"; +import { PageContentBrowser, PageEditorHeaderRoot } from "components/pages"; // services const fileService = new FileService(); const PageDetailsPage: NextPageWithLayout = observer(() => { - // states - const [gptModalOpen, setGptModal] = useState(false); // refs const editorRef = useRef(null); // router const router = useRouter(); - const { workspaceSlug, projectId, pageId } = router.query; const workspaceStore = useWorkspace(); const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string; - // store hooks const { - config: { envConfig }, - } = useApplication(); - const { - currentUser, membership: { currentProjectRole }, } = useUser(); - + const { projectPageMap, projectArchivedPageMap, fetchProjectPages, fetchArchivedProjectPages } = useProjectPages(); + // form info const { handleSubmit, getValues, control, reset } = useForm({ - defaultValues: { name: "", description_html: "" }, + defaultValues: { + name: "", + description_html: "", + }, }); - - const { - archivePage: archivePageAction, - restorePage: restorePageAction, - createPage: createPageAction, - projectPageMap, - projectArchivedPageMap, - fetchProjectPages, - fetchArchivedProjectPages, - } = useProjectPages(); - + // editor markings hook + const { markings } = useEditorMarkings(); useSWR( workspaceSlug && projectId ? `ALL_PAGES_LIST_${projectId}` : null, workspaceSlug && projectId && !projectPageMap[projectId as string] && !projectArchivedPageMap[projectId as string] @@ -92,34 +75,27 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { useEffect( () => () => { - if (pageStore) { - console.log("ran cleanup"); - pageStore.cleanup(); - } + if (pageStore) pageStore.cleanup(); }, [pageStore] ); - if (!pageStore) { + if (!pageStore) return (
    ); - } // We need to get the values of title and description from the page store but we don't have to subscribe to those values const pageTitle = pageStore?.name; const pageDescription = pageStore?.description_html; const { - lockPage: lockPageAction, - unlockPage: unlockPageAction, updateName: updateNameAction, updateDescription: updateDescriptionAction, id: pageIdMobx, isSubmitting, setIsSubmitting, - owned_by, is_locked, archived_at, created_at, @@ -133,12 +109,6 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { updateDescriptionAction(formData.description_html); }; - const handleAiAssistance = async (response: string) => { - if (!workspaceSlug || !projectId || !pageId) return; - - editorRef.current?.setEditorValueAtCursorPosition(response); - }; - const actionCompleteAlert = ({ title, message, @@ -159,208 +129,83 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { if (!workspaceSlug || !projectId || !pageId) return; updateNameAction(title); }; - - const createPage = async (payload: Partial) => { - if (!workspaceSlug || !projectId) return; - await createPageAction(workspaceSlug as string, projectId as string, payload); - }; - - // ================ Page Menu Actions ================== - const duplicate_page = async () => { - const currentPageValues = getValues(); - - if (!currentPageValues?.description_html) { - // TODO: We need to get latest data the above variable will give us stale data - currentPageValues.description_html = pageDescription as string; - } - - const formData: Partial = { - name: "Copy of " + pageTitle, - description_html: currentPageValues.description_html, - }; - - try { - await createPage(formData); - } catch (error) { - actionCompleteAlert({ - title: `Page could not be duplicated`, - message: `Sorry, page could not be duplicated, please try again later`, - type: "error", - }); - } - }; - - const archivePage = async () => { - if (!workspaceSlug || !projectId || !pageId) return; - try { - await archivePageAction(workspaceSlug as string, projectId as string, pageId as string); - } catch (error) { - 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; - try { - await restorePageAction(workspaceSlug as string, projectId as string, pageId as string); - } catch (error) { - actionCompleteAlert({ - title: `Page could not be restored`, - message: `Sorry, page could not be restored, please try again later`, - type: "error", - }); - } - }; - - const lockPage = async () => { - if (!workspaceSlug || !projectId || !pageId) return; - try { - await lockPageAction(); - } catch (error) { - actionCompleteAlert({ - title: `Page could not be locked`, - message: `Sorry, page could not be locked, please try again later`, - type: "error", - }); - } - }; - - const unlockPage = async () => { - if (!workspaceSlug || !projectId || !pageId) return; - try { - await unlockPageAction(); - } catch (error) { - actionCompleteAlert({ - title: `Page could not be unlocked`, - message: `Sorry, page could not be unlocked, please try again later`, - type: "error", - }); - } - }; - + // auth const isPageReadOnly = is_locked || archived_at || (currentProjectRole && [EUserProjectRoles.VIEWER, EUserProjectRoles.GUEST].includes(currentProjectRole)); - const isCurrentUserOwner = owned_by === currentUser?.id; - - const userCanDuplicate = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - const userCanArchive = isCurrentUserOwner || currentProjectRole === EUserProjectRoles.ADMIN; - const userCanLock = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - return pageIdMobx ? ( <>
    -
    - {isPageReadOnly ? ( - + {editorRef.current && projectId && ( + - ) : ( -
    - ( - { - setIsSubmitting("submitting"); - setShowAlert(true); - onChange(description_html); - handleSubmit(updatePage)(); - }} - duplicationConfig={userCanDuplicate ? { action: duplicate_page } : undefined} - pageArchiveConfig={ - userCanArchive - ? { - is_archived: archived_at ? true : false, - action: archived_at ? unArchivePage : archivePage, - } - : undefined - } - pageLockConfig={userCanLock ? { is_locked: false, action: lockPage } : undefined} - /> - )} - /> - {projectId && envConfig?.has_openai_configured && ( -
    - { - setGptModal((prevData) => !prevData); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - onResponse={(response) => { - handleAiAssistance(response); - }} - placement="top-end" - button={ - - } - className="!min-w-[38rem]" - /> -
    + )} +
    + {editorRef.current && ( +
    + +
    + )} +
    + {isPageReadOnly ? ( + + ) : ( + ( + { + setIsSubmitting("submitting"); + setShowAlert(true); + onChange(description_html); + handleSubmit(updatePage)(); + }} + /> + )} + /> )}
    - )} +
    +
    From e50e4c05fc82b2834f34b3df6cca2925779418db Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Tue, 19 Mar 2024 17:38:17 +0530 Subject: [PATCH 038/179] refactor: page details granular components --- .../document-editor/src/types/editor-types.ts | 7 - .../src/ui/components/page-renderer.tsx | 13 +- .../editor/document-editor/src/ui/index.tsx | 36 ++-- .../document-editor/src/ui/readonly/index.tsx | 18 +- web/components/pages/editor-body.tsx | 134 ++++++++++++ .../pages/header/options-dropdown.tsx | 35 +--- web/components/pages/header/root.tsx | 41 +++- web/components/pages/index.ts | 1 + .../projects/[projectId]/pages/[pageId].tsx | 192 +++++------------- 9 files changed, 254 insertions(+), 223 deletions(-) create mode 100644 web/components/pages/editor-body.tsx diff --git a/packages/editor/document-editor/src/types/editor-types.ts b/packages/editor/document-editor/src/types/editor-types.ts index 5a28daf9e2f..476642103c3 100644 --- a/packages/editor/document-editor/src/types/editor-types.ts +++ b/packages/editor/document-editor/src/types/editor-types.ts @@ -1,10 +1,3 @@ -export interface DocumentDetails { - title: string; - created_by: string; - created_on: Date; - last_updated_by: string; - last_updated_at: Date; -} export interface IMarking { type: "heading"; level: number; 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 3ce32e1968a..cc8280206b8 100644 --- a/packages/editor/document-editor/src/ui/components/page-renderer.tsx +++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx @@ -16,11 +16,9 @@ import { } from "@floating-ui/react"; // ui import { TextArea } from "@plane/ui"; -// types -import { DocumentDetails } from "src/types/editor-types"; type IPageRenderer = { - documentDetails: DocumentDetails; + title: string; updatePageTitle: (title: string) => void; editor: Editor; onActionCompleteHandler: (action: { @@ -37,7 +35,7 @@ type IPageRenderer = { export const PageRenderer = (props: IPageRenderer) => { const { - documentDetails, + title, tabIndex, editor, editorClassNames, @@ -47,7 +45,7 @@ export const PageRenderer = (props: IPageRenderer) => { hideDragHandle, } = props; - const [pageTitle, setPageTitle] = useState(documentDetails.title); + const [pageTitle, setPageTitle] = useState(title); const [linkViewProps, setLinkViewProps] = useState(); const [isOpen, setIsOpen] = useState(false); @@ -157,13 +155,14 @@ export const PageRenderer = (props: IPageRenderer) => { ); return ( -
    +
    {readonly ? (
    {pageTitle}
    ) : (