feat: pages (#533)

* style: page details

* style: page blocks design

* chore: pages list end points

* feat: add blocks, push blocks to issues

* feat: page labels, color options

* feat: added labels to pages

* fix: update page mutation
This commit is contained in:
Aaryan Khandelwal 2023-03-25 23:39:46 +05:30 committed by GitHub
parent 578d724e41
commit 5d67029b5a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1842 additions and 1058 deletions

View File

@ -0,0 +1,24 @@
import React from "react";
import type { Props } from "./types";
export const ColorPalletteIcon: React.FC<Props> = ({
width = "20",
height = "20",
className,
color = "#858e96",
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9 16.5C7.975 16.5 7.00625 16.3031 6.09375 15.9094C5.18125 15.5156 4.38437 14.9781 3.70312 14.2969C3.02187 13.6156 2.48437 12.8187 2.09062 11.9062C1.69687 10.9937 1.5 10.025 1.5 9C1.5 7.9375 1.7 6.95 2.1 6.0375C2.5 5.125 3.04687 4.33125 3.74062 3.65625C4.43437 2.98125 5.24687 2.45312 6.17812 2.07187C7.10937 1.69062 8.10625 1.5 9.16875 1.5C10.1562 1.5 11.0937 1.66563 11.9812 1.99687C12.8687 2.32812 13.6469 2.7875 14.3156 3.375C14.9844 3.9625 15.5156 4.65937 15.9094 5.46562C16.3031 6.27187 16.5 7.15625 16.5 8.11875C16.5 9.46875 16.1062 10.5344 15.3187 11.3156C14.5312 12.0969 13.4875 12.4875 12.1875 12.4875H10.7812C10.5562 12.4875 10.3625 12.575 10.2 12.75C10.0375 12.925 9.95625 13.1187 9.95625 13.3312C9.95625 13.6687 10.0469 13.9562 10.2281 14.1937C10.4094 14.4312 10.5 14.7062 10.5 15.0187C10.5 15.4937 10.3687 15.8594 10.1062 16.1156C9.84375 16.3719 9.475 16.5 9 16.5ZM4.63125 9.4875C4.88125 9.4875 5.1 9.39375 5.2875 9.20625C5.475 9.01875 5.56875 8.8 5.56875 8.55C5.56875 8.3 5.475 8.08125 5.2875 7.89375C5.1 7.70625 4.88125 7.6125 4.63125 7.6125C4.38125 7.6125 4.1625 7.70625 3.975 7.89375C3.7875 8.08125 3.69375 8.3 3.69375 8.55C3.69375 8.8 3.7875 9.01875 3.975 9.20625C4.1625 9.39375 4.38125 9.4875 4.63125 9.4875ZM6.99375 6.3C7.24375 6.3 7.4625 6.20625 7.65 6.01875C7.8375 5.83125 7.93125 5.6125 7.93125 5.3625C7.93125 5.1125 7.8375 4.89375 7.65 4.70625C7.4625 4.51875 7.24375 4.425 6.99375 4.425C6.74375 4.425 6.525 4.51875 6.3375 4.70625C6.15 4.89375 6.05625 5.1125 6.05625 5.3625C6.05625 5.6125 6.15 5.83125 6.3375 6.01875C6.525 6.20625 6.74375 6.3 6.99375 6.3ZM11.0062 6.3C11.2562 6.3 11.475 6.20625 11.6625 6.01875C11.85 5.83125 11.9437 5.6125 11.9437 5.3625C11.9437 5.1125 11.85 4.89375 11.6625 4.70625C11.475 4.51875 11.2562 4.425 11.0062 4.425C10.7562 4.425 10.5375 4.51875 10.35 4.70625C10.1625 4.89375 10.0687 5.1125 10.0687 5.3625C10.0687 5.6125 10.1625 5.83125 10.35 6.01875C10.5375 6.20625 10.7562 6.3 11.0062 6.3ZM13.4625 9.4875C13.7125 9.4875 13.9312 9.39375 14.1187 9.20625C14.3062 9.01875 14.4 8.8 14.4 8.55C14.4 8.3 14.3062 8.08125 14.1187 7.89375C13.9312 7.70625 13.7125 7.6125 13.4625 7.6125C13.2125 7.6125 12.9937 7.70625 12.8062 7.89375C12.6187 8.08125 12.525 8.3 12.525 8.55C12.525 8.8 12.6187 9.01875 12.8062 9.20625C12.9937 9.39375 13.2125 9.4875 13.4625 9.4875ZM9 15.375C9.1375 15.375 9.23437 15.3469 9.29062 15.2906C9.34687 15.2344 9.375 15.1437 9.375 15.0187C9.375 14.8437 9.28437 14.6812 9.10312 14.5312C8.92187 14.3812 8.83125 14.05 8.83125 13.5375C8.83125 12.9625 9.01875 12.4562 9.39375 12.0187C9.76875 11.5812 10.2437 11.3625 10.8187 11.3625H12.1875C13.1375 11.3625 13.9062 11.0844 14.4937 10.5281C15.0812 9.97187 15.375 9.16875 15.375 8.11875C15.375 6.46875 14.75 5.14062 13.5 4.13437C12.25 3.12812 10.8062 2.625 9.16875 2.625C7.34375 2.625 5.79687 3.24062 4.52812 4.47187C3.25937 5.70312 2.625 7.2125 2.625 9C2.625 10.7625 3.24687 12.2656 4.49062 13.5094C5.73438 14.7531 7.2375 15.375 9 15.375Z"
fill={color}
/>
</svg>
);

View File

@ -7,6 +7,7 @@ export * from "./calendar-month-icon";
export * from "./cancel-icon";
export * from "./cancelled-state-icon";
export * from "./clipboard-icon";
export * from "./color-pallette-icon";
export * from "./comment-icon";
export * from "./completed-cycle-icon";
export * from "./completed-state-icon";
@ -23,6 +24,7 @@ export * from "./started-state-icon";
export * from "./layer-diagonal-icon";
export * from "./lock-icon";
export * from "./menu-icon";
export * from "./pencil-scribble-icon";
export * from "./plus-icon";
export * from "./priority-icon";
export * from "./question-mark-circle-icon";
@ -52,3 +54,4 @@ export * from "./cloud-upload";
export * from "./users";
export * from "./import-layers";
export * from "./check";
export * from "./water-drop-icon";

View File

@ -0,0 +1,23 @@
import React from "react";
import type { Props } from "./types";
export const PencilScribbleIcon: React.FC<Props> = ({
width = "20",
height = "20",
className,
color = "#000000",
}) => (
<svg
width={width}
height={height}
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 96 960 960"
>
<path
d="M560 936q-12 0-21-9t-9-21q0-13 9-21.5t21-8.5q59 0 99.5-24t40.5-56q0-23-29.5-45T591 717l47-47q63 19 92.5 52.5T760 796q0 67-61 103.5T560 936ZM240 642q-64-14-92-44t-28-62q0-35 26-63t120-62q66-24 85-39t19-35q0-25-22-43t-68-18q-27 0-46 7t-34 22q-8 8-20.5 9.5T157 308q-11-8-11.5-20t7.5-21q17-22 51-36.5t76-14.5q68 0 109 32.5t41 88.5q0 41-28.5 69.5T290 466q-67 25-88.5 39.5T180 536q0 16 27 30.5t81 27.5l-48 48Zm496-154L608 360l45-45q18-18 40-18t40 18l48 48q18 18 18 40t-18 40l-45 45ZM220 876h42l345-345-42-42-345 345v42Zm-60 60V808l405-405 128 128-405 405H160Zm405-447 42 42-42-42Z"
fill={color}
/>
</svg>
);

View File

@ -0,0 +1,24 @@
import React from "react";
import type { Props } from "./types";
export const WaterDropIcon: React.FC<Props> = ({
width = "24",
height = "24",
className,
color = "#858e96",
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 14 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.00016 17.334C5.23627 17.334 3.68419 16.7194 2.34391 15.4902C1.00363 14.2611 0.333496 12.5979 0.333496 10.5007C0.333496 9.16732 0.843913 7.7194 1.86475 6.1569C2.88558 4.5944 4.43766 2.90343 6.521 1.08398C6.59044 1.02843 6.66683 0.98329 6.75016 0.948568C6.8335 0.913845 6.91683 0.896484 7.00016 0.896484C7.0835 0.896484 7.16683 0.913845 7.25016 0.948568C7.3335 0.98329 7.40988 1.02843 7.47933 1.08398C9.56266 2.90343 11.1147 4.5944 12.1356 6.1569C13.1564 7.7194 13.6668 9.16732 13.6668 10.5007C13.6668 12.5979 12.9967 14.2611 11.6564 15.4902C10.3161 16.7194 8.76405 17.334 7.00016 17.334ZM7.00016 16.084C8.52794 16.084 9.81266 15.5562 10.8543 14.5007C11.896 13.4451 12.4168 12.1118 12.4168 10.5007C12.4168 9.40343 11.955 8.1569 11.0314 6.76107C10.1078 5.36523 8.76405 3.88954 7.00016 2.33398C5.23627 3.88954 3.89252 5.36523 2.96891 6.76107C2.0453 8.1569 1.5835 9.40343 1.5835 10.5007C1.5835 12.1118 2.10433 13.4451 3.146 14.5007C4.18766 15.5562 5.47238 16.084 7.00016 16.084ZM6.97933 14.6673C7.20155 14.6673 7.37169 14.6291 7.48975 14.5527C7.6078 14.4763 7.66683 14.3618 7.66683 14.209C7.66683 14.0562 7.6078 13.9382 7.48975 13.8548C7.37169 13.7715 7.19461 13.7298 6.9585 13.7298C6.37516 13.7298 5.78141 13.5458 5.17725 13.1777C4.57308 12.8097 4.18766 12.1604 4.021 11.2298C3.99322 11.1048 3.93072 11.0041 3.8335 10.9277C3.73627 10.8513 3.63211 10.8132 3.521 10.8132C3.36822 10.8132 3.25016 10.8722 3.16683 10.9902C3.0835 11.1083 3.05572 11.2298 3.0835 11.3548C3.29183 12.5215 3.78488 13.3652 4.56266 13.8861C5.34044 14.4069 6.146 14.6673 6.97933 14.6673Z"
fill={color}
/>
</svg>
);

View File

@ -49,7 +49,6 @@ export const IssueLabelSelect: React.FC<Props> = ({ setIsOpen, value, onChange,
: issueLabels?.filter((l) => l.name.toLowerCase().includes(query.toLowerCase()));
return (
<>
<Combobox
as="div"
value={value}
@ -134,9 +133,7 @@ export const IssueLabelSelect: React.FC<Props> = ({ setIsOpen, value, onChange,
className="h-3 w-3 flex-shrink-0 rounded-full"
style={{
backgroundColor:
label.color && label.color !== ""
? label.color
: "#000",
label.color && label.color !== "" ? label.color : "#000",
}}
/>
<span>{label.name}</span>
@ -217,6 +214,5 @@ export const IssueLabelSelect: React.FC<Props> = ({ setIsOpen, value, onChange,
</>
)}
</Combobox>
</>
);
};

View File

@ -0,0 +1,34 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import pagesService from "services/pages.service";
// components
import { PagesView } from "components/pages";
// types
import { TPageViewProps } from "types";
// fetch-keys
import { ALL_PAGES_LIST } from "constants/fetch-keys";
type Props = {
viewType: TPageViewProps;
};
export const AllPagesList: React.FC<Props> = ({ viewType }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: pages } = useSWR(
workspaceSlug && projectId ? ALL_PAGES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => pagesService.getAllPages(workspaceSlug as string, projectId as string)
: null
);
return (
<div className="mt-4 space-y-4">
<PagesView pages={pages} viewType={viewType} />
</div>
);
};

View File

@ -13,14 +13,14 @@ import useToast from "hooks/use-toast";
// components
import { PageForm } from "./page-form";
// types
import { IPage, IPageForm } from "types";
import { IPage } from "types";
// fetch-keys
import { PAGE_LIST } from "constants/fetch-keys";
import { RECENT_PAGES_LIST } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
handleClose: () => void;
data?: IPage;
data?: IPage | null;
};
export const CreateUpdatePageModal: React.FC<Props> = ({ isOpen, handleClose, data }) => {
@ -33,11 +33,11 @@ export const CreateUpdatePageModal: React.FC<Props> = ({ isOpen, handleClose, da
handleClose();
};
const createPage = async (payload: IPageForm) => {
const createPage = async (payload: IPage) => {
await pagesService
.createPage(workspaceSlug as string, projectId as string, payload)
.then(() => {
mutate(PAGE_LIST(projectId as string));
mutate(RECENT_PAGES_LIST(projectId as string));
onClose();
setToastAlert({
@ -55,20 +55,11 @@ export const CreateUpdatePageModal: React.FC<Props> = ({ isOpen, handleClose, da
});
};
const updatePage = async (payload: IPageForm) => {
const updatePage = async (payload: IPage) => {
await pagesService
.patchPage(workspaceSlug as string, projectId as string, data?.id ?? "", payload)
.then((res) => {
mutate<IPage[]>(
PAGE_LIST(projectId as string),
(prevData) =>
prevData?.map((p) => {
if (p.id === res.id) return { ...p, ...payload };
return p;
}),
false
);
.then(() => {
mutate(RECENT_PAGES_LIST(projectId as string));
onClose();
setToastAlert({
@ -86,7 +77,7 @@ export const CreateUpdatePageModal: React.FC<Props> = ({ isOpen, handleClose, da
});
};
const handleFormSubmit = async (formData: IPageForm) => {
const handleFormSubmit = async (formData: IPage) => {
if (!workspaceSlug || !projectId) return;
if (!data) await createPage(formData);

View File

@ -1,8 +1,7 @@
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
@ -15,13 +14,12 @@ import { DangerButton, SecondaryButton } from "components/ui";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// types
import type { IPage } from "types";
type TConfirmPageDeletionProps = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
data?: IPage;
data?: IPage | null;
};
// fetch-keys
import { PAGE_LIST } from "constants/fetch-keys";
export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({
isOpen,
@ -31,7 +29,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
@ -42,22 +40,16 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({
const handleDeletion = async () => {
setIsDeleteLoading(true);
if (!data || !workspaceSlug) return;
if (!data || !workspaceSlug || !projectId) return;
await pagesService
.deletePage(workspaceSlug as string, data.project, data.id)
.then(() => {
mutate<IPage[]>(
PAGE_LIST(data.project),
(prevData) => prevData?.filter((page) => page.id !== data?.id),
false
);
handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "Page deleted successfully",
title: "Success!",
message: "Page deleted successfully.",
});
})
.catch(() => {

View File

@ -0,0 +1,34 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import pagesService from "services/pages.service";
// components
import { PagesView } from "components/pages";
// types
import { TPageViewProps } from "types";
// fetch-keys
import { FAVORITE_PAGES_LIST } from "constants/fetch-keys";
type Props = {
viewType: TPageViewProps;
};
export const FavoritePagesList: React.FC<Props> = ({ viewType }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: pages } = useSWR(
workspaceSlug && projectId ? FAVORITE_PAGES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => pagesService.getFavoritePages(workspaceSlug as string, projectId as string)
: null
);
return (
<div className="mt-4 space-y-4">
<PagesView pages={pages} viewType={viewType} />
</div>
);
};

View File

@ -1,5 +1,12 @@
export * from "./all-pages-list";
export * from "./create-update-page-modal";
export * from "./delete-page-modal";
export * from "./favorite-pages-list";
export * from "./my-pages-list";
export * from "./other-pages-list";
export * from "./page-form";
export * from "./pages-list";
export * from "./pages-view";
export * from "./recent-pages-list";
export * from "./single-page-block";
export * from "./single-page-detailed-item";
export * from "./single-page-list-item";

View File

@ -0,0 +1,34 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import pagesService from "services/pages.service";
// components
import { PagesView } from "components/pages";
// types
import { TPageViewProps } from "types";
// fetch-keys
import { MY_PAGES_LIST } from "constants/fetch-keys";
type Props = {
viewType: TPageViewProps;
};
export const MyPagesList: React.FC<Props> = ({ viewType }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: pages } = useSWR(
workspaceSlug && projectId ? MY_PAGES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => pagesService.getMyPages(workspaceSlug as string, projectId as string)
: null
);
return (
<div className="mt-4 space-y-4">
<PagesView pages={pages} viewType={viewType} />
</div>
);
};

View File

@ -0,0 +1,34 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import pagesService from "services/pages.service";
// components
import { PagesView } from "components/pages";
// types
import { TPageViewProps } from "types";
// fetch-keys
import { OTHER_PAGES_LIST } from "constants/fetch-keys";
type Props = {
viewType: TPageViewProps;
};
export const OtherPagesList: React.FC<Props> = ({ viewType }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: pages } = useSWR(
workspaceSlug && projectId ? OTHER_PAGES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => pagesService.getOtherPages(workspaceSlug as string, projectId as string)
: null
);
return (
<div className="mt-4 space-y-4">
<PagesView pages={pages} viewType={viewType} />
</div>
);
};

View File

@ -3,16 +3,16 @@ import { useForm } from "react-hook-form";
// ui
import { Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
// types
import { IPageForm } from "types";
import { IPage } from "types";
type Props = {
handleFormSubmit: (values: IPageForm) => Promise<void>;
handleFormSubmit: (values: IPage) => Promise<void>;
handleClose: () => void;
status: boolean;
data?: IPageForm;
data?: IPage | null;
};
const defaultValues: IPageForm = {
const defaultValues = {
name: "",
description: "",
};
@ -23,11 +23,11 @@ export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, statu
formState: { errors, isSubmitting },
handleSubmit,
reset,
} = useForm<IPageForm>({
} = useForm<IPage>({
defaultValues,
});
const handleCreateUpdatePage = async (formData: IPageForm) => {
const handleCreateUpdatePage = async (formData: IPage) => {
await handleFormSubmit(formData);
reset({

View File

@ -1,172 +0,0 @@
import React from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
// services
import pagesService from "services/pages.service";
// ui
import { CustomMenu } from "components/ui";
// icons
import { PencilIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
// helpers
import { truncateText } from "helpers/string.helper";
// hooks
import useToast from "hooks/use-toast";
// types
import { IPage } from "types";
// fetch keys
import { PAGE_LIST } from "constants/fetch-keys";
import Label from "./page-label";
type TSingleStatProps = {
page: IPage;
handleEditPage: () => void;
handleDeletePage: () => void;
};
export const SinglePageGridItem: React.FC<TSingleStatProps> = (props) => {
const { page, handleEditPage, handleDeletePage } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const handleAddToFavorites = () => {
if (!workspaceSlug && !projectId && !page) return;
pagesService
.addPageToFavorites(workspaceSlug as string, projectId as string, {
page: page.id,
})
.then(() => {
mutate<IPage[]>(
PAGE_LIST(projectId as string),
(prevData) =>
(prevData ?? []).map((m) => ({
...m,
is_favorite: m.id === page.id ? true : m.is_favorite,
})),
false
);
setToastAlert({
type: "success",
title: "Success!",
message: "Successfully added the page to favorites.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the page to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !page) return;
pagesService
.removePageFromFavorites(workspaceSlug as string, projectId as string, page.id)
.then(() => {
mutate<IPage[]>(
PAGE_LIST(projectId as string),
(prevData) =>
(prevData ?? []).map((m) => ({
...m,
is_favorite: m.id === page.id ? false : m.is_favorite,
})),
false
);
setToastAlert({
type: "success",
title: "Success!",
message: "Successfully removed the page from favorites.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the page from favorites. Please try again.",
});
});
};
return (
<div>
<li>
<div className="relative rounded px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
<a className="after:absolute after:inset-0">
<p className="mr-2 truncate text-sm font-medium">{truncateText(page.name, 75)}</p>
</a>
</Link>
<Label variant="green">Meetings</Label>
<Label variant="red">Standup</Label>
<Label variant="blue">Plans</Label>
</div>
<div className="ml-2 flex flex-shrink-0">
<div className="flex items-center gap-2">
<p className="text-sm text-gray-400">
{new Date(page.updated_at).toLocaleTimeString()}
</p>
{page.is_favorite ? (
<button onClick={handleRemoveFromFavorites} className="z-10">
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button onClick={handleAddToFavorites} type="button" className="z-10">
<StarIcon className="h-4 w-4 " color="#858E96" />
</button>
)}
<CustomMenu width="auto" verticalEllipsis>
<CustomMenu.MenuItem onClick={handleEditPage}>
<span className="flex items-center justify-start gap-2 text-gray-800">
<PencilIcon className="h-4 w-4" />
<span>Edit Page</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeletePage}>
<span className="flex items-center justify-start gap-2 text-gray-800">
<TrashIcon className="h-4 w-4" />
<span>Delete Page</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
<div className="relative mt-6 space-y-2 text-sm leading-relaxed text-gray-600">
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Reiciendis atque aliquam
saepe sapiente illo ratione delectus dolorem repellat, id autem, molestiae neque
quaerat ipsum perspiciatis pariatur? Unde consectetur quibusdam ut.
</p>
<p>
Quisquam quas expedita cupiditate ipsum cumque fugit at, optio quia ea? Id doloribus
assumenda ad magni laborum aut, aspernatur nemo similique, suscipit dolores porro
necessitatibus, inventore ab aliquid molestias. Aspernatur.
</p>
<p>
Beatae obcaecati minus temporibus sunt, quo nulla, tenetur nisi sit maiores aspernatur
numquam facilis asperiores eos rerum, ad dolorem quos laboriosam dicta eaque! Pariatur
magni eos, architecto itaque esse minus.
</p>
<p>
Dolorum saepe impedit officiis odit! Porro aliquid dolorum corporis impedit eaque
iusto, illo hic neque quia vero aperiam? Nemo aliquam, hic incidunt mollitia totam
asperiores sunt nam inventore voluptatibus eum?
</p>
<div className="absolute bottom-0 h-24 w-full bg-gradient-to-t from-white" />
</div>
</div>
</li>
</div>
);
};

View File

@ -1,23 +0,0 @@
import React from "react";
type TLabelProps = {
variant: "red" | "blue" | string;
children?: React.ReactNode;
};
const Label: React.FC<TLabelProps> = (props) => {
let color = "bg-green-100 text-green-800";
if (props.variant === "red") {
color = "bg-red-100 text-red-800";
} else if (props.variant === "blue") {
color = "bg-blue-100 text-blue-800";
}
return (
<p className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${color}`}>
{props.children}
</p>
);
};
export default Label;

View File

@ -1,58 +0,0 @@
import { useState } from "react";
// components
import { DeletePageModal } from "components/pages";
import { Loader } from "components/ui";
import { SinglePageGridItem } from "components/pages/page-grid-item";
// types
import { IPage } from "types";
export const PagesGrid: React.FC<any> = ({ pages, setCreateUpdatePageModal, setSelectedPage }) => {
const [pageDeleteModal, setPageDeleteModal] = useState(false);
const [selectedPageForDelete, setSelectedPageForDelete] = useState<any>();
const handleDeletePage = (page: IPage) => {
setSelectedPageForDelete({ ...page, actionType: "delete" });
setPageDeleteModal(true);
};
const handleEditPage = (page: IPage) => {
setSelectedPage({ ...page, actionType: "edit" });
setCreateUpdatePageModal(true);
};
return (
<>
<DeletePageModal
isOpen={
pageDeleteModal &&
!!selectedPageForDelete &&
selectedPageForDelete.actionType === "delete"
}
setIsOpen={setPageDeleteModal}
data={selectedPageForDelete}
/>
{pages ? (
pages.length > 0 ? (
<div className="rounded-[10px] border border-gray-200 bg-white">
<ul role="list" className="divide-y divide-gray-200">
{pages.map((page: any) => (
<SinglePageGridItem
page={page}
key={page.id}
handleDeletePage={() => handleDeletePage(page)}
handleEditPage={() => handleEditPage(page)}
/>
))}
</ul>
</div>
) : (
"No Pages found"
)
) : (
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="200px" />
</Loader>
)}
</>
);
};

View File

@ -1,65 +0,0 @@
import { useState } from "react";
// components
import { DeletePageModal } from "components/pages";
import { Loader } from "components/ui";
// types
import { IPage } from "types";
import { SinglePageListItem } from "./single-page-list-item";
type TPagesListProps = {
pages: IPage[] | undefined;
setCreateUpdatePageModal: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedPage: React.Dispatch<React.SetStateAction<any>>;
};
export const PagesList: React.FC<TPagesListProps> = ({
pages,
setCreateUpdatePageModal,
setSelectedPage,
}) => {
const [pageDeleteModal, setPageDeleteModal] = useState(false);
const [selectedPageForDelete, setSelectedPageForDelete] = useState<any>();
const handleDeletePage = (page: IPage) => {
setSelectedPageForDelete({ ...page, actionType: "delete" });
setPageDeleteModal(true);
};
const handleEditPage = (page: IPage) => {
setSelectedPage({ ...page, actionType: "edit" });
setCreateUpdatePageModal(true);
};
return (
<>
<DeletePageModal
isOpen={
pageDeleteModal &&
!!selectedPageForDelete &&
selectedPageForDelete.actionType === "delete"
}
setIsOpen={setPageDeleteModal}
data={selectedPageForDelete}
/>
{pages ? (
pages.length > 0 ? (
<ul role="list" className="divide-y divide-gray-200">
{pages.map((page) => (
<SinglePageListItem
page={page}
key={page.id}
handleDeletePage={() => handleDeletePage(page)}
handleEditPage={() => handleEditPage(page)}
/>
))}
</ul>
) : (
"No Pages found"
)
) : (
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="200px" />
</Loader>
)}
</>
);
};

View File

@ -1,82 +0,0 @@
import React from "react";
// ui
import { CustomMenu } from "components/ui";
// icons
import { PencilIcon, StarIcon, SwatchIcon, TrashIcon } from "@heroicons/react/24/outline";
const MasonryItem: React.FC<any> = (props) => (
<div
className="mb-6 w-full rounded-lg border border-gray-200 bg-white p-3"
style={{
backgroundColor: props.color,
}}
>
<h2 className="text-lg font-medium">Personal Diary</h2>
<p className="mt-2 text-sm leading-relaxed">{props.children}</p>
<div className="mt-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button onClick={() => {}} type="button" className="z-10">
<SwatchIcon className="h-4 w-4 " color="#858E96" />
</button>
{false ? (
<button onClick={() => {}} className="z-10">
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button onClick={() => {}} type="button" className="z-10">
<StarIcon className="h-4 w-4 " color="#858E96" />
</button>
)}
<CustomMenu width="auto" verticalEllipsis>
<CustomMenu.MenuItem onClick={() => {}}>
<span className="flex items-center justify-start gap-2 text-gray-800">
<PencilIcon className="h-4 w-4" />
<span>Edit Page</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => {}}>
<span className="flex items-center justify-start gap-2 text-gray-800">
<TrashIcon className="h-4 w-4" />
<span>Delete Page</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
<p className="text-sm text-gray-400">9:41 PM</p>
</div>
</div>
</div>
);
export default function PagesMasonry() {
return (
<div className="columns-4 gap-6">
<MasonryItem color="#FF9E9E" isVideo>
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Impedit porro mollitia iure,
reiciendis quo tempora rem debitis velit quas doloremque. Dicta velit voluptas, blanditiis
excepturi vitae eum corporis totam eius?
</MasonryItem>
<MasonryItem>
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Impedit porro mollitia iure,
reiciendis quo tempora rem debitis velit quas doloremque. Dicta velit voluptas, blanditiis
excepturi vitae eum corporis totam eius? Lorem, ipsum dolor sit amet consectetur adipisicing
elit. Impedit porro mollitia iure, reiciendis quo tempora rem debitis velit quas doloremque.
Dicta velit voluptas, blanditiis excepturi vitae eum corporis totam eius?
</MasonryItem>
<MasonryItem color="#FCBE1D">
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Impedit porro mollitia iure,
reiciendis quo tempora rem debitis velit quas doloremque. Dicta velit voluptas, blanditiis
excepturi vitae eum corporis totam eius?
</MasonryItem>
<MasonryItem>
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Impedit porro mollitia iure,
reiciendis quo tempora rem debitis velit quas doloremque. Dicta velit voluptas, blanditiis
excepturi vitae eum corporis totam eius? Lorem, ipsum dolor sit amet consectetur adipisicing
elit. Impedit porro mollitia iure, reiciendis quo tempora rem debitis velit quas doloremque.
Dicta velit voluptas, blanditiis excepturi vitae eum corporis totam eius?
</MasonryItem>
</div>
);
}

View File

@ -0,0 +1,172 @@
import { useState } from "react";
import { useRouter } from "next/router";
// services
import pagesService from "services/pages.service";
// hooks
import useToast from "hooks/use-toast";
// components
import {
CreateUpdatePageModal,
DeletePageModal,
SinglePageDetailedItem,
SinglePageListItem,
} from "components/pages";
// ui
import { EmptyState, Loader } from "components/ui";
// images
import emptyPage from "public/empty-state/empty-page.svg";
// types
import { IPage, TPageViewProps } from "types";
type Props = {
pages: IPage[] | undefined;
viewType: TPageViewProps;
};
export const PagesView: React.FC<Props> = ({ pages, viewType }) => {
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
const [selectedPageToUpdate, setSelectedPageToUpdate] = useState<IPage | null>(null);
const [deletePageModal, setDeletePageModal] = useState(false);
const [selectedPageToDelete, setSelectedPageToDelete] = useState<IPage | null>(null);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const handleEditPage = (page: IPage) => {
setSelectedPageToUpdate(page);
setCreateUpdatePageModal(true);
};
const handleDeletePage = (page: IPage) => {
setSelectedPageToDelete(page);
setDeletePageModal(true);
};
const handleAddToFavorites = (page: IPage) => {
if (!workspaceSlug || !projectId) return;
pagesService
.addPageToFavorites(workspaceSlug as string, projectId as string, {
page: page.id,
})
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Successfully added the page to favorites.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the page to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = (page: IPage) => {
if (!workspaceSlug || !projectId) return;
pagesService
.removePageFromFavorites(workspaceSlug as string, projectId as string, page.id)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Successfully removed the page from favorites.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the page from favorites. Please try again.",
});
});
};
return (
<>
<CreateUpdatePageModal
isOpen={createUpdatePageModal}
handleClose={() => setCreateUpdatePageModal(false)}
data={selectedPageToUpdate}
/>
<DeletePageModal
isOpen={deletePageModal}
setIsOpen={setDeletePageModal}
data={selectedPageToDelete}
/>
{pages ? (
pages.length > 0 ? (
viewType === "list" ? (
<ul role="list" className="divide-y">
{pages.map((page) => (
<SinglePageListItem
key={page.id}
page={page}
handleEditPage={() => handleEditPage(page)}
handleDeletePage={() => handleDeletePage(page)}
handleAddToFavorites={() => handleAddToFavorites(page)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
/>
))}
</ul>
) : viewType === "detailed" ? (
<div className="rounded-[10px] border border-gray-200 bg-white">
{pages.map((page) => (
<SinglePageDetailedItem
key={page.id}
page={page}
handleEditPage={() => handleEditPage(page)}
handleDeletePage={() => handleDeletePage(page)}
handleAddToFavorites={() => handleAddToFavorites(page)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
/>
))}
</div>
) : (
<div className="rounded-[10px] border border-gray-200 bg-white">
{pages.map((page) => (
<SinglePageDetailedItem
key={page.id}
page={page}
handleEditPage={() => handleEditPage(page)}
handleDeletePage={() => handleDeletePage(page)}
handleAddToFavorites={() => handleAddToFavorites(page)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
/>
))}
</div>
)
) : (
<EmptyState
type="page"
title="Create New Page"
description="Sprint more effectively with Cycles by confining your project
to a fixed amount of time. Create new cycle now."
imgURL={emptyPage}
/>
)
) : viewType === "list" ? (
<Loader className="space-y-4">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : (
<Loader className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="150px" />
<Loader.Item height="150px" />
<Loader.Item height="150px" />
</Loader>
)}
</>
);
};

View File

@ -0,0 +1,46 @@
import React from "react";
// components
import { PagesView } from "components/pages";
// ui
import { Loader } from "components/ui";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types
import { RecentPagesResponse, TPageViewProps } from "types";
type Props = {
pages: RecentPagesResponse | undefined;
viewType: TPageViewProps;
};
export const RecentPagesList: React.FC<Props> = ({ pages, viewType }) => (
<>
{pages ? (
Object.keys(pages).length > 0 ? (
<div className="mt-4 space-y-4">
{Object.keys(pages).map((key) => {
if (pages[key].length === 0) return null;
return (
<React.Fragment key={key}>
<h2 className="text-xl font-medium capitalize">
{replaceUnderscoreIfSnakeCase(key)}
</h2>
<PagesView pages={pages[key as keyof RecentPagesResponse]} viewType={viewType} />
</React.Fragment>
);
})}
</div>
) : (
<p className="mt-4 text-center">No issues found</p>
)
) : (
<Loader className="mt-8 space-y-4">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
</>
);

View File

@ -0,0 +1,230 @@
import { useEffect, useState } from "react";
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 { CustomMenu, Loader, TextArea } from "components/ui";
// icons
import { WaterDropIcon } from "components/icons";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { IPageBlock } from "types";
// fetch-keys
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
import { CreateUpdateIssueModal } from "components/issues";
type Props = {
block: IPageBlock;
};
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false,
loading: () => (
<Loader>
<Loader.Item height="100px" width="100%" />
</Loader>
),
});
export const SinglePageBlock: React.FC<Props> = ({ block }) => {
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, pageId } = router.query;
const { setToastAlert } = useToast();
const { handleSubmit, watch, reset, setValue, control } = useForm<IPageBlock>({
defaultValues: {
name: "",
description: {},
description_html: "<p></p>",
},
});
const updatePageBlock = async (formData: Partial<IPageBlock>) => {
if (!workspaceSlug || !projectId || !pageId) return;
if (!formData.name || formData.name.length === 0 || formData.name === "") return;
mutate<IPageBlock[]>(
PAGE_BLOCKS_LIST(pageId as string),
(prevData) =>
prevData?.map((p) => {
if (p.id === block.id) return { ...p, ...formData };
return p;
}),
false
);
await pagesService.patchPageBlock(
workspaceSlug as string,
projectId as string,
pageId as string,
block.id,
{
name: formData.name,
description: formData.description,
description_html: formData.description_html,
}
);
};
const pushBlockIntoIssues = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
await pagesService
.convertPageBlockToIssue(
workspaceSlug as string,
projectId as string,
pageId as string,
block.id
)
.then((res) => {
mutate<IPageBlock[]>(
PAGE_BLOCKS_LIST(pageId as string),
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === block.id) return { ...p, issue: res.id };
return p;
}),
false
);
setToastAlert({
type: "success",
title: "Success!",
message: "Page block converted to issue successfully.",
});
})
.catch((res) => {
setToastAlert({
type: "error",
title: "Error!",
message: "Page block could not be converted to issue. Please try again.",
});
});
};
const editAndPushBlockIntoIssues = async () => {
setCreateUpdateIssueModal(true);
};
const deletePageBlock = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
mutate<IPageBlock[]>(
PAGE_BLOCKS_LIST(pageId as string),
(prevData) => (prevData ?? []).filter((p) => p.id !== block.id),
false
);
await pagesService
.deletePageBlock(workspaceSlug as string, projectId as string, pageId as string, block.id)
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Page could not be deleted. Please try again.",
});
});
};
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(
`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${block.issue}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
useEffect(() => {
if (!block) return;
reset({ ...block });
}, [reset, block]);
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-2 flex items-center justify-between gap-2">
<TextArea
id="name"
name="name"
placeholder="Enter issue name"
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 text-base font-medium"
role="textbox"
disabled={block.issue ? true : false}
/>
<CustomMenu label={<WaterDropIcon width={14} height={15} />} noBorder noChevron>
{block.issue ? (
<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 className="page-block-section -mx-3 -mt-5">
<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="Description..."
editable={block.issue ? false : true}
customClassName="text-gray-500"
noBorder
/>
)}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,126 @@
import React from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import dynamic from "next/dynamic";
// ui
import { CustomMenu, Loader } from "components/ui";
// icons
import { PencilIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
// helpers
import { truncateText } from "helpers/string.helper";
import { renderShortTime } from "helpers/date-time.helper";
// types
import { IPage } from "types";
type TSingleStatProps = {
page: IPage;
handleEditPage: () => void;
handleDeletePage: () => void;
handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void;
};
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false,
loading: () => (
<Loader className="p-4">
<Loader.Item height="100px" width="100%" />
</Loader>
),
});
export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
page,
handleEditPage,
handleDeletePage,
handleAddToFavorites,
handleRemoveFromFavorites,
}) => {
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">
<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>
<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 text-gray-800">
<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 text-gray-800">
<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>
);
};

View File

@ -1,139 +1,95 @@
import React from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
// services
import pagesService from "services/pages.service";
// ui
import { CustomMenu } from "components/ui";
import Label from "./page-label";
import { CustomMenu, Tooltip } from "components/ui";
// icons
import { PencilIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
// helpers
import { truncateText } from "helpers/string.helper";
// hooks
import useToast from "hooks/use-toast";
import { renderShortDate, renderShortTime } from "helpers/date-time.helper";
// types
import { IPage } from "types";
// fetch keys
import { PAGE_LIST } from "constants/fetch-keys";
type TSingleStatProps = {
page: IPage;
handleEditPage: () => void;
handleDeletePage: () => void;
handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void;
};
export const SinglePageListItem: React.FC<TSingleStatProps> = (props) => {
const { page, handleEditPage, handleDeletePage } = props;
export const SinglePageListItem: React.FC<TSingleStatProps> = ({
page,
handleEditPage,
handleDeletePage,
handleAddToFavorites,
handleRemoveFromFavorites,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const handleAddToFavorites = () => {
if (!workspaceSlug && !projectId && !page) return;
pagesService
.addPageToFavorites(workspaceSlug as string, projectId as string, {
page: page.id,
})
.then(() => {
mutate<IPage[]>(
PAGE_LIST(projectId as string),
(prevData) =>
(prevData ?? []).map((m) => ({
...m,
is_favorite: m.id === page.id ? true : m.is_favorite,
})),
false
);
setToastAlert({
type: "success",
title: "Success!",
message: "Successfully added the page to favorites.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the page to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !page) return;
pagesService
.removePageFromFavorites(workspaceSlug as string, projectId as string, page.id)
.then(() => {
mutate<IPage[]>(
PAGE_LIST(projectId as string),
(prevData) =>
(prevData ?? []).map((m) => ({
...m,
is_favorite: m.id === page.id ? false : m.is_favorite,
})),
false
);
setToastAlert({
type: "success",
title: "Success!",
message: "Successfully removed the page from favorites.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the page from favorites. Please try again.",
});
});
};
return (
<div>
<li>
<div className="relative rounded px-4 py-4 hover:bg-gray-200 sm:px-6">
<div className="relative rounded p-4 hover:bg-gray-100">
<div className="flex items-center justify-between">
<div className="flex gap-2">
<div className="flex items-center gap-2">
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
<a className="after:absolute after:inset-0">
<a>
<p className="mr-2 truncate text-sm font-medium">{truncateText(page.name, 75)}</p>
</a>
</Link>
<Label variant="green">Meetings</Label>
<Label variant="red">Standup</Label>
<Label variant="blue">Plans</Label>
{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="ml-2 flex flex-shrink-0">
<div className="flex items-center gap-2">
<p className="text-sm text-gray-400">
{new Date(page.updated_at).toLocaleTimeString()}
</p>
<Tooltip
tooltipContent={`Last updated at ${renderShortTime(
page.updated_at
)} ${renderShortDate(page.updated_at)}`}
>
<p className="text-sm text-gray-400">{renderShortTime(page.updated_at)}</p>
</Tooltip>
{page.is_favorite ? (
<button onClick={handleRemoveFromFavorites} className="z-10">
<button onClick={handleRemoveFromFavorites}>
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button onClick={handleAddToFavorites} type="button" className="z-10">
<StarIcon className="h-4 w-4 " color="#858E96" />
<button onClick={handleAddToFavorites} type="button">
<StarIcon className="h-4 w-4 " color="#858e96" />
</button>
)}
<CustomMenu width="auto" verticalEllipsis>
<CustomMenu.MenuItem onClick={handleEditPage}>
<span className="flex items-center justify-start gap-2 text-gray-800">
<PencilIcon className="h-4 w-4" />
<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 text-gray-800">
<TrashIcon className="h-4 w-4" />
<TrashIcon className="h-3.5 w-3.5" />
<span>Delete Page</span>
</span>
</CustomMenu.MenuItem>
@ -143,6 +99,5 @@ export const SinglePageListItem: React.FC<TSingleStatProps> = (props) => {
</div>
</div>
</li>
</div>
);
};

View File

@ -10,6 +10,7 @@ import { ChevronDownIcon } from "@heroicons/react/24/outline";
import {
ContrastIcon,
LayerDiagonalIcon,
PencilScribbleIcon,
PeopleGroupIcon,
SettingIcon,
ViewListIcon,
@ -52,7 +53,7 @@ const navigation = (workspaceSlug: string, projectId: string) => [
{
name: "Pages",
href: `/${workspaceSlug}/projects/${projectId}/pages`,
icon: ViewListIcon,
icon: PencilScribbleIcon,
},
{
name: "Settings",
@ -164,6 +165,7 @@ export const SingleSidebarProject: React.FC<Props> = ({
? "text-gray-900"
: "text-gray-500 group-hover:text-gray-900"
} ${!sidebarCollapse ? "mr-3" : ""}`}
color={item.href === router.asPath ? "#111827" : "#858e96"}
aria-hidden="true"
/>
</div>

View File

@ -48,6 +48,7 @@ export interface IRemirrorRichTextEditor {
showToolbar?: boolean;
editable?: boolean;
customClassName?: string;
noBorder?: boolean;
}
// eslint-disable-next-line no-duplicate-imports
@ -65,6 +66,7 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
showToolbar = true,
editable = true,
customClassName,
noBorder = false,
} = props;
const [imageLoader, setImageLoader] = useState(false);
@ -184,7 +186,9 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
manager={manager}
initialContent={state}
classNames={[
`p-4 relative focus:outline-none rounded-md border focus:border-theme ${customClassName}`,
`p-4 relative focus:outline-none rounded-md focus:border-theme ${
noBorder ? "" : "border"
} ${customClassName}`,
]}
editable={editable}
onBlur={() => {

View File

@ -1,3 +1,4 @@
export * from "./danger-button";
export * from "./no-border-button";
export * from "./primary-button";
export * from "./secondary-button";

View File

@ -0,0 +1,36 @@
// types
import { ButtonProps } from "./type";
export const NoBorderButton: React.FC<ButtonProps> = ({
children,
className = "",
onClick,
type = "button",
disabled = false,
loading = false,
size = "sm",
outline = false,
}) => (
<button
type={type}
className={`${className} border border-red-500 font-medium duration-300 ${
size === "sm"
? "rounded px-3 py-2 text-xs"
: size === "md"
? "rounded-md px-3.5 py-2 text-sm"
: "rounded-lg px-4 py-2 text-base"
} ${
disabled
? "cursor-not-allowed border-gray-300 bg-gray-300 text-black hover:border-gray-300 hover:border-opacity-100 hover:bg-gray-300 hover:bg-opacity-100 hover:text-black"
: ""
} ${
outline
? "bg-transparent hover:bg-red-500 hover:text-white"
: "bg-red-500 text-white hover:border-opacity-90 hover:bg-opacity-90"
} ${loading ? "cursor-wait" : ""}`}
onClick={onClick}
disabled={disabled || loading}
>
{children}
</button>
);

View File

@ -9,7 +9,7 @@ import { PlusIcon } from "@heroicons/react/24/outline";
import { capitalizeFirstLetter } from "helpers/string.helper";
type Props = {
type: "cycle" | "module" | "project" | "issue" | "view";
type: "cycle" | "module" | "project" | "issue" | "view" | "page";
title: string;
description: React.ReactNode | string;
imgURL: string;

View File

@ -107,7 +107,11 @@ export const SUB_ISSUES = (issueId: string) => `SUB_ISSUES_${issueId}`;
// integrations
// Pages
export const PAGE_LIST = (pageId: string) => `PAGE_LIST_${pageId}`;
export const RECENT_PAGES_LIST = (projectId: string) => `RECENT_PAGES_LIST_${projectId}`;
export const ALL_PAGES_LIST = (projectId: string) => `ALL_PAGES_LIST_${projectId}`;
export const FAVORITE_PAGES_LIST = (projectId: string) => `FAVORITE_PAGES_LIST_${projectId}`;
export const MY_PAGES_LIST = (projectId: string) => `MY_PAGES_LIST_${projectId}`;
export const OTHER_PAGES_LIST = (projectId: string) => `OTHER_PAGES_LIST_${projectId}`;
export const PAGE_DETAILS = (pageId: string) => `PAGE_DETAILS_${pageId}`;
export const PAGE_BLOCK_LIST = (pageId: string) => `PAGE_BLOCK_LIST_${pageId}`;
export const PAGE_BLOCKS_LIST = (pageId: string) => `PAGE_BLOCK_LIST_${pageId}`;
export const PAGE_BLOCK_DETAILS = (pageId: string) => `PAGE_BLOCK_DETAILS_${pageId}`;

View File

@ -105,7 +105,11 @@ export const getDateRangeStatus = (startDate: string | null, endDate: string | n
}
};
export const renderShortDateWithYearFormat = (date: Date) => {
export const renderShortDateWithYearFormat = (date: string | Date) => {
if (!date || date === "") return null;
date = new Date(date);
const months = [
"Jan",
"Feb",
@ -126,7 +130,11 @@ export const renderShortDateWithYearFormat = (date: Date) => {
return isNaN(date.getTime()) ? "N/A" : ` ${month} ${day}, ${year}`;
};
export const renderShortDate = (date: Date) => {
export const renderShortDate = (date: string | Date) => {
if (!date || date === "") return null;
date = new Date(date);
const months = [
"Jan",
"Feb",
@ -145,3 +153,19 @@ export const renderShortDate = (date: Date) => {
const month = months[date.getMonth()];
return isNaN(date.getTime()) ? "N/A" : `${day} ${month}`;
};
export const renderShortTime = (date: string | Date) => {
if (!date || date === "") return null;
date = new Date(date);
const hours = date.getHours();
let minutes: any = date.getMinutes();
// Add leading zero to single-digit minutes
if (minutes < 10) {
minutes = "0" + minutes;
}
return hours + ":" + minutes;
};

View File

@ -1,126 +1,78 @@
import React, { useState } from "react";
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// react-color
import { TwitterPicker } from "react-color";
// lib
import { requiredAuth } from "lib/auth";
// services
import projectService from "services/project.service";
import pagesService from "services/pages.service";
import issuesService from "services/issues.service";
// hooks
import useToast from "hooks/use-toast";
// layouts
import AppLayout from "layouts/app-layout";
// components
import { SinglePageBlock } from "components/pages";
// ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// fetching keys
import { PAGE_BLOCK_LIST, PROJECT_DETAILS } from "constants/fetch-keys";
// components
import { CustomMenu } from "components/ui";
import { CustomSearchSelect, Loader, PrimaryButton, TextArea } from "components/ui";
// icons
import { ArrowLeftIcon, 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";
// types
import { IPageBlock, IView } from "types";
import type { NextPage, GetServerSidePropsContext } from "next";
import pagesService from "services/pages.service";
import useToast from "hooks/use-toast";
import { IIssueLabels, IPage, IPageBlock } from "types";
// fetch-keys
import {
PAGE_BLOCKS_LIST,
PAGE_DETAILS,
PROJECT_DETAILS,
PROJECT_ISSUE_LABELS,
} from "constants/fetch-keys";
const SinglePage: NextPage = () => {
const router = useRouter();
const { workspaceSlug, projectId, pageId } = router.query;
const PageBlock: React.FC<any> = ({ pageBlock }: { pageBlock: IPageBlock }) => {
const [name, setName] = useState(pageBlock.name);
const { setToastAlert } = useToast();
const {
query: { workspaceSlug, projectId, pageId },
} = useRouter();
const updatePageBlock = async () => {
const pageBlockId = pageBlock.id;
await pagesService
.patchPageBlock(
workspaceSlug as string,
projectId as string,
pageId as string,
pageBlockId as string,
{
name,
}
)
.then(() => {
mutate(PAGE_BLOCK_LIST(pageId as string));
console.log("Updated block");
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Page could not be updated. Please try again.",
const { handleSubmit, reset, watch, setValue, control } = useForm<IPage>({
defaultValues: { name: "" },
});
});
};
const deletePageBlock = async () => {
const pageBlockId = pageBlock.id;
await pagesService
.deletePageBlock(
workspaceSlug as string,
projectId as string,
pageId as string,
pageBlockId as string
)
.then(() => {
mutate(PAGE_BLOCK_LIST(pageId as string));
console.log("deleted block");
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Page could not be deleted. Please try again.",
});
});
};
return (
<li className="group flex justify-between rounded p-2 hover:bg-slate-100">
<input
type="text"
value={name}
onKeyDown={(e) => {
if (e.key === "Enter") {
console.log("Updating...");
updatePageBlock();
}
}}
onChange={(e) => {
setName(e.target.value);
}}
className="border-none bg-transparent outline-none"
/>
<div className="hidden group-hover:block">
<CustomMenu>
<CustomMenu.MenuItem>Convert to issue</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={deletePageBlock}>Delete block</CustomMenu.MenuItem>
</CustomMenu>
</div>
</li>
);
};
const ProjectPages: NextPage = () => {
const { setToastAlert } = useToast();
const {
query: { workspaceSlug, projectId, pageId },
} = useRouter();
const { data: activeProject } = useSWR(
const { data: projectDetails } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const { data: pageDetails } = useSWR(
workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId as string) : null,
workspaceSlug && projectId
? () =>
pagesService.getPageDetails(
workspaceSlug as string,
projectId as string,
pageId as string
)
: null
);
const { data: pageBlocks } = useSWR(
workspaceSlug && projectId && pageId ? PAGE_BLOCK_LIST(pageId as string) : null,
workspaceSlug && projectId && pageId ? PAGE_BLOCKS_LIST(pageId as string) : null,
workspaceSlug && projectId
? () =>
pagesService.listPageBlocks(
@ -131,13 +83,65 @@ const ProjectPages: NextPage = () => {
: null
);
const { data: labels } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
: null
);
const updatePage = async (formData: IPage) => {
if (!workspaceSlug || !projectId || !pageId) return;
if (!formData.name || formData.name.length === 0 || formData.name === "") return;
await pagesService
.patchPage(workspaceSlug as string, projectId as string, pageId as string, formData)
.then(() => {
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => ({
...prevData,
...formData,
}),
false
);
});
};
const partialUpdatePage = async (formData: Partial<IPage>) => {
if (!workspaceSlug || !projectId || !pageId) return;
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => ({
...(prevData as IPage),
...formData,
labels: formData.labels_list ? formData.labels_list : (prevData as IPage).labels,
}),
false
);
await pagesService
.patchPage(workspaceSlug as string, projectId as string, pageId as string, formData)
.then(() => {
mutate(PAGE_DETAILS(pageId as string));
});
};
const createPageBlock = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
await pagesService
.createPageBlock(workspaceSlug as string, projectId as string, pageId as string, {
name: "New block",
})
.then(() => {
mutate(PAGE_BLOCK_LIST(pageId as string));
.then((res) => {
mutate<IPageBlock[]>(
PAGE_BLOCKS_LIST(pageId as string),
(prevData) => [...(prevData as IPageBlock[]), res],
false
);
})
.catch(() => {
setToastAlert({
@ -148,6 +152,82 @@ const ProjectPages: NextPage = () => {
});
};
const handleAddToFavorites = () => {
if (!workspaceSlug || !projectId || !pageId) return;
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => ({
...(prevData as IPage),
is_favorite: true,
}),
false
);
pagesService.addPageToFavorites(workspaceSlug as string, projectId as string, {
page: pageId as string,
});
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !projectId || !pageId) return;
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => ({
...(prevData as IPage),
is_favorite: false,
}),
false
);
pagesService.removePageFromFavorites(
workspaceSlug as string,
projectId as string,
pageId as string
);
};
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/pages/${pageId}`).then(
() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Page link copied to clipboard.",
});
}
);
};
const options =
labels?.map((label) => ({
value: label.id,
query: label.name,
content: (
<div className="flex items-center gap-2">
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: label.color && label.color !== "" ? label.color : "#000000",
}}
/>
{label.name}
</div>
),
})) ?? [];
useEffect(() => {
if (!pageDetails) return;
reset({
...pageDetails,
});
}, [reset, pageDetails]);
return (
<AppLayout
meta={{
@ -156,21 +236,205 @@ const ProjectPages: NextPage = () => {
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Projects" link={`/${workspaceSlug}/projects`} />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Pages`} />
<BreadcrumbItem title={`${projectDetails?.name ?? "Project"} Pages`} />
</Breadcrumbs>
}
>
<div className="flex space-x-4 px-2">
<button onClick={createPageBlock}>Li</button>
<button onClick={() => {}}>P</button>
{pageDetails ? (
<div className="h-full w-full space-y-4 rounded-md border bg-white p-4">
<div className="flex items-center justify-between gap-2 px-3">
<button
type="button"
className="flex items-center gap-2 text-sm text-gray-500"
onClick={() => router.back()}
>
<ArrowLeftIcon className="h-4 w-4" />
Back
</button>
<div className="flex flex-wrap gap-1">
{pageDetails.labels.length > 0 ? (
<>
{pageDetails.labels.map((labelId) => {
const label = labels?.find((label) => label.id === labelId);
if (!label) return;
return (
<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 className="rounded border border-slate-200 bg-white p-4 ">
{pageBlocks
? pageBlocks.length === 0
? "Write something..."
: pageBlocks.map((pageBlock) => <PageBlock key={pageBlock.id} pageBlock={pageBlock} />)
: "Loading..."}
);
})}
<CustomSearchSelect
customButton={
<button
type="button"
className="flex items-center gap-1 rounded-md bg-gray-100 p-1.5 text-xs hover:bg-gray-200"
>
<PlusIcon className="h-3.5 w-3.5" />
</button>
}
value={pageDetails.labels}
onChange={(val: string[]) => partialUpdatePage({ labels_list: val })}
options={options}
multiple
noChevron
/>
</>
) : (
<CustomSearchSelect
customButton={
<button
type="button"
className="flex items-center gap-1 rounded-md bg-gray-100 px-3 py-1.5 text-xs hover:bg-gray-200"
>
<PlusIcon className="h-3 w-3" />
Add new label
</button>
}
value={pageDetails.labels}
onChange={(val: string[]) => partialUpdatePage({ labels_list: val })}
options={options}
multiple
noChevron
/>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-500">
{renderShortTime(pageDetails.created_at)}
</span>
<PrimaryButton className="flex items-center gap-2" onClick={handleCopyText}>
<ShareIcon className="h-4 w-4" />
Share
</PrimaryButton>
<button type="button" className="text-sm">
AI
</button>
<div className="flex-shrink-0">
<Popover className="relative grid place-items-center">
{({ open }) => (
<>
<Popover.Button
type="button"
className={`group inline-flex items-center outline-none ${
open ? "text-gray-900" : "text-gray-500"
}`}
>
{watch("color") && watch("color") !== "" ? (
<span
className="h-4 w-4 rounded"
style={{
backgroundColor: watch("color") ?? "black",
}}
/>
) : (
<ColorPalletteIcon height={16} width={16} />
)}
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-full right-0 z-20 mt-1 max-w-xs px-2 sm:px-0">
<TwitterPicker
color={pageDetails.color}
onChange={(val) => partialUpdatePage({ color: val.hex })}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
{pageDetails.is_favorite ? (
<button onClick={handleRemoveFromFavorites} className="z-10">
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button onClick={handleAddToFavorites} type="button" className="z-10">
<StarIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
<div>
<TextArea
id="name"
name="name"
placeholder="Enter issue name"
value={watch("name")}
onBlur={handleSubmit(updatePage)}
onChange={(e) => setValue("name", e.target.value)}
required={true}
className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-2xl font-semibold outline-none ring-0 focus:ring-1 focus:ring-theme"
role="textbox"
/>
</div>
<div className="px-3">
{pageBlocks ? (
pageBlocks.length === 0 ? (
<button
type="button"
className="flex items-center gap-1 rounded px-2.5 py-1 text-xs hover:bg-gray-100"
onClick={createPageBlock}
>
<PlusIcon className="h-3 w-3" />
Add new block
</button>
) : (
<>
<div className="space-y-4">
{pageBlocks.map((block) => (
<SinglePageBlock key={block.id} block={block} />
))}
</div>
<div className="">
<button
type="button"
className="flex items-center gap-1 rounded px-2.5 py-1 text-xs hover:bg-gray-100"
onClick={createPageBlock}
>
<PlusIcon className="h-3 w-3" />
Add new block
</button>
</div>
</>
)
) : (
<Loader>
<Loader.Item height="150px" />
<Loader.Item height="150px" />
</Loader>
)}
</div>
</div>
) : (
<Loader>
<Loader.Item height="200px" />
</Loader>
)}
</AppLayout>
);
};
@ -196,4 +460,4 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
};
};
export default ProjectPages;
export default SinglePage;

View File

@ -1,36 +1,183 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { useRouter } from "next/router";
import type { GetServerSidePropsContext, NextPage } from "next";
import dynamic from "next/dynamic";
import useSWR from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// lib
import { requiredAuth } from "lib/auth";
// headless ui
import { Tab } from "@headlessui/react";
// services
import projectService from "services/project.service";
import pagesService from "services/pages.service";
// hooks
import useToast from "hooks/use-toast";
// icons
import { PlusIcon } from "components/icons";
// layouts
import AppLayout from "layouts/app-layout";
// ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// fetching keys
import { PAGE_LIST, PROJECT_DETAILS } from "constants/fetch-keys";
// components
import { HeaderButton } from "components/ui";
import { CreateUpdatePageModal } from "components/pages/create-update-page-modal";
import { PagesList } from "components/pages/pages-list";
import { IPage } from "types";
import PagesMasonry from "components/pages/pages-masonry";
import { Tab } from "@headlessui/react";
import { ListBulletIcon, RectangleGroupIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
import { PagesGrid } from "components/pages/pages-grid";
import { RecentPagesList, CreateUpdatePageModal } from "components/pages";
// ui
import { HeaderButton, Input, PrimaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons
import { ListBulletIcon, RectangleGroupIcon } from "@heroicons/react/20/solid";
// types
import { IPage, TPageViewProps } from "types";
// fetch-keys
import { PROJECT_DETAILS, RECENT_PAGES_LIST } from "constants/fetch-keys";
const TabPill: React.FC<any> = (props) => (
const AllPagesList = dynamic<{ viewType: TPageViewProps }>(
() => import("components/pages").then((a) => a.AllPagesList),
{
ssr: false,
}
);
const FavoritePagesList = dynamic<{ viewType: TPageViewProps }>(
() => import("components/pages").then((a) => a.FavoritePagesList),
{
ssr: false,
}
);
const MyPagesList = dynamic<{ viewType: TPageViewProps }>(
() => import("components/pages").then((a) => a.MyPagesList),
{
ssr: false,
}
);
const OtherPagesList = dynamic<{ viewType: TPageViewProps }>(
() => import("components/pages").then((a) => a.OtherPagesList),
{
ssr: false,
}
);
const ProjectPages: NextPage = () => {
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
const [viewType, setViewType] = useState<TPageViewProps>("list");
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const {
handleSubmit,
register,
watch,
reset,
formState: { isSubmitting },
} = useForm<Partial<IPage>>({
defaultValues: {
name: "",
},
});
const { data: projectDetails } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const { data: recentPages } = useSWR(
workspaceSlug && projectId ? RECENT_PAGES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => pagesService.getRecentPages(workspaceSlug as string, projectId as string)
: null
);
const createPage = async (formData: Partial<IPage>) => {
if (!workspaceSlug || !projectId) return;
if (formData.name === "") {
setToastAlert({
type: "error",
title: "Error!",
message: "Page name is required",
});
return;
}
await pagesService
.createPage(workspaceSlug as string, projectId as string, formData)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Page created successfully.",
});
reset();
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Page could not be created. Please try again",
});
});
};
return (
<>
<CreateUpdatePageModal
isOpen={createUpdatePageModal}
handleClose={() => setCreateUpdatePageModal(false)}
/>
<AppLayout
meta={{
title: "Plane - Pages",
}}
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Projects" link={`/${workspaceSlug}/projects`} />
<BreadcrumbItem title={`${projectDetails?.name ?? "Project"} Pages`} />
</Breadcrumbs>
}
right={
<HeaderButton
Icon={PlusIcon}
label="Create Page"
onClick={() => setCreateUpdatePageModal(true)}
/>
}
>
<div className="space-y-4">
<form
onSubmit={handleSubmit(createPage)}
className="flex items-center justify-between gap-2 rounded-[10px] border border-gray-200 bg-white p-2 shadow-sm"
>
<Input
type="text"
name="name"
register={register}
className="border-none outline-none focus:ring-0"
placeholder="Type to create a new page..."
/>
{watch("name") !== "" && (
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Creating..." : "Create"}
</PrimaryButton>
)}
</form>
<div>
<Tab.Group>
<Tab.List as="div" className="flex items-center justify-between">
<div className="flex gap-4">
{["Recent", "All", "Favorites", "Created by me", "Created by others"].map(
(tab, index) => (
<Tab
key={index}
className={({ selected }) =>
`rounded-full border px-5 py-1.5 text-sm outline-none ${
selected
@ -39,108 +186,10 @@ const TabPill: React.FC<any> = (props) => (
}`
}
>
{props.children}
{tab}
</Tab>
);
const ProjectPages: NextPage = () => {
const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false);
const [selectedPage, setSelectedPage] = useState<IPage>();
const [viewType, setViewType] = useState("list");
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: activeProject } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const { data: pages } = useSWR(
workspaceSlug && projectId ? PAGE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => pagesService.listPages(workspaceSlug as string, projectId as string)
: null
);
useEffect(() => {
if (isCreateUpdatePageModalOpen) return;
const timer = setTimeout(() => {
setSelectedPage(undefined);
clearTimeout(timer);
}, 500);
return () => {
clearTimeout(timer);
};
}, [isCreateUpdatePageModalOpen]);
return (
<AppLayout
meta={{
title: "Plane - Pages",
}}
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Projects" link={`/${workspaceSlug}/projects`} />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Pages`} />
</Breadcrumbs>
}
right={
<HeaderButton
Icon={PlusIcon}
label="Create Page"
onClick={() => setIsCreateUpdatePageModalOpen(true)}
/>
}
>
<CreateUpdatePageModal
isOpen={isCreateUpdatePageModalOpen}
handleClose={() => setIsCreateUpdatePageModalOpen(false)}
data={selectedPage}
/>
<div className="space-y-4">
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white px-4 pt-3 pb-4 shadow-sm ">
<label htmlFor="name" className="sr-only">
Title
</label>
<input
type="text"
name="name"
id="name"
className="block w-full border-0 pt-2.5 text-lg font-medium placeholder-gray-500 outline-none focus:ring-0"
placeholder="Title"
/>
<label htmlFor="description" className="sr-only">
Description
</label>
<textarea
rows={2}
name="description"
id="description"
className="block w-full resize-none border-0 pb-8 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Write something..."
defaultValue={""}
/>
</div>
{/* <div className="space-y-2 pb-8">
<h3 className="text-3xl font-semibold text-black">Pages</h3>
<p className="text-sm text-gray-500">
Note down all the important and minor details in the way you want to.
</p>
</div> */}
<div>
<Tab.Group>
<Tab.List as="div" className="flex items-center justify-between ">
<div className="flex gap-4 text-base font-medium">
<TabPill>Recent</TabPill>
<TabPill>All</TabPill>
<TabPill>Favorites</TabPill>
<TabPill>Created by me</TabPill>
<TabPill>Created by others</TabPill>
)
)}
</div>
<div className="flex items-center gap-x-1">
<button
@ -152,15 +201,15 @@ const ProjectPages: NextPage = () => {
>
<ListBulletIcon className="h-4 w-4" />
</button>
<button
{/* <button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
viewType === "grid" ? "bg-gray-200" : ""
viewType === "detailed" ? "bg-gray-200" : ""
}`}
onClick={() => setViewType("grid")}
onClick={() => setViewType("detailed")}
>
<Squares2X2Icon className="h-4 w-4" />
</button>
</button> */}
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
@ -172,26 +221,28 @@ const ProjectPages: NextPage = () => {
</button>
</div>
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<RecentPagesList pages={recentPages} viewType={viewType} />
</Tab.Panel>
<Tab.Panel>
<AllPagesList viewType={viewType} />
</Tab.Panel>
<Tab.Panel>
<FavoritePagesList viewType={viewType} />
</Tab.Panel>
<Tab.Panel>
<MyPagesList viewType={viewType} />
</Tab.Panel>
<Tab.Panel>
<OtherPagesList viewType={viewType} />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
{viewType === "list" && (
<PagesList
setSelectedPage={setSelectedPage}
setCreateUpdatePageModal={setIsCreateUpdatePageModalOpen}
pages={pages}
/>
)}
{viewType === "grid" && (
<PagesGrid
setSelectedPage={setSelectedPage}
setCreateUpdatePageModal={setIsCreateUpdatePageModalOpen}
pages={pages}
/>
)}
{viewType === "masonry" && <PagesMasonry />}
</div>
</AppLayout>
</>
);
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -1,7 +1,8 @@
// services
import APIService from "services/api.service";
import { IIssue } from "types";
// types
import { IPage, IPageBlock, IPageBlockForm, IPageFavorite, IPageForm } from "types/pages";
import { IPage, IPageBlock, RecentPagesResponse } from "types/pages";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
@ -10,7 +11,7 @@ class PageServices extends APIService {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async createPage(workspaceSlug: string, projectId: string, data: IPageForm): Promise<IPage> {
async createPage(workspaceSlug: string, projectId: string, data: Partial<IPage>): Promise<IPage> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/`, data)
.then((response) => response?.data)
.catch((error) => {
@ -22,7 +23,7 @@ class PageServices extends APIService {
workspaceSlug: string,
projectId: string,
pageId: string,
data: Partial<IPageForm>
data: Partial<IPage>
): Promise<IPage> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`,
@ -48,7 +49,7 @@ class PageServices extends APIService {
data: {
page: string;
}
): Promise<IPageFavorite> {
): Promise<any> {
return this.post(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-pages/`,
data
@ -69,7 +70,15 @@ class PageServices extends APIService {
});
}
async listPages(workspaceSlug: string, projectId: string): Promise<IPage[]> {
async getRecentPages(workspaceSlug: string, projectId: string): Promise<RecentPagesResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/recent-pages/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getAllPages(workspaceSlug: string, projectId: string): Promise<IPage[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/`)
.then((response) => response?.data)
.catch((error) => {
@ -77,12 +86,46 @@ class PageServices extends APIService {
});
}
async getFavoritePages(workspaceSlug: string, projectId: string): Promise<IPage[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/favorite-pages/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getMyPages(workspaceSlug: string, projectId: string): Promise<IPage[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/my-pages/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getOtherPages(workspaceSlug: string, projectId: string): Promise<IPage[]> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/created-by-other-pages/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getPageDetails(workspaceSlug: string, projectId: string, pageId: string): Promise<IPage> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async createPageBlock(
workspaceSlug: string,
projectId: string,
pageId: string,
data: IPageBlockForm
): Promise<IPage> {
data: Partial<IPageBlock>
): Promise<IPageBlock> {
return this.post(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/page-blocks/`,
data
@ -113,7 +156,7 @@ class PageServices extends APIService {
projectId: string,
pageId: string,
pageBlockId: string,
data: Partial<IPageBlockForm>
data: Partial<IPageBlock>
): Promise<IPage> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/page-blocks/${pageBlockId}/`,
@ -153,6 +196,21 @@ class PageServices extends APIService {
throw error?.response?.data;
});
}
async convertPageBlockToIssue(
workspaceSlug: string,
projectId: string,
pageId: string,
blockId: string
): Promise<IIssue> {
return this.post(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/page-blocks/${blockId}/issues/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
export default new PageServices();

View File

@ -358,7 +358,8 @@ img.ProseMirror-separator {
min-height: 150px;
}
.issue-comments-section .remirror-editor-wrapper .remirror-editor {
.issue-comments-section .remirror-editor-wrapper .remirror-editor,
.page-block-section .remirror-editor-wrapper .remirror-editor {
min-height: 50px;
}

View File

@ -1,74 +1,48 @@
export interface LabelDetail {
id: string;
created_at: Date;
updated_at: Date;
name: string;
description: string;
color: string;
created_by: string;
updated_by: string;
project: string;
workspace: string;
parent: string | null;
}
// types
import { IIssueLabels } from "./issues";
export interface IPage {
id: string;
is_favorite: boolean;
access: number;
blocks: IPageBlock[];
color: string;
created_at: Date;
updated_at: Date;
name: string;
labels: string[];
label_details: LabelDetail[];
created_by: string;
description: string;
description_html: string;
description_stripped: string | null;
access: number;
created_by: string;
updated_by: string;
project: string;
workspace: string;
id: string;
is_favorite: boolean;
label_details: IIssueLabels[];
labels: string[];
labels_list: string[];
name: string;
owned_by: string;
project: string;
updated_at: Date;
updated_by: string;
workspace: string;
}
export interface IPageForm {
name: string;
description?: string;
labels_list?: string[];
export interface RecentPagesResponse {
[key: string]: IPage[];
}
export interface IPageBlock {
id: string;
issue_detail: string | null;
created_at: Date;
updated_at: Date;
name: string;
description: string;
description_html: string;
description_stripped: string | null;
completed_at: Date | null;
created_by: string;
updated_by: string;
project: string;
workspace: string;
page: string;
issue: string | null;
}
export interface IPageBlockForm {
name: string;
description?: string;
}
export interface IPageFavorite {
id: string;
page_detail: IPage;
created_at: Date;
updated_at: Date;
created_by: string;
updated_by: string;
project: string;
workspace: string;
user: string;
description: any;
description_html: any;
id: string;
issue: string | null;
issue_detail: string | null;
name: string;
page: string;
project: string;
sort_order: number;
updated_at: Date;
updated_by: string;
workspace: string;
}
export type TPageViewProps = "list" | "detailed" | "masonry";