feat: init
This commit is contained in:
38
apps/web/core/components/project/applied-filters/access.tsx
Normal file
38
apps/web/core/components/project/applied-filters/access.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
54
apps/web/core/components/project/applied-filters/date.tsx
Normal file
54
apps/web/core/components/project/applied-filters/date.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
55
apps/web/core/components/project/applied-filters/members.tsx
Normal file
55
apps/web/core/components/project/applied-filters/members.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
130
apps/web/core/components/project/applied-filters/root.tsx
Normal file
130
apps/web/core/components/project/applied-filters/root.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
117
apps/web/core/components/project/card-list.tsx
Normal file
117
apps/web/core/components/project/card-list.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
371
apps/web/core/components/project/card.tsx
Normal file
371
apps/web/core/components/project/card.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
81
apps/web/core/components/project/create-project-modal.tsx
Normal file
81
apps/web/core/components/project/create-project-modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
151
apps/web/core/components/project/create/common-attributes.tsx
Normal file
151
apps/web/core/components/project/create/common-attributes.tsx
Normal 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;
|
||||
107
apps/web/core/components/project/create/header.tsx
Normal file
107
apps/web/core/components/project/create/header.tsx
Normal 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;
|
||||
@@ -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;
|
||||
201
apps/web/core/components/project/delete-project-modal.tsx
Normal file
201
apps/web/core/components/project/delete-project-modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
111
apps/web/core/components/project/dropdowns/filters/lead.tsx
Normal file
111
apps/web/core/components/project/dropdowns/filters/lead.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
111
apps/web/core/components/project/dropdowns/filters/members.tsx
Normal file
111
apps/web/core/components/project/dropdowns/filters/members.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
104
apps/web/core/components/project/dropdowns/filters/root.tsx
Normal file
104
apps/web/core/components/project/dropdowns/filters/root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
92
apps/web/core/components/project/dropdowns/order-by.tsx
Normal file
92
apps/web/core/components/project/dropdowns/order-by.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
54
apps/web/core/components/project/empty-state.tsx
Normal file
54
apps/web/core/components/project/empty-state.tsx
Normal 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>
|
||||
);
|
||||
98
apps/web/core/components/project/filters.tsx
Normal file
98
apps/web/core/components/project/filters.tsx
Normal 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;
|
||||
62
apps/web/core/components/project/form-loader.tsx
Normal file
62
apps/web/core/components/project/form-loader.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
445
apps/web/core/components/project/form.tsx
Normal file
445
apps/web/core/components/project/form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
75
apps/web/core/components/project/header.tsx
Normal file
75
apps/web/core/components/project/header.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
123
apps/web/core/components/project/integration-card.tsx
Normal file
123
apps/web/core/components/project/integration-card.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
107
apps/web/core/components/project/join-project-modal.tsx
Normal file
107
apps/web/core/components/project/join-project-modal.tsx
Normal 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 'Join
|
||||
Project' 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>
|
||||
);
|
||||
};
|
||||
224
apps/web/core/components/project/leave-project-modal.tsx
Normal file
224
apps/web/core/components/project/leave-project-modal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
115
apps/web/core/components/project/member-header-column.tsx
Normal file
115
apps/web/core/components/project/member-header-column.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
104
apps/web/core/components/project/member-list-item.tsx
Normal file
104
apps/web/core/components/project/member-list-item.tsx
Normal 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 can’t 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
130
apps/web/core/components/project/member-list.tsx
Normal file
130
apps/web/core/components/project/member-list.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
98
apps/web/core/components/project/member-select.tsx
Normal file
98
apps/web/core/components/project/member-select.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
185
apps/web/core/components/project/multi-select-modal.tsx
Normal file
185
apps/web/core/components/project/multi-select-modal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
60
apps/web/core/components/project/project-feature-update.tsx
Normal file
60
apps/web/core/components/project/project-feature-update.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
30
apps/web/core/components/project/project-network-icon.tsx
Normal file
30
apps/web/core/components/project/project-network-icon.tsx
Normal 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)} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
332
apps/web/core/components/project/publish-project/modal.tsx
Normal file
332
apps/web/core/components/project/publish-project/modal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
99
apps/web/core/components/project/root.tsx
Normal file
99
apps/web/core/components/project/root.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
83
apps/web/core/components/project/search-projects.tsx
Normal file
83
apps/web/core/components/project/search-projects.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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 won’t 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
118
apps/web/core/components/project/settings/features-list.tsx
Normal file
118
apps/web/core/components/project/settings/features-list.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
41
apps/web/core/components/project/settings/helper.tsx
Normal file
41
apps/web/core/components/project/settings/helper.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
189
apps/web/core/components/project/settings/member-columns.tsx
Normal file
189
apps/web/core/components/project/settings/member-columns.tsx
Normal 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 can’t 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user