feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
"use client";
import { useState, Fragment } from "react";
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
type Props = {
workspaceSlug: string;
projectId: string;
isOpen: boolean;
onClose: () => void;
archive: boolean;
};
export const ArchiveRestoreProjectModal: React.FC<Props> = (props) => {
const { workspaceSlug, projectId, isOpen, onClose, archive } = props;
// router
const router = useAppRouter();
// states
const [isLoading, setIsLoading] = useState(false);
// store hooks
const { getProjectById, archiveProject, restoreProject } = useProject();
const projectDetails = getProjectById(projectId);
if (!projectDetails) return null;
const handleClose = () => {
setIsLoading(false);
onClose();
};
const handleArchiveProject = async () => {
setIsLoading(true);
await archiveProject(workspaceSlug, projectId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Archive success",
message: `${projectDetails.name} has been archived successfully`,
});
onClose();
router.push(`/${workspaceSlug}/projects/`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Project could not be archived. Please try again.",
})
)
.finally(() => setIsLoading(false));
};
const handleRestoreProject = async () => {
setIsLoading(true);
await restoreProject(workspaceSlug, projectId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Restore success",
message: `You can find ${projectDetails.name} in your projects.`,
});
onClose();
router.push(`/${workspaceSlug}/projects/`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Project could not be restored. Please try again.",
})
)
.finally(() => setIsLoading(false));
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
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-200"
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="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="px-5 py-4">
<h3 className="text-xl font-medium 2xl:text-2xl">
{archive ? "Archive" : "Restore"} {projectDetails.name}
</h3>
<p className="mt-3 text-sm text-custom-text-200">
{archive
? "This project and its work items, cycles, modules, and pages will be archived. Its work items wont appear in search. Only project admins can restore the project."
: "Restoring a project will activate it and make it visible to all members of the project. Are you sure you want to continue?"}
</p>
<div className="mt-3 flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
size="sm"
tabIndex={1}
onClick={archive ? handleArchiveProject : handleRestoreProject}
loading={isLoading}
>
{archive ? (isLoading ? "Archiving" : "Archive") : isLoading ? "Restoring" : "Restore"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@@ -0,0 +1,63 @@
"use client";
import React from "react";
import { ChevronRight, ChevronUp } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// types
import { Button } from "@plane/propel/button";
import type { IProject } from "@plane/types";
// ui
import { Loader } from "@plane/ui";
export interface IArchiveProject {
projectDetails: IProject;
handleArchive: () => void;
}
export const ArchiveProjectSelection: React.FC<IArchiveProject> = (props) => {
const { projectDetails, handleArchive } = props;
return (
<Disclosure as="div" className="border-t border-custom-border-100 py-4">
{({ open }) => (
<div className="w-full">
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between">
<span className="text-xl tracking-tight">Archive project</span>
{open ? <ChevronUp className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />}
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="flex flex-col gap-8 pt-4">
<span className="text-sm tracking-tight">
Archiving a project will unlist your project from your side navigation although you will still be able
to access it from your projects page. You can restore the project or delete it whenever you want.
</span>
<div>
{projectDetails ? (
<div>
<Button variant="outline-danger" onClick={handleArchive}>
Archive project
</Button>
</div>
) : (
<Loader className="mt-2 w-full">
<Loader.Item height="38px" width="144px" />
</Loader>
)}
</div>
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
);
};

View File

@@ -0,0 +1,69 @@
"use client";
import React from "react";
import { ChevronRight, ChevronUp } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// types
import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { Button } from "@plane/propel/button";
import type { IProject } from "@plane/types";
// ui
import { Loader } from "@plane/ui";
export interface IDeleteProjectSection {
projectDetails: IProject;
handleDelete: () => void;
}
export const DeleteProjectSection: React.FC<IDeleteProjectSection> = (props) => {
const { projectDetails, handleDelete } = props;
return (
<Disclosure as="div" className="border-t border-custom-border-100 py-4">
{({ open }) => (
<div className="w-full">
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between">
<span className="text-xl tracking-tight">Delete project</span>
{open ? <ChevronUp className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />}
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="flex flex-col gap-8 pt-4">
<span className="text-sm tracking-tight">
When deleting a project, all of the data and resources within that project will be permanently removed
and cannot be recovered.
</span>
<div>
{projectDetails ? (
<div>
<Button
variant="danger"
onClick={handleDelete}
data-ph-element={PROJECT_TRACKER_ELEMENTS.DELETE_PROJECT_BUTTON}
>
Delete my project
</Button>
</div>
) : (
<Loader className="mt-2 w-full">
<Loader.Item height="38px" width="144px" />
</Loader>
)}
</div>
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
);
};

View File

@@ -0,0 +1,118 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { PROJECT_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { setPromiseToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { IProject } from "@plane/types";
// components
import { SettingsHeading } from "@/components/settings/heading";
// helpers
import { captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUser } from "@/hooks/store/user";
// plane web imports
import { UpgradeBadge } from "@/plane-web/components/workspace/upgrade-badge";
import { PROJECT_FEATURES_LIST } from "@/plane-web/constants/project/settings";
import { ProjectFeatureToggle } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
isAdmin: boolean;
};
export const ProjectFeaturesList: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, isAdmin } = props;
// store hooks
const { t } = useTranslation();
const { data: currentUser } = useUser();
const { getProjectById, updateProject } = useProject();
// derived values
const currentProjectDetails = getProjectById(projectId);
const handleSubmit = async (featureKey: string, featureProperty: string) => {
if (!workspaceSlug || !projectId || !currentProjectDetails) return;
// making the request to update the project feature
const settingsPayload = {
[featureProperty]: !currentProjectDetails?.[featureProperty as keyof IProject],
};
const updateProjectPromise = updateProject(workspaceSlug, projectId, settingsPayload);
setPromiseToast(updateProjectPromise, {
loading: "Updating project feature...",
success: {
title: "Success!",
message: () => "Project feature updated successfully.",
},
error: {
title: "Error!",
message: () => "Something went wrong while updating project feature. Please try again.",
},
});
updateProjectPromise.then(() => {
captureSuccess({
eventName: PROJECT_TRACKER_EVENTS.feature_toggled,
payload: {
feature_key: featureKey,
},
});
});
};
if (!currentUser) return <></>;
return (
<div className="space-y-6">
{Object.entries(PROJECT_FEATURES_LIST).map(([featureSectionKey, feature]) => (
<div key={featureSectionKey} className="">
<SettingsHeading title={t(feature.key)} description={t(`${feature.key}_description`)} />
{Object.entries(feature.featureList).map(([featureItemKey, featureItem]) => (
<div
key={featureItemKey}
className="gap-x-8 gap-y-2 border-b border-custom-border-100 bg-custom-background-100 py-4"
>
<div key={featureItemKey} className="flex items-center justify-between">
<div className="flex items-start gap-3">
<div className="flex items-center justify-center rounded bg-custom-background-90 p-3">
{featureItem.icon}
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium leading-5">{t(featureItem.key)}</h4>
{featureItem.isPro && (
<Tooltip tooltipContent="Pro feature" position="top">
<UpgradeBadge className="rounded" />
</Tooltip>
)}
</div>
<p className="text-sm leading-5 tracking-tight text-custom-text-300">
{t(`${featureItem.key}_description`)}
</p>
</div>
</div>
<ProjectFeatureToggle
workspaceSlug={workspaceSlug}
projectId={projectId}
featureItem={featureItem}
value={Boolean(currentProjectDetails?.[featureItem.property as keyof IProject])}
handleSubmit={handleSubmit}
disabled={!isAdmin}
/>
</div>
<div className="pl-14">
{currentProjectDetails?.[featureItem.property as keyof IProject] &&
featureItem.renderChildren?.(currentProjectDetails, workspaceSlug)}
</div>
</div>
))}
</div>
))}
</div>
);
});

View File

@@ -0,0 +1,41 @@
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { EPillVariant, Pill, EPillSize } from "@plane/propel/pill";
import { ToggleSwitch } from "@plane/ui";
import type { TProperties } from "@/plane-web/constants/project/settings/features";
type Props = {
workspaceSlug: string;
projectId: string;
featureItem: TProperties;
value: boolean;
handleSubmit: (featureKey: string, featureProperty: string) => void;
disabled?: boolean;
};
export const ProjectFeatureToggle = (props: Props) => {
const { workspaceSlug, projectId, featureItem, value, handleSubmit, disabled } = props;
return featureItem.href ? (
<Link href={`/${workspaceSlug}/settings/projects/${projectId}/features/${featureItem.href}`}>
<div className="flex items-center gap-2">
<Pill
variant={value ? EPillVariant.PRIMARY : EPillVariant.DEFAULT}
size={EPillSize.SM}
className="border-none rounded-lg"
>
{value ? "Enabled" : "Disabled"}
</Pill>
<ChevronRight className="h-4 w-4 text-custom-text-300" />
</div>
</Link>
) : (
<ToggleSwitch
value={value}
onChange={() => handleSubmit(featureItem.key, featureItem.property)}
disabled={disabled}
size="sm"
data-ph-element={PROJECT_TRACKER_ELEMENTS.TOGGLE_FEATURE}
/>
);
};

View File

@@ -0,0 +1,189 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form";
import { CircleMinus } from "lucide-react";
import { Disclosure } from "@headlessui/react";
// plane imports
import { ROLE, EUserPermissions, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { EUserProjectRoles, IUser, IWorkspaceMember, TProjectMembership } from "@plane/types";
import { CustomMenu, CustomSelect } from "@plane/ui";
import { getFileURL } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser, useUserPermissions } from "@/hooks/store/user";
export interface RowData extends Pick<TProjectMembership, "original_role"> {
member: IWorkspaceMember;
}
type NameProps = {
rowData: RowData;
workspaceSlug: string;
isAdmin: boolean;
currentUser: IUser | undefined;
setRemoveMemberModal: (rowData: RowData) => void;
};
type AccountTypeProps = {
rowData: RowData;
currentProjectRole: EUserPermissions | undefined;
workspaceSlug: string;
projectId: string;
};
export const NameColumn: React.FC<NameProps> = (props) => {
const { rowData, workspaceSlug, isAdmin, currentUser, setRemoveMemberModal } = props;
// derived values
const { avatar_url, display_name, email, first_name, id, last_name } = rowData.member;
return (
<Disclosure>
{({}) => (
<div className="relative group">
<div className="flex items-center gap-2 w-72">
<div className="flex items-center gap-x-2 gap-y-2 flex-1">
{avatar_url && avatar_url.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-4 items-center justify-center rounded-full capitalize text-white">
<img
src={getFileURL(avatar_url)}
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
alt={display_name || email}
/>
</span>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-4 items-center justify-center rounded-full bg-gray-700 capitalize text-white text-xs">
{(email ?? display_name ?? "?")[0]}
</span>
</Link>
)}
{first_name} {last_name}
</div>
{(isAdmin || id === currentUser?.id) && (
<CustomMenu
ellipsis
buttonClassName="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
optionsClassName="p-1.5"
placement="bottom-end"
>
<CustomMenu.MenuItem>
<div
className="flex items-center gap-x-1 cursor-pointer text-red-600 font-medium"
data-ph-element={MEMBER_TRACKER_ELEMENTS.PROJECT_MEMBER_TABLE_CONTEXT_MENU}
onClick={() => setRemoveMemberModal(rowData)}
>
<CircleMinus className="flex-shrink-0 size-3.5" />
{rowData.member?.id === currentUser?.id ? "Leave " : "Remove "}
</div>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
</div>
)}
</Disclosure>
);
};
export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) => {
const { rowData, projectId, workspaceSlug } = props;
// store hooks
const {
project: { updateMemberRole },
workspace: { getWorkspaceMemberDetails },
} = useMember();
const { data: currentUser } = useUser();
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
// form info
const {
control,
formState: { errors },
} = useForm();
// derived values
const roleLabel = ROLE[rowData.original_role ?? EUserPermissions.GUEST];
const isCurrentUser = currentUser?.id === rowData.member.id;
const isRowDataWorkspaceAdmin = [EUserPermissions.ADMIN].includes(
Number(getWorkspaceMemberDetails(rowData.member.id)?.role) ?? EUserPermissions.GUEST
);
const isCurrentUserWorkspaceAdmin = currentUser
? [EUserPermissions.ADMIN].includes(
Number(getWorkspaceMemberDetails(currentUser.id)?.role) ?? EUserPermissions.GUEST
)
: false;
const currentProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
const isCurrentUserProjectAdmin = currentProjectRole
? ![EUserPermissions.MEMBER, EUserPermissions.GUEST].includes(Number(currentProjectRole) ?? EUserPermissions.GUEST)
: false;
// logic
// Workspace admin can change his own role
// Project admin can change any role except his own and workspace admin's role
const isRoleEditable =
(isCurrentUserWorkspaceAdmin && isCurrentUser) ||
(isCurrentUserProjectAdmin && !isRowDataWorkspaceAdmin && !isCurrentUser);
const checkCurrentOptionWorkspaceRole = (value: string) => {
const currentMemberWorkspaceRole = getWorkspaceMemberDetails(value)?.role as EUserPermissions | undefined;
if (!value || !currentMemberWorkspaceRole) return ROLE;
const isGuest = [EUserPermissions.GUEST].includes(currentMemberWorkspaceRole);
return Object.fromEntries(
Object.entries(ROLE).filter(([key]) => !isGuest || parseInt(key) === EUserPermissions.GUEST)
);
};
return (
<>
{isRoleEditable ? (
<Controller
name="role"
control={control}
rules={{ required: "Role is required." }}
render={() => (
<CustomSelect
value={rowData.original_role}
onChange={async (value: EUserProjectRoles) => {
if (!workspaceSlug) return;
await updateMemberRole(workspaceSlug.toString(), projectId.toString(), rowData.member.id, value).catch(
(err) => {
console.log(err, "err");
const error = err.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToast({
type: TOAST_TYPE.ERROR,
title: "You cant change this role yet.",
message: errorString ?? "An error occurred while updating member role. Please try again.",
});
}
);
}}
label={
<div className="flex ">
<span>{roleLabel}</span>
</div>
}
buttonClassName={`!px-0 !justify-start hover:bg-custom-background-100 ${errors.role ? "border-red-500" : "border-none"}`}
className="rounded-md p-0 w-32"
input
>
{Object.entries(checkCurrentOptionWorkspaceRole(rowData.member.id)).map(([key, label]) => (
<CustomSelect.Option key={key} value={key}>
{label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
) : (
<div className="w-32 flex ">
<span>{roleLabel}</span>
</div>
)}
</>
);
});