diff --git a/apps/app/components/pages/create-update-block-inline.tsx b/apps/app/components/pages/create-update-block-inline.tsx new file mode 100644 index 000000000..baf5accf8 --- /dev/null +++ b/apps/app/components/pages/create-update-block-inline.tsx @@ -0,0 +1,199 @@ +import { useRouter } from "next/router"; +import dynamic from "next/dynamic"; + +import { mutate } from "swr"; + +// react-hook-form +import { Controller, useForm } from "react-hook-form"; +// services +import pagesService from "services/pages.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Input, Loader, PrimaryButton, SecondaryButton } from "components/ui"; +// types +import { IPageBlock } from "types"; +// fetch-keys +import { PAGE_BLOCKS_LIST } from "constants/fetch-keys"; +import { useCallback, useEffect } from "react"; +import issuesService from "services/issues.service"; + +type Props = { + handleClose: () => void; + data?: IPageBlock; + setIsSyncing?: React.Dispatch>; +}; + +const defaultValues = { + name: "", + description: "

", +}; + +const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { + ssr: false, + loading: () => ( + + + + ), +}); + +export const CreateUpdateBlockInline: React.FC = ({ handleClose, data, setIsSyncing }) => { + const router = useRouter(); + const { workspaceSlug, projectId, pageId } = router.query; + + const { setToastAlert } = useToast(); + + const { + handleSubmit, + register, + control, + watch, + setValue, + reset, + formState: { isSubmitting }, + } = useForm({ + defaultValues, + }); + + const onClose = useCallback(() => { + handleClose(); + reset(); + }, [handleClose, reset]); + + const createPageBlock = async (formData: Partial) => { + if (!workspaceSlug || !projectId || !pageId) return; + + await pagesService + .createPageBlock(workspaceSlug as string, projectId as string, pageId as string, { + name: formData.name, + description: formData.description ?? "", + description_html: formData.description_html ?? "

", + }) + .then((res) => { + mutate( + PAGE_BLOCKS_LIST(pageId as string), + (prevData) => [...(prevData as IPageBlock[]), res], + false + ); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Page could not be created. Please try again.", + }); + }) + .finally(() => onClose()); + }; + + const updatePageBlock = async (formData: Partial) => { + if (!workspaceSlug || !projectId || !pageId || !data) return; + + if (data.issue && data.sync && setIsSyncing) setIsSyncing(true); + + mutate( + PAGE_BLOCKS_LIST(pageId as string), + (prevData) => + prevData?.map((p) => { + if (p.id === data.id) return { ...p, ...formData }; + + return p; + }), + false + ); + + await pagesService + .patchPageBlock(workspaceSlug as string, projectId as string, pageId as string, data.id, { + name: formData.name, + description: formData.description, + description_html: formData.description_html, + }) + .then((res) => { + mutate(PAGE_BLOCKS_LIST(pageId as string)); + if (data.issue && data.sync) + issuesService + .patchIssue(workspaceSlug as string, projectId as string, data.issue, { + name: res.name, + description: res.description, + description_html: res.description_html, + }) + .finally(() => { + if (setIsSyncing) setIsSyncing(false); + }); + }) + .finally(() => onClose()); + }; + + useEffect(() => { + if (!data) return; + + reset({ + ...defaultValues, + name: data.name, + description: data.description, + description_html: data.description_html, + }); + }, [reset, data]); + + useEffect(() => { + window.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }); + + return () => { + window.removeEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }); + }; + }, [onClose]); + + return ( +
+
+ +
+ ( + setValue("description", jsonValue)} + onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)} + placeholder="Description" + customClassName="text-sm" + noBorder + borderOnFocus={false} + /> + )} + /> +
+
+ Cancel + + {data + ? isSubmitting + ? "Updating..." + : "Update block" + : isSubmitting + ? "Adding..." + : "Add block"} + +
+
+
+ ); +}; diff --git a/apps/app/components/pages/index.ts b/apps/app/components/pages/index.ts index d2707db89..ce2692162 100644 --- a/apps/app/components/pages/index.ts +++ b/apps/app/components/pages/index.ts @@ -1,4 +1,5 @@ export * from "./pages-list"; +export * from "./create-update-block-inline"; export * from "./create-update-page-modal"; export * from "./delete-page-modal"; export * from "./page-form"; diff --git a/apps/app/components/pages/pages-view.tsx b/apps/app/components/pages/pages-view.tsx index aca7ef648..59a917788 100644 --- a/apps/app/components/pages/pages-view.tsx +++ b/apps/app/components/pages/pages-view.tsx @@ -152,6 +152,32 @@ export const PagesView: React.FC = ({ pages, viewType }) => { }); }; + const partialUpdatePage = (page: IPage, formData: Partial) => { + if (!workspaceSlug || !projectId) return; + + mutate( + ALL_PAGES_LIST(projectId as string), + (prevData) => (prevData ?? []).map((p) => ({ ...p, ...formData })), + false + ); + mutate( + MY_PAGES_LIST(projectId as string), + (prevData) => (prevData ?? []).map((p) => ({ ...p, ...formData })), + false + ); + mutate( + FAVORITE_PAGES_LIST(projectId as string), + (prevData) => (prevData ?? []).map((p) => ({ ...p, ...formData })), + false + ); + + pagesService + .patchPage(workspaceSlug as string, projectId as string, page.id, formData) + .then(() => { + mutate(RECENT_PAGES_LIST(projectId as string)); + }); + }; + return ( <> = ({ pages, viewType }) => { handleDeletePage={() => handleDeletePage(page)} handleAddToFavorites={() => handleAddToFavorites(page)} handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)} + partialUpdatePage={partialUpdatePage} /> ))} @@ -189,6 +216,7 @@ export const PagesView: React.FC = ({ pages, viewType }) => { handleDeletePage={() => handleDeletePage(page)} handleAddToFavorites={() => handleAddToFavorites(page)} handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)} + partialUpdatePage={partialUpdatePage} /> ))} @@ -202,6 +230,7 @@ export const PagesView: React.FC = ({ pages, viewType }) => { handleDeletePage={() => handleDeletePage(page)} handleAddToFavorites={() => handleAddToFavorites(page)} handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)} + partialUpdatePage={partialUpdatePage} /> ))} diff --git a/apps/app/components/pages/single-page-block.tsx b/apps/app/components/pages/single-page-block.tsx index 6dd8b02bc..213edd5ef 100644 --- a/apps/app/components/pages/single-page-block.tsx +++ b/apps/app/components/pages/single-page-block.tsx @@ -14,24 +14,32 @@ import issuesService from "services/issues.service"; // hooks import useToast from "hooks/use-toast"; // components -import { CreateUpdateIssueModal } from "components/issues"; import { GptAssistantModal } from "components/core"; +import { CreateUpdateBlockInline } from "components/pages"; // ui -import { CustomMenu, Input, Loader, TextArea } from "components/ui"; +import { CustomMenu, Input, Loader } from "components/ui"; // icons import { LayerDiagonalIcon } from "components/icons"; import { ArrowPathIcon } from "@heroicons/react/20/solid"; -import { BoltIcon, CheckIcon, SparklesIcon } from "@heroicons/react/24/outline"; +import { + BoltIcon, + CheckIcon, + EllipsisVerticalIcon, + PencilIcon, + SparklesIcon, +} from "@heroicons/react/24/outline"; // helpers import { copyTextToClipboard } from "helpers/string.helper"; // types import { IIssue, IPageBlock, IProject } from "types"; // fetch-keys import { PAGE_BLOCKS_LIST } from "constants/fetch-keys"; +import { Draggable } from "react-beautiful-dnd"; type Props = { block: IPageBlock; projectDetails: IProject | undefined; + index: number; }; const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { @@ -43,9 +51,9 @@ const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor ), }); -export const SinglePageBlock: React.FC = ({ block, projectDetails }) => { - const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); +export const SinglePageBlock: React.FC = ({ block, projectDetails, index }) => { const [isSyncing, setIsSyncing] = useState(false); + const [createBlockForm, setCreateBlockForm] = useState(false); const [gptAssistantModal, setGptAssistantModal] = useState(false); @@ -54,7 +62,7 @@ export const SinglePageBlock: React.FC = ({ block, projectDetails }) => { const { setToastAlert } = useToast(); - const { handleSubmit, watch, reset, setValue, control } = useForm({ + const { handleSubmit, watch, reset, setValue, control, register } = useForm({ defaultValues: { name: "", description: {}, @@ -136,10 +144,6 @@ export const SinglePageBlock: React.FC = ({ block, projectDetails }) => { }); }; - const editAndPushBlockIntoIssues = async () => { - setCreateUpdateIssueModal(true); - }; - const deletePageBlock = async () => { if (!workspaceSlug || !projectId || !pageId) return; @@ -229,109 +233,133 @@ export const SinglePageBlock: React.FC = ({ block, projectDetails }) => { }, [reset, block]); return ( -
- setCreateUpdateIssueModal(false)} - prePopulateData={{ - name: watch("name"), - description: watch("description"), - description_html: watch("description_html"), - }} - /> -
- setValue("name", e.target.value)} - required={true} - className="min-h-10 block w-full resize-none overflow-hidden border-none bg-transparent py-1 text-base font-medium ring-0 focus:ring-1 focus:ring-gray-200" - role="textbox" - /> -
- {block.issue && block.sync && ( -
- {isSyncing ? ( - - ) : ( - - )} - {isSyncing ? "Syncing..." : "Synced"} + + {(provided, snapshot) => ( + <> + {createBlockForm ? ( +
+ setCreateBlockForm(false)} + data={block} + setIsSyncing={setIsSyncing} + /> +
+ ) : ( +
+
+
+ +

setCreateBlockForm(true)}> + {block.name} +

+
+
+ {block.issue && block.sync && ( +
+ {isSyncing ? ( + + ) : ( + + )} + {isSyncing ? "Syncing..." : "Synced"} +
+ )} + {block.issue && ( + + + + {projectDetails?.identifier}-{block.issue_detail?.sequence_id} + + + )} + + + } noBorder noChevron> + {block.issue ? ( + <> + + <>Turn sync {block.sync ? "off" : "on"} + + + Copy issue link + + + ) : ( + + Push into issues + + )} + + Delete block + + +
+
+
setCreateBlockForm(true)} + > + ( + + )} + /> + setGptAssistantModal(false)} + inset="top-2 left-0" + content={block.description_stripped} + htmlContent={block.description_html} + onResponse={handleAiAssistance} + projectId={projectId as string} + /> +
)} - {block.issue && ( - - - - {projectDetails?.identifier}-{block.issue_detail?.sequence_id} - - - )} - - } noBorder noChevron> - {block.issue ? ( - <> - - <>Turn sync {block.sync ? "off" : "on"} - - Copy issue link - - ) : ( - <> - - Push into issues - - {/* - Edit and push into issues - */} - - )} - Delete block - -
-
-
- ( - setValue("description", jsonValue)} - onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)} - placeholder="Block description..." - customClassName="border border-transparent text-sm" - noBorder - borderOnFocus={false} - /> - )} - /> - setGptAssistantModal(false)} - inset="top-2 left-0" - content={block.description_stripped} - htmlContent={block.description_html} - onResponse={handleAiAssistance} - projectId={projectId as string} - /> -
-
+ + )} + ); }; diff --git a/apps/app/components/pages/single-page-detailed-item.tsx b/apps/app/components/pages/single-page-detailed-item.tsx index 47156ad92..b07b87f89 100644 --- a/apps/app/components/pages/single-page-detailed-item.tsx +++ b/apps/app/components/pages/single-page-detailed-item.tsx @@ -5,9 +5,15 @@ import { useRouter } from "next/router"; import dynamic from "next/dynamic"; // ui -import { CustomMenu, Loader } from "components/ui"; +import { CustomMenu, Loader, Tooltip } from "components/ui"; // icons -import { PencilIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline"; +import { + LockClosedIcon, + LockOpenIcon, + PencilIcon, + StarIcon, + TrashIcon, +} from "@heroicons/react/24/outline"; // helpers import { truncateText } from "helpers/string.helper"; import { renderShortTime } from "helpers/date-time.helper"; @@ -20,6 +26,7 @@ type TSingleStatProps = { handleDeletePage: () => void; handleAddToFavorites: () => void; handleRemoveFromFavorites: () => void; + partialUpdatePage: (page: IPage, formData: Partial) => void; }; const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { @@ -37,90 +44,139 @@ export const SinglePageDetailedItem: React.FC = ({ handleDeletePage, handleAddToFavorites, handleRemoveFromFavorites, + partialUpdatePage, }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; return ( -
-
-
- - + + +
+
+

{truncateText(page.name, 75)}

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

{renderShortTime(page.updated_at)}

- {page.is_favorite ? ( - - ) : ( - - )} - - - - - Edit Page - - - - - - Delete Page - - - +
+

{renderShortTime(page.updated_at)}

+ {page.is_favorite ? ( + + ) : ( + + )} + + + + + { + e.preventDefault(); + e.stopPropagation(); + handleEditPage(); + }} + > + + + Edit Page + + + { + e.preventDefault(); + e.stopPropagation(); + handleDeletePage(); + }} + > + + + Delete Page + + + +
+
+
+
+ {page.blocks.length > 0 ? ( + + ) : null} +
+
-
-
-
- {page.blocks.length > 0 ? ( - - ) : null} -
-
-
+ + ); }; diff --git a/apps/app/components/pages/single-page-list-item.tsx b/apps/app/components/pages/single-page-list-item.tsx index e4cc69147..db3d1d95f 100644 --- a/apps/app/components/pages/single-page-list-item.tsx +++ b/apps/app/components/pages/single-page-list-item.tsx @@ -6,7 +6,14 @@ import { useRouter } from "next/router"; // ui import { CustomMenu, Tooltip } from "components/ui"; // icons -import { DocumentTextIcon, PencilIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline"; +import { + DocumentTextIcon, + LockClosedIcon, + LockOpenIcon, + PencilIcon, + StarIcon, + TrashIcon, +} from "@heroicons/react/24/outline"; // helpers import { truncateText } from "helpers/string.helper"; import { renderShortDate, renderShortTime } from "helpers/date-time.helper"; @@ -19,6 +26,7 @@ type TSingleStatProps = { handleDeletePage: () => void; handleAddToFavorites: () => void; handleRemoveFromFavorites: () => void; + partialUpdatePage: (page: IPage, formData: Partial) => void; }; export const SinglePageListItem: React.FC = ({ @@ -27,6 +35,7 @@ export const SinglePageListItem: React.FC = ({ handleDeletePage, handleAddToFavorites, handleRemoveFromFavorites, + partialUpdatePage, }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -94,6 +103,29 @@ export const SinglePageListItem: React.FC = ({ )} + + + { diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index c12580afb..35097094b 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -10,6 +10,9 @@ import { useForm } from "react-hook-form"; import { Popover, Transition } from "@headlessui/react"; // react-color import { TwitterPicker } from "react-color"; +// react-beautiful-dnd +import { DragDropContext, DropResult } from "react-beautiful-dnd"; +import StrictModeDroppable from "components/dnd/StrictModeDroppable"; // lib import { requiredAdmin, requiredAuth } from "lib/auth"; // services @@ -21,16 +24,24 @@ import useToast from "hooks/use-toast"; // layouts import AppLayout from "layouts/app-layout"; // components -import { SinglePageBlock } from "components/pages"; +import { CreateUpdateBlockInline, SinglePageBlock } from "components/pages"; // ui import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { CustomSearchSelect, Loader, PrimaryButton, TextArea, Tooltip } from "components/ui"; // icons -import { ArrowLeftIcon, PlusIcon, ShareIcon, StarIcon } from "@heroicons/react/24/outline"; +import { + ArrowLeftIcon, + LockClosedIcon, + LockOpenIcon, + PlusIcon, + ShareIcon, + StarIcon, +} from "@heroicons/react/24/outline"; import { ColorPalletteIcon } from "components/icons"; // helpers import { renderShortTime } from "helpers/date-time.helper"; import { copyTextToClipboard } from "helpers/string.helper"; +import { orderArrayBy } from "helpers/array.helper"; // types import type { NextPage, GetServerSidePropsContext } from "next"; import { IIssueLabels, IPage, IPageBlock, UserAuth } from "types"; @@ -44,13 +55,14 @@ import { const SinglePage: NextPage = (props) => { const [isAddingBlock, setIsAddingBlock] = useState(false); + const [createBlockForm, setCreateBlockForm] = useState(false); const router = useRouter(); const { workspaceSlug, projectId, pageId } = router.query; const { setToastAlert } = useToast(); - const { handleSubmit, reset, watch, setValue, control } = useForm({ + const { handleSubmit, reset, watch, setValue } = useForm({ defaultValues: { name: "" }, }); @@ -131,34 +143,6 @@ const SinglePage: NextPage = (props) => { }); }; - const createPageBlock = async () => { - if (!workspaceSlug || !projectId || !pageId) return; - - setIsAddingBlock(true); - - await pagesService - .createPageBlock(workspaceSlug as string, projectId as string, pageId as string, { - name: "New block", - }) - .then((res) => { - mutate( - PAGE_BLOCKS_LIST(pageId as string), - (prevData) => [...(prevData as IPageBlock[]), res], - false - ); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Page could not be created. Please try again.", - }); - }) - .finally(() => { - setIsAddingBlock(false); - }); - }; - const handleAddToFavorites = () => { if (!workspaceSlug || !projectId || !pageId) return; @@ -195,6 +179,50 @@ const SinglePage: NextPage = (props) => { ); }; + const handleOnDragEnd = (result: DropResult) => { + if (!result.destination || !workspaceSlug || !projectId || !pageId || !pageBlocks) return; + + const { source, destination } = result; + + let newSortOrder = pageBlocks.find((p) => p.id === result.draggableId)?.sort_order ?? 65535; + + if (destination.index === 0) newSortOrder = pageBlocks[0].sort_order - 10000; + else if (destination.index === pageBlocks.length - 1) + newSortOrder = pageBlocks[pageBlocks.length - 1].sort_order + 10000; + else { + if (destination.index > source.index) + newSortOrder = + (pageBlocks[destination.index].sort_order + + pageBlocks[destination.index + 1].sort_order) / + 2; + else if (destination.index < source.index) + newSortOrder = + (pageBlocks[destination.index - 1].sort_order + + pageBlocks[destination.index].sort_order) / + 2; + } + + const newBlocksList = pageBlocks.map((p) => ({ + ...p, + sort_order: p.id === result.draggableId ? newSortOrder : p.sort_order, + })); + mutate( + PAGE_BLOCKS_LIST(pageId as string), + orderArrayBy(newBlocksList, "sort_order", "ascending"), + false + ); + + pagesService.patchPageBlock( + workspaceSlug as string, + projectId as string, + pageId as string, + result.draggableId, + { + sort_order: newSortOrder, + } + ); + }; + const handleCopyText = () => { const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; @@ -354,7 +382,7 @@ const SinglePage: NextPage = (props) => { }} /> ) : ( - + )} @@ -378,6 +406,19 @@ const SinglePage: NextPage = (props) => { )}
+ {pageDetails.access ? ( + + ) : ( + + )} {pageDetails.is_favorite ? ( + + {!createBlockForm && ( + + )} + {createBlockForm && ( + setCreateBlockForm(false)} /> + )} ) : (