feat / public deploy settings workflow (#1863)

* Feat: Implemented project publish settings

* dev: updated the env dependancy in turbo and enabling the publish access to admin
This commit is contained in:
guru_sainath 2023-08-14 19:18:38 +05:30 committed by GitHub
parent d2cdaaccb9
commit daa8f7d79b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1177 additions and 224 deletions

View File

@ -21,6 +21,8 @@ NEXT_PUBLIC_TRACK_EVENTS=0
NEXT_PUBLIC_SLACK_CLIENT_ID="" NEXT_PUBLIC_SLACK_CLIENT_ID=""
# For Telemetry, set it to "app.plane.so" # For Telemetry, set it to "app.plane.so"
NEXT_PUBLIC_PLAUSIBLE_DOMAIN="" NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
# public boards deploy url
NEXT_PUBLIC_DEPLOY_URL=""
# Backend # Backend
# Debug value for api server use it as 0 for production use # Debug value for api server use it as 0 for production use

View File

@ -0,0 +1,446 @@
import React, { useEffect } from "react";
// next imports
import { useRouter } from "next/router";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui components
import { ToggleSwitch, PrimaryButton, SecondaryButton } from "components/ui";
import { CustomPopover } from "./popover";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
import { IProjectPublishSettingsViews } from "store/project-publish";
type Props = {
// user: ICurrentUserResponse | undefined;
};
const defaultValues: Partial<any> = {
id: null,
comments: false,
reactions: false,
votes: false,
inbox: null,
views: [],
};
const viewOptions = [
{ key: "list", value: "List" },
{ key: "kanban", value: "Kanban" },
// { key: "calendar", value: "Calendar" },
// { key: "gantt", value: "Gantt" },
// { key: "spreadsheet", value: "Spreadsheet" },
];
export const PublishProjectModal: React.FC<Props> = observer(() => {
const store: RootStore = useMobxStore();
const { projectPublish } = store;
const { NEXT_PUBLIC_DEPLOY_URL } = process.env;
const plane_deploy_url = NEXT_PUBLIC_DEPLOY_URL
? NEXT_PUBLIC_DEPLOY_URL
: "http://localhost:3001";
const router = useRouter();
const { workspaceSlug } = router.query;
const {
formState: { errors, isSubmitting },
handleSubmit,
reset,
watch,
setValue,
} = useForm<any>({
defaultValues,
reValidateMode: "onChange",
});
const handleClose = () => {
projectPublish.handleProjectModal(null);
reset({ ...defaultValues });
};
useEffect(() => {
if (
projectPublish.projectPublishSettings &&
projectPublish.projectPublishSettings != "not-initialized"
) {
let userBoards: string[] = [];
if (projectPublish.projectPublishSettings?.views) {
const _views: IProjectPublishSettingsViews | null =
projectPublish.projectPublishSettings?.views || null;
if (_views != null) {
if (_views.list) userBoards.push("list");
if (_views.kanban) userBoards.push("kanban");
if (_views.calendar) userBoards.push("calendar");
if (_views.gantt) userBoards.push("gantt");
if (_views.spreadsheet) userBoards.push("spreadsheet");
userBoards = userBoards && userBoards.length > 0 ? userBoards : ["list"];
}
}
const updatedData = {
id: projectPublish.projectPublishSettings?.id || null,
comments: projectPublish.projectPublishSettings?.comments || false,
reactions: projectPublish.projectPublishSettings?.reactions || false,
votes: projectPublish.projectPublishSettings?.votes || false,
inbox: projectPublish.projectPublishSettings?.inbox || null,
views: userBoards,
};
reset({ ...updatedData });
}
}, [reset, projectPublish.projectPublishSettings]);
useEffect(() => {
if (
projectPublish.projectPublishModal &&
workspaceSlug &&
projectPublish.project_id != null &&
projectPublish?.projectPublishSettings === "not-initialized"
) {
projectPublish.getProjectSettingsAsync(
workspaceSlug as string,
projectPublish.project_id as string,
null
);
}
}, [workspaceSlug, projectPublish, projectPublish.projectPublishModal]);
const onSettingsPublish = async (formData: any) => {
const payload = {
comments: formData.comments || false,
reactions: formData.reactions || false,
votes: formData.votes || false,
inbox: formData.inbox || null,
views: {
list: formData.views.includes("list") || false,
kanban: formData.views.includes("kanban") || false,
calendar: formData.views.includes("calendar") || false,
gantt: formData.views.includes("gantt") || false,
spreadsheet: formData.views.includes("spreadsheet") || false,
},
};
return projectPublish
.createProjectSettingsAsync(
workspaceSlug as string,
projectPublish.project_id as string,
payload,
null
)
.then((response) => response)
.catch((error) => {
console.error("error", error);
return error;
});
};
const onSettingsUpdate = async (key: string, value: any) => {
const payload = {
comments: key === "comments" ? value : watch("comments"),
reactions: key === "reactions" ? value : watch("reactions"),
votes: key === "votes" ? value : watch("votes"),
inbox: key === "inbox" ? value : watch("inbox"),
views:
key === "views"
? {
list: value.includes("list") ? true : false,
kanban: value.includes("kanban") ? true : false,
calendar: value.includes("calendar") ? true : false,
gantt: value.includes("gantt") ? true : false,
spreadsheet: value.includes("spreadsheet") ? true : false,
}
: {
list: watch("views").includes("list") ? true : false,
kanban: watch("views").includes("kanban") ? true : false,
calendar: watch("views").includes("calendar") ? true : false,
gantt: watch("views").includes("gantt") ? true : false,
spreadsheet: watch("views").includes("spreadsheet") ? true : false,
},
};
return projectPublish
.updateProjectSettingsAsync(
workspaceSlug as string,
projectPublish.project_id as string,
watch("id"),
payload,
null
)
.then((response) => response)
.catch((error) => {
console.log("error", error);
return error;
});
};
const onSettingsUnPublish = async (formData: any) =>
projectPublish
.deleteProjectSettingsAsync(
workspaceSlug as string,
projectPublish.project_id as string,
formData?.id,
null
)
.then((response) => {
reset({ ...defaultValues });
return response;
})
.catch((error) => {
console.error("error", error);
return error;
});
const CopyLinkToClipboard = ({ copy_link }: { copy_link: string }) => {
const [status, setStatus] = React.useState(false);
const copyText = () => {
navigator.clipboard.writeText(copy_link);
setStatus(true);
setTimeout(() => {
setStatus(false);
}, 1000);
};
return (
<div
className="border border-custom-border-100 bg-custom-background-100 text-xs px-2 min-w-[30px] h-[30px] rounded flex justify-center items-center hover:bg-custom-background-90 cursor-pointer"
onClick={() => copyText()}
>
{status ? "Copied" : "Copy Link"}
</div>
);
};
return (
<Transition.Root show={projectPublish.projectPublishModal} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-100"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="transform rounded-lg bg-custom-background-100 border border-custom-border-100 text-left shadow-xl transition-all w-full sm:w-3/5 lg:w-1/2 xl:w-2/5 space-y-4">
{/* heading */}
<div className="p-3 px-4 pb-0 flex gap-2 justify-between items-center">
<div className="font-medium text-xl">Publish</div>
{projectPublish.loader && (
<div className="text-xs text-custom-text-400">Changes saved</div>
)}
<div
className="hover:bg-custom-background-90 w-[30px] h-[30px] rounded flex justify-center items-center cursor-pointer transition-all"
onClick={handleClose}
>
<span className="material-symbols-rounded text-[16px]">close</span>
</div>
</div>
{/* content */}
<div className="space-y-3">
{watch("id") && (
<div className="flex items-center gap-1 px-4 text-custom-primary-100">
<div className="w-[20px] h-[20px] overflow-hidden flex items-center">
<span className="material-symbols-rounded text-[18px]">
radio_button_checked
</span>
</div>
<div className="text-sm">This project is live on web</div>
</div>
)}
<div className="mx-4 border border-custom-border-100 bg-custom-background-90 rounded p-3 py-2 relative flex gap-2 items-center">
<div className="relative line-clamp-1 overflow-hidden w-full text-sm">
{`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`}
</div>
<div className="flex-shrink-0 relative flex items-center gap-1">
<a
href={`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`}
target="_blank"
rel="noreferrer"
>
<div className="border border-custom-border-100 bg-custom-background-100 w-[30px] h-[30px] rounded flex justify-center items-center hover:bg-custom-background-90 cursor-pointer">
<span className="material-symbols-rounded text-[16px]">open_in_new</span>
</div>
</a>
<CopyLinkToClipboard
copy_link={`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`}
/>
</div>
</div>
<div className="space-y-3 px-4">
<div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Views</div>
<div>
<CustomPopover
label={
watch("views") && watch("views").length > 0
? viewOptions
.filter(
(_view) => watch("views").includes(_view.key) && _view.value
)
.map((_view) => _view.value)
.join(", ")
: ``
}
placeholder="Select views"
>
<>
{viewOptions &&
viewOptions.length > 0 &&
viewOptions.map((_view) => (
<div
key={_view.value}
className={`relative flex items-center gap-2 justify-between p-1 m-1 px-2 cursor-pointer rounded-sm text-custom-text-200 ${
watch("views").includes(_view.key)
? `bg-custom-background-80 text-custom-text-100`
: `hover:bg-custom-background-80 hover:text-custom-text-100`
}`}
onClick={() => {
const _views =
watch("views") && watch("views").length > 0
? watch("views").includes(_view?.key)
? watch("views").filter((_o: string) => _o !== _view?.key)
: [...watch("views"), _view?.key]
: [_view?.key];
setValue("views", _views);
if (watch("id") != null) onSettingsUpdate("views", _views);
}}
>
<div className="text-sm">{_view.value}</div>
<div
className={`w-[18px] h-[18px] relative flex justify-center items-center`}
>
{watch("views") &&
watch("views").length > 0 &&
watch("views").includes(_view.key) && (
<span className="material-symbols-rounded text-[18px]">
done
</span>
)}
</div>
</div>
))}
</>
</CustomPopover>
</div>
</div>
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Allow comments</div>
<div>
<ToggleSwitch
value={watch("comments") ?? false}
onChange={() => {
const _comments = !watch("comments");
setValue("comments", _comments);
if (watch("id") != null) onSettingsUpdate("comments", _comments);
}}
size="sm"
/>
</div>
</div> */}
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Allow reactions</div>
<div>
<ToggleSwitch
value={watch("reactions") ?? false}
onChange={() => {
const _reactions = !watch("reactions");
setValue("reactions", _reactions);
if (watch("id") != null) onSettingsUpdate("reactions", _reactions);
}}
size="sm"
/>
</div>
</div> */}
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Allow Voting</div>
<div>
<ToggleSwitch
value={watch("votes") ?? false}
onChange={() => {
const _votes = !watch("votes");
setValue("votes", _votes);
if (watch("id") != null) onSettingsUpdate("votes", _votes);
}}
size="sm"
/>
</div>
</div> */}
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Allow issue proposals</div>
<div>
<ToggleSwitch
value={watch("inbox") ?? false}
onChange={() => {
setValue("inbox", !watch("inbox"));
}}
size="sm"
/>
</div>
</div> */}
</div>
</div>
{/* modal handlers */}
<div className="border-t border-custom-border-300 p-3 px-4 relative flex justify-between items-center">
<div className="flex items-center gap-1 text-custom-text-300">
<div className="w-[20px] h-[20px] overflow-hidden flex items-center">
<span className="material-symbols-rounded text-[18px]">public</span>
</div>
<div className="text-sm">Anyone with the link can access</div>
</div>
<div className="relative flex items-center gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
{watch("id") != null ? (
<PrimaryButton
outline
onClick={handleSubmit(onSettingsUnPublish)}
disabled={isSubmitting}
>
{isSubmitting ? "Unpublishing..." : "Unpublish"}
</PrimaryButton>
) : (
<PrimaryButton
onClick={handleSubmit(onSettingsPublish)}
disabled={isSubmitting}
>
{isSubmitting ? "Publishing..." : "Publish"}
</PrimaryButton>
)}
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@ -0,0 +1,54 @@
import React, { Fragment } from "react";
// headless ui
import { Popover, Transition } from "@headlessui/react";
export const CustomPopover = ({
children,
label,
placeholder = "Select",
}: {
children: React.ReactNode;
label?: string;
placeholder?: string;
}) => (
<div className="relative">
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`${
open ? "" : ""
} relative flex items-center gap-1 border border-custom-border-300 shadow-sm p-1 px-2 ring-0 outline-none`}
>
<div className="text-sm font-medium">
{label ? label : placeholder ? placeholder : "Select"}
</div>
<div className="w-[20px] h-[20px] relative flex justify-center items-center">
{!open ? (
<span className="material-symbols-rounded text-[20px]">expand_more</span>
) : (
<span className="material-symbols-rounded text-[20px]">expand_less</span>
)}
</div>
</Popover.Button>
<Transition
as={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 right-0 z-[9999]">
<div className="overflow-hidden rounded-sm border border-custom-border-300 mt-1 overflow-y-auto bg-custom-background-90 shadow-lg focus:outline-none">
{children}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
);

View File

@ -32,6 +32,11 @@ import { renderEmoji } from "helpers/emoji.helper";
import { IProject } from "types"; import { IProject } from "types";
// fetch-keys // fetch-keys
import { PROJECTS_LIST } from "constants/fetch-keys"; import { PROJECTS_LIST } from "constants/fetch-keys";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
type Props = { type Props = {
project: IProject; project: IProject;
@ -76,252 +81,277 @@ const navigation = (workspaceSlug: string, projectId: string) => [
}, },
]; ];
export const SingleSidebarProject: React.FC<Props> = ({ export const SingleSidebarProject: React.FC<Props> = observer(
project, ({
sidebarCollapse, project,
provided, sidebarCollapse,
snapshot, provided,
handleDeleteProject, snapshot,
handleCopyText, handleDeleteProject,
shortContextMenu = false, handleCopyText,
}) => { shortContextMenu = false,
const router = useRouter(); }) => {
const { workspaceSlug, projectId } = router.query; const store: RootStore = useMobxStore();
const { projectPublish } = store;
const { setToastAlert } = useToast(); const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const isAdmin = project.member_role === 20; const { setToastAlert } = useToast();
const handleAddToFavorites = () => { const isAdmin = project.member_role === 20;
if (!workspaceSlug) return;
mutate<IProject[]>( const handleAddToFavorites = () => {
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }), if (!workspaceSlug) return;
(prevData) =>
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: true } : p)),
false
);
projectService mutate<IProject[]>(
.addProjectToFavorites(workspaceSlug as string, { PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
project: project.id, (prevData) =>
}) (prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: true } : p)),
.catch(() => false
);
projectService
.addProjectToFavorites(workspaceSlug as string, {
project: project.id,
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.",
})
);
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug) return;
mutate<IProject[]>(
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
(prevData) =>
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: false } : p)),
false
);
projectService.removeProjectFromFavorites(workspaceSlug as string, project.id).catch(() =>
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.", message: "Couldn't remove the project from favorites. Please try again.",
}) })
); );
}; };
const handleRemoveFromFavorites = () => { return (
if (!workspaceSlug) return; <Disclosure key={project.id} defaultOpen={projectId === project.id}>
{({ open }) => (
mutate<IProject[]>( <>
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }), <div
(prevData) => className={`group relative text-custom-sidebar-text-10 px-2 py-1 w-full flex items-center hover:bg-custom-sidebar-background-80 rounded-md ${
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: false } : p)), snapshot?.isDragging ? "opacity-60" : ""
false }`}
);
projectService.removeProjectFromFavorites(workspaceSlug as string, project.id).catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.",
})
);
};
return (
<Disclosure key={project.id} defaultOpen={projectId === project.id}>
{({ open }) => (
<>
<div
className={`group relative text-custom-sidebar-text-10 px-2 py-1 w-full flex items-center hover:bg-custom-sidebar-background-80 rounded-md ${
snapshot?.isDragging ? "opacity-60" : ""
}`}
>
{provided && (
<Tooltip
tooltipContent={
project.sort_order === null
? "Join the project to rearrange"
: "Drag to rearrange"
}
position="top-right"
>
<button
type="button"
className={`absolute top-1/2 -translate-y-1/2 -left-4 hidden rounded p-0.5 text-custom-sidebar-text-400 ${
sidebarCollapse ? "" : "group-hover:!flex"
} ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""}`}
{...provided?.dragHandleProps}
>
<EllipsisVerticalIcon className="h-4" />
<EllipsisVerticalIcon className="-ml-5 h-4" />
</button>
</Tooltip>
)}
<Tooltip
tooltipContent={`${project.name}`}
position="right"
className="ml-2"
disabled={!sidebarCollapse}
> >
<Disclosure.Button {provided && (
as="div" <Tooltip
className={`flex items-center flex-grow truncate cursor-pointer select-none text-left text-sm font-medium ${ tooltipContent={
sidebarCollapse ? "justify-center" : `justify-between` project.sort_order === null
}`} ? "Join the project to rearrange"
: "Drag to rearrange"
}
position="top-right"
>
<button
type="button"
className={`absolute top-1/2 -translate-y-1/2 -left-4 hidden rounded p-0.5 text-custom-sidebar-text-400 ${
sidebarCollapse ? "" : "group-hover:!flex"
} ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""}`}
{...provided?.dragHandleProps}
>
<EllipsisVerticalIcon className="h-4" />
<EllipsisVerticalIcon className="-ml-5 h-4" />
</button>
</Tooltip>
)}
<Tooltip
tooltipContent={`${project.name}`}
position="right"
className="ml-2"
disabled={!sidebarCollapse}
> >
<div <Disclosure.Button
className={`flex items-center flex-grow w-full truncate gap-x-2 ${ as="div"
sidebarCollapse ? "justify-center" : "" className={`flex items-center flex-grow truncate cursor-pointer select-none text-left text-sm font-medium ${
sidebarCollapse ? "justify-center" : `justify-between`
}`} }`}
> >
{project.emoji ? ( <div
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase"> className={`flex items-center flex-grow w-full truncate gap-x-2 ${
{renderEmoji(project.emoji)} sidebarCollapse ? "justify-center" : ""
</span> }`}
) : project.icon_prop ? ( >
<div className="h-7 w-7 flex-shrink-0 grid place-items-center"> {project.emoji ? (
{renderEmoji(project.icon_prop)} <span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
</div> {renderEmoji(project.emoji)}
) : ( </span>
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white"> ) : project.icon_prop ? (
{project?.name.charAt(0)} <div className="h-7 w-7 flex-shrink-0 grid place-items-center">
</span> {renderEmoji(project.icon_prop)}
)} </div>
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{project?.name.charAt(0)}
</span>
)}
{!sidebarCollapse && (
<p className={`truncate ${open ? "" : "text-custom-sidebar-text-200"}`}>
{project.name}
</p>
)}
</div>
{!sidebarCollapse && ( {!sidebarCollapse && (
<p className={`truncate ${open ? "" : "text-custom-sidebar-text-200"}`}> <ExpandMoreOutlined
{project.name} fontSize="small"
</p> className={`flex-shrink-0 ${
open ? "rotate-180" : ""
} !hidden group-hover:!block text-custom-sidebar-text-400 duration-300`}
/>
)} )}
</div> </Disclosure.Button>
{!sidebarCollapse && ( </Tooltip>
<ExpandMoreOutlined
fontSize="small"
className={`flex-shrink-0 ${
open ? "rotate-180" : ""
} !hidden group-hover:!block text-custom-sidebar-text-400 duration-300`}
/>
)}
</Disclosure.Button>
</Tooltip>
{!sidebarCollapse && ( {!sidebarCollapse && (
<CustomMenu <CustomMenu
className="hidden group-hover:block flex-shrink-0" className="hidden group-hover:block flex-shrink-0"
buttonClassName="!text-custom-sidebar-text-400 hover:text-custom-sidebar-text-400" buttonClassName="!text-custom-sidebar-text-400 hover:text-custom-sidebar-text-400"
ellipsis ellipsis
> >
{!shortContextMenu && isAdmin && ( {!shortContextMenu && isAdmin && (
<CustomMenu.MenuItem onClick={handleDeleteProject}> <CustomMenu.MenuItem onClick={handleDeleteProject}>
<span className="flex items-center justify-start gap-2 "> <span className="flex items-center justify-start gap-2 ">
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />
<span>Delete project</span> <span>Delete project</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
{!project.is_favorite && ( {!project.is_favorite && (
<CustomMenu.MenuItem onClick={handleAddToFavorites}> <CustomMenu.MenuItem onClick={handleAddToFavorites}>
<span className="flex items-center justify-start gap-2">
<StarIcon className="h-4 w-4" />
<span>Add to favorites</span>
</span>
</CustomMenu.MenuItem>
)}
{project.is_favorite && (
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
<span className="flex items-center justify-start gap-2">
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<StarIcon className="h-4 w-4" /> <LinkIcon className="h-4 w-4" />
<span>Add to favorites</span> <span>Copy project link</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)}
{project.is_favorite && ( {/* publish project settings */}
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}> {isAdmin && (
<span className="flex items-center justify-start gap-2"> <CustomMenu.MenuItem
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" /> onClick={() => projectPublish.handleProjectModal(project?.id)}
<span>Remove from favorites</span> >
</span> <div className="flex-shrink-0 relative flex items-center justify-start gap-2">
</CustomMenu.MenuItem> <div className="rounded transition-all w-4 h-4 flex justify-center items-center text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 duration-300 cursor-pointer">
)} <span className="material-symbols-rounded text-[16px]">ios_share</span>
<CustomMenu.MenuItem onClick={handleCopyText}> </div>
<span className="flex items-center justify-start gap-2"> <div>Publish</div>
<LinkIcon className="h-4 w-4" /> </div>
<span>Copy project link</span> {/* <PublishProjectModal /> */}
</span> </CustomMenu.MenuItem>
</CustomMenu.MenuItem> )}
{project.archive_in > 0 && (
{project.archive_in > 0 && (
<CustomMenu.MenuItem
onClick={() =>
router.push(`/${workspaceSlug}/projects/${project?.id}/archived-issues/`)
}
>
<div className="flex items-center justify-start gap-2">
<ArchiveOutlined fontSize="small" />
<span>Archived Issues</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => onClick={() =>
router.push(`/${workspaceSlug}/projects/${project?.id}/archived-issues/`) router.push(`/${workspaceSlug}/projects/${project?.id}/settings`)
} }
> >
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<ArchiveOutlined fontSize="small" /> <Icon iconName="settings" className="!text-base !leading-4" />
<span>Archived Issues</span> <span>Settings</span>
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} </CustomMenu>
<CustomMenu.MenuItem )}
onClick={() => router.push(`/${workspaceSlug}/projects/${project?.id}/settings`)} </div>
>
<div className="flex items-center justify-start gap-2">
<Icon iconName="settings" className="!text-base !leading-4" />
<span>Settings</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
<Transition <Transition
enter="transition duration-100 ease-out" enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0" enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100" enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out" leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100" leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0" leaveTo="transform scale-95 opacity-0"
> >
<Disclosure.Panel className={`space-y-2 mt-1 ${sidebarCollapse ? "" : "ml-[2.25rem]"}`}> <Disclosure.Panel
{navigation(workspaceSlug as string, project?.id).map((item) => { className={`space-y-2 mt-1 ${sidebarCollapse ? "" : "ml-[2.25rem]"}`}
if ( >
(item.name === "Cycles" && !project.cycle_view) || {navigation(workspaceSlug as string, project?.id).map((item) => {
(item.name === "Modules" && !project.module_view) || if (
(item.name === "Views" && !project.issue_views_view) || (item.name === "Cycles" && !project.cycle_view) ||
(item.name === "Pages" && !project.page_view) (item.name === "Modules" && !project.module_view) ||
) (item.name === "Views" && !project.issue_views_view) ||
return; (item.name === "Pages" && !project.page_view)
)
return;
return ( return (
<Link key={item.name} href={item.href}> <Link key={item.name} href={item.href}>
<a className="block w-full"> <a className="block w-full">
<Tooltip <Tooltip
tooltipContent={`${project?.name}: ${item.name}`} tooltipContent={`${project?.name}: ${item.name}`}
position="right" position="right"
className="ml-2" className="ml-2"
disabled={!sidebarCollapse} disabled={!sidebarCollapse}
>
<div
className={`group flex items-center rounded-md px-2 py-1.5 gap-2.5 text-xs font-medium outline-none ${
router.asPath.includes(item.href)
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
} ${sidebarCollapse ? "justify-center" : ""}`}
> >
<item.Icon <div
sx={{ className={`group flex items-center rounded-md px-2 py-1.5 gap-2.5 text-xs font-medium outline-none ${
fontSize: 18, router.asPath.includes(item.href)
}} ? "bg-custom-primary-100/10 text-custom-primary-100"
/> : "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
{!sidebarCollapse && item.name} } ${sidebarCollapse ? "justify-center" : ""}`}
</div> >
</Tooltip> <item.Icon
</a> sx={{
</Link> fontSize: 18,
); }}
})} />
</Disclosure.Panel> {!sidebarCollapse && item.name}
</Transition> </div>
</> </Tooltip>
)} </a>
</Disclosure> </Link>
); );
}; })}
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
);
}
);

View File

@ -8,6 +8,7 @@ import {
WorkspaceSidebarQuickAction, WorkspaceSidebarQuickAction,
} from "components/workspace"; } from "components/workspace";
import { ProjectSidebarList } from "components/project"; import { ProjectSidebarList } from "components/project";
import { PublishProjectModal } from "components/project/publish-project/modal";
// mobx react lite // mobx react lite
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// mobx store // mobx store
@ -37,6 +38,7 @@ const Sidebar: React.FC<SidebarProps> = observer(({ toggleSidebar, setToggleSide
<ProjectSidebarList /> <ProjectSidebarList />
<WorkspaceHelpSection setSidebarActive={setToggleSidebar} /> <WorkspaceHelpSection setSidebarActive={setToggleSidebar} />
</div> </div>
<PublishProjectModal />
</div> </div>
); );
}); });

View File

@ -1,24 +1,22 @@
"use client";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
// mobx store // mobx store
import { RootStore } from "store/root"; import { RootStore } from "store/root";
let rootStore: any = null; let rootStore: RootStore = new RootStore();
export const MobxStoreContext = createContext(null); export const MobxStoreContext = createContext<RootStore>(rootStore);
const initializeStore = () => { const initializeStore = () => {
const _rootStore = rootStore ?? new RootStore(); const _rootStore: RootStore = rootStore ?? new RootStore();
if (typeof window === "undefined") return _rootStore; if (typeof window === "undefined") return _rootStore;
if (!rootStore) rootStore = _rootStore; if (!rootStore) rootStore = _rootStore;
return _rootStore; return _rootStore;
}; };
export const MobxStoreProvider = ({ children }: any) => { export const MobxStoreProvider = ({ children }: any) => {
const store = initializeStore(); const store: RootStore = initializeStore();
return <MobxStoreContext.Provider value={store}>{children}</MobxStoreContext.Provider>; return <MobxStoreContext.Provider value={store}>{children}</MobxStoreContext.Provider>;
}; };

View File

@ -0,0 +1,117 @@
// services
import APIService from "services/api.service";
import trackEventServices from "services/track-event.service";
// types
import { ICurrentUserResponse } from "types";
import { IProjectPublishSettings } from "store/project-publish";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
class ProjectServices extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async getProjectSettingsAsync(
workspace_slug: string,
project_slug: string,
user: ICurrentUserResponse | undefined
): Promise<any> {
return this.get(
`/api/workspaces/${workspace_slug}/projects/${project_slug}/project-deploy-boards/`
)
.then((response) => {
if (trackEvent) {
// trackEventServices.trackProjectPublishSettingsEvent(
// response.data,
// "GET_PROJECT_PUBLISH_SETTINGS",
// user
// );
}
return response?.data;
})
.catch((error) => {
throw error?.response;
});
}
async createProjectSettingsAsync(
workspace_slug: string,
project_slug: string,
data: IProjectPublishSettings,
user: ICurrentUserResponse | undefined
): Promise<any> {
return this.post(
`/api/workspaces/${workspace_slug}/projects/${project_slug}/project-deploy-boards/`,
data
)
.then((response) => {
if (trackEvent) {
// trackEventServices.trackProjectPublishSettingsEvent(
// response.data,
// "CREATE_PROJECT_PUBLISH_SETTINGS",
// user
// );
}
return response?.data;
})
.catch((error) => {
throw error?.response;
});
}
async updateProjectSettingsAsync(
workspace_slug: string,
project_slug: string,
project_publish_id: string,
data: IProjectPublishSettings,
user: ICurrentUserResponse | undefined
): Promise<any> {
return this.patch(
`/api/workspaces/${workspace_slug}/projects/${project_slug}/project-deploy-boards/${project_publish_id}/`,
data
)
.then((response) => {
if (trackEvent) {
// trackEventServices.trackProjectPublishSettingsEvent(
// response.data,
// "UPDATE_PROJECT_PUBLISH_SETTINGS",
// user
// );
}
return response?.data;
})
.catch((error) => {
throw error?.response;
});
}
async deleteProjectSettingsAsync(
workspace_slug: string,
project_slug: string,
project_publish_id: string,
user: ICurrentUserResponse | undefined
): Promise<any> {
return this.delete(
`/api/workspaces/${workspace_slug}/projects/${project_slug}/project-deploy-boards/${project_publish_id}/`
)
.then((response) => {
if (trackEvent) {
// trackEventServices.trackProjectPublishSettingsEvent(
// response.data,
// "DELETE_PROJECT_PUBLISH_SETTINGS",
// user
// );
}
return response?.data;
})
.catch((error) => {
throw error?.response;
});
}
}
export default ProjectServices;

View File

@ -856,6 +856,27 @@ class TrackEventServices extends APIService {
}, },
}); });
} }
// project publish settings track events starts
async trackProjectPublishSettingsEvent(
data: any,
eventName: string,
user: ICurrentUserResponse | undefined
): Promise<any> {
const payload: any = data;
return this.request({
url: "/api/track-event",
method: "POST",
data: {
eventName,
extra: payload,
user: user,
},
});
}
// project publish settings track events ends
} }
const trackEventServices = new TrackEventServices(); const trackEventServices = new TrackEventServices();

View File

@ -0,0 +1,279 @@
import { observable, action, computed, makeObservable, runInAction } from "mobx";
// types
import { RootStore } from "./root";
// services
import ProjectServices from "services/project-publish.service";
export type IProjectPublishSettingsViewKeys =
| "list"
| "gantt"
| "kanban"
| "calendar"
| "spreadsheet"
| string;
export interface IProjectPublishSettingsViews {
list: boolean;
gantt: boolean;
kanban: boolean;
calendar: boolean;
spreadsheet: boolean;
}
export interface IProjectPublishSettings {
id?: string;
project?: string;
comments: boolean;
reactions: boolean;
votes: boolean;
views: IProjectPublishSettingsViews;
inbox: null;
}
export interface IProjectPublishStore {
loader: boolean;
error: any | null;
projectPublishModal: boolean;
project_id: string | null;
projectPublishSettings: IProjectPublishSettings | "not-initialized";
handleProjectModal: (project_id: string | null) => void;
getProjectSettingsAsync: (
workspace_slug: string,
project_slug: string,
user: any
) => Promise<void>;
createProjectSettingsAsync: (
workspace_slug: string,
project_slug: string,
data: IProjectPublishSettings,
user: any
) => Promise<void>;
updateProjectSettingsAsync: (
workspace_slug: string,
project_slug: string,
project_publish_id: string,
data: IProjectPublishSettings,
user: any
) => Promise<void>;
deleteProjectSettingsAsync: (
workspace_slug: string,
project_slug: string,
project_publish_id: string,
user: any
) => Promise<void>;
}
class ProjectPublishStore implements IProjectPublishStore {
loader: boolean = false;
error: any | null = null;
projectPublishModal: boolean = false;
project_id: string | null = null;
projectPublishSettings: IProjectPublishSettings | "not-initialized" = "not-initialized";
// root store
rootStore;
// service
projectPublishService;
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observable
loader: observable,
error: observable,
projectPublishModal: observable,
project_id: observable,
projectPublishSettings: observable.ref,
// action
handleProjectModal: action,
// computed
});
this.rootStore = _rootStore;
this.projectPublishService = new ProjectServices();
}
handleProjectModal = (project_id: string | null = null) => {
if (project_id) {
this.projectPublishModal = !this.projectPublishModal;
this.project_id = project_id;
} else {
this.projectPublishModal = !this.projectPublishModal;
this.project_id = null;
this.projectPublishSettings = "not-initialized";
}
};
getProjectSettingsAsync = async (workspace_slug: string, project_slug: string, user: any) => {
try {
this.loader = true;
this.error = null;
const response = await this.projectPublishService.getProjectSettingsAsync(
workspace_slug,
project_slug,
user
);
if (response && response.length > 0) {
const _projectPublishSettings: IProjectPublishSettings = {
id: response[0]?.id,
comments: response[0]?.comments,
reactions: response[0]?.reactions,
votes: response[0]?.votes,
views: {
list: response[0]?.views?.list || false,
kanban: response[0]?.views?.kanban || false,
calendar: response[0]?.views?.calendar || false,
gantt: response[0]?.views?.gantt || false,
spreadsheet: response[0]?.views?.spreadsheet || false,
},
inbox: response[0]?.inbox || null,
project: response[0]?.project || null,
};
runInAction(() => {
this.projectPublishSettings = _projectPublishSettings;
this.loader = false;
this.error = null;
});
} else {
this.projectPublishSettings = "not-initialized";
this.loader = false;
this.error = null;
}
return response;
} catch (error) {
this.loader = false;
this.error = error;
return error;
}
};
createProjectSettingsAsync = async (
workspace_slug: string,
project_slug: string,
data: IProjectPublishSettings,
user: any
) => {
try {
this.loader = true;
this.error = null;
const response = await this.projectPublishService.createProjectSettingsAsync(
workspace_slug,
project_slug,
data,
user
);
if (response) {
const _projectPublishSettings: IProjectPublishSettings = {
id: response?.id || null,
comments: response?.comments || false,
reactions: response?.reactions || false,
votes: response?.votes || false,
views: { ...response?.views },
inbox: response?.inbox || null,
project: response?.project || null,
};
runInAction(() => {
this.projectPublishSettings = _projectPublishSettings;
this.loader = false;
this.error = null;
});
return response;
}
} catch (error) {
this.loader = false;
this.error = error;
return error;
}
};
updateProjectSettingsAsync = async (
workspace_slug: string,
project_slug: string,
project_publish_id: string,
data: IProjectPublishSettings,
user: any
) => {
try {
this.loader = true;
this.error = null;
const response = await this.projectPublishService.updateProjectSettingsAsync(
workspace_slug,
project_slug,
project_publish_id,
data,
user
);
if (response) {
const _projectPublishSettings: IProjectPublishSettings = {
id: response?.id || null,
comments: response?.comments || false,
reactions: response?.reactions || false,
votes: response?.votes || false,
views: { ...response?.views },
inbox: response?.inbox || null,
project: response?.project || null,
};
runInAction(() => {
this.projectPublishSettings = _projectPublishSettings;
this.loader = false;
this.error = null;
});
return response;
}
} catch (error) {
this.loader = false;
this.error = error;
return error;
}
};
deleteProjectSettingsAsync = async (
workspace_slug: string,
project_slug: string,
project_publish_id: string,
user: any
) => {
try {
this.loader = true;
this.error = null;
const response = await this.projectPublishService.deleteProjectSettingsAsync(
workspace_slug,
project_slug,
project_publish_id,
user
);
if (response) {
runInAction(() => {
this.projectPublishSettings = "not-initialized";
this.loader = false;
this.error = null;
});
return response;
}
} catch (error) {
this.loader = false;
this.error = error;
return error;
}
};
}
export default ProjectPublishStore;

View File

@ -3,15 +3,18 @@ import { enableStaticRendering } from "mobx-react-lite";
// store imports // store imports
import UserStore from "./user"; import UserStore from "./user";
import ThemeStore from "./theme"; import ThemeStore from "./theme";
import ProjectPublishStore, { IProjectPublishStore } from "./project-publish";
enableStaticRendering(typeof window === "undefined"); enableStaticRendering(typeof window === "undefined");
export class RootStore { export class RootStore {
user; user;
theme; theme;
projectPublish: IProjectPublishStore;
constructor() { constructor() {
this.user = new UserStore(this); this.user = new UserStore(this);
this.theme = new ThemeStore(this); this.theme = new ThemeStore(this);
this.projectPublish = new ProjectPublishStore(this);
} }
} }

View File

@ -4,6 +4,7 @@
"NEXT_PUBLIC_GITHUB_ID", "NEXT_PUBLIC_GITHUB_ID",
"NEXT_PUBLIC_GOOGLE_CLIENTID", "NEXT_PUBLIC_GOOGLE_CLIENTID",
"NEXT_PUBLIC_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL",
"NEXT_PUBLIC_DEPLOY_URL",
"API_BASE_URL", "API_BASE_URL",
"NEXT_PUBLIC_SENTRY_DSN", "NEXT_PUBLIC_SENTRY_DSN",
"SENTRY_AUTH_TOKEN", "SENTRY_AUTH_TOKEN",