forked from github/plane
style: revamp pages (#688)
* feat: dnd added for blocks * chore: added access option to pages * style: ui fixes * fix: polishing * fix: build error
This commit is contained in:
parent
f6f9caf9e6
commit
5ba7d271b7
199
apps/app/components/pages/create-update-block-inline.tsx
Normal file
199
apps/app/components/pages/create-update-block-inline.tsx
Normal file
@ -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<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
const defaultValues = {
|
||||
name: "",
|
||||
description: "<p></p>",
|
||||
};
|
||||
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Loader className="mx-4 mt-6">
|
||||
<Loader.Item height="100px" width="100%" />
|
||||
</Loader>
|
||||
),
|
||||
});
|
||||
|
||||
export const CreateUpdateBlockInline: React.FC<Props> = ({ 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<IPageBlock>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
handleClose();
|
||||
reset();
|
||||
}, [handleClose, reset]);
|
||||
|
||||
const createPageBlock = async (formData: Partial<IPageBlock>) => {
|
||||
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 ?? "<p></p>",
|
||||
})
|
||||
.then((res) => {
|
||||
mutate<IPageBlock[]>(
|
||||
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<IPageBlock>) => {
|
||||
if (!workspaceSlug || !projectId || !pageId || !data) return;
|
||||
|
||||
if (data.issue && data.sync && setIsSyncing) setIsSyncing(true);
|
||||
|
||||
mutate<IPageBlock[]>(
|
||||
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 (
|
||||
<div className="border rounded-[10px] p-2 ml-6">
|
||||
<form onSubmit={data ? handleSubmit(updatePageBlock) : handleSubmit(createPageBlock)}>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Title"
|
||||
register={register}
|
||||
required={true}
|
||||
className="min-h-10 block w-full resize-none overflow-hidden border-none bg-transparent py-1 text-base ring-0 -ml-2 focus:ring-gray-200"
|
||||
role="textbox"
|
||||
/>
|
||||
<div className="page-block-section font relative -mx-3 -mt-3">
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<RemirrorRichTextEditor
|
||||
value={
|
||||
!value || (typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("description_html")
|
||||
: value
|
||||
}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Description"
|
||||
customClassName="text-sm"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end items-center gap-2">
|
||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{data
|
||||
? isSubmitting
|
||||
? "Updating..."
|
||||
: "Update block"
|
||||
: isSubmitting
|
||||
? "Adding..."
|
||||
: "Add block"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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";
|
||||
|
@ -152,6 +152,32 @@ export const PagesView: React.FC<Props> = ({ pages, viewType }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const partialUpdatePage = (page: IPage, formData: Partial<IPage>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutate<IPage[]>(
|
||||
ALL_PAGES_LIST(projectId as string),
|
||||
(prevData) => (prevData ?? []).map((p) => ({ ...p, ...formData })),
|
||||
false
|
||||
);
|
||||
mutate<IPage[]>(
|
||||
MY_PAGES_LIST(projectId as string),
|
||||
(prevData) => (prevData ?? []).map((p) => ({ ...p, ...formData })),
|
||||
false
|
||||
);
|
||||
mutate<IPage[]>(
|
||||
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 (
|
||||
<>
|
||||
<CreateUpdatePageModal
|
||||
@ -176,6 +202,7 @@ export const PagesView: React.FC<Props> = ({ pages, viewType }) => {
|
||||
handleDeletePage={() => handleDeletePage(page)}
|
||||
handleAddToFavorites={() => handleAddToFavorites(page)}
|
||||
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
|
||||
partialUpdatePage={partialUpdatePage}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@ -189,6 +216,7 @@ export const PagesView: React.FC<Props> = ({ pages, viewType }) => {
|
||||
handleDeletePage={() => handleDeletePage(page)}
|
||||
handleAddToFavorites={() => handleAddToFavorites(page)}
|
||||
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
|
||||
partialUpdatePage={partialUpdatePage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -202,6 +230,7 @@ export const PagesView: React.FC<Props> = ({ pages, viewType }) => {
|
||||
handleDeletePage={() => handleDeletePage(page)}
|
||||
handleAddToFavorites={() => handleAddToFavorites(page)}
|
||||
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
|
||||
partialUpdatePage={partialUpdatePage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
@ -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<Props> = ({ block, projectDetails }) => {
|
||||
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
|
||||
export const SinglePageBlock: React.FC<Props> = ({ 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<Props> = ({ block, projectDetails }) => {
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { handleSubmit, watch, reset, setValue, control } = useForm<IPageBlock>({
|
||||
const { handleSubmit, watch, reset, setValue, control, register } = useForm<IPageBlock>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: {},
|
||||
@ -136,10 +144,6 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const editAndPushBlockIntoIssues = async () => {
|
||||
setCreateUpdateIssueModal(true);
|
||||
};
|
||||
|
||||
const deletePageBlock = async () => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
@ -229,109 +233,133 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails }) => {
|
||||
}, [reset, block]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={createUpdateIssueModal}
|
||||
handleClose={() => setCreateUpdateIssueModal(false)}
|
||||
prePopulateData={{
|
||||
name: watch("name"),
|
||||
description: watch("description"),
|
||||
description_html: watch("description_html"),
|
||||
}}
|
||||
/>
|
||||
<div className="-mx-3 mt-4 flex items-center justify-between gap-2">
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Block title"
|
||||
value={watch("name")}
|
||||
onBlur={handleSubmit(updatePageBlock)}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{block.issue && block.sync && (
|
||||
<div className="flex flex-shrink-0 cursor-default items-center gap-1 rounded bg-gray-100 py-1 px-1.5 text-xs">
|
||||
{isSyncing ? (
|
||||
<ArrowPathIcon className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
)}
|
||||
{isSyncing ? "Syncing..." : "Synced"}
|
||||
<Draggable draggableId={block.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<>
|
||||
{createBlockForm ? (
|
||||
<div className="mb-4">
|
||||
<CreateUpdateBlockInline
|
||||
handleClose={() => setCreateBlockForm(false)}
|
||||
data={block}
|
||||
setIsSyncing={setIsSyncing}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`group ${
|
||||
snapshot.isDragging
|
||||
? "border-2 bg-white border-theme shadow-lg rounded-md p-4 pl-0"
|
||||
: ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex p-0.5 hover:bg-gray-100 rounded opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-[18px]" />
|
||||
<EllipsisVerticalIcon className="h-[18px] -ml-3" />
|
||||
</button>
|
||||
<h3 className="font-medium" onClick={() => setCreateBlockForm(true)}>
|
||||
{block.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{block.issue && block.sync && (
|
||||
<div className="flex flex-shrink-0 cursor-default items-center gap-1 rounded bg-gray-100 py-1 px-1.5 text-xs">
|
||||
{isSyncing ? (
|
||||
<ArrowPathIcon className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
)}
|
||||
{isSyncing ? "Syncing..." : "Synced"}
|
||||
</div>
|
||||
)}
|
||||
{block.issue && (
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${block.issue}`}>
|
||||
<a className="flex flex-shrink-0 items-center gap-1 rounded bg-gray-100 px-1.5 py-1 text-xs">
|
||||
<LayerDiagonalIcon height="16" width="16" color="black" />
|
||||
{projectDetails?.identifier}-{block.issue_detail?.sequence_id}
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-2 flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-gray-100"
|
||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
||||
>
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
AI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-2 flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-gray-100"
|
||||
onClick={() => setCreateBlockForm(true)}
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<CustomMenu label={<BoltIcon className="h-4.5 w-3.5" />} noBorder noChevron>
|
||||
{block.issue ? (
|
||||
<>
|
||||
<CustomMenu.MenuItem onClick={handleBlockSync}>
|
||||
<>Turn sync {block.sync ? "off" : "on"}</>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
Copy issue link
|
||||
</CustomMenu.MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<CustomMenu.MenuItem onClick={pushBlockIntoIssues}>
|
||||
Push into issues
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={deletePageBlock}>
|
||||
Delete block
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="page-block-section font relative -mx-3 -mt-3 ml-6"
|
||||
onClick={() => setCreateBlockForm(true)}
|
||||
>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<RemirrorRichTextEditor
|
||||
value={
|
||||
!value || (typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("description_html")
|
||||
: value
|
||||
}
|
||||
placeholder="Description"
|
||||
customClassName="text-sm"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
editable={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<GptAssistantModal
|
||||
block={block}
|
||||
isOpen={gptAssistantModal}
|
||||
handleClose={() => setGptAssistantModal(false)}
|
||||
inset="top-2 left-0"
|
||||
content={block.description_stripped}
|
||||
htmlContent={block.description_html}
|
||||
onResponse={handleAiAssistance}
|
||||
projectId={projectId as string}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{block.issue && (
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${block.issue}`}>
|
||||
<a className="flex flex-shrink-0 items-center gap-1 rounded bg-gray-100 px-1.5 py-1 text-xs">
|
||||
<LayerDiagonalIcon height="16" width="16" color="black" />
|
||||
{projectDetails?.identifier}-{block.issue_detail?.sequence_id}
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-2 flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-gray-100"
|
||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
||||
>
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
AI
|
||||
</button>
|
||||
<CustomMenu label={<BoltIcon className="h-4.5 w-3.5" />} noBorder noChevron>
|
||||
{block.issue ? (
|
||||
<>
|
||||
<CustomMenu.MenuItem onClick={handleBlockSync}>
|
||||
<>Turn sync {block.sync ? "off" : "on"}</>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CustomMenu.MenuItem onClick={pushBlockIntoIssues}>
|
||||
Push into issues
|
||||
</CustomMenu.MenuItem>
|
||||
{/* <CustomMenu.MenuItem onClick={editAndPushBlockIntoIssues}>
|
||||
Edit and push into issues
|
||||
</CustomMenu.MenuItem> */}
|
||||
</>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={deletePageBlock}>Delete block</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-block-section font relative -mx-3 -mt-3">
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<RemirrorRichTextEditor
|
||||
value={
|
||||
!value || (typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("description_html")
|
||||
: value
|
||||
}
|
||||
onBlur={handleSubmit(updatePageBlock)}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Block description..."
|
||||
customClassName="border border-transparent text-sm"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<GptAssistantModal
|
||||
block={block}
|
||||
isOpen={gptAssistantModal}
|
||||
handleClose={() => setGptAssistantModal(false)}
|
||||
inset="top-2 left-0"
|
||||
content={block.description_stripped}
|
||||
htmlContent={block.description_html}
|
||||
onResponse={handleAiAssistance}
|
||||
projectId={projectId as string}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
};
|
||||
|
@ -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<IPage>) => void;
|
||||
};
|
||||
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
@ -37,90 +44,139 @@ export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
|
||||
handleDeletePage,
|
||||
handleAddToFavorites,
|
||||
handleRemoveFromFavorites,
|
||||
partialUpdatePage,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
return (
|
||||
<div className="relative rounded border p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
|
||||
<a className="after:absolute after:inset-0">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
|
||||
<a>
|
||||
<div className="relative rounded border p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="mr-2 truncate text-sm font-medium">{truncateText(page.name, 75)}</p>
|
||||
</a>
|
||||
</Link>
|
||||
{page.label_details.length > 0 &&
|
||||
page.label_details.map((label) => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
||||
style={{
|
||||
backgroundColor: `${
|
||||
label?.color && label.color !== "" ? label.color : "#000000"
|
||||
}20`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{page.label_details.length > 0 &&
|
||||
page.label_details.map((label) => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
||||
style={{
|
||||
backgroundColor: `${
|
||||
label?.color && label.color !== "" ? label.color : "#000000"
|
||||
}20`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
label?.color && label.color !== "" ? label.color : "#000000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-gray-400">{renderShortTime(page.updated_at)}</p>
|
||||
{page.is_favorite ? (
|
||||
<button onClick={handleRemoveFromFavorites} className="z-10 grid place-items-center">
|
||||
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleAddToFavorites}
|
||||
type="button"
|
||||
className="z-10 grid place-items-center"
|
||||
>
|
||||
<StarIcon className="h-4 w-4 " color="#858E96" />
|
||||
</button>
|
||||
)}
|
||||
<CustomMenu verticalEllipsis>
|
||||
<CustomMenu.MenuItem onClick={handleEditPage}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
<span>Edit Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleDeletePage}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<TrashIcon className="h-3.5 w-3.5" />
|
||||
<span>Delete Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-gray-400">{renderShortTime(page.updated_at)}</p>
|
||||
{page.is_favorite ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleRemoveFromFavorites();
|
||||
}}
|
||||
className="z-10 grid place-items-center"
|
||||
>
|
||||
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleAddToFavorites();
|
||||
}}
|
||||
className="z-10 grid place-items-center"
|
||||
>
|
||||
<StarIcon className="h-4 w-4 " color="#858E96" />
|
||||
</button>
|
||||
)}
|
||||
<Tooltip
|
||||
tooltipContent={`${
|
||||
page.access
|
||||
? "This page is only visible to you."
|
||||
: "This page can be viewed by anyone in the project."
|
||||
}`}
|
||||
theme="dark"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
partialUpdatePage(page, { access: page.access ? 0 : 1 });
|
||||
}}
|
||||
>
|
||||
{page.access ? (
|
||||
<LockClosedIcon className="h-4 w-4" color="#858e96" />
|
||||
) : (
|
||||
<LockOpenIcon className="h-4 w-4" color="#858e96" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<CustomMenu verticalEllipsis>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEditPage();
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
<span>Edit Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDeletePage();
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<TrashIcon className="h-3.5 w-3.5" />
|
||||
<span>Delete Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-6 space-y-2 text-sm text-gray-600">
|
||||
<div className="page-block-section -m-4 -mt-6">
|
||||
{page.blocks.length > 0 ? (
|
||||
<RemirrorRichTextEditor
|
||||
value={
|
||||
!page.blocks[0].description ||
|
||||
(typeof page.blocks[0].description === "object" &&
|
||||
Object.keys(page.blocks[0].description).length === 0)
|
||||
? page.blocks[0].description_html
|
||||
: page.blocks[0].description
|
||||
}
|
||||
editable={false}
|
||||
customClassName="text-gray-500"
|
||||
noBorder
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-6 space-y-2 text-sm text-gray-600">
|
||||
<div className="page-block-section -m-4 -mt-6">
|
||||
{page.blocks.length > 0 ? (
|
||||
<RemirrorRichTextEditor
|
||||
value={
|
||||
!page.blocks[0].description ||
|
||||
(typeof page.blocks[0].description === "object" &&
|
||||
Object.keys(page.blocks[0].description).length === 0)
|
||||
? page.blocks[0].description_html
|
||||
: page.blocks[0].description
|
||||
}
|
||||
editable={false}
|
||||
customClassName="text-gray-500"
|
||||
noBorder
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
@ -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<IPage>) => void;
|
||||
};
|
||||
|
||||
export const SinglePageListItem: React.FC<TSingleStatProps> = ({
|
||||
@ -27,6 +35,7 @@ export const SinglePageListItem: React.FC<TSingleStatProps> = ({
|
||||
handleDeletePage,
|
||||
handleAddToFavorites,
|
||||
handleRemoveFromFavorites,
|
||||
partialUpdatePage,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -94,6 +103,29 @@ export const SinglePageListItem: React.FC<TSingleStatProps> = ({
|
||||
<StarIcon className="h-4 w-4 " color="#858e96" />
|
||||
</button>
|
||||
)}
|
||||
<Tooltip
|
||||
tooltipContent={`${
|
||||
page.access
|
||||
? "This page is only visible to you."
|
||||
: "This page can be viewed by anyone in the project."
|
||||
}`}
|
||||
theme="dark"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
partialUpdatePage(page, { access: page.access ? 0 : 1 });
|
||||
}}
|
||||
>
|
||||
{page.access ? (
|
||||
<LockClosedIcon className="h-4 w-4" color="#858e96" />
|
||||
) : (
|
||||
<LockOpenIcon className="h-4 w-4" color="#858e96" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<CustomMenu width="auto" verticalEllipsis>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e: any) => {
|
||||
|
@ -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<UserAuth> = (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<IPage>({
|
||||
const { handleSubmit, reset, watch, setValue } = useForm<IPage>({
|
||||
defaultValues: { name: "" },
|
||||
});
|
||||
|
||||
@ -131,34 +143,6 @@ const SinglePage: NextPage<UserAuth> = (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<IPageBlock[]>(
|
||||
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<UserAuth> = (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<IPageBlock[]>(
|
||||
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<UserAuth> = (props) => {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ColorPalletteIcon height={16} width={16} />
|
||||
<ColorPalletteIcon height={16} width={16} color="#000000" />
|
||||
)}
|
||||
</Popover.Button>
|
||||
|
||||
@ -378,6 +406,19 @@ const SinglePage: NextPage<UserAuth> = (props) => {
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
{pageDetails.access ? (
|
||||
<button onClick={() => partialUpdatePage({ access: 0 })} className="z-10">
|
||||
<LockClosedIcon className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => partialUpdatePage({ access: 1 })}
|
||||
type="button"
|
||||
className="z-10"
|
||||
>
|
||||
<LockOpenIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{pageDetails.is_favorite ? (
|
||||
<button onClick={handleRemoveFromFavorites} className="z-10">
|
||||
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
||||
@ -405,34 +446,44 @@ const SinglePage: NextPage<UserAuth> = (props) => {
|
||||
<div className="px-3">
|
||||
{pageBlocks ? (
|
||||
<>
|
||||
{pageBlocks.length !== 0 && (
|
||||
<div className="space-y-4 divide-y">
|
||||
{pageBlocks.map((block, index) => (
|
||||
<>
|
||||
<SinglePageBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
projectDetails={projectDetails}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded bg-gray-100 px-2.5 py-1 text-xs hover:bg-gray-200 mt-4"
|
||||
onClick={createPageBlock}
|
||||
disabled={isAddingBlock}
|
||||
>
|
||||
{isAddingBlock ? (
|
||||
"Adding block..."
|
||||
) : (
|
||||
<>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add new block
|
||||
</>
|
||||
<DragDropContext onDragEnd={handleOnDragEnd}>
|
||||
{pageBlocks.length !== 0 && (
|
||||
<StrictModeDroppable droppableId="blocks-list">
|
||||
{(provided, snapshot) => (
|
||||
<div className="" ref={provided.innerRef} {...provided.droppableProps}>
|
||||
{pageBlocks.map((block, index) => (
|
||||
<SinglePageBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
projectDetails={projectDetails}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
)}
|
||||
</button>
|
||||
</DragDropContext>
|
||||
{!createBlockForm && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded bg-gray-100 px-2.5 py-1 text-xs hover:bg-gray-200 mt-4"
|
||||
onClick={() => setCreateBlockForm(true)}
|
||||
>
|
||||
{isAddingBlock ? (
|
||||
"Adding block..."
|
||||
) : (
|
||||
<>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add new block
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{createBlockForm && (
|
||||
<CreateUpdateBlockInline handleClose={() => setCreateBlockForm(false)} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Loader>
|
||||
|
Loading…
Reference in New Issue
Block a user