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,38 @@
import { observer } from "mobx-react";
import { X } from "lucide-react";
// constants
import { NETWORK_CHOICES } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedAccessFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
const { t } = useTranslation();
return (
<>
{values.map((status) => {
const accessDetails = NETWORK_CHOICES.find((s) => `${s.key}` === status);
return (
<div key={status} className="flex items-center gap-1 rounded px-1.5 py-1 text-xs bg-custom-background-80">
{accessDetails && t(accessDetails?.i18n_label)}
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(status)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,54 @@
import { observer } from "mobx-react";
import { X } from "lucide-react";
// helpers
import { PROJECT_CREATED_AT_FILTER_OPTIONS } from "@plane/constants";
import { renderFormattedDate, capitalizeFirstLetter } from "@plane/utils";
// constants
type Props = {
editable: boolean | undefined;
handleRemove: (val: string) => void;
values: string[];
};
export const AppliedDateFilters: React.FC<Props> = observer((props) => {
const { editable, handleRemove, values } = props;
const getDateLabel = (value: string): string => {
let dateLabel = "";
const dateDetails = PROJECT_CREATED_AT_FILTER_OPTIONS.find((d) => d.value === value);
if (dateDetails) dateLabel = dateDetails.name;
else {
const dateParts = value.split(";");
if (dateParts.length === 2) {
const [date, time] = dateParts;
dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`;
}
}
return dateLabel;
};
return (
<>
{values.map((date) => (
<div key={date} className="flex items-center gap-1 rounded bg-custom-background-80 px-1.5 py-1 text-xs">
<span className="normal-case">{getDateLabel(date)}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(date)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
))}
</>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,55 @@
"use client";
import { observer } from "mobx-react";
import { X } from "lucide-react";
// ui
import { Avatar } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
// types
import { useMember } from "@/hooks/store/use-member";
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedMembersFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
// store hooks
const {
workspace: { getWorkspaceMemberDetails },
} = useMember();
return (
<>
{values.map((memberId) => {
const memberDetails = getWorkspaceMemberDetails(memberId)?.member;
if (!memberDetails) return null;
return (
<div key={memberId} className="flex items-center gap-1 rounded bg-custom-background-80 px-1.5 py-1 text-xs">
<Avatar
name={memberDetails.display_name}
src={getFileURL(memberDetails.avatar_url)}
showTooltip={false}
size={"sm"}
/>
<span className="normal-case">{memberDetails.display_name}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(memberId)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,41 @@
import { observer } from "mobx-react";
// icons
import { X } from "lucide-react";
// types
import { PROJECT_DISPLAY_FILTER_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TProjectAppliedDisplayFilterKeys } from "@plane/types";
// constants
type Props = {
handleRemove: (key: TProjectAppliedDisplayFilterKeys) => void;
values: TProjectAppliedDisplayFilterKeys[];
editable: boolean | undefined;
};
export const AppliedProjectDisplayFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
const { t } = useTranslation();
return (
<>
{values.map((key) => {
const filterLabel = PROJECT_DISPLAY_FILTER_OPTIONS.find((s) => s.key === key)?.i18n_label;
return (
<div key={key} className="flex items-center gap-1 rounded px-1.5 py-1 text-xs bg-custom-background-80">
{filterLabel && t(filterLabel)}
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(key)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,130 @@
"use client";
import { X } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
import type { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types";
import { EHeaderVariant, Header, Tag } from "@plane/ui";
import { replaceUnderscoreIfSnakeCase } from "@plane/utils";
// local imports
import { AppliedAccessFilters } from "./access";
import { AppliedDateFilters } from "./date";
import { AppliedMembersFilters } from "./members";
import { AppliedProjectDisplayFilters } from "./project-display-filters";
type Props = {
appliedFilters: TProjectFilters;
appliedDisplayFilters: TProjectAppliedDisplayFilterKeys[];
handleClearAllFilters: () => void;
handleRemoveFilter: (key: keyof TProjectFilters, value: string | null) => void;
handleRemoveDisplayFilter: (key: TProjectAppliedDisplayFilterKeys) => void;
alwaysAllowEditing?: boolean;
filteredProjects: number;
totalProjects: number;
};
const MEMBERS_FILTERS = ["lead", "members"];
const DATE_FILTERS = ["created_at"];
export const ProjectAppliedFiltersList: React.FC<Props> = (props) => {
const { t } = useTranslation();
const {
appliedFilters,
appliedDisplayFilters,
handleClearAllFilters,
handleRemoveFilter,
handleRemoveDisplayFilter,
alwaysAllowEditing,
filteredProjects,
totalProjects,
} = props;
if (!appliedFilters && !appliedDisplayFilters) return null;
if (Object.keys(appliedFilters).length === 0 && appliedDisplayFilters.length === 0) return null;
const isEditingAllowed = alwaysAllowEditing;
return (
<Header variant={EHeaderVariant.TERNARY}>
<Header.LeftItem>
{/* Applied filters */}
{Object.entries(appliedFilters ?? {}).map(([key, value]) => {
const filterKey = key as keyof TProjectFilters;
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
return (
<Tag key={filterKey}>
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
{filterKey === "access" && (
<AppliedAccessFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("access", val)}
values={value}
/>
)}
{DATE_FILTERS.includes(filterKey) && (
<AppliedDateFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={value}
/>
)}
{MEMBERS_FILTERS.includes(filterKey) && (
<AppliedMembersFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={value}
/>
)}
{isEditingAllowed && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemoveFilter(filterKey, null)}
>
<X size={12} strokeWidth={2} />
</button>
)}
</Tag>
);
})}
{/* Applied display filters */}
{appliedDisplayFilters.length > 0 && (
<Tag key="project_display_filters">
<span className="text-xs text-custom-text-300">{t("projects.label", { count: 2 })}</span>
<AppliedProjectDisplayFilters
editable={isEditingAllowed}
values={appliedDisplayFilters}
handleRemove={(key) => handleRemoveDisplayFilter(key)}
/>
</Tag>
)}
{isEditingAllowed && (
<button type="button" onClick={handleClearAllFilters}>
<Tag>
{t("common.clear_all")}
<X size={12} strokeWidth={2} />
</Tag>
</button>
)}
</Header.LeftItem>
<Header.RightItem>
<Tooltip
tooltipContent={
<p>
<span className="font-semibold">{filteredProjects}</span> of{" "}
<span className="font-semibold">{totalProjects}</span> projects match the applied filters.
</p>
}
>
<span className="bg-custom-background-80 rounded-full text-sm font-medium py-1 px-2.5">
{filteredProjects}/{totalProjects}
</span>
</Tooltip>
</Header.RightItem>
</Header>
);
};

View File

@@ -0,0 +1,117 @@
import { observer } from "mobx-react";
import Image from "next/image";
// plane imports
import { EUserPermissionsLevel, EUserPermissions, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ContentWrapper } from "@plane/ui";
// components
import { ComicBoxButton } from "@/components/empty-state/comic-box-button";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { ProjectsLoader } from "@/components/ui/loader/projects-loader";
import { captureClick } from "@/helpers/event-tracker.helper";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useProjectFilter } from "@/hooks/store/use-project-filter";
import { useUserPermissions } from "@/hooks/store/user";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
// local imports
import { ProjectCard } from "./card";
type TProjectCardListProps = {
totalProjectIds?: string[];
filteredProjectIds?: string[];
};
export const ProjectCardList = observer((props: TProjectCardListProps) => {
const { totalProjectIds: totalProjectIdsProps, filteredProjectIds: filteredProjectIdsProps } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const { toggleCreateProjectModal } = useCommandPalette();
const {
loader,
fetchStatus,
workspaceProjectIds: storeWorkspaceProjectIds,
filteredProjectIds: storeFilteredProjectIds,
getProjectById,
} = useProject();
const { searchQuery, currentWorkspaceDisplayFilters } = useProjectFilter();
const { allowPermissions } = useUserPermissions();
// helper hooks
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/onboarding/projects" });
const resolvedFiltersImage = useResolvedAssetPath({
basePath: "/empty-state/project/all-filters",
extension: "svg",
});
const resolvedNameFilterImage = useResolvedAssetPath({
basePath: "/empty-state/project/name-filter",
extension: "svg",
});
// derived values
const workspaceProjectIds = totalProjectIdsProps ?? storeWorkspaceProjectIds;
const filteredProjectIds = filteredProjectIdsProps ?? storeFilteredProjectIds;
// permissions
const canPerformEmptyStateActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
if (!filteredProjectIds || !workspaceProjectIds || loader === "init-loader" || fetchStatus !== "complete")
return <ProjectsLoader />;
if (workspaceProjectIds?.length === 0 && !currentWorkspaceDisplayFilters?.archived_projects)
return (
<DetailedEmptyState
title={t("workspace_projects.empty_state.general.title")}
description={t("workspace_projects.empty_state.general.description")}
assetPath={resolvedPath}
customPrimaryButton={
<ComicBoxButton
label={t("workspace_projects.empty_state.general.primary_button.text")}
title={t("workspace_projects.empty_state.general.primary_button.comic.title")}
description={t("workspace_projects.empty_state.general.primary_button.comic.description")}
onClick={() => {
toggleCreateProjectModal(true);
captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_PROJECT_BUTTON });
}}
disabled={!canPerformEmptyStateActions}
/>
}
/>
);
if (filteredProjectIds.length === 0)
return (
<div className="grid h-full w-full place-items-center">
<div className="text-center">
<Image
src={searchQuery.trim() === "" ? resolvedFiltersImage : resolvedNameFilterImage}
className="mx-auto h-36 w-36 sm:h-48 sm:w-48"
alt="No matching projects"
/>
<h5 className="mb-1 mt-7 text-xl font-medium">{t("workspace_projects.empty_state.filter.title")}</h5>
<p className="whitespace-pre-line text-base text-custom-text-400">
{searchQuery.trim() === ""
? t("workspace_projects.empty_state.filter.description")
: t("workspace_projects.empty_state.search.description")}
</p>
</div>
</div>
);
return (
<ContentWrapper>
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
{filteredProjectIds.map((projectId) => {
const projectDetails = getProjectById(projectId);
if (!projectDetails) return;
return <ProjectCard key={projectDetails.id} project={projectDetails} />;
})}
</div>
</ContentWrapper>
);
});

View File

@@ -0,0 +1,371 @@
"use client";
import React, { useRef, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ArchiveRestoreIcon, Check, ExternalLink, LinkIcon, Lock, Settings, Trash2, UserPlus } from "lucide-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel, IS_FAVORITE_MENU_OPEN } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
import { Button } from "@plane/propel/button";
import { setPromiseToast, setToast, TOAST_TYPE } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { IProject } from "@plane/types";
import type { TContextMenuItem } from "@plane/ui";
import { Avatar, AvatarGroup, ContextMenu, FavoriteStar } from "@plane/ui";
import { copyUrlToClipboard, cn, getFileURL, renderFormattedDate } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { DeleteProjectModal } from "./delete-project-modal";
import { JoinProjectModal } from "./join-project-modal";
import { ArchiveRestoreProjectModal } from "./settings/archive-project/archive-restore-modal";
type Props = {
project: IProject;
};
export const ProjectCard: React.FC<Props> = observer((props) => {
const { project } = props;
// states
const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false);
const [joinProjectModalOpen, setJoinProjectModal] = useState(false);
const [restoreProject, setRestoreProject] = useState(false);
// refs
const projectCardRef = useRef(null);
// router
const router = useAppRouter();
const { workspaceSlug } = useParams();
// store hooks
const { getUserDetails } = useMember();
const { addProjectToFavorites, removeProjectFromFavorites } = useProject();
const { allowPermissions } = useUserPermissions();
// hooks
const { isMobile } = usePlatformOS();
// derived values
const projectMembersIds = project.members;
const shouldRenderFavorite = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
// auth
const isMemberOfProject = !!project.member_role;
const hasAdminRole = project.member_role === EUserPermissions.ADMIN;
const hasMemberRole = project.member_role === EUserPermissions.MEMBER;
// archive
const isArchived = !!project.archived_at;
// local storage
const { setValue: toggleFavoriteMenu, storedValue: isFavoriteMenuOpen } = useLocalStorage<boolean>(
IS_FAVORITE_MENU_OPEN,
false
);
const handleAddToFavorites = () => {
if (!workspaceSlug) return;
const addToFavoritePromise = addProjectToFavorites(workspaceSlug.toString(), project.id);
setPromiseToast(addToFavoritePromise, {
loading: "Adding project to favorites...",
success: {
title: "Success!",
message: () => "Project added to favorites.",
actionItems: () => {
if (!isFavoriteMenuOpen) toggleFavoriteMenu(true);
return <></>;
},
},
error: {
title: "Error!",
message: () => "Couldn't add the project to favorites. Please try again.",
},
});
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug) return;
const removeFromFavoritePromise = removeProjectFromFavorites(workspaceSlug.toString(), project.id);
setPromiseToast(removeFromFavoritePromise, {
loading: "Removing project from favorites...",
success: {
title: "Success!",
message: () => "Project removed from favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't remove the project from favorites. Please try again.",
},
});
};
const projectLink = `${workspaceSlug}/projects/${project.id}/issues`;
const handleCopyText = () =>
copyUrlToClipboard(projectLink).then(() =>
setToast({
type: TOAST_TYPE.INFO,
title: "Link Copied!",
message: "Project link copied to clipboard.",
})
);
const handleOpenInNewTab = () => window.open(`/${projectLink}`, "_blank");
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "settings",
action: () => router.push(`/${workspaceSlug}/settings/projects/${project.id}`, { showProgress: false }),
title: "Settings",
icon: Settings,
shouldRender: !isArchived && (hasAdminRole || hasMemberRole),
},
{
key: "join",
action: () => setJoinProjectModal(true),
title: "Join",
icon: UserPlus,
shouldRender: !isMemberOfProject && !isArchived,
},
{
key: "open-new-tab",
action: handleOpenInNewTab,
title: "Open in new tab",
icon: ExternalLink,
shouldRender: !isMemberOfProject && !isArchived,
},
{
key: "copy-link",
action: handleCopyText,
title: "Copy link",
icon: LinkIcon,
shouldRender: !isArchived,
},
{
key: "restore",
action: () => setRestoreProject(true),
title: "Restore",
icon: ArchiveRestoreIcon,
shouldRender: isArchived && hasAdminRole,
},
{
key: "delete",
action: () => setDeleteProjectModal(true),
title: "Delete",
icon: Trash2,
shouldRender: isArchived && hasAdminRole,
},
];
return (
<>
{/* Delete Project Modal */}
<DeleteProjectModal
project={project}
isOpen={deleteProjectModalOpen}
onClose={() => setDeleteProjectModal(false)}
/>
{/* Join Project Modal */}
{workspaceSlug && (
<JoinProjectModal
workspaceSlug={workspaceSlug.toString()}
project={project}
isOpen={joinProjectModalOpen}
handleClose={() => setJoinProjectModal(false)}
/>
)}
{/* Restore project modal */}
{workspaceSlug && project && (
<ArchiveRestoreProjectModal
workspaceSlug={workspaceSlug.toString()}
projectId={project.id}
isOpen={restoreProject}
onClose={() => setRestoreProject(false)}
archive={false}
/>
)}
<Link
ref={projectCardRef}
href={`/${workspaceSlug}/projects/${project.id}/issues`}
onClick={(e) => {
if (!isMemberOfProject || isArchived) {
e.preventDefault();
e.stopPropagation();
if (!isArchived) setJoinProjectModal(true);
}
}}
data-prevent-progress={!isMemberOfProject || isArchived}
className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100"
>
<ContextMenu parentRef={projectCardRef} items={MENU_ITEMS} />
<div className="relative h-[118px] w-full rounded-t ">
<div className="absolute inset-0 z-[1] bg-gradient-to-t from-black/60 to-transparent" />
<img
src={getFileURL(
project.cover_image_url ??
"https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80"
)}
alt={project.name}
className="absolute left-0 top-0 h-full w-full rounded-t object-cover"
/>
<div className="absolute bottom-4 z-[1] flex h-10 w-full items-center justify-between gap-3 px-4">
<div className="flex flex-grow items-center gap-2.5 truncate">
<div className="h-9 w-9 flex-shrink-0 grid place-items-center rounded bg-white/10">
<Logo logo={project.logo_props} size={18} />
</div>
<div className="flex w-full flex-col justify-between gap-0.5 truncate">
<h3 className="truncate font-semibold text-white">{project.name}</h3>
<span className="flex items-center gap-1.5">
<p className="text-xs font-medium text-white">{project.identifier} </p>
{project.network === 0 && <Lock className="h-2.5 w-2.5 text-white " />}
</span>
</div>
</div>
{!isArchived && (
<div data-prevent-progress className="flex h-full flex-shrink-0 items-center gap-2">
<button
className="flex h-6 w-6 items-center justify-center rounded bg-white/10"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleCopyText();
}}
>
<LinkIcon className="h-3 w-3 text-white" />
</button>
{shouldRenderFavorite && (
<FavoriteStar
buttonClassName="h-6 w-6 bg-white/10 rounded"
iconClassName={cn("h-3 w-3", {
"text-white": !project.is_favorite,
})}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (project.is_favorite) handleRemoveFromFavorites();
else handleAddToFavorites();
}}
selected={!!project.is_favorite}
/>
)}
</div>
)}
</div>
</div>
<div
className={cn("flex h-[104px] w-full flex-col justify-between rounded-b p-4", {
"opacity-90": isArchived,
})}
>
<p className="line-clamp-2 break-words text-sm text-custom-text-300">
{project.description && project.description.trim() !== ""
? project.description
: `Created on ${renderFormattedDate(project.created_at)}`}
</p>
<div className="item-center flex justify-between">
<div className="flex items-center justify-center gap-2">
<Tooltip
isMobile={isMobile}
tooltipHeading="Members"
tooltipContent={
project.members && project.members.length > 0 ? `${project.members.length} Members` : "No Member"
}
position="top"
>
{projectMembersIds && projectMembersIds.length > 0 ? (
<div className="flex cursor-pointer items-center gap-2 text-custom-text-200">
<AvatarGroup showTooltip={false}>
{projectMembersIds.map((memberId) => {
const member = getUserDetails(memberId);
if (!member) return null;
return (
<Avatar key={member.id} name={member.display_name} src={getFileURL(member.avatar_url)} />
);
})}
</AvatarGroup>
</div>
) : (
<span className="text-sm italic text-custom-text-400">No Member Yet</span>
)}
</Tooltip>
{isArchived && <div className="text-xs text-custom-text-400 font-medium">Archived</div>}
</div>
{isArchived ? (
hasAdminRole && (
<div className="flex items-center justify-center gap-2">
<div
className="flex items-center justify-center text-xs text-custom-text-400 font-medium hover:text-custom-text-200"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setRestoreProject(true);
}}
>
<div className="flex items-center gap-1.5">
<ArchiveRestoreIcon className="h-3.5 w-3.5" />
Restore
</div>
</div>
<div
className="flex items-center justify-center text-xs text-custom-text-400 font-medium hover:text-custom-text-200"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDeleteProjectModal(true);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</div>
</div>
)
) : (
<>
{isMemberOfProject &&
(hasAdminRole || hasMemberRole ? (
<Link
className="flex items-center justify-center rounded p-1 text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-200"
onClick={(e) => {
e.stopPropagation();
}}
href={`/${workspaceSlug}/settings/projects/${project.id}`}
>
<Settings className="h-3.5 w-3.5" />
</Link>
) : (
<span className="flex items-center gap-1 text-custom-text-400 text-sm">
<Check className="h-3.5 w-3.5" />
Joined
</span>
))}
{!isMemberOfProject && (
<div className="flex items-center">
<Button
variant="link-primary"
className="!p-0 font-semibold"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setJoinProjectModal(true);
}}
>
Join
</Button>
</div>
)}
</>
)}
</div>
</div>
</Link>
</>
);
});

View File

@@ -0,0 +1,129 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { AlertTriangle } from "lucide-react";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// types
import { Button } from "@plane/propel/button";
import type { IUserLite } from "@plane/types";
// ui
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUser } from "@/hooks/store/user";
type Props = {
data: Partial<IUserLite>;
onSubmit: () => Promise<void>;
isOpen: boolean;
onClose: () => void;
};
export const ConfirmProjectMemberRemove: React.FC<Props> = observer((props) => {
const { data, onSubmit, isOpen, onClose } = props;
// router
const { projectId } = useParams();
// states
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// store hooks
const { data: currentUser } = useUser();
const { getProjectById } = useProject();
const handleClose = () => {
onClose();
setIsDeleteLoading(false);
};
const handleDeletion = async () => {
setIsDeleteLoading(true);
await onSubmit();
handleClose();
};
if (!projectId) return <></>;
const isCurrentUser = currentUser?.id === data?.id;
const currentProjectDetails = getProjectById(projectId.toString());
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.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-20 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={React.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-[40rem]">
<div className="bg-custom-background-100 px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<AlertTriangle className="h-6 w-6 text-red-600" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
{isCurrentUser ? "Leave project?" : `Remove ${data?.display_name}?`}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-custom-text-200">
{isCurrentUser ? (
<>
Are you sure you want to leave the{" "}
<span className="font-bold">{currentProjectDetails?.name}</span> project? You will be able
to join the project if invited again or if it{"'"}s public.
</>
) : (
<>
Are you sure you want to remove member-{" "}
<span className="font-bold">{data?.display_name}</span>? They will no longer have access
to this project. This action cannot be undone.
</>
)}
</p>
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-2 p-4 sm:px-6">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button variant="danger" size="sm" tabIndex={1} onClick={handleDeletion} loading={isDeleteLoading}>
{isCurrentUser
? isDeleteLoading
? "Leaving..."
: "Leave"
: isDeleteLoading
? "Removing..."
: "Remove"}
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@@ -0,0 +1,81 @@
import type { FC } from "react";
import { useEffect, useState } from "react";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
import { getAssetIdFromUrl, checkURLValidity } from "@plane/utils";
// plane ui
// helpers
// hooks
import useKeypress from "@/hooks/use-keypress";
// plane web components
import { CreateProjectForm } from "@/plane-web/components/projects/create/root";
// plane web types
import type { TProject } from "@/plane-web/types/projects";
// services
import { FileService } from "@/services/file.service";
const fileService = new FileService();
import { ProjectFeatureUpdate } from "./project-feature-update";
type Props = {
isOpen: boolean;
onClose: () => void;
setToFavorite?: boolean;
workspaceSlug: string;
data?: Partial<TProject>;
templateId?: string;
};
enum EProjectCreationSteps {
CREATE_PROJECT = "CREATE_PROJECT",
FEATURE_SELECTION = "FEATURE_SELECTION",
}
export const CreateProjectModal: FC<Props> = (props) => {
const { isOpen, onClose, setToFavorite = false, workspaceSlug, data, templateId } = props;
// states
const [currentStep, setCurrentStep] = useState<EProjectCreationSteps>(EProjectCreationSteps.CREATE_PROJECT);
const [createdProjectId, setCreatedProjectId] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setCurrentStep(EProjectCreationSteps.CREATE_PROJECT);
setCreatedProjectId(null);
}
}, [isOpen]);
const handleNextStep = (projectId: string) => {
if (!projectId) return;
setCreatedProjectId(projectId);
setCurrentStep(EProjectCreationSteps.FEATURE_SELECTION);
};
const handleCoverImageStatusUpdate = async (projectId: string, coverImage: string) => {
if (!checkURLValidity(coverImage)) {
await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, projectId, projectId, {
asset_ids: [getAssetIdFromUrl(coverImage)],
});
}
};
useKeypress("Escape", () => {
if (isOpen) onClose();
});
return (
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL}>
{currentStep === EProjectCreationSteps.CREATE_PROJECT && (
<CreateProjectForm
setToFavorite={setToFavorite}
workspaceSlug={workspaceSlug}
onClose={onClose}
updateCoverImageStatus={handleCoverImageStatusUpdate}
handleNextStep={handleNextStep}
data={data}
templateId={templateId}
/>
)}
{currentStep === EProjectCreationSteps.FEATURE_SELECTION && (
<ProjectFeatureUpdate projectId={createdProjectId} workspaceSlug={workspaceSlug} onClose={onClose} />
)}
</ModalCore>
);
};

View File

@@ -0,0 +1,151 @@
import type { ChangeEvent } from "react";
import type { UseFormSetValue } from "react-hook-form";
import { Controller, useFormContext } from "react-hook-form";
import { Info } from "lucide-react";
// plane imports
import { ETabIndices } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { Tooltip } from "@plane/propel/tooltip";
import { Input, TextArea } from "@plane/ui";
import { cn, projectIdentifierSanitizer, getTabIndex } from "@plane/utils";
// plane utils
// helpers
// plane-web types
import type { TProject } from "@/plane-web/types/projects";
type Props = {
setValue: UseFormSetValue<TProject>;
isMobile: boolean;
isChangeInIdentifierRequired: boolean;
setIsChangeInIdentifierRequired: (value: boolean) => void;
handleFormOnChange?: () => void;
};
const ProjectCommonAttributes: React.FC<Props> = (props) => {
const { setValue, isMobile, isChangeInIdentifierRequired, setIsChangeInIdentifierRequired, handleFormOnChange } =
props;
const {
formState: { errors },
control,
} = useFormContext<TProject>();
const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile);
const { t } = useTranslation();
const handleNameChange = (onChange: (...event: any[]) => void) => (e: ChangeEvent<HTMLInputElement>) => {
if (!isChangeInIdentifierRequired) {
onChange(e);
return;
}
if (e.target.value === "") setValue("identifier", "");
else setValue("identifier", projectIdentifierSanitizer(e.target.value).substring(0, 5));
onChange(e);
handleFormOnChange?.();
};
const handleIdentifierChange = (onChange: any) => (e: ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
const alphanumericValue = projectIdentifierSanitizer(value);
setIsChangeInIdentifierRequired(false);
onChange(alphanumericValue);
handleFormOnChange?.();
};
return (
<div className="grid grid-cols-1 gap-x-2 gap-y-3 md:grid-cols-4">
<div className="md:col-span-3">
<Controller
control={control}
name="name"
rules={{
required: t("name_is_required"),
maxLength: {
value: 255,
message: t("title_should_be_less_than_255_characters"),
},
}}
render={({ field: { value, onChange } }) => (
<Input
id="name"
name="name"
type="text"
value={value}
onChange={handleNameChange(onChange)}
hasError={Boolean(errors.name)}
placeholder={t("project_name")}
className="w-full focus:border-blue-400"
tabIndex={getIndex("name")}
/>
)}
/>
<span className="text-xs text-red-500">{errors?.name?.message}</span>
</div>
<div className="relative">
<Controller
control={control}
name="identifier"
rules={{
required: t("project_id_is_required"),
// allow only alphanumeric & non-latin characters
validate: (value) =>
/^[ÇŞĞIİÖÜA-Z0-9]+$/.test(value.toUpperCase()) || t("only_alphanumeric_non_latin_characters_allowed"),
minLength: {
value: 1,
message: t("project_id_must_be_at_least_1_character"),
},
maxLength: {
value: 5,
message: t("project_id_must_be_at_most_5_characters"),
},
}}
render={({ field: { value, onChange } }) => (
<Input
id="identifier"
name="identifier"
type="text"
value={value}
onChange={handleIdentifierChange(onChange)}
hasError={Boolean(errors.identifier)}
placeholder={t("project_id")}
className={cn("w-full text-xs focus:border-blue-400 pr-7", {
uppercase: value,
})}
tabIndex={getIndex("identifier")}
/>
)}
/>
<Tooltip
isMobile={isMobile}
tooltipContent={t("project_id_tooltip_content")}
className="text-sm"
position="right-start"
>
<Info className="absolute right-2 top-2.5 h-3 w-3 text-custom-text-400" />
</Tooltip>
<span className="text-xs text-red-500">{errors?.identifier?.message}</span>
</div>
<div className="md:col-span-4">
<Controller
name="description"
control={control}
render={({ field: { value, onChange } }) => (
<TextArea
id="description"
name="description"
value={value}
placeholder={t("description")}
onChange={(e) => {
onChange(e);
handleFormOnChange?.();
}}
className="!h-24 text-sm focus:border-blue-400"
hasError={Boolean(errors?.description)}
tabIndex={getIndex("description")}
/>
)}
/>
</div>
</div>
);
};
export default ProjectCommonAttributes;

View File

@@ -0,0 +1,107 @@
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { X } from "lucide-react";
// plane imports
import { ETabIndices } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmojiPicker, EmojiIconPickerTypes } from "@plane/propel/emoji-icon-picker";
// plane types
import type { IProject } from "@plane/types";
// plane ui
import { getFileURL, getTabIndex } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
// plane web imports
import { ProjectTemplateSelect } from "@/plane-web/components/projects/create/template-select";
type Props = {
handleClose: () => void;
isMobile?: boolean;
};
const ProjectCreateHeader: React.FC<Props> = (props) => {
const { handleClose, isMobile = false } = props;
const { watch, control } = useFormContext<IProject>();
const { t } = useTranslation();
// derived values
const coverImage = watch("cover_image_url");
const [isOpen, setIsOpen] = useState(false);
const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile);
return (
<div className="group relative h-44 w-full rounded-lg bg-custom-background-80">
{coverImage && (
<img
src={getFileURL(coverImage)}
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
alt={t("project_cover_image_alt")}
/>
)}
<div className="absolute left-2.5 top-2.5">
<ProjectTemplateSelect handleModalClose={handleClose} />
</div>
<div className="absolute right-2 top-2 p-2">
<button data-posthog="PROJECT_MODAL_CLOSE" type="button" onClick={handleClose} tabIndex={getIndex("close")}>
<X className="h-5 w-5 text-white" />
</button>
</div>
<div className="absolute bottom-2 right-2">
<Controller
name="cover_image_url"
control={control}
render={({ field: { value, onChange } }) => (
<ImagePickerPopover
label={t("change_cover")}
onChange={onChange}
control={control}
value={value ?? null}
tabIndex={getIndex("cover_image")}
/>
)}
/>
</div>
<div className="absolute -bottom-[22px] left-3">
<Controller
name="logo_props"
control={control}
render={({ field: { value, onChange } }) => (
<EmojiPicker
iconType="material"
isOpen={isOpen}
handleToggle={(val: boolean) => setIsOpen(val)}
className="flex items-center justify-center"
buttonClassName="flex items-center justify-center"
label={
<span className="grid h-11 w-11 place-items-center rounded-md bg-custom-background-80">
<Logo logo={value} size={20} />
</span>
}
onChange={(val: any) => {
let logoValue = {};
if (val?.type === "emoji")
logoValue = {
value: val.value,
};
else if (val?.type === "icon") logoValue = val.value;
onChange({
in_use: val?.type,
[val?.type]: logoValue,
});
setIsOpen(false);
}}
defaultIconColor={value.in_use && value.in_use === "icon" ? value.icon?.color : undefined}
defaultOpen={
value.in_use && value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON
}
/>
)}
/>
</div>
</div>
);
};
export default ProjectCreateHeader;

View File

@@ -0,0 +1,37 @@
import { useFormContext } from "react-hook-form";
// plane imports
import { ETabIndices } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { IProject } from "@plane/types";
// ui
// helpers
import { getTabIndex } from "@plane/utils";
type Props = {
handleClose: () => void;
isMobile?: boolean;
};
const ProjectCreateButtons: React.FC<Props> = (props) => {
const { t } = useTranslation();
const { handleClose, isMobile = false } = props;
const {
formState: { isSubmitting },
} = useFormContext<IProject>();
const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile);
return (
<div className="flex justify-end gap-2 py-4 border-t border-custom-border-100">
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={getIndex("cancel")}>
{t("common.cancel")}
</Button>
<Button variant="primary" type="submit" size="sm" loading={isSubmitting} tabIndex={getIndex("submit")}>
{isSubmitting ? t("creating") : t("create_project")}
</Button>
</div>
);
};
export default ProjectCreateButtons;

View File

@@ -0,0 +1,201 @@
"use client";
import React from "react";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import { AlertTriangle } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
// types
import { PROJECT_TRACKER_EVENTS } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IProject } from "@plane/types";
// ui
import { Input } from "@plane/ui";
// constants
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
type DeleteProjectModal = {
isOpen: boolean;
project: IProject;
onClose: () => void;
};
const defaultValues = {
projectName: "",
confirmDelete: "",
};
export const DeleteProjectModal: React.FC<DeleteProjectModal> = (props) => {
const { isOpen, project, onClose } = props;
// store hooks
const { deleteProject } = useProject();
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
// form info
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
reset,
watch,
} = useForm({ defaultValues });
const canDelete = watch("projectName") === project?.name && watch("confirmDelete") === "delete my project";
const handleClose = () => {
const timer = setTimeout(() => {
reset(defaultValues);
clearTimeout(timer);
}, 350);
onClose();
};
const onSubmit = async () => {
if (!workspaceSlug || !canDelete) return;
await deleteProject(workspaceSlug.toString(), project.id)
.then(() => {
if (projectId && projectId.toString() === project.id) router.push(`/${workspaceSlug}/projects`);
handleClose();
captureSuccess({
eventName: PROJECT_TRACKER_EVENTS.delete,
payload: {
id: project.id,
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Project deleted successfully.",
});
})
.catch(() => {
captureError({
eventName: PROJECT_TRACKER_EVENTS.delete,
payload: {
id: project.id,
},
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again later.",
});
});
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.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-20 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={React.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-2xl">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6 p-6">
<div className="flex w-full items-center justify-start gap-6">
<span className="place-items-center rounded-full bg-red-500/20 p-4">
<AlertTriangle className="h-6 w-6 text-red-600" aria-hidden="true" />
</span>
<span className="flex items-center justify-start">
<h3 className="text-xl font-medium 2xl:text-2xl">Delete project</h3>
</span>
</div>
<span>
<p className="text-sm leading-7 text-custom-text-200">
Are you sure you want to delete project{" "}
<span className="break-words font-semibold">{project?.name}</span>? All of the data related to the
project will be permanently removed. This action cannot be undone
</p>
</span>
<div className="text-custom-text-200">
<p className="break-words text-sm ">
Enter the project name <span className="font-medium text-custom-text-100">{project?.name}</span>{" "}
to continue:
</p>
<Controller
control={control}
name="projectName"
render={({ field: { value, onChange, ref } }) => (
<Input
id="projectName"
name="projectName"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.projectName)}
placeholder="Project name"
className="mt-2 w-full"
autoComplete="off"
/>
)}
/>
</div>
<div className="text-custom-text-200">
<p className="text-sm">
To confirm, type <span className="font-medium text-custom-text-100">delete my project</span>{" "}
below:
</p>
<Controller
control={control}
name="confirmDelete"
render={({ field: { value, onChange, ref } }) => (
<Input
id="confirmDelete"
name="confirmDelete"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.confirmDelete)}
placeholder="Enter 'delete my project'"
className="mt-2 w-full"
autoComplete="off"
/>
)}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button variant="danger" size="sm" type="submit" disabled={!canDelete} loading={isSubmitting}>
{isSubmitting ? "Deleting" : "Delete project"}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@@ -0,0 +1,52 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { NETWORK_CHOICES } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// components
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// local imports
import { ProjectNetworkIcon } from "../../project-network-icon";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
searchQuery: string;
};
export const FilterAccess: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
// states
const [previewEnabled, setPreviewEnabled] = useState(true);
const { t } = useTranslation();
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = NETWORK_CHOICES.filter((a) => a.i18n_label.includes(searchQuery.toLowerCase()));
return (
<>
<FilterHeader
title={`Access${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
filteredOptions.map((access) => (
<FilterOption
key={access.key}
isChecked={appliedFilters?.includes(`${access.key}`) ? true : false}
onClick={() => handleUpdate(`${access.key}`)}
icon={<ProjectNetworkIcon iconKey={access.iconKey} />}
title={t(access.i18n_label)}
/>
))
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,82 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
// plane constants
import { PROJECT_CREATED_AT_FILTER_OPTIONS } from "@plane/constants";
// components
import { isInDateFormat } from "@plane/utils";
import { DateFilterModal } from "@/components/core/filters/date-filter-modal";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// helpers
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string | string[]) => void;
searchQuery: string;
};
export const FilterCreatedDate: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
// state
const [previewEnabled, setPreviewEnabled] = useState(true);
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
// derived values
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = PROJECT_CREATED_AT_FILTER_OPTIONS.filter((d) =>
d.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const isCustomDateSelected = () => {
const isValidDateSelected = appliedFilters?.filter((f) => isInDateFormat(f.split(";")[0])) || [];
return isValidDateSelected.length > 0 ? true : false;
};
const handleCustomDate = () => {
if (isCustomDateSelected()) {
const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || [];
handleUpdate(updateAppliedFilters);
} else setIsDateFilterModalOpen(true);
};
return (
<>
{isDateFilterModalOpen && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleUpdate(val)}
title="Created date"
/>
)}
<FilterHeader
title={`Created date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
<>
{filteredOptions.map((option) => (
<FilterOption
key={option.value}
isChecked={appliedFilters?.includes(option.value) ? true : false}
onClick={() => handleUpdate(option.value)}
title={option.name}
multiple={false}
/>
))}
<FilterOption
isChecked={isCustomDateSelected()}
onClick={handleCustomDate}
title="Custom"
multiple={false}
/>
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,111 @@
"use client";
import { useMemo, useState } from "react";
import { sortBy } from "lodash-es";
import { observer } from "mobx-react";
// plane ui
import { Avatar, Loader } from "@plane/ui";
// components
import { getFileURL } from "@plane/utils";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// helpers
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser } from "@/hooks/store/user";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
memberIds: string[] | undefined;
searchQuery: string;
};
export const FilterLead: React.FC<Props> = observer((props: Props) => {
const { appliedFilters, handleUpdate, memberIds, searchQuery } = props;
// states
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
// store hooks
const { getUserDetails } = useMember();
const { data: currentUser } = useUser();
const appliedFiltersCount = appliedFilters?.length ?? 0;
const sortedOptions = useMemo(() => {
const filteredOptions = (memberIds || []).filter((memberId) =>
getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
);
return sortBy(filteredOptions, [
(memberId) => !(appliedFilters ?? []).includes(memberId),
(memberId) => memberId !== currentUser?.id,
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
return (
<>
<FilterHeader
title={`Lead${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((memberId) => {
const member = getUserDetails(memberId);
if (!member) return null;
return (
<FilterOption
key={`lead-${member.id}`}
isChecked={appliedFilters?.includes(member.id) ? true : false}
onClick={() => handleUpdate(member.id)}
icon={
<Avatar
name={member.display_name}
src={getFileURL(member.avatar_url)}
showTooltip={false}
size="md"
/>
}
title={currentUser?.id === member.id ? "You" : member?.display_name}
/>
);
})}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,108 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { ChevronDown } from "lucide-react";
// plane imports
import { Button } from "@plane/propel/button";
import { EUserProjectRoles, EUserWorkspaceRoles } from "@plane/types";
// plane ui
import { CustomMenu } from "@plane/ui";
// components
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
interface IRoleOption {
value: string;
label: string;
}
type Props = {
appliedFilters: string[] | null;
handleUpdate: (role: string) => void;
memberType: "project" | "workspace";
};
const PROJECT_ROLE_OPTIONS: IRoleOption[] = [
{ value: String(EUserProjectRoles.ADMIN), label: "Admin" },
{ value: String(EUserProjectRoles.MEMBER), label: "Member" },
{ value: String(EUserProjectRoles.GUEST), label: "Guest" },
];
const WORKSPACE_ROLE_OPTIONS: IRoleOption[] = [
{ value: String(EUserWorkspaceRoles.ADMIN), label: "Admin" },
{ value: String(EUserWorkspaceRoles.MEMBER), label: "Member" },
{ value: String(EUserWorkspaceRoles.GUEST), label: "Guest" },
{ value: "suspended", label: "Suspended" },
];
// Role filter group component
const RoleFilterGroup: React.FC<{
appliedFilters: string[] | null;
handleUpdate: (role: string) => void;
memberType: "project" | "workspace";
}> = observer(({ appliedFilters, handleUpdate, memberType }) => {
const [isExpanded, setIsExpanded] = useState(true);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const roleOptions = memberType === "project" ? PROJECT_ROLE_OPTIONS : WORKSPACE_ROLE_OPTIONS;
return (
<div className="space-y-2">
<FilterHeader
title={`Roles${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={isExpanded}
handleIsPreviewEnabled={() => setIsExpanded(!isExpanded)}
/>
{isExpanded && (
<div className="space-y-1">
{roleOptions.map((role) => {
const isSelected = appliedFilters?.includes(role.value) ?? false;
return (
<FilterOption
key={`role-${role.value}`}
isChecked={isSelected}
title={role.label}
onClick={() => handleUpdate(role.value)}
/>
);
})}
</div>
)}
</div>
);
});
export const MemberListFilters: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, memberType } = props;
return (
<div className="space-y-4">
{/* Role Filter Group */}
<RoleFilterGroup appliedFilters={appliedFilters} handleUpdate={handleUpdate} memberType={memberType} />
</div>
);
});
// Dropdown component for member list filters
export const MemberListFiltersDropdown: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, memberType } = props;
const appliedFiltersCount = appliedFilters?.length ?? 0;
return (
<CustomMenu
customButton={
<div className="relative">
<Button variant="neutral-primary" size="sm" className="flex items-center gap-2">
<span>Filters</span>
<ChevronDown className="h-3 w-3" />
</Button>
{appliedFiltersCount > 0 && (
<div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-custom-primary-100" />
)}
</div>
}
placement="bottom-start"
>
<MemberListFilters appliedFilters={appliedFilters} handleUpdate={handleUpdate} memberType={memberType} />
</CustomMenu>
);
});

View File

@@ -0,0 +1,111 @@
"use client";
import { useMemo, useState } from "react";
import { sortBy } from "lodash-es";
import { observer } from "mobx-react";
// plane ui
import { Avatar, Loader } from "@plane/ui";
// components
import { getFileURL } from "@plane/utils";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// helpers
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser } from "@/hooks/store/user";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
memberIds: string[] | undefined;
searchQuery: string;
};
export const FilterMembers: React.FC<Props> = observer((props: Props) => {
const { appliedFilters, handleUpdate, memberIds, searchQuery } = props;
// states
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
// store hooks
const { getUserDetails } = useMember();
const { data: currentUser } = useUser();
const appliedFiltersCount = appliedFilters?.length ?? 0;
const sortedOptions = useMemo(() => {
const filteredOptions = (memberIds || []).filter((memberId) =>
getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
);
return sortBy(filteredOptions, [
(memberId) => !(appliedFilters ?? []).includes(memberId),
(memberId) => memberId !== currentUser?.id,
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
return (
<>
<FilterHeader
title={`Members${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((memberId) => {
const member = getUserDetails(memberId);
if (!member) return null;
return (
<FilterOption
key={`member-${member.id}`}
isChecked={appliedFilters?.includes(member.id) ? true : false}
onClick={() => handleUpdate(member.id)}
icon={
<Avatar
name={member.display_name}
src={getFileURL(member.avatar_url)}
showTooltip={false}
size="md"
/>
}
title={currentUser?.id === member.id ? "You" : member?.display_name}
/>
);
})}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,104 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { Search, X } from "lucide-react";
// plane imports
import type { TProjectDisplayFilters, TProjectFilters } from "@plane/types";
// components
import { FilterOption } from "@/components/issues/issue-layouts/filters";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { FilterAccess } from "./access";
import { FilterCreatedDate } from "./created-at";
import { FilterLead } from "./lead";
import { FilterMembers } from "./members";
type Props = {
displayFilters: TProjectDisplayFilters;
filters: TProjectFilters;
handleFiltersUpdate: (key: keyof TProjectFilters, value: string | string[]) => void;
handleDisplayFiltersUpdate: (updatedDisplayProperties: Partial<TProjectDisplayFilters>) => void;
memberIds?: string[] | undefined;
};
export const ProjectFiltersSelection: React.FC<Props> = observer((props) => {
const { displayFilters, filters, handleFiltersUpdate, handleDisplayFiltersUpdate, memberIds } = props;
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
// store
const { isMobile } = usePlatformOS();
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="bg-custom-background-100 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
<input
type="text"
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
autoFocus={!isMobile}
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<X className="text-custom-text-300" size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
<div className="py-2">
<FilterOption
isChecked={!!displayFilters.my_projects}
onClick={() =>
handleDisplayFiltersUpdate({
my_projects: !displayFilters.my_projects,
})
}
title="My projects"
/>
</div>
{/* access */}
<div className="py-2">
<FilterAccess
appliedFilters={filters.access ?? null}
handleUpdate={(val) => handleFiltersUpdate("access", val)}
searchQuery={filtersSearchQuery}
/>
</div>
{/* lead */}
<div className="py-2">
<FilterLead
appliedFilters={filters.lead ?? null}
handleUpdate={(val) => handleFiltersUpdate("lead", val)}
searchQuery={filtersSearchQuery}
memberIds={memberIds}
/>
</div>
{/* members */}
<div className="py-2">
<FilterMembers
appliedFilters={filters.members ?? null}
handleUpdate={(val) => handleFiltersUpdate("members", val)}
searchQuery={filtersSearchQuery}
memberIds={memberIds}
/>
</div>
{/* created date */}
<div className="py-2">
<FilterCreatedDate
appliedFilters={filters.created_at ?? null}
handleUpdate={(val) => handleFiltersUpdate("created_at", val)}
searchQuery={filtersSearchQuery}
/>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,92 @@
"use client";
import { ArrowDownWideNarrow, Check, ChevronDown } from "lucide-react";
import { PROJECT_ORDER_BY_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { getButtonStyling } from "@plane/propel/button";
import type { TProjectOrderByOptions } from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
// types
// constants
type Props = {
onChange: (value: TProjectOrderByOptions) => void;
value: TProjectOrderByOptions | undefined;
isMobile?: boolean;
};
const DISABLED_ORDERING_OPTIONS = ["sort_order"];
export const ProjectOrderByDropdown: React.FC<Props> = (props) => {
const { onChange, value, isMobile = false } = props;
const { t } = useTranslation();
const orderByDetails = PROJECT_ORDER_BY_OPTIONS.find((option) => value?.includes(option.key));
const isDescending = value?.[0] === "-";
const isOrderingDisabled = !!value && DISABLED_ORDERING_OPTIONS.includes(value);
return (
<CustomMenu
className={`${isMobile ? "flex w-full justify-center" : ""}`}
customButton={
<>
{isMobile ? (
<div className="flex text-sm items-center gap-2 neutral-primary text-custom-text-200">
<ArrowDownWideNarrow className="h-3 w-3" />
{orderByDetails && t(orderByDetails?.i18n_label)}
<ChevronDown className="h-3 w-3" strokeWidth={2} />
</div>
) : (
<div className={cn(getButtonStyling("neutral-primary", "sm"), "px-2 text-custom-text-200")}>
<ArrowDownWideNarrow className="h-3 w-3" />
{orderByDetails && t(orderByDetails?.i18n_label)}
<ChevronDown className="h-3 w-3" strokeWidth={2} />
</div>
)}
</>
}
placement="bottom-end"
closeOnSelect
>
{PROJECT_ORDER_BY_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
className="flex items-center justify-between gap-2"
onClick={() => {
if (isDescending)
onChange(option.key == "sort_order" ? option.key : (`-${option.key}` as TProjectOrderByOptions));
else onChange(option.key);
}}
>
{option && t(option?.i18n_label)}
{value?.includes(option.key) && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
))}
<hr className="my-2 border-custom-border-200" />
<CustomMenu.MenuItem
className="flex items-center justify-between gap-2"
onClick={() => {
if (isDescending) onChange(value.slice(1) as TProjectOrderByOptions);
}}
disabled={isOrderingDisabled}
>
Ascending
{!isOrderingDisabled && !isDescending && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
className="flex items-center justify-between gap-2"
onClick={() => {
if (!isDescending) onChange(`-${value}` as TProjectOrderByOptions);
}}
disabled={isOrderingDisabled}
>
Descending
{!isOrderingDisabled && isDescending && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
</CustomMenu>
);
};

View File

@@ -0,0 +1,54 @@
"use client";
import React from "react";
import Image from "next/image";
// ui
import { Button } from "@plane/propel/button";
type Props = {
title: string;
description?: React.ReactNode;
image: any;
primaryButton?: {
icon?: any;
text: string;
onClick: () => void;
};
secondaryButton?: React.ReactNode;
disabled?: boolean;
};
export const EmptyState: React.FC<Props> = ({
title,
description,
image,
primaryButton,
secondaryButton,
disabled = false,
}) => (
<div className="flex h-full w-full items-center justify-center px-5 md:px-10 lg:p-20">
<div className="relative h-full w-full max-w-6xl">
<Image src={image} className="w-52 sm:w-60" alt={primaryButton?.text ?? ""} layout="fill" />
</div>
<div className="absolute flex w-full flex-col items-center pt-[30vh] text-center md:pt-[35vh] lg:pt-[45vh]">
<h6 className="mt-6 text-xl font-semibold">{title}</h6>
{description && <p className="mb-7 text-custom-text-300">{description}</p>}
<div className="flex items-center gap-4">
{primaryButton && (
<Button
size="lg"
variant="primary"
prependIcon={primaryButton.icon}
onClick={primaryButton.onClick}
disabled={disabled}
>
{primaryButton.text}
</Button>
)}
{secondaryButton}
</div>
</div>
</div>
);

View File

@@ -0,0 +1,98 @@
import { useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ListFilter } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TProjectFilters } from "@plane/types";
import { cn, calculateTotalFilters } from "@plane/utils";
// components
import { FiltersDropdown } from "@/components/issues/issue-layouts/filters";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useProjectFilter } from "@/hooks/store/use-project-filter";
// local imports
import { ProjectFiltersSelection } from "./dropdowns/filters";
import { ProjectOrderByDropdown } from "./dropdowns/order-by";
type Props = {
filterMenuButton?: React.ReactNode;
classname?: string;
filterClassname?: string;
isMobile?: boolean;
};
const HeaderFilters = observer(({ filterMenuButton, isMobile, classname = "", filterClassname = "" }: Props) => {
// i18n
const { t } = useTranslation();
// router
const { workspaceSlug } = useParams();
const {
currentWorkspaceDisplayFilters: displayFilters,
currentWorkspaceFilters: filters,
updateFilters,
updateDisplayFilters,
} = useProjectFilter();
const {
workspace: { workspaceMemberIds },
} = useMember();
const handleFilters = useCallback(
(key: keyof TProjectFilters, value: string | string[]) => {
if (!workspaceSlug) return;
let newValues = filters?.[key] ?? [];
if (Array.isArray(value)) {
if (key === "created_at" && newValues.find((v) => v.includes("custom"))) newValues = [];
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
} else {
if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else {
if (key === "created_at") newValues = [value];
else newValues.push(value);
}
}
updateFilters(workspaceSlug.toString(), { [key]: newValues });
},
[filters, updateFilters, workspaceSlug]
);
const isFiltersApplied = calculateTotalFilters(filters ?? {}) !== 0;
return (
<div className={cn("flex gap-3", classname)}>
<ProjectOrderByDropdown
value={displayFilters?.order_by}
onChange={(val) => {
if (!workspaceSlug || val === displayFilters?.order_by) return;
updateDisplayFilters(workspaceSlug.toString(), {
order_by: val,
});
}}
isMobile={isMobile}
/>
<div className={cn(filterClassname)}>
<FiltersDropdown
icon={<ListFilter className="h-3 w-3" />}
title={t("common.filters")}
placement="bottom-end"
isFiltersApplied={isFiltersApplied}
menuButton={filterMenuButton || null}
>
<ProjectFiltersSelection
displayFilters={displayFilters ?? {}}
filters={filters ?? {}}
handleFiltersUpdate={handleFilters}
handleDisplayFiltersUpdate={(val) => {
if (!workspaceSlug) return;
updateDisplayFilters(workspaceSlug.toString(), val);
}}
memberIds={workspaceMemberIds ?? undefined}
/>
</FiltersDropdown>
</div>
</div>
);
});
export default HeaderFilters;

View File

@@ -0,0 +1,62 @@
"use client";
import type { FC } from "react";
// components
import { Loader } from "@plane/ui";
export const ProjectDetailsFormLoader: FC = () => (
<>
<div className="relative mt-6 h-44 w-full">
<Loader>
<Loader.Item height="auto" width="46px" />
</Loader>
<div className="absolute bottom-4 flex w-full items-end justify-between gap-3 px-4">
<div className="flex flex-grow gap-3 truncate">
<div className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-lg bg-custom-background-90">
<Loader>
<Loader.Item height="46px" width="46px" />
</Loader>
</div>
</div>
<div className="flex flex-shrink-0 justify-center">
<Loader>
<Loader.Item height="32px" width="108px" />
</Loader>
</div>
</div>
</div>
<div className="my-8 flex flex-col gap-8">
<div className="flex flex-col gap-1">
<h4 className="text-sm">Project name</h4>
<Loader>
<Loader.Item height="46px" width="100%" />
</Loader>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Description</h4>
<Loader className="w-full">
<Loader.Item height="102px" width="full" />
</Loader>
</div>
<div className="flex w-full items-center justify-between gap-10">
<div className="flex w-1/2 flex-col gap-1">
<h4 className="text-sm">Identifier</h4>
<Loader>
<Loader.Item height="36px" width="100%" />
</Loader>
</div>
<div className="flex w-1/2 flex-col gap-1">
<h4 className="text-sm">Network</h4>
<Loader className="w-full">
<Loader.Item height="46px" width="100%" />
</Loader>
</div>
</div>
<div className="flex items-center justify-between py-2">
<Loader className="mt-2 w-full">
<Loader.Item height="34px" width="100px" />
</Loader>
</div>
</div>
</>
);

View File

@@ -0,0 +1,445 @@
"use client";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Info, Lock } from "lucide-react";
import { NETWORK_CHOICES, PROJECT_TRACKER_ELEMENTS, PROJECT_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// plane imports
import { Button } from "@plane/propel/button";
import { EmojiPicker } from "@plane/propel/emoji-icon-picker";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { IProject, IWorkspace } from "@plane/types";
import { CustomSelect, Input, TextArea, EmojiIconPickerTypes } from "@plane/ui";
import { renderFormattedDate, getFileURL } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
import { TimezoneSelect } from "@/components/global";
// helpers
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { usePlatformOS } from "@/hooks/use-platform-os";
// services
import { ProjectService } from "@/services/project";
// local imports
import { ProjectNetworkIcon } from "./project-network-icon";
export interface IProjectDetailsForm {
project: IProject;
workspaceSlug: string;
projectId: string;
isAdmin: boolean;
}
const projectService = new ProjectService();
export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
const { project, workspaceSlug, projectId, isAdmin } = props;
const { t } = useTranslation();
// states
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// store hooks
const { updateProject } = useProject();
const { isMobile } = usePlatformOS();
// form info
const {
handleSubmit,
watch,
control,
setValue,
setError,
reset,
formState: { errors },
getValues,
} = useForm<IProject>({
defaultValues: {
...project,
workspace: (project.workspace as IWorkspace).id,
},
});
// derived values
const currentNetwork = NETWORK_CHOICES.find((n) => n.key === project?.network);
const coverImage = watch("cover_image_url");
useEffect(() => {
if (project && projectId !== getValues("id")) {
reset({
...project,
workspace: (project.workspace as IWorkspace).id,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project, projectId]);
// handlers
const handleIdentifierChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
const alphanumericValue = value.replace(/[^a-zA-Z0-9]/g, "");
const formattedValue = alphanumericValue.toUpperCase();
setValue("identifier", formattedValue);
};
const handleUpdateChange = async (payload: Partial<IProject>) => {
if (!workspaceSlug || !project) return;
return updateProject(workspaceSlug.toString(), project.id, payload)
.then(() => {
captureSuccess({
eventName: PROJECT_TRACKER_EVENTS.update,
payload: {
id: projectId,
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("toast.success"),
message: t("project_settings.general.toast.success"),
});
})
.catch((err) => {
try {
captureError({
eventName: PROJECT_TRACKER_EVENTS.update,
payload: {
id: projectId,
},
});
// Handle the new error format where codes are nested in arrays under field names
const errorData = err ?? {};
const nameError = errorData.name?.includes("PROJECT_NAME_ALREADY_EXIST");
const identifierError = errorData?.identifier?.includes("PROJECT_IDENTIFIER_ALREADY_EXIST");
if (nameError || identifierError) {
if (nameError) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("project_name_already_taken"),
});
}
if (identifierError) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("project_identifier_already_taken"),
});
}
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("something_went_wrong"),
});
}
} catch (error) {
// Fallback error handling if the error processing fails
console.error("Error processing API error:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("something_went_wrong"),
});
}
});
};
const onSubmit = async (formData: IProject) => {
if (!workspaceSlug) return;
setIsLoading(true);
const payload: Partial<IProject> = {
name: formData.name,
network: formData.network,
identifier: formData.identifier,
description: formData.description,
logo_props: formData.logo_props,
timezone: formData.timezone,
};
// if unsplash or a pre-defined image is uploaded, delete the old uploaded asset
if (formData.cover_image_url?.startsWith("http")) {
payload.cover_image = formData.cover_image_url;
payload.cover_image_asset = null;
}
if (project.identifier !== formData.identifier)
await projectService
.checkProjectIdentifierAvailability(workspaceSlug as string, payload.identifier ?? "")
.then(async (res) => {
if (res.exists) setError("identifier", { message: t("common.identifier_already_exists") });
else await handleUpdateChange(payload);
});
else await handleUpdateChange(payload);
setTimeout(() => {
setIsLoading(false);
}, 300);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="relative h-44 w-full">
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
<img
src={getFileURL(
coverImage ??
"https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80"
)}
alt="Project cover image"
className="h-44 w-full rounded-md object-cover"
/>
<div className="z-5 absolute bottom-4 flex w-full items-end justify-between gap-3 px-4">
<div className="flex flex-grow gap-3 truncate">
<Controller
control={control}
name="logo_props"
render={({ field: { value, onChange } }) => (
<EmojiPicker
iconType="material"
closeOnSelect={false}
isOpen={isOpen}
handleToggle={(val: boolean) => setIsOpen(val)}
className="flex items-center justify-center"
buttonClassName="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-lg bg-white/10"
label={<Logo logo={value} size={28} />}
// TODO: fix types
onChange={(val: any) => {
let logoValue = {};
if (val?.type === "emoji")
logoValue = {
value: val.value,
};
else if (val?.type === "icon") logoValue = val.value;
onChange({
in_use: val?.type,
[val?.type]: logoValue,
});
setIsOpen(false);
}}
defaultIconColor={value?.in_use && value.in_use === "icon" ? value?.icon?.color : undefined}
defaultOpen={
value.in_use && value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON
}
disabled={!isAdmin}
/>
)}
/>
<div className="flex flex-col gap-1 truncate text-white">
<span className="truncate text-lg font-semibold">{watch("name")}</span>
<span className="flex items-center gap-2 text-sm">
<span>{watch("identifier")} .</span>
<span className="flex items-center gap-1.5">
{project.network === 0 && <Lock className="h-2.5 w-2.5 text-white " />}
{currentNetwork && t(currentNetwork?.i18n_label)}
</span>
</span>
</div>
</div>
<div className="flex flex-shrink-0 justify-center">
<div>
<Controller
control={control}
name="cover_image_url"
render={({ field: { value, onChange } }) => (
<ImagePickerPopover
label={t("change_cover")}
control={control}
onChange={onChange}
value={value ?? null}
disabled={!isAdmin}
projectId={project.id}
/>
)}
/>
</div>
</div>
</div>
</div>
<div className="my-8 flex flex-col gap-8">
<div className="flex flex-col gap-1">
<h4 className="text-sm">{t("common.project_name")}</h4>
<Controller
control={control}
name="name"
rules={{
required: t("name_is_required"),
maxLength: {
value: 255,
message: "Project name should be less than 255 characters",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
ref={ref}
value={value}
onChange={onChange}
hasError={Boolean(errors.name)}
className="rounded-md !p-3 font-medium"
placeholder={t("common.project_name")}
disabled={!isAdmin}
/>
)}
/>
<span className="text-xs text-red-500">{errors?.name?.message}</span>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">{t("description")}</h4>
<Controller
name="description"
control={control}
render={({ field: { value, onChange } }) => (
<TextArea
id="description"
name="description"
value={value}
placeholder={t("project_description_placeholder")}
onChange={onChange}
className="min-h-[102px] text-sm font-medium"
hasError={Boolean(errors?.description)}
disabled={!isAdmin}
/>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="flex flex-col gap-1">
<h4 className="text-sm">Project ID</h4>
<div className="relative">
<Controller
control={control}
name="identifier"
rules={{
required: t("project_id_is_required"),
validate: (value) => /^[ÇŞĞIİÖÜA-Z0-9]+$/.test(value.toUpperCase()) || t("project_id_allowed_char"),
minLength: {
value: 1,
message: t("project_id_min_char"),
},
maxLength: {
value: 5,
message: t("project_id_max_char"),
},
}}
render={({ field: { value, ref } }) => (
<Input
id="identifier"
name="identifier"
type="text"
value={value}
onChange={handleIdentifierChange}
ref={ref}
hasError={Boolean(errors.identifier)}
placeholder={t("project_settings.general.enter_project_id")}
className="w-full font-medium"
disabled={!isAdmin}
/>
)}
/>
<Tooltip
isMobile={isMobile}
tooltipContent="Helps you identify work items in the project uniquely. Max 5 characters."
className="text-sm"
position="right-start"
>
<Info className="absolute right-2 top-2.5 h-4 w-4 text-custom-text-400" />
</Tooltip>
</div>
<span className="text-xs text-red-500">
<>{errors?.identifier?.message}</>
</span>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">{t("workspace_projects.network.label")}</h4>
<Controller
name="network"
control={control}
render={({ field: { value, onChange } }) => {
const selectedNetwork = NETWORK_CHOICES.find((n) => n.key === value);
return (
<CustomSelect
value={value}
onChange={onChange}
label={
<div className="flex items-center gap-1">
{selectedNetwork ? (
<>
<ProjectNetworkIcon iconKey={selectedNetwork.iconKey} className="h-3.5 w-3.5" />
{t(selectedNetwork.i18n_label)}
</>
) : (
<span className="text-custom-text-400">{t("select_network")}</span>
)}
</div>
}
buttonClassName="!border-custom-border-200 !shadow-none font-medium rounded-md"
input
disabled={!isAdmin}
// optionsClassName="w-full"
>
{NETWORK_CHOICES.map((network) => (
<CustomSelect.Option key={network.key} value={network.key}>
<div className="flex items-start gap-2">
<ProjectNetworkIcon iconKey={network.iconKey} className="h-3.5 w-3.5" />
<div className="-mt-1">
<p>{t(network.i18n_label)}</p>
<p className="text-xs text-custom-text-400">{t(network.description)}</p>
</div>
</div>
</CustomSelect.Option>
))}
</CustomSelect>
);
}}
/>
</div>
<div className="flex flex-col gap-1 col-span-1 sm:col-span-2 xl:col-span-1">
<h4 className="text-sm">{t("common.project_timezone")}</h4>
<Controller
name="timezone"
control={control}
rules={{ required: t("project_settings.general.please_select_a_timezone") }}
render={({ field: { value, onChange } }) => (
<>
<TimezoneSelect
value={value}
onChange={(value: string) => {
onChange(value);
}}
error={Boolean(errors.timezone)}
buttonClassName="border-none"
/>
</>
)}
/>
{errors.timezone && <span className="text-xs text-red-500">{errors.timezone.message}</span>}
</div>
</div>
<div className="flex items-center justify-between py-2">
<>
<Button
data-ph-element={PROJECT_TRACKER_ELEMENTS.UPDATE_PROJECT_BUTTON}
variant="primary"
type="submit"
loading={isLoading}
disabled={!isAdmin}
>
{isLoading ? `${t("updating")}...` : t("common.update_project")}
</Button>
<span className="text-sm italic text-custom-sidebar-text-400">
{t("common.created_on")} {renderFormattedDate(project?.created_at)}
</span>
</>
</div>
</div>
</form>
);
};

View File

@@ -0,0 +1,75 @@
"use client";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
// i18n
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { Button } from "@plane/propel/button";
import { ProjectIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useUserPermissions } from "@/hooks/store/user";
// plane web constants
// components
import HeaderFilters from "./filters";
import { ProjectSearch } from "./search-projects";
export const ProjectsBaseHeader = observer(() => {
// i18n
const { t } = useTranslation();
// store hooks
const { toggleCreateProjectModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const pathname = usePathname();
// auth
const isAuthorizedUser = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const isArchived = pathname.includes("/archives");
return (
<Header>
<Header.LeftItem>
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t("workspace_projects.label", { count: 2 })}
icon={<ProjectIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
{isArchived && <Breadcrumbs.Item component={<BreadcrumbLink label="Archived" />} />}
</Breadcrumbs>
</Header.LeftItem>
<Header.RightItem>
<ProjectSearch />
<div className="hidden md:flex">
<HeaderFilters />
</div>
{isAuthorizedUser && !isArchived ? (
<Button
size="sm"
onClick={() => {
toggleCreateProjectModal(true);
}}
data-ph-element={PROJECT_TRACKER_ELEMENTS.CREATE_HEADER_BUTTON}
className="items-center gap-1"
>
<span className="hidden sm:inline-block">{t("workspace_projects.create.label")}</span>
<span className="inline-block sm:hidden">{t("workspace_projects.label", { count: 1 })}</span>
</Button>
) : (
<></>
)}
</Header.RightItem>
</Header>
);
});

View File

@@ -0,0 +1,123 @@
"use client";
import React from "react";
import Image from "next/image";
import { useParams } from "next/navigation";
import useSWR, { mutate } from "swr";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspaceIntegration } from "@plane/types";
// components
import { SelectRepository, SelectChannel } from "@/components/integration";
// constants
import { PROJECT_GITHUB_REPOSITORY } from "@/constants/fetch-keys";
// icons
import GithubLogo from "@/public/logos/github-square.png";
import SlackLogo from "@/public/services/slack.png";
import { ProjectService } from "@/services/project";
// types
type Props = {
integration: IWorkspaceIntegration;
};
const integrationDetails: { [key: string]: any } = {
github: {
logo: GithubLogo,
description: "Select GitHub repository to enable sync.",
},
slack: {
logo: SlackLogo,
description: "Get regular updates and control which notification you want to receive.",
},
};
// services
const projectService = new ProjectService();
export const IntegrationCard: React.FC<Props> = ({ integration }) => {
const { workspaceSlug, projectId } = useParams();
const { data: syncedGithubRepository } = useSWR(
projectId ? PROJECT_GITHUB_REPOSITORY(projectId as string) : null,
() =>
workspaceSlug && projectId && integration
? projectService.getProjectGithubRepository(workspaceSlug as string, projectId as string, integration.id)
: null
);
const handleChange = (repo: any) => {
if (!workspaceSlug || !projectId || !integration) return;
const {
html_url,
owner: { login },
id,
name,
} = repo;
projectService
.syncGithubRepository(workspaceSlug as string, projectId as string, integration.id, {
name,
owner: login,
repository_id: id,
url: html_url,
})
.then(() => {
mutate(PROJECT_GITHUB_REPOSITORY(projectId as string));
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: `${login}/${name} repository synced with the project successfully.`,
});
})
.catch((err) => {
console.error(err);
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Repository could not be synced with the project. Please try again.",
});
});
};
return (
<>
{integration && (
<div className="flex items-center justify-between gap-2 border-b border-custom-border-100 bg-custom-background-100 px-4 py-6">
<div className="flex items-start gap-4">
<div className="h-10 w-10 flex-shrink-0">
<Image
src={integrationDetails[integration.integration_detail.provider].logo}
alt={`${integration.integration_detail.title} Logo`}
/>
</div>
<div>
<h3 className="flex items-center gap-4 text-sm font-medium">{integration.integration_detail.title}</h3>
<p className="text-sm tracking-tight text-custom-text-200">
{integrationDetails[integration.integration_detail.provider].description}
</p>
</div>
</div>
{integration.integration_detail.provider === "github" && (
<SelectRepository
integration={integration}
value={
syncedGithubRepository && syncedGithubRepository.length > 0
? `${syncedGithubRepository[0].repo_detail.owner}/${syncedGithubRepository[0].repo_detail.name}`
: null
}
label={
syncedGithubRepository && syncedGithubRepository.length > 0
? `${syncedGithubRepository[0].repo_detail.owner}/${syncedGithubRepository[0].repo_detail.name}`
: "Select Repository"
}
onChange={handleChange}
/>
)}
{integration.integration_detail.provider === "slack" && <SelectChannel integration={integration} />}
</div>
)}
</>
);
};

View File

@@ -0,0 +1,107 @@
"use client";
import { useState, Fragment } from "react";
import { Transition, Dialog } from "@headlessui/react";
// types
import { Button } from "@plane/propel/button";
import type { IProject } from "@plane/types";
// ui
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// type
type TJoinProjectModalProps = {
isOpen: boolean;
workspaceSlug: string;
project: IProject;
handleClose: () => void;
};
export const JoinProjectModal: React.FC<TJoinProjectModalProps> = (props) => {
const { handleClose, isOpen, project, workspaceSlug } = props;
// states
const [isJoiningLoading, setIsJoiningLoading] = useState(false);
// store hooks
const { joinProject } = useUserPermissions();
const { fetchProjectDetails } = useProject();
// router
const router = useAppRouter();
const handleJoin = () => {
setIsJoiningLoading(true);
joinProject(workspaceSlug, project.id)
.then(() => {
router.push(`/${workspaceSlug}/projects/${project.id}/issues`);
fetchProjectDetails(workspaceSlug, project.id);
handleClose();
})
.finally(() => {
setIsJoiningLoading(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-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-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 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-xl sm:p-6">
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Join Project?
</Dialog.Title>
<p>
Are you sure you want to join the project{" "}
<span className="break-words font-semibold">{project?.name}</span>? Please click the &apos;Join
Project&apos; button below to continue.
</p>
<div className="space-y-3" />
</div>
<div className="mt-5 flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
tabIndex={1}
type="submit"
onClick={handleJoin}
loading={isJoiningLoading}
>
{isJoiningLoading ? "Joining..." : "Join Project"}
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@@ -0,0 +1,224 @@
"use client";
import type { FC } from "react";
import { Fragment } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
// headless ui
import { AlertTriangleIcon } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
// types
import { MEMBER_TRACKER_EVENTS } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IProject } from "@plane/types";
// ui
import { Input } from "@plane/ui";
// constants
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
type FormData = {
projectName: string;
confirmLeave: string;
};
const defaultValues: FormData = {
projectName: "",
confirmLeave: "",
};
export interface ILeaveProjectModal {
project: IProject;
isOpen: boolean;
onClose: () => void;
}
export const LeaveProjectModal: FC<ILeaveProjectModal> = observer((props) => {
const { project, isOpen, onClose } = props;
// router
const router = useAppRouter();
const { workspaceSlug } = useParams();
// store hooks
const { leaveProject } = useUserPermissions();
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
reset,
} = useForm({ defaultValues });
const handleClose = () => {
reset({ ...defaultValues });
onClose();
};
const onSubmit = async (data: any) => {
if (!workspaceSlug) return;
if (data) {
if (data.projectName === project?.name) {
if (data.confirmLeave === "Leave Project") {
router.push(`/${workspaceSlug}/projects`);
return leaveProject(workspaceSlug.toString(), project.id)
.then(() => {
handleClose();
captureSuccess({
eventName: MEMBER_TRACKER_EVENTS.project.leave,
payload: {
project: project.id,
},
});
})
.catch((err) => {
captureError({
eventName: MEMBER_TRACKER_EVENTS.project.leave,
payload: {
project: project.id,
},
error: err,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong please try again later.",
});
});
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please confirm leaving the project by typing the 'Leave Project'.",
});
}
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please enter the project name as shown in the description.",
});
}
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please fill all fields.",
});
}
};
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-20 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-2xl">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6 p-6">
<div className="flex w-full items-center justify-start gap-6">
<span className="place-items-center rounded-full bg-red-500/20 p-4">
<AlertTriangleIcon className="h-6 w-6 text-red-600" aria-hidden="true" />
</span>
<span className="flex items-center justify-start">
<h3 className="text-xl font-medium 2xl:text-2xl">Leave Project</h3>
</span>
</div>
<span>
<p className="text-sm leading-7 text-custom-text-200">
Are you sure you want to leave the project -
<span className="font-medium text-custom-text-100">{` "${project?.name}" `}</span>? All of the
work items associated with you will become inaccessible.
</p>
</span>
<div className="text-custom-text-200">
<p className="break-words text-sm ">
Enter the project name <span className="font-medium text-custom-text-100">{project?.name}</span>{" "}
to continue:
</p>
<Controller
control={control}
name="projectName"
rules={{
required: "Label title is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="projectName"
name="projectName"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.projectName)}
placeholder="Enter project name"
className="mt-2 w-full"
/>
)}
/>
</div>
<div className="text-custom-text-200">
<p className="text-sm">
To confirm, type <span className="font-medium text-custom-text-100">Leave Project</span> below:
</p>
<Controller
control={control}
name="confirmLeave"
render={({ field: { value, onChange, ref } }) => (
<Input
id="confirmLeave"
name="confirmLeave"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.confirmLeave)}
placeholder="Enter 'leave project'"
className="mt-2 w-full"
/>
)}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button variant="danger" size="sm" type="submit" loading={isSubmitting}>
{isSubmitting ? "Leaving..." : "Leave Project"}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@@ -0,0 +1,115 @@
// ui
import { observer } from "mobx-react";
import { ArrowDownWideNarrow, ArrowUpNarrowWide, CheckIcon, ChevronDownIcon, Eraser, MoveRight } from "lucide-react";
// constants
import type { IProjectMemberDisplayProperties, TMemberOrderByOptions } from "@plane/constants";
import { MEMBER_PROPERTY_DETAILS } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
// types
import { CustomMenu } from "@plane/ui";
import type { IMemberFilters } from "@/store/member/utils";
interface Props {
property: keyof IProjectMemberDisplayProperties;
displayFilters?: IMemberFilters;
handleDisplayFilterUpdate: (data: Partial<IMemberFilters>) => void;
}
export const MemberHeaderColumn = observer((props: Props) => {
const { displayFilters, handleDisplayFilterUpdate, property } = props;
// i18n
const { t } = useTranslation();
const propertyDetails = MEMBER_PROPERTY_DETAILS[property];
const activeSortingProperty = displayFilters?.order_by;
const handleOrderBy = (order: TMemberOrderByOptions, _itemKey: keyof IProjectMemberDisplayProperties) => {
handleDisplayFilterUpdate({ order_by: order });
};
const handleClearSorting = () => {
handleDisplayFilterUpdate({ order_by: undefined });
};
if (!propertyDetails) return null;
return (
<CustomMenu
customButtonClassName="clickable !w-full"
customButtonTabIndex={-1}
className="!w-full"
customButton={
<div className="flex w-full cursor-pointer items-center justify-between gap-1.5 py-2 text-sm text-custom-text-200 hover:text-custom-text-100">
<span>{t(propertyDetails.i18n_title)}</span>
<div className="ml-3 flex">
{(activeSortingProperty === propertyDetails.ascendingOrderKey ||
activeSortingProperty === propertyDetails.descendingOrderKey) && (
<div className="flex h-3.5 w-3.5 items-center justify-center rounded-full">
{propertyDetails.ascendingOrderKey === activeSortingProperty ? (
<ArrowDownWideNarrow className="h-3 w-3" />
) : (
<ArrowUpNarrowWide className="h-3 w-3" />
)}
</div>
)}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
</div>
}
placement="bottom-end"
closeOnSelect
>
{propertyDetails.isSortingAllowed && (
<>
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.ascendingOrderKey, property)}>
<div
className={`flex items-center justify-between gap-1.5 px-1 ${
activeSortingProperty === propertyDetails.ascendingOrderKey
? "text-custom-text-100"
: "text-custom-text-200 hover:text-custom-text-100"
}`}
>
<div className="flex items-center gap-2">
<ArrowDownWideNarrow className="h-3 w-3 stroke-[1.5]" />
<span>{propertyDetails.ascendingOrderTitle}</span>
<MoveRight className="h-3 w-3" />
<span>{propertyDetails.descendingOrderTitle}</span>
</div>
{activeSortingProperty === propertyDetails.ascendingOrderKey && <CheckIcon className="h-3 w-3" />}
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.descendingOrderKey, property)}>
<div
className={`flex items-center justify-between gap-1.5 px-1 ${
activeSortingProperty === propertyDetails.descendingOrderKey
? "text-custom-text-100"
: "text-custom-text-200 hover:text-custom-text-100"
}`}
>
<div className="flex items-center gap-2">
<ArrowUpNarrowWide className="h-3 w-3 stroke-[1.5]" />
<span>{propertyDetails.descendingOrderTitle}</span>
<MoveRight className="h-3 w-3" />
<span>{propertyDetails.ascendingOrderTitle}</span>
</div>
{activeSortingProperty === propertyDetails.descendingOrderKey && <CheckIcon className="h-3 w-3" />}
</div>
</CustomMenu.MenuItem>
{(activeSortingProperty === propertyDetails.ascendingOrderKey ||
activeSortingProperty === propertyDetails.descendingOrderKey) && (
<CustomMenu.MenuItem className="mt-0.5" key={property} onClick={handleClearSorting}>
<div className="flex items-center gap-2 px-1">
<Eraser className="h-3 w-3" />
<span>{t("common.actions.clear_sorting")}</span>
</div>
</CustomMenu.MenuItem>
)}
</>
)}
</CustomMenu>
);
});

View File

@@ -0,0 +1,104 @@
"use client";
import { observer } from "mobx-react";
// plane imports
import { MEMBER_TRACKER_EVENTS } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Table } from "@plane/ui";
// helpers
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { useProjectColumns } from "@/plane-web/components/projects/settings/useProjectColumns";
// store
import type { IProjectMemberDetails } from "@/store/member/project/base-project-member.store";
// local imports
import { ConfirmProjectMemberRemove } from "./confirm-project-member-remove";
type Props = {
memberDetails: (IProjectMemberDetails | null)[];
projectId: string;
workspaceSlug: string;
};
export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
const { memberDetails, projectId, workspaceSlug } = props;
// router
const router = useAppRouter();
// store hooks
const { leaveProject } = useUserPermissions();
const { data: currentUser } = useUser();
const {
project: { removeMemberFromProject },
} = useMember();
// helper hooks
const { columns, removeMemberModal, setRemoveMemberModal } = useProjectColumns({
projectId,
workspaceSlug,
});
const handleRemove = async (memberId: string) => {
if (!workspaceSlug || !projectId || !memberId) return;
if (memberId === currentUser?.id) {
await leaveProject(workspaceSlug.toString(), projectId.toString())
.then(async () => {
router.push(`/${workspaceSlug}/projects`);
captureSuccess({
eventName: MEMBER_TRACKER_EVENTS.project.leave,
payload: {
project: projectId,
},
});
})
.catch((err) => {
captureError({
eventName: MEMBER_TRACKER_EVENTS.project.leave,
payload: {
project: projectId,
},
error: err,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "You cant leave this project yet.",
message: err?.error || "Something went wrong. Please try again.",
});
});
} else
await removeMemberFromProject(workspaceSlug.toString(), projectId.toString(), memberId).catch((err) =>
setToast({
type: TOAST_TYPE.ERROR,
title: "You can't remove the member from this project yet.",
message: err?.error || "Something went wrong. Please try again.",
})
);
};
if (!memberDetails) return null;
return (
<>
{removeMemberModal && (
<ConfirmProjectMemberRemove
isOpen={removeMemberModal !== null}
onClose={() => setRemoveMemberModal(null)}
data={{ id: removeMemberModal.member.id, display_name: removeMemberModal.member.display_name || "" }}
onSubmit={() => handleRemove(removeMemberModal.member.id)}
/>
)}
<Table
columns={columns}
data={(memberDetails?.filter((member): member is IProjectMemberDetails => member !== null) ?? []) as any}
keyExtractor={(rowData) => rowData?.member.id ?? ""}
tHeadClassName="border-b border-custom-border-100"
thClassName="text-left font-medium divide-x-0 text-custom-text-400"
tBodyClassName="divide-y-0"
tBodyTrClassName="divide-x-0 p-4 h-[40px] text-custom-text-200"
tHeadTrClassName="divide-x-0"
/>
</>
);
});

View File

@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { Search } from "lucide-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
// components
import { MembersSettingsLoader } from "@/components/ui/loader/settings/members";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { MemberListFiltersDropdown } from "./dropdowns/filters/member-list";
import { ProjectMemberListItem } from "./member-list-item";
import { SendProjectInvitationModal } from "./send-project-invitation-modal";
type TProjectMemberListProps = {
projectId: string;
workspaceSlug: string;
};
export const ProjectMemberList: React.FC<TProjectMemberListProps> = observer((props) => {
const { projectId, workspaceSlug } = props;
// states
const [inviteModal, setInviteModal] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const {
project: { projectMemberIds, getFilteredProjectMemberDetails, filters },
} = useMember();
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
const searchedProjectMembers = (projectMemberIds ?? []).filter((userId) => {
const memberDetails = projectId ? getFilteredProjectMemberDetails(userId, projectId.toString()) : null;
if (!memberDetails?.member || !memberDetails.original_role) return false;
const fullName = `${memberDetails?.member.first_name} ${memberDetails?.member.last_name}`.toLowerCase();
const displayName = memberDetails?.member.display_name.toLowerCase();
return displayName?.includes(searchQuery.toLowerCase()) || fullName.includes(searchQuery.toLowerCase());
});
const memberDetails = searchedProjectMembers?.map((memberId) =>
projectId ? getFilteredProjectMemberDetails(memberId, projectId.toString()) : null
);
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
// Handler for role filter updates
const handleRoleFilterUpdate = (role: string) => {
if (projectId) {
const currentFilters = filters.getFilters(projectId);
const currentRoles = currentFilters?.roles || [];
const updatedRoles = currentRoles.includes(role)
? currentRoles.filter((r) => r !== role)
: [...currentRoles, role];
filters.updateFilters(projectId, {
roles: updatedRoles.length > 0 ? updatedRoles : undefined,
});
}
};
// Get current role filters
const appliedRoleFilters = projectId ? filters.getFilters(projectId)?.roles || [] : [];
return (
<>
<SendProjectInvitationModal
isOpen={inviteModal}
onClose={() => setInviteModal(false)}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
<div className="flex items-center justify-between gap-4 py-2 overflow-x-hidden border-b border-custom-border-100">
<div className="text-base font-semibold">{t("common.members")}</div>
<div className="flex items-center gap-2">
<div className="flex items-center justify-start gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2 py-1">
<Search className="h-3.5 w-3.5" />
<input
className="w-full max-w-[234px] border-none bg-transparent text-sm focus:outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<MemberListFiltersDropdown
appliedFilters={appliedRoleFilters}
handleUpdate={handleRoleFilterUpdate}
memberType="project"
/>
{isAdmin && (
<Button
variant="primary"
size="sm"
onClick={() => {
setInviteModal(true);
}}
data-ph-element={MEMBER_TRACKER_ELEMENTS.HEADER_ADD_BUTTON}
>
{t("add_member")}
</Button>
)}
</div>
</div>
{!projectMemberIds ? (
<MembersSettingsLoader />
) : (
<div className="divide-y divide-custom-border-100 overflow-scroll">
{searchedProjectMembers.length !== 0 && (
<ProjectMemberListItem
memberDetails={memberDetails ?? []}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
)}
{searchedProjectMembers.length === 0 && (
<h4 className="text-sm mt-16 text-center text-custom-text-400">{t("no_matching_members")}</h4>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,98 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Ban } from "lucide-react";
import { EUserProjectRoles } from "@plane/types";
// plane ui
import { Avatar, CustomSearchSelect } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
type Props = {
value: any;
onChange: (val: string) => void;
isDisabled?: boolean;
};
export const MemberSelect: React.FC<Props> = observer((props) => {
const { value, onChange, isDisabled = false } = props;
// router
const { projectId } = useParams();
// store hooks
const {
project: { projectMemberIds, getProjectMemberDetails },
} = useMember();
const options = projectMemberIds
?.map((userId) => {
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId.toString()) : null;
if (!memberDetails?.member) return;
const isGuest = memberDetails.role === EUserProjectRoles.GUEST;
if (isGuest) return;
return {
value: `${memberDetails?.member.id}`,
query: `${memberDetails?.member.display_name}`,
content: (
<div className="flex items-center gap-2">
<Avatar name={memberDetails?.member.display_name} src={getFileURL(memberDetails?.member.avatar_url)} />
{memberDetails?.member.display_name}
</div>
),
};
})
.filter((option) => !!option) as
| {
value: string;
query: string;
content: React.ReactNode;
}[]
| undefined;
const selectedOption = projectId ? getProjectMemberDetails(value, projectId.toString()) : null;
return (
<CustomSearchSelect
value={value}
label={
<div className="flex items-center gap-2 h-3.5">
{selectedOption && (
<Avatar name={selectedOption.member?.display_name} src={getFileURL(selectedOption.member?.avatar_url)} />
)}
{selectedOption ? (
selectedOption.member?.display_name
) : (
<div className="flex items-center gap-2">
<Ban className="h-3.5 w-3.5 rotate-90 text-custom-sidebar-text-400" />
<span className="text-sm text-custom-sidebar-text-400">None</span>
</div>
)}
</div>
}
buttonClassName="!px-3 !py-2 bg-custom-background-100"
options={
options &&
options && [
...options,
{
value: "none",
query: "none",
content: (
<div className="flex items-center gap-2">
<Ban className="h-3.5 w-3.5 rotate-90 text-custom-sidebar-text-400" />
<span className="py-0.5 text-sm text-custom-sidebar-text-400">None</span>
</div>
),
},
]
}
maxHeight="md"
onChange={onChange}
disabled={isDisabled}
/>
);
});

View File

@@ -0,0 +1,185 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { xor } from "lodash-es";
import { observer } from "mobx-react";
import { Search, X } from "lucide-react";
import { Combobox } from "@headlessui/react";
// plane ui
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { Checkbox, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// components
import { cn } from "@plane/utils";
import { Logo } from "@/components/common/logo";
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
// helpers
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
type Props = {
isOpen: boolean;
onClose: () => void;
selectedProjectIds: string[];
projectIds: string[];
onSubmit: (projectIds: string[]) => Promise<void>;
};
export const ProjectMultiSelectModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose, selectedProjectIds: selectedProjectIdsProp, projectIds, onSubmit } = props;
// states
const [searchTerm, setSearchTerm] = useState("");
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
// refs
const moveButtonRef = useRef<HTMLButtonElement>(null);
// plane hooks
const { t } = useTranslation();
// store hooks
const { getProjectById } = useProject();
// derived values
const projectDetailsMap = useMemo(
() => new Map(projectIds.map((id) => [id, getProjectById(id)])),
[projectIds, getProjectById]
);
const areSelectedProjectsChanged = xor(selectedProjectIds, selectedProjectIdsProp).length > 0;
const filteredProjectIds = projectIds.filter((id) => {
const project = projectDetailsMap.get(id);
const projectQuery = `${project?.identifier} ${project?.name}`.toLowerCase();
return projectQuery.includes(searchTerm.toLowerCase());
});
const filteredProjectResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/search/project",
});
useEffect(() => {
if (isOpen) setSelectedProjectIds(selectedProjectIdsProp);
}, [isOpen, selectedProjectIdsProp]);
const handleClose = () => {
onClose();
setTimeout(() => {
setSearchTerm("");
setSelectedProjectIds([]);
}, 300);
};
const handleSubmit = async () => {
setIsSubmitting(true);
await onSubmit(selectedProjectIds);
setIsSubmitting(false);
handleClose();
};
const handleSelectedProjectChange = (val: string[]) => {
setSelectedProjectIds(val);
setSearchTerm("");
moveButtonRef.current?.focus();
};
if (!isOpen) return null;
return (
<ModalCore isOpen={isOpen} width={EModalWidth.LG} position={EModalPosition.TOP} handleClose={handleClose}>
<Combobox as="div" multiple value={selectedProjectIds} onChange={handleSelectedProjectChange}>
<div className="flex items-center gap-2 px-4 border-b border-custom-border-100">
<Search className="flex-shrink-0 size-4 text-custom-text-400" aria-hidden="true" />
<Combobox.Input
className="h-12 w-full border-0 bg-transparent text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0"
placeholder="Search for projects"
displayValue={() => ""}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{selectedProjectIds.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2 px-4">
{selectedProjectIds.map((projectId) => {
const projectDetails = projectDetailsMap.get(projectId);
if (!projectDetails) return null;
return (
<div
key={projectDetails.id}
className="group flex items-center gap-1.5 bg-custom-background-90 px-2 py-1 rounded cursor-pointer"
onClick={() => {
handleSelectedProjectChange(selectedProjectIds.filter((id) => id !== projectDetails.id));
}}
>
<Logo logo={projectDetails.logo_props} size={14} />
<p className="text-xs truncate text-custom-text-300 group-hover:text-custom-text-200 transition-colors">
{projectDetails.identifier}
</p>
<X className="size-3 flex-shrink-0 text-custom-text-400 group-hover:text-custom-text-200 transition-colors" />
</div>
);
})}
</div>
)}
<Combobox.Options
static
className="py-2 vertical-scrollbar scrollbar-md max-h-80 scroll-py-2 overflow-y-auto transition-[height] duration-200 ease-in-out"
>
{filteredProjectIds.length === 0 ? (
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
<SimpleEmptyState
title={t("workspace_projects.empty_state.filter.title")}
description={t("workspace_projects.empty_state.filter.description")}
assetPath={filteredProjectResolvedPath}
/>
</div>
) : (
<ul
className={cn("text-custom-text-100", {
"px-2": filteredProjectIds.length > 0,
})}
>
{filteredProjectIds.map((projectId) => {
const projectDetails = projectDetailsMap.get(projectId);
if (!projectDetails) return null;
const isProjectSelected = selectedProjectIds.includes(projectDetails.id);
return (
<Combobox.Option
key={projectDetails.id}
value={projectDetails.id}
className={({ active }) =>
cn(
"flex items-center justify-between gap-2 truncate w-full cursor-pointer select-none rounded-md p-2 text-custom-text-200 transition-colors",
{
"bg-custom-background-80": active,
"text-custom-text-100": isProjectSelected,
}
)
}
>
<div className="flex items-center gap-2 truncate">
<span className="flex-shrink-0 flex items-center gap-2.5">
<Checkbox checked={isProjectSelected} />
<Logo logo={projectDetails.logo_props} size={16} />
</span>
<span className="flex-shrink-0 text-[10px]">{projectDetails.identifier}</span>
<p className="text-sm truncate">{projectDetails.name}</p>
</div>
</Combobox.Option>
);
})}
</ul>
)}
</Combobox.Options>
</Combobox>
<div className="flex items-center justify-end gap-2 p-3 border-t border-custom-border-100">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
{t("cancel")}
</Button>
<Button
ref={moveButtonRef}
variant="primary"
size="sm"
onClick={handleSubmit}
loading={isSubmitting}
disabled={!areSelectedProjectsChanged}
>
{isSubmitting ? t("confirming") : t("confirm")}
</Button>
</div>
</ModalCore>
);
});

View File

@@ -0,0 +1,60 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useTranslation } from "@plane/i18n";
// ui
import { Button, getButtonStyling } from "@plane/propel/button";
import { Row } from "@plane/ui";
// components
import { Logo } from "@/components/common/logo";
import { ProjectFeaturesList } from "@/components/project/settings/features-list";
// hooks
import { useProject } from "@/hooks/store/use-project";
type Props = {
workspaceSlug: string;
projectId: string | null;
onClose: () => void;
};
export const ProjectFeatureUpdate: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, onClose } = props;
// store hooks
const { t } = useTranslation();
const { getProjectById } = useProject();
if (!workspaceSlug || !projectId) return null;
const currentProjectDetails = getProjectById(projectId);
if (!currentProjectDetails) return null;
return (
<>
<Row className="py-6">
<ProjectFeaturesList workspaceSlug={workspaceSlug} projectId={projectId} isAdmin />
</Row>
<div className="flex items-center justify-between gap-2 mt-4 px-6 py-4 border-t border-custom-border-100">
<div className="flex gap-1 text-sm text-custom-text-300 font-medium">
{t("congrats")}
<Logo logo={currentProjectDetails.logo_props} /> <p className="break-all">{currentProjectDetails.name}</p>{" "}
{t("created").toLowerCase()}.
</div>
<div className="flex gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose} tabIndex={1}>
{t("close")}
</Button>
<Link
href={`/${workspaceSlug}/projects/${projectId}/issues`}
onClick={onClose}
className={getButtonStyling("primary", "sm")}
tabIndex={2}
>
{t("open_project")}
</Link>
</div>
</div>
</>
);
});

View File

@@ -0,0 +1,30 @@
import { Lock, Globe2 } from "lucide-react";
// plane imports
import type { TNetworkChoiceIconKey } from "@plane/constants";
import { cn } from "@plane/utils";
type Props = {
iconKey: TNetworkChoiceIconKey;
className?: string;
};
export const ProjectNetworkIcon = (props: Props) => {
const { iconKey, className } = props;
// Get the icon key
const getProjectNetworkIcon = () => {
switch (iconKey) {
case "Lock":
return Lock;
case "Globe2":
return Globe2;
default:
return null;
}
};
// Get the icon
const Icon = getProjectNetworkIcon();
if (!Icon) return null;
return <Icon className={cn("h-3 w-3", className)} />;
};

View File

@@ -0,0 +1,193 @@
"use client";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import useSWR from "swr";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IProject, IUserLite, IWorkspace } from "@plane/types";
import { Loader, ToggleSwitch } from "@plane/ui";
// constants
import { PROJECT_MEMBERS } from "@/constants/fetch-keys";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { MemberSelect } from "./member-select";
const defaultValues: Partial<IProject> = {
project_lead: null,
default_assignee: null,
};
type TDefaultSettingItemProps = {
title: string;
description: string;
children: ReactNode;
};
const DefaultSettingItem: React.FC<TDefaultSettingItemProps> = ({ title, description, children }) => (
<div className="flex items-center justify-between gap-x-2">
<div className="flex flex-col gap-0.5">
<h4 className="text-sm font-medium">{title}</h4>
<p className="text-xs text-custom-text-300">{description}</p>
</div>
<div className="w-full max-w-48 sm:max-w-64">{children}</div>
</div>
);
type TProjectSettingsMemberDefaultsProps = {
workspaceSlug: string;
projectId: string;
};
export const ProjectSettingsMemberDefaults: React.FC<TProjectSettingsMemberDefaultsProps> = observer((props) => {
const { workspaceSlug, projectId } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const { allowPermissions } = useUserPermissions();
const { currentProjectDetails, fetchProjectDetails, updateProject } = useProject();
// derived values
const isAdmin = allowPermissions(
[EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
currentProjectDetails?.id
);
// form info
const { reset, control } = useForm<IProject>({ defaultValues });
// fetching user members
useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId) : null,
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null
);
useEffect(() => {
if (!currentProjectDetails) return;
reset({
...currentProjectDetails,
default_assignee:
(currentProjectDetails.default_assignee as IUserLite)?.id ?? currentProjectDetails.default_assignee,
project_lead: (currentProjectDetails.project_lead as IUserLite)?.id ?? currentProjectDetails.project_lead,
workspace: (currentProjectDetails.workspace as IWorkspace).id,
});
}, [currentProjectDetails, reset]);
const submitChanges = async (formData: Partial<IProject>) => {
if (!workspaceSlug || !projectId) return;
reset({
...currentProjectDetails,
default_assignee:
(currentProjectDetails?.default_assignee as IUserLite)?.id ?? currentProjectDetails?.default_assignee,
project_lead: (currentProjectDetails?.project_lead as IUserLite)?.id ?? currentProjectDetails?.project_lead,
...formData,
});
await updateProject(workspaceSlug, projectId, {
default_assignee:
formData.default_assignee === "none"
? null
: (formData.default_assignee ?? currentProjectDetails?.default_assignee),
project_lead:
formData.project_lead === "none" ? null : (formData.project_lead ?? currentProjectDetails?.project_lead),
})
.then(() => {
setToast({
title: `${t("success")}!`,
type: TOAST_TYPE.SUCCESS,
message: t("project_settings.general.toast.success"),
});
})
.catch((err) => {
console.error(err);
});
};
const toggleGuestViewAllIssues = async (value: boolean) => {
if (!workspaceSlug || !projectId) return;
updateProject(workspaceSlug, projectId, {
guest_view_all_features: value,
})
.then(() => {
setToast({
title: `${t("success")}!`,
type: TOAST_TYPE.SUCCESS,
message: t("project_settings.general.toast.success"),
});
})
.catch((err) => {
console.error(err);
});
};
return (
<div className="flex flex-col gap-y-6 my-6">
<DefaultSettingItem title="Project Lead" description="Select the project lead for the project.">
{currentProjectDetails ? (
<Controller
control={control}
name="project_lead"
render={({ field: { value } }) => (
<MemberSelect
value={value}
onChange={(val: string) => {
submitChanges({ project_lead: val });
}}
isDisabled={!isAdmin}
/>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</DefaultSettingItem>
<DefaultSettingItem title="Default Assignee" description="Select the default assignee for the project.">
{currentProjectDetails ? (
<Controller
control={control}
name="default_assignee"
render={({ field: { value } }) => (
<MemberSelect
value={value}
onChange={(val: string) => {
submitChanges({ default_assignee: val });
}}
isDisabled={!isAdmin}
/>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</DefaultSettingItem>
{currentProjectDetails && (
<DefaultSettingItem
title="Guest access"
description="This will allow guests to have view access to all the project work items."
>
<div className="flex items-center justify-end">
<ToggleSwitch
value={!!currentProjectDetails?.guest_view_all_features}
onChange={() => toggleGuestViewAllIssues(!currentProjectDetails?.guest_view_all_features)}
disabled={!isAdmin}
size="sm"
/>
</div>
</DefaultSettingItem>
)}
</div>
);
});

View File

@@ -0,0 +1,332 @@
"use client";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import { Check, ExternalLink, Globe2 } from "lucide-react";
// types
import { SPACE_BASE_PATH, SPACE_BASE_URL } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TProjectPublishLayouts, TProjectPublishSettings } from "@plane/types";
// ui
import { Loader, ToggleSwitch, CustomSelect, ModalCore, EModalWidth } from "@plane/ui";
// helpers
import { copyTextToClipboard } from "@plane/utils";
// hooks
import { useProjectPublish } from "@/hooks/store/use-project-publish";
type Props = {
isOpen: boolean;
projectId: string;
onClose: () => void;
};
const defaultValues: Partial<TProjectPublishSettings> = {
is_comments_enabled: false,
is_reactions_enabled: false,
is_votes_enabled: false,
inbox: null,
view_props: {
list: true,
kanban: true,
},
};
const VIEW_OPTIONS: {
key: TProjectPublishLayouts;
label: string;
}[] = [
{ key: "list", label: "List" },
{ key: "kanban", label: "Kanban" },
];
export const PublishProjectModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose, projectId } = props;
// states
const [isUnPublishing, setIsUnPublishing] = useState(false);
// router
const { workspaceSlug } = useParams();
// store hooks
const {
fetchPublishSettings,
getPublishSettingsByProjectID,
publishProject,
updatePublishSettings,
unPublishProject,
fetchSettingsLoader,
} = useProjectPublish();
// derived values
const projectPublishSettings = getPublishSettingsByProjectID(projectId);
const isProjectPublished = !!projectPublishSettings?.anchor;
// form info
const {
control,
formState: { isDirty, isSubmitting },
handleSubmit,
reset,
watch,
} = useForm({
defaultValues,
});
const handleClose = () => {
onClose();
};
// fetch publish settings
useEffect(() => {
if (!workspaceSlug || !isOpen) return;
if (!projectPublishSettings) {
fetchPublishSettings(workspaceSlug.toString(), projectId);
}
}, [fetchPublishSettings, isOpen, projectId, projectPublishSettings, workspaceSlug]);
const handlePublishProject = async (payload: Partial<TProjectPublishSettings>) => {
if (!workspaceSlug) return;
await publishProject(workspaceSlug.toString(), projectId, payload);
};
const handleUpdatePublishSettings = async (payload: Partial<TProjectPublishSettings>) => {
if (!workspaceSlug || !payload.id) return;
await updatePublishSettings(workspaceSlug.toString(), projectId, payload.id, payload).then((res) => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Publish settings updated successfully!",
});
handleClose();
return res;
});
};
const handleUnPublishProject = async (publishId: string) => {
if (!workspaceSlug || !publishId) return;
setIsUnPublishing(true);
await unPublishProject(workspaceSlug.toString(), projectId, publishId)
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong while unpublishing the project.",
})
)
.finally(() => setIsUnPublishing(false));
};
const selectedLayouts = Object.entries(watch("view_props") ?? {})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([key, value]) => value)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(([key, value]) => key)
.filter((l) => VIEW_OPTIONS.find((o) => o.key === l));
const handleFormSubmit = async (formData: Partial<TProjectPublishSettings>) => {
if (!selectedLayouts || selectedLayouts.length === 0) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please select at least one view layout to publish the project.",
});
return;
}
const payload: Partial<TProjectPublishSettings> = {
id: formData.id,
is_comments_enabled: formData.is_comments_enabled,
is_reactions_enabled: formData.is_reactions_enabled,
is_votes_enabled: formData.is_votes_enabled,
view_props: formData.view_props,
};
if (formData.id && isProjectPublished) await handleUpdatePublishSettings(payload);
else await handlePublishProject(payload);
};
// prefill form values for already published projects
useEffect(() => {
if (!projectPublishSettings?.anchor) return;
reset({
...defaultValues,
...projectPublishSettings,
});
}, [projectPublishSettings, reset]);
const SPACE_APP_URL = (SPACE_BASE_URL.trim() === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH;
const publishLink = `${SPACE_APP_URL}/issues/${projectPublishSettings?.anchor}`;
const handleCopyLink = () =>
copyTextToClipboard(publishLink).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "",
message: "Published page link copied successfully.",
})
);
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} width={EModalWidth.XXL}>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="flex items-center justify-between gap-2 p-5">
<h5 className="text-xl font-medium text-custom-text-200">Publish project</h5>
{isProjectPublished && (
<Button variant="danger" onClick={() => handleUnPublishProject(watch("id") ?? "")} loading={isUnPublishing}>
{isUnPublishing ? "Unpublishing" : "Unpublish"}
</Button>
)}
</div>
{/* content */}
{fetchSettingsLoader ? (
<Loader className="space-y-4 px-5">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
) : (
<div className="px-5 space-y-4">
{isProjectPublished && projectPublishSettings && (
<>
<div className="bg-custom-background-80 border border-custom-border-300 rounded-md py-1.5 pl-4 pr-1 flex items-center justify-between gap-2">
<a
href={publishLink}
className="text-sm text-custom-text-200 truncate"
target="_blank"
rel="noopener noreferrer"
>
{publishLink}
</a>
<div className="flex-shrink-0 flex items-center gap-1">
<a
href={publishLink}
className="size-8 grid place-items-center bg-custom-background-90 hover:bg-custom-background-100 rounded"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="size-4" />
</a>
<button
type="button"
className="h-8 bg-custom-background-90 hover:bg-custom-background-100 rounded text-xs font-medium py-2 px-3"
onClick={handleCopyLink}
>
Copy link
</button>
</div>
</div>
<p className="text-sm font-medium text-custom-primary-100 flex items-center gap-1 mt-3">
<span className="relative grid place-items-center size-2.5">
<span className="animate-ping absolute inline-flex size-full rounded-full bg-custom-primary-100 opacity-75" />
<span className="relative inline-flex rounded-full size-1.5 bg-custom-primary-100" />
</span>
This project is now live on web
</p>
</>
)}
<div className="space-y-4">
<div className="relative flex items-center justify-between gap-2">
<div className="text-sm">Views</div>
<Controller
control={control}
name="view_props"
render={({ field: { onChange, value } }) => (
<CustomSelect
value={value}
label={VIEW_OPTIONS.filter((o) => selectedLayouts.includes(o.key))
.map((o) => o.label)
.join(", ")}
onChange={(val: TProjectPublishLayouts) => {
if (selectedLayouts.length === 1 && selectedLayouts[0] === val) return;
onChange({
...value,
[val]: !value?.[val],
});
}}
buttonClassName="border-none"
placement="bottom-end"
>
{VIEW_OPTIONS.map((option) => (
<CustomSelect.Option
key={option.key}
value={option.key}
className="flex items-center justify-between gap-2"
>
{option.label}
{selectedLayouts.includes(option.key) && <Check className="size-3.5 flex-shrink-0" />}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="relative flex items-center justify-between gap-2">
<div className="text-sm">Allow comments</div>
<Controller
control={control}
name="is_comments_enabled"
render={({ field: { onChange, value } }) => (
<ToggleSwitch value={!!value} onChange={onChange} size="sm" />
)}
/>
</div>
<div className="relative flex items-center justify-between gap-2">
<div className="text-sm">Allow reactions</div>
<Controller
control={control}
name="is_reactions_enabled"
render={({ field: { onChange, value } }) => (
<ToggleSwitch value={!!value} onChange={onChange} size="sm" />
)}
/>
</div>
<div className="relative flex items-center justify-between gap-2">
<div className="text-sm">Allow voting</div>
<Controller
control={control}
name="is_votes_enabled"
render={({ field: { onChange, value } }) => (
<ToggleSwitch value={!!value} onChange={onChange} size="sm" />
)}
/>
</div>
</div>
</div>
)}
{/* modal handlers */}
<div className="relative flex items-center justify-between border-t border-custom-border-200 px-5 py-4 mt-4">
<div className="flex items-center gap-1 text-sm text-custom-text-400">
<Globe2 className="size-3.5" />
<div className="text-sm">Anyone with the link can access</div>
</div>
{!fetchSettingsLoader && (
<div className="relative flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
{isProjectPublished ? (
isDirty && (
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{isSubmitting ? "Updating" : "Update settings"}
</Button>
)
) : (
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{isSubmitting ? "Publishing" : "Publish"}
</Button>
)}
</div>
)}
</div>
</form>
</ModalCore>
);
});

View File

@@ -0,0 +1,99 @@
"use client";
import { useCallback, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types";
import { calculateTotalFilters } from "@plane/utils";
// components
import { PageHead } from "@/components/core/page-title";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useProjectFilter } from "@/hooks/store/use-project-filter";
import { useWorkspace } from "@/hooks/store/use-workspace";
// local imports
import { ProjectAppliedFiltersList } from "./applied-filters";
import { ProjectCardList } from "./card-list";
export const ProjectRoot = observer(() => {
const { currentWorkspace } = useWorkspace();
const { workspaceSlug } = useParams();
const pathname = usePathname();
const { t } = useTranslation();
// store
const { totalProjectIds, filteredProjectIds } = useProject();
const {
currentWorkspaceFilters,
currentWorkspaceAppliedDisplayFilters,
clearAllFilters,
clearAllAppliedDisplayFilters,
updateFilters,
updateDisplayFilters,
} = useProjectFilter();
// derived values
const pageTitle = currentWorkspace?.name
? `${currentWorkspace?.name} - ${t("workspace_projects.label", { count: 2 })}`
: undefined;
const isArchived = pathname.includes("/archives");
const allowedDisplayFilters =
currentWorkspaceAppliedDisplayFilters?.filter((filter) => filter !== "archived_projects") ?? [];
const handleRemoveFilter = useCallback(
(key: keyof TProjectFilters, value: string | null) => {
if (!workspaceSlug) return;
let newValues = currentWorkspaceFilters?.[key] ?? [];
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value);
updateFilters(workspaceSlug.toString(), { [key]: newValues });
},
[currentWorkspaceFilters, updateFilters, workspaceSlug]
);
const handleRemoveDisplayFilter = useCallback(
(key: TProjectAppliedDisplayFilterKeys) => {
if (!workspaceSlug) return;
updateDisplayFilters(workspaceSlug.toString(), { [key]: false });
},
[updateDisplayFilters, workspaceSlug]
);
const handleClearAllFilters = useCallback(() => {
if (!workspaceSlug) return;
clearAllFilters(workspaceSlug.toString());
clearAllAppliedDisplayFilters(workspaceSlug.toString());
if (isArchived) updateDisplayFilters(workspaceSlug.toString(), { archived_projects: true });
}, [clearAllFilters, clearAllAppliedDisplayFilters, workspaceSlug]);
useEffect(() => {
isArchived
? updateDisplayFilters(workspaceSlug.toString(), { archived_projects: true })
: updateDisplayFilters(workspaceSlug.toString(), { archived_projects: false });
}, [pathname]);
return (
<>
<PageHead title={pageTitle} />
<div className="flex h-full w-full flex-col">
{(calculateTotalFilters(currentWorkspaceFilters ?? {}) !== 0 || allowedDisplayFilters.length > 0) && (
<ProjectAppliedFiltersList
appliedFilters={currentWorkspaceFilters ?? {}}
appliedDisplayFilters={allowedDisplayFilters}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
handleRemoveDisplayFilter={handleRemoveDisplayFilter}
filteredProjects={filteredProjectIds?.length ?? 0}
totalProjects={totalProjectIds?.length ?? 0}
alwaysAllowEditing
/>
)}
<ProjectCardList />
</div>
</>
);
});

View File

@@ -0,0 +1,83 @@
"use client";
import type { FC } from "react";
import { useRef, useState } from "react";
import { observer } from "mobx-react";
import { Search, X } from "lucide-react";
// plane hooks
import { useOutsideClickDetector } from "@plane/hooks";
// i18n
import { useTranslation } from "@plane/i18n";
// helpers
import { cn } from "@plane/utils";
// hooks
import { useProjectFilter } from "@/hooks/store/use-project-filter";
export const ProjectSearch: FC = observer(() => {
// i18n
const { t } = useTranslation();
// hooks
const { searchQuery, updateSearchQuery } = useProjectFilter();
// refs
const inputRef = useRef<HTMLInputElement>(null);
// states
const [isSearchOpen, setIsSearchOpen] = useState(false);
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
});
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
else setIsSearchOpen(false);
}
};
return (
<div className="flex items-center">
{!isSearchOpen && (
<button
type="button"
className="-mr-1 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-30 md:w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder={t("common.search.label")}
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,365 @@
"use client";
import React, { useEffect } from "react";
import { observer } from "mobx-react";
import { useForm, Controller, useFieldArray } from "react-hook-form";
import { ChevronDown, Plus, X } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
// plane imports
import { ROLE, EUserPermissions, MEMBER_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Avatar, CustomSelect, CustomSearchSelect } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useMember } from "@/hooks/store/use-member";
import { useUserPermissions } from "@/hooks/store/user";
type Props = {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
projectId: string;
workspaceSlug: string;
};
type member = {
role: EUserPermissions;
member_id: string;
};
type FormValues = {
members: member[];
};
const defaultValues: FormValues = {
members: [
{
role: 5,
member_id: "",
},
],
};
export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose, onSuccess, projectId, workspaceSlug } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const {
project: { getProjectMemberDetails, bulkAddMembersToProject },
workspace: { workspaceMemberIds, getWorkspaceMemberDetails },
} = useMember();
// form info
const {
formState: { errors, isSubmitting },
watch,
setValue,
reset,
handleSubmit,
control,
} = useForm<FormValues>();
const { fields, append, remove } = useFieldArray({
control,
name: "members",
});
// derived values
const currentProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
const uninvitedPeople = workspaceMemberIds?.filter((userId) => {
const projectMemberDetails = getProjectMemberDetails(userId, projectId);
const isInvited = projectMemberDetails?.member.id && projectMemberDetails?.original_role;
return !isInvited;
});
const onSubmit = async (formData: FormValues) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
const payload = { ...formData };
await bulkAddMembersToProject(workspaceSlug.toString(), projectId.toString(), payload)
.then(() => {
if (onSuccess) onSuccess();
onClose();
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Members added successfully.",
});
captureSuccess({
eventName: MEMBER_TRACKER_EVENTS.project.add,
payload: {
members: [...payload.members.map((member) => member.member_id)],
},
});
})
.catch((error) => {
console.error(error);
captureError({
eventName: MEMBER_TRACKER_EVENTS.project.add,
payload: {
members: [...payload.members.map((member) => member.member_id)],
},
error: error,
});
})
.finally(() => {
reset(defaultValues);
});
};
const handleClose = () => {
onClose();
const timeout = setTimeout(() => {
reset(defaultValues);
clearTimeout(timeout);
}, 500);
};
const appendField = () => {
append({
role: 5,
member_id: "",
});
};
useEffect(() => {
if (fields.length === 0) {
append([
{
role: 5,
member_id: "",
},
]);
}
}, [fields, append]);
const options = uninvitedPeople
?.map((userId) => {
const memberDetails = getWorkspaceMemberDetails(userId);
if (!memberDetails?.member) return;
return {
value: `${memberDetails?.member.id}`,
query: `${memberDetails?.member.first_name} ${
memberDetails?.member.last_name
} ${memberDetails?.member.display_name.toLowerCase()}`,
content: (
<div className="flex w-full items-center gap-2">
<div className="flex-shrink-0 pt-0.5">
<Avatar name={memberDetails?.member.display_name} src={getFileURL(memberDetails?.member.avatar_url)} />
</div>
<div className="truncate">
{memberDetails?.member.display_name} (
{memberDetails?.member.first_name + " " + memberDetails?.member.last_name})
</div>
</div>
),
};
})
.filter((option) => !!option) as
| {
value: string;
query: string;
content: React.ReactNode;
}[]
| undefined;
const checkCurrentOptionWorkspaceRole = (value: string) => {
const currentMemberWorkspaceRole = getWorkspaceMemberDetails(value)?.role;
if (!value || !currentMemberWorkspaceRole) return ROLE;
const isGuestOROwner = [EUserPermissions.ADMIN, EUserPermissions.GUEST].includes(
currentMemberWorkspaceRole as EUserPermissions
);
return Object.fromEntries(
Object.entries(ROLE).filter(([key]) => !isGuestOROwner || [currentMemberWorkspaceRole].includes(parseInt(key)))
);
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.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-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={React.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 rounded-lg bg-custom-background-100 p-5 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-2xl">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
{t("project_settings.members.invite_members.title")}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-custom-text-200">
{t("project_settings.members.invite_members.sub_heading")}
</p>
</div>
<div className="mb-3 space-y-4">
{fields.map((field, index) => (
<div
key={field.id}
className="group mb-1 flex items-start justify-between gap-x-4 text-sm w-full"
>
<div className="flex flex-col gap-1 flex-grow w-full">
<Controller
control={control}
name={`members.${index}.member_id`}
rules={{ required: "Please select a member" }}
render={({ field: { value, onChange } }) => {
const selectedMember = getWorkspaceMemberDetails(value);
return (
<CustomSearchSelect
value={value}
customButton={
<button className="flex w-full items-center justify-between gap-1 rounded-md border border-custom-border-200 px-3 py-2 text-left text-sm text-custom-text-200 shadow-sm duration-300 hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none">
{value && value !== "" ? (
<div className="flex items-center gap-2">
<Avatar
name={selectedMember?.member.display_name}
src={getFileURL(selectedMember?.member.avatar_url ?? "")}
/>
{selectedMember?.member.display_name}
</div>
) : (
<div className="flex items-center gap-2 py-0.5">Select co-worker</div>
)}
<ChevronDown className="h-3 w-3" aria-hidden="true" />
</button>
}
onChange={(val: string) => {
onChange(val);
// Update the role to the workspace role when member ID changes
const workspaceMemberDetails = getWorkspaceMemberDetails(val);
const workspaceRole = workspaceMemberDetails?.role ?? 5;
const newValue = ROLE[workspaceRole].toUpperCase();
setValue(
`members.${index}.role`,
EUserPermissions[newValue as keyof typeof EUserPermissions]
);
}}
options={options}
optionsClassName="w-48"
/>
);
}}
/>
{errors.members && errors.members[index]?.member_id && (
<span className="px-1 text-sm text-red-500">
{errors.members[index]?.member_id?.message}
</span>
)}
</div>
<div className="flex items-center justify-between gap-2 flex-shrink-0 ">
<div className="flex flex-col gap-1">
<Controller
name={`members.${index}.role`}
control={control}
rules={{ required: "Select Role" }}
render={({ field }) => (
<CustomSelect
{...field}
customButton={
<div className="flex w-24 items-center justify-between gap-1 rounded-md border border-custom-border-200 px-3 py-2.5 text-left text-sm text-custom-text-200 shadow-sm duration-300 hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none">
<span className="capitalize">
{field.value ? ROLE[field.value] : "Select role"}
</span>
<ChevronDown className="h-3 w-3" aria-hidden="true" />
</div>
}
input
>
{Object.entries(
checkCurrentOptionWorkspaceRole(watch(`members.${index}.member_id`))
).map(([key, label]) => {
if (parseInt(key) > (currentProjectRole ?? EUserPermissions.GUEST)) return null;
return (
<CustomSelect.Option key={key} value={key}>
{label}
</CustomSelect.Option>
);
})}
</CustomSelect>
)}
/>
{errors.members && errors.members[index]?.role && (
<span className="px-1 text-sm text-red-500">
{errors.members[index]?.role?.message}
</span>
)}
</div>
{fields.length > 1 && (
<div className="flex-item flex w-6">
<button
type="button"
className="place-items-center self-center rounded"
onClick={() => remove(index)}
>
<X className="h-4 w-4 text-custom-text-200" />
</button>
</div>
)}
</div>
</div>
))}
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<button
type="button"
className="flex items-center gap-2 bg-transparent py-2 pr-3 text-sm font-medium text-custom-primary outline-custom-primary"
onClick={appendField}
>
<Plus className="h-4 w-4" />
{t("common.add_more")}
</button>
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
{t("cancel")}
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{isSubmitting
? `${fields && fields.length > 1 ? `${t("add_members")}...` : `${t("add_member")}...`}`
: `${fields && fields.length > 1 ? t("add_members") : t("add_member")}`}
</Button>
</div>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

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