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:
Aaryan Khandelwal 2023-04-03 23:30:29 +05:30 committed by GitHub
parent f6f9caf9e6
commit 5ba7d271b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 647 additions and 251 deletions

View 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>
);
};

View File

@ -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";

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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) => {

View File

@ -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>