[WEB-1959]: Chore/settings member page (#5144)

* chore: implemented table component in ui library

* chore: added export in the UI package

* chore/member-page-revamp

* fix: added custom popover className

* fix: updated ui for projects

* fix: hide pending invites for members

* fix: added ee component

* removed unwanted logging

* fix: seperated components

* fix: used collapsible instead of disclosure

* fix: removed commented code

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
This commit is contained in:
Akshita Goyal
2024-07-18 13:02:22 +05:30
committed by GitHub
parent 474d7ef3c0
commit fff27c60e4
20 changed files with 758 additions and 491 deletions

View File

@@ -1,192 +1,199 @@
import { EUserWorkspaceRoles } from "@/constants/workspace"; import {EUserWorkspaceRoles} from "@/constants/workspace";
import type { import type {
IProjectMember, IProjectMember,
IUser, IUser,
IUserLite, IUserLite,
IWorkspaceViewProps, IWorkspaceViewProps,
} from "@plane/types"; } from "@plane/types";
export interface IWorkspace { export interface IWorkspace {
readonly id: string; readonly id: string;
readonly owner: IUser; readonly owner: IUser;
readonly created_at: Date; readonly created_at: Date;
readonly updated_at: Date; readonly updated_at: Date;
name: string; name: string;
url: string; url: string;
logo: string | null; logo: string | null;
slug: string; slug: string;
readonly total_members: number; readonly total_members: number;
readonly slug: string; readonly slug: string;
readonly created_by: string; readonly created_by: string;
readonly updated_by: string; readonly updated_by: string;
organization_size: string; organization_size: string;
total_issues: number; total_issues: number;
} }
export interface IWorkspaceLite { export interface IWorkspaceLite {
readonly id: string; readonly id: string;
name: string; name: string;
slug: string; slug: string;
} }
export interface IWorkspaceMemberInvitation { export interface IWorkspaceMemberInvitation {
accepted: boolean; accepted: boolean;
email: string; email: string;
id: string; id: string;
message: string; message: string;
responded_at: Date; responded_at: Date;
role: EUserWorkspaceRoles; role: EUserWorkspaceRoles;
token: string; token: string;
workspace: { workspace: {
id: string; id: string;
logo: string; logo: string;
name: string; name: string;
slug: string; slug: string;
}; };
} }
export interface IWorkspaceBulkInviteFormData { export interface IWorkspaceBulkInviteFormData {
emails: { email: string; role: EUserWorkspaceRoles }[]; emails: {email: string; role: EUserWorkspaceRoles}[];
} }
export type Properties = { export type Properties = {
assignee: boolean; assignee: boolean;
start_date: boolean; start_date: boolean;
due_date: boolean; due_date: boolean;
labels: boolean; labels: boolean;
key: boolean; key: boolean;
priority: boolean; priority: boolean;
state: boolean; state: boolean;
sub_issue_count: boolean; sub_issue_count: boolean;
link: boolean; link: boolean;
attachment_count: boolean; attachment_count: boolean;
estimate: boolean; estimate: boolean;
created_on: boolean; created_on: boolean;
updated_on: boolean; updated_on: boolean;
}; };
export interface IWorkspaceMember { export interface IWorkspaceMember {
id: string; id: string;
member: IUserLite; member: IUserLite;
role: EUserWorkspaceRoles; role: EUserWorkspaceRoles;
created_at: string;
avatar?: string;
email?: string;
first_name?: string;
last_name?: string;
joining_date: string;
display_name?: string;
} }
export interface IWorkspaceMemberMe { export interface IWorkspaceMemberMe {
company_role: string | null; company_role: string | null;
created_at: Date; created_at: Date;
created_by: string; created_by: string;
default_props: IWorkspaceViewProps; default_props: IWorkspaceViewProps;
id: string; id: string;
member: string; member: string;
role: EUserWorkspaceRoles; role: EUserWorkspaceRoles;
updated_at: Date; updated_at: Date;
updated_by: string; updated_by: string;
view_props: IWorkspaceViewProps; view_props: IWorkspaceViewProps;
workspace: string; workspace: string;
} }
export interface ILastActiveWorkspaceDetails { export interface ILastActiveWorkspaceDetails {
workspace_details: IWorkspace; workspace_details: IWorkspace;
project_details?: IProjectMember[]; project_details?: IProjectMember[];
} }
export interface IWorkspaceDefaultSearchResult { export interface IWorkspaceDefaultSearchResult {
id: string; id: string;
name: string; name: string;
project_id: string; project_id: string;
project__identifier: string; project__identifier: string;
workspace__slug: string; workspace__slug: string;
} }
export interface IWorkspaceSearchResult { export interface IWorkspaceSearchResult {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
} }
export interface IWorkspaceIssueSearchResult { export interface IWorkspaceIssueSearchResult {
id: string; id: string;
name: string; name: string;
project__identifier: string; project__identifier: string;
project_id: string; project_id: string;
sequence_id: number; sequence_id: number;
workspace__slug: string; workspace__slug: string;
} }
export interface IWorkspacePageSearchResult { export interface IWorkspacePageSearchResult {
id: string; id: string;
name: string; name: string;
project_ids: string[]; project_ids: string[];
project__identifiers: string[]; project__identifiers: string[];
workspace__slug: string; workspace__slug: string;
} }
export interface IWorkspaceProjectSearchResult { export interface IWorkspaceProjectSearchResult {
id: string; id: string;
identifier: string; identifier: string;
name: string; name: string;
workspace__slug: string; workspace__slug: string;
} }
export interface IWorkspaceSearchResults { export interface IWorkspaceSearchResults {
results: { results: {
workspace: IWorkspaceSearchResult[]; workspace: IWorkspaceSearchResult[];
project: IWorkspaceProjectSearchResult[]; project: IWorkspaceProjectSearchResult[];
issue: IWorkspaceIssueSearchResult[]; issue: IWorkspaceIssueSearchResult[];
cycle: IWorkspaceDefaultSearchResult[]; cycle: IWorkspaceDefaultSearchResult[];
module: IWorkspaceDefaultSearchResult[]; module: IWorkspaceDefaultSearchResult[];
issue_view: IWorkspaceDefaultSearchResult[]; issue_view: IWorkspaceDefaultSearchResult[];
page: IWorkspacePageSearchResult[]; page: IWorkspacePageSearchResult[];
}; };
} }
export interface IProductUpdateResponse { export interface IProductUpdateResponse {
url: string; url: string;
assets_url: string; assets_url: string;
upload_url: string; upload_url: string;
html_url: string; html_url: string;
id: number; id: number;
author: { author: {
login: string; login: string;
id: string; id: string;
node_id: string; node_id: string;
avatar_url: string; avatar_url: string;
gravatar_id: ""; gravatar_id: "";
url: string; url: string;
html_url: string; html_url: string;
followers_url: string; followers_url: string;
following_url: string; following_url: string;
gists_url: string; gists_url: string;
starred_url: string; starred_url: string;
subscriptions_url: string; subscriptions_url: string;
organizations_url: string; organizations_url: string;
repos_url: string; repos_url: string;
events_url: string; events_url: string;
received_events_url: string; received_events_url: string;
type: string; type: string;
site_admin: false; site_admin: false;
}; };
node_id: string; node_id: string;
tag_name: string; tag_name: string;
target_commitish: string; target_commitish: string;
name: string; name: string;
draft: boolean; draft: boolean;
prerelease: true; prerelease: true;
created_at: string; created_at: string;
published_at: string; published_at: string;
assets: []; assets: [];
tarball_url: string; tarball_url: string;
zipball_url: string; zipball_url: string;
body: string; body: string;
reactions: { reactions: {
url: string; url: string;
total_count: number; total_count: number;
"+1": number; "+1": number;
"-1": number; "-1": number;
laugh: number; laugh: number;
hooray: number; hooray: number;
confused: number; confused: number;
heart: number; heart: number;
rocket: number; rocket: number;
eyes: number; eyes: number;
}; };
} }

View File

@@ -82,11 +82,16 @@ const CustomSelect = (props: ICustomSelectProps) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={`flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 ${ className={cn(
input ? "px-3 py-2 text-sm" : "px-2 py-1 text-xs" "flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300",
} ${ {
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80" "px-3 py-2 text-sm": input,
} ${buttonClassName}`} "px-2 py-1 text-xs": !input,
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer hover:bg-custom-background-80": !disabled,
},
buttonClassName
)}
onClick={toggleDropdown} onClick={toggleDropdown}
> >
{label} {label}

View File

@@ -14,6 +14,7 @@ export const PopoverMenu = <T,>(props: TPopoverMenu<T>) => {
button, button,
panelClassName = "", panelClassName = "",
data, data,
popoverClassName = "",
keyExtractor, keyExtractor,
render, render,
} = props; } = props;
@@ -28,6 +29,7 @@ export const PopoverMenu = <T,>(props: TPopoverMenu<T>) => {
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2 text-xs shadow-custom-shadow-rg focus:outline-none", "my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2 text-xs shadow-custom-shadow-rg focus:outline-none",
panelClassName panelClassName
)} )}
popoverClassName={popoverClassName}
> >
<Fragment> <Fragment>
{data.map((item, index) => ( {data.map((item, index) => (

View File

@@ -12,6 +12,7 @@ export const Popover = (props: TPopover) => {
popperPosition = "bottom-end", popperPosition = "bottom-end",
popperPadding = 0, popperPadding = 0,
buttonClassName = "", buttonClassName = "",
popoverClassName = "",
button, button,
panelClassName = "", panelClassName = "",
children, children,
@@ -34,7 +35,7 @@ export const Popover = (props: TPopover) => {
}); });
return ( return (
<HeadlessReactPopover className="relative flex h-full w-full items-center justify-center"> <HeadlessReactPopover className={cn("relative flex h-full w-full items-center justify-center", popoverClassName)}>
<HeadlessReactPopover.Button ref={setReferenceElement} className="flex justify-center items-center"> <HeadlessReactPopover.Button ref={setReferenceElement} className="flex justify-center items-center">
{button ? ( {button ? (
button button

View File

@@ -13,6 +13,7 @@ export type TPopoverDefaultOptions = TPopoverButtonDefaultOptions & {
popperPadding?: number | undefined; popperPadding?: number | undefined;
// panel styling // panel styling
panelClassName?: string; panelClassName?: string;
popoverClassName?: string;
}; };
export type TPopover = TPopoverDefaultOptions & { export type TPopover = TPopoverDefaultOptions & {

View File

@@ -29,7 +29,7 @@ export const Table = <T,>(props: TTableData<T>) => {
))} ))}
</tr> </tr>
</thead> </thead>
<tbody className={cn("divide-y divide-x divide-custom-border-200", tBodyClassName)}> <tbody className={cn("divide-y divide-custom-border-200", tBodyClassName)}>
{data.map((item) => ( {data.map((item) => (
<tr <tr
key={keyExtractor(item)} key={keyExtractor(item)}

View File

@@ -79,8 +79,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
}; };
// derived values // derived values
const hasAddMemberPermission = const isAdmin = currentWorkspaceRole && [EUserWorkspaceRoles.ADMIN].includes(currentWorkspaceRole);
currentWorkspaceRole && [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER].includes(currentWorkspaceRole);
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Members` : undefined; const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Members` : undefined;
return ( return (
@@ -104,13 +103,13 @@ const WorkspaceMembersSettingsPage = observer(() => {
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
</div> </div>
{hasAddMemberPermission && ( {isAdmin && (
<Button variant="primary" size="sm" onClick={() => setInviteModal(true)}> <Button variant="primary" size="sm" onClick={() => setInviteModal(true)}>
Add member Add member
</Button> </Button>
)} )}
</div> </div>
<WorkspaceMembersList searchQuery={searchQuery} /> <WorkspaceMembersList searchQuery={searchQuery} isAdmin={isAdmin ?? false} />
</section> </section>
</> </>
); );

View File

@@ -0,0 +1,75 @@
import { useState } from "react";
import { useParams } from "next/navigation";
import { IWorkspaceMember } from "@plane/types";
import { EUserProjectRoles } from "@plane/types/src/enums";
import { AccountTypeColumn, NameColumn } from "@/components/project/settings/member-columns";
import { EUserWorkspaceRoles } from "@/constants/workspace";
import { useUser } from "@/hooks/store";
interface RowData {
member: IWorkspaceMember;
role: EUserWorkspaceRoles;
}
const useProjectColumns = () => {
// states
const [removeMemberModal, setRemoveMemberModal] = useState<RowData | null>(null);
const { workspaceSlug, projectId } = useParams();
const {
membership: { currentProjectRole },
data: currentUser,
} = useUser();
const getFormattedDate = (dateStr: string) => {
const date = new Date(dateStr);
const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long", day: "numeric" };
return date.toLocaleDateString("en-US", options);
};
// derived values
const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN;
const columns = [
{
key: "Full Name",
content: "Full Name",
thClassName: "text-left",
tdRender: (rowData: RowData) => (
<NameColumn
rowData={rowData}
workspaceSlug={workspaceSlug as string}
isAdmin={isAdmin}
currentUser={currentUser}
setRemoveMemberModal={setRemoveMemberModal}
/>
),
},
{
key: "Display Name",
content: "Display Name",
tdRender: (rowData: RowData) => <div className="w-32">{rowData.member.display_name}</div>,
},
{
key: "Account Type",
content: "Account Type",
tdRender: (rowData: RowData) => (
<AccountTypeColumn
rowData={rowData}
currentProjectRole={currentProjectRole}
projectId={projectId as string}
workspaceSlug={workspaceSlug as string}
/>
),
},
{
key: "Joining Date",
content: "Joining Date",
tdRender: (rowData: RowData) => <div>{getFormattedDate(rowData.member.joining_date)}</div>,
},
];
return { columns, workspaceSlug, projectId, removeMemberModal, setRemoveMemberModal };
};
export default useProjectColumns;

View File

@@ -0,0 +1,69 @@
import { useState } from "react";
import { useParams } from "next/navigation";
import { AccountTypeColumn, NameColumn, RowData } from "@/components/workspace/settings/member-columns";
import { EUserWorkspaceRoles } from "@/constants/workspace";
import { useUser } from "@/hooks/store";
const useMemberColumns = () => {
// states
const [removeMemberModal, setRemoveMemberModal] = useState<RowData | null>(null);
const { workspaceSlug } = useParams();
const {
membership: { currentWorkspaceRole },
data: currentUser,
} = useUser();
const getFormattedDate = (dateStr: string) => {
const date = new Date(dateStr);
const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long", day: "numeric" };
return date.toLocaleDateString("en-US", options);
};
const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
const columns = [
{
key: "Full Name",
content: "Full Name",
thClassName: "text-left",
tdRender: (rowData: RowData) => (
<NameColumn
rowData={rowData}
workspaceSlug={workspaceSlug as string}
isAdmin={isAdmin}
currentUser={currentUser}
setRemoveMemberModal={setRemoveMemberModal}
/>
),
},
{
key: "Display Name",
content: "Display Name",
tdRender: (rowData: RowData) => <div className="w-32">{rowData.member.display_name}</div>,
},
{
key: "Account Type",
content: "Account Type",
tdRender: (rowData: RowData) => (
<AccountTypeColumn
rowData={rowData}
currentWorkspaceRole={currentWorkspaceRole}
workspaceSlug={workspaceSlug as string}
/>
),
},
{
key: "Joining Date",
content: "Joining Date",
tdRender: (rowData: RowData) => <div>{getFormattedDate(rowData.member.joining_date)}</div>,
},
];
return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal };
};
export default useMemberColumns;

View File

@@ -1,54 +1,45 @@
"use client"; "use client";
import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation"; import { TOAST_TYPE, Table, setToast } from "@plane/ui";
// icons
import { ChevronDown, Dot, XCircle } from "lucide-react";
// ui
import { CustomSelect, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// components // components
import { ConfirmProjectMemberRemove } from "@/components/project"; import { ConfirmProjectMemberRemove } from "@/components/project";
// constants // constants
import { PROJECT_MEMBER_LEAVE } from "@/constants/event-tracker"; import { PROJECT_MEMBER_LEAVE } from "@/constants/event-tracker";
import { EUserProjectRoles } from "@/constants/project";
import { ROLE } from "@/constants/workspace";
// hooks // hooks
import { useEventTracker, useMember, useProject, useUser } from "@/hooks/store"; import { useEventTracker, useMember, useProject, useUser } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os"; import useProjectColumns from "@/plane-web/components/projects/settings/useProjectColumns";
import { IProjectMemberDetails } from "@/store/member/project-member.store";
type Props = { type Props = {
userId: string; memberDetails: (IProjectMemberDetails | null)[];
}; };
export const ProjectMemberListItem: React.FC<Props> = observer((props) => { export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
const { userId } = props; const { memberDetails } = props;
// states const { columns, workspaceSlug, projectId, removeMemberModal, setRemoveMemberModal } = useProjectColumns();
const [removeMemberModal, setRemoveMemberModal] = useState(false);
// router // router
const router = useAppRouter(); const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
// store hooks // store hooks
const { const {
membership: { currentProjectRole, leaveProject }, membership: { leaveProject },
} = useUser(); } = useUser();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { fetchProjects } = useProject(); const { fetchProjects } = useProject();
const { const {
project: { removeMemberFromProject, getProjectMemberDetails, updateMember }, project: { removeMemberFromProject },
} = useMember(); } = useMember();
const { captureEvent } = useEventTracker(); const { captureEvent } = useEventTracker();
const { isMobile } = usePlatformOS(); // const { isMobile } = usePlatformOS();
// derived values
const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN;
const userDetails = getProjectMemberDetails(userId);
const handleRemove = async () => { const handleRemove = async (memberId: string) => {
if (!workspaceSlug || !projectId || !userDetails) return; if (!workspaceSlug || !projectId || !memberId) return;
if (userDetails.member?.id === currentUser?.id) { if (memberId === currentUser?.id) {
await leaveProject(workspaceSlug.toString(), projectId.toString()) await leaveProject(workspaceSlug.toString(), projectId.toString())
.then(async () => { .then(async () => {
captureEvent(PROJECT_MEMBER_LEAVE, { captureEvent(PROJECT_MEMBER_LEAVE, {
@@ -58,7 +49,7 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
await fetchProjects(workspaceSlug.toString()); await fetchProjects(workspaceSlug.toString());
router.push(`/${workspaceSlug}/projects`); router.push(`/${workspaceSlug}/projects`);
}) })
.catch((err: any) => .catch((err) =>
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: "Error!",
@@ -66,130 +57,36 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
}) })
); );
} else } else
await removeMemberFromProject(workspaceSlug.toString(), projectId.toString(), userDetails.member?.id).catch( await removeMemberFromProject(workspaceSlug.toString(), projectId.toString(), memberId).catch((err) =>
(err) => setToast({
setToast({ type: TOAST_TYPE.ERROR,
type: TOAST_TYPE.ERROR, title: "Error!",
title: "Error!", message: err?.error || "Something went wrong. Please try again.",
message: err?.error || "Something went wrong. Please try again.", })
})
); );
}; };
if (!userDetails) return null; if (!memberDetails) return null;
removeMemberModal && console.log("removeMemberModal", JSON.parse(JSON.stringify(removeMemberModal?.member)));
return ( return (
<> <>
<ConfirmProjectMemberRemove {removeMemberModal && (
isOpen={removeMemberModal} <ConfirmProjectMemberRemove
onClose={() => setRemoveMemberModal(false)} isOpen={removeMemberModal !== null}
data={userDetails.member} onClose={() => setRemoveMemberModal(null)}
onSubmit={handleRemove} 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) ?? []}
keyExtractor={(rowData) => rowData?.member.id ?? ""}
thClassName="text-left font-medium divide-x-0 border-b border-t divide-custom-border-200"
tBodyClassName="divide-y-0"
tBodyTrClassName="divide-x-0"
tHeadTrClassName="divide-x-0"
/> />
<div className="group flex items-center justify-between px-3 py-4 hover:bg-custom-background-90">
<div className="flex items-center gap-x-4 gap-y-2">
{userDetails.member?.avatar && userDetails.member?.avatar !== "" ? (
<Link href={`/${workspaceSlug}/profile/${userDetails.member?.id}`}>
<span className="relative flex h-10 w-10 items-center justify-center rounded p-4 capitalize text-white">
<img
src={userDetails.member?.avatar}
alt={userDetails.member?.display_name || userDetails.member?.email}
className="absolute left-0 top-0 h-full w-full rounded object-cover"
/>
</span>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${userDetails.id}`}>
<span className="relative flex h-10 w-10 items-center justify-center rounded bg-gray-700 p-4 capitalize text-white">
{(userDetails.member?.display_name ?? userDetails.member?.email ?? "?")[0]}
</span>
</Link>
)}
<div>
<Link href={`/${workspaceSlug}/profile/${userDetails.member?.id}`}>
<span className="text-sm font-medium">
{userDetails.member?.first_name} {userDetails.member?.last_name}
</span>
</Link>
<div className="flex items-center">
<p className="text-xs text-custom-text-300">{userDetails.member?.display_name}</p>
{isAdmin && (
<>
<Dot height={16} width={16} className="text-custom-text-300" />
<p className="text-xs text-custom-text-300">{userDetails.member?.email}</p>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<CustomSelect
customButton={
<div className="item-center flex gap-1 rounded px-2 py-0.5">
<span
className={`flex items-center rounded text-xs font-medium ${
userDetails.member?.id !== currentUser?.id ? "" : "text-custom-text-400"
}`}
>
{ROLE[userDetails.role]}
</span>
{userDetails.member?.id !== currentUser?.id && (
<span className="grid place-items-center">
<ChevronDown className="h-3 w-3" />
</span>
)}
</div>
}
value={userDetails.role}
onChange={(value: EUserProjectRoles) => {
if (!workspaceSlug || !projectId) return;
updateMember(workspaceSlug.toString(), projectId.toString(), userDetails.member?.id, {
role: value,
}).catch((err) => {
const error = err.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: errorString ?? "An error occurred while updating member role. Please try again.",
});
});
}}
disabled={
userDetails.member?.id === currentUser?.id || !currentProjectRole || currentProjectRole < userDetails.role
}
placement="bottom-end"
>
{Object.keys(ROLE).map((key) => {
if (currentProjectRole && !isAdmin && currentProjectRole < parseInt(key)) return null;
return (
<CustomSelect.Option key={key} value={parseInt(key, 10)}>
<>{ROLE[parseInt(key) as keyof typeof ROLE]}</>
</CustomSelect.Option>
);
})}
</CustomSelect>
{(isAdmin || userDetails.member?.id === currentUser?.id) && (
<Tooltip
tooltipContent={userDetails.member?.id === currentUser?.id ? "Leave project" : "Remove member"}
isMobile={isMobile}
>
<button
type="button"
onClick={() => setRemoveMemberModal(true)}
className="pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100"
>
<XCircle className="h-3.5 w-3.5 text-red-500" strokeWidth={2} />
</button>
</Tooltip>
)}
</div>
</div>
</> </>
); );
}); });

View File

@@ -9,7 +9,8 @@ import { Button } from "@plane/ui";
import { ProjectMemberListItem, SendProjectInvitationModal } from "@/components/project"; import { ProjectMemberListItem, SendProjectInvitationModal } from "@/components/project";
// ui // ui
import { MembersSettingsLoader } from "@/components/ui"; import { MembersSettingsLoader } from "@/components/ui";
import { useEventTracker, useMember } from "@/hooks/store"; import { EUserProjectRoles } from "@/constants/project";
import { useEventTracker, useMember, useUser } from "@/hooks/store";
export const ProjectMemberList: React.FC = observer(() => { export const ProjectMemberList: React.FC = observer(() => {
// states // states
@@ -20,7 +21,9 @@ export const ProjectMemberList: React.FC = observer(() => {
const { const {
project: { projectMemberIds, getProjectMemberDetails }, project: { projectMemberIds, getProjectMemberDetails },
} = useMember(); } = useMember();
const {
membership: { currentProjectRole },
} = useUser();
const searchedMembers = (projectMemberIds ?? []).filter((userId) => { const searchedMembers = (projectMemberIds ?? []).filter((userId) => {
const memberDetails = getProjectMemberDetails(userId); const memberDetails = getProjectMemberDetails(userId);
@@ -31,12 +34,13 @@ export const ProjectMemberList: React.FC = observer(() => {
return displayName?.includes(searchQuery.toLowerCase()) || fullName.includes(searchQuery.toLowerCase()); return displayName?.includes(searchQuery.toLowerCase()) || fullName.includes(searchQuery.toLowerCase());
}); });
const memberDetails = searchedMembers?.map((memberId) => getProjectMemberDetails(memberId));
return ( return (
<> <>
<SendProjectInvitationModal isOpen={inviteModal} onClose={() => setInviteModal(false)} /> <SendProjectInvitationModal isOpen={inviteModal} onClose={() => setInviteModal(false)} />
<div className="flex items-center justify-between gap-4 border-b border-custom-border-100 py-3.5"> <div className="flex items-center justify-between gap-4 border-b border-custom-border-100 py-3.5 overflow-x-hidden">
<h4 className="text-xl font-medium">Members</h4> <h4 className="text-xl font-medium">Members</h4>
<div className="ml-auto flex items-center justify-start gap-1 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5 text-custom-text-400"> <div className="ml-auto flex items-center justify-start gap-1 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5 text-custom-text-400">
<Search className="h-3.5 w-3.5" /> <Search className="h-3.5 w-3.5" />
@@ -48,23 +52,24 @@ export const ProjectMemberList: React.FC = observer(() => {
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
</div> </div>
<Button {currentProjectRole === EUserProjectRoles.ADMIN && (
variant="primary" <Button
onClick={() => { variant="primary"
setTrackElement("PROJECT_SETTINGS_MEMBERS_PAGE_HEADER"); onClick={() => {
setInviteModal(true); setTrackElement("PROJECT_SETTINGS_MEMBERS_PAGE_HEADER");
}} setInviteModal(true);
> }}
Add member >
</Button> Add member
</Button>
)}
</div> </div>
{!projectMemberIds ? ( {!projectMemberIds ? (
<MembersSettingsLoader /> <MembersSettingsLoader />
) : ( ) : (
<div className="divide-y divide-custom-border-100"> <div className="divide-y divide-custom-border-100 overflow-scroll">
{projectMemberIds.length > 0 <ProjectMemberListItem memberDetails={memberDetails ?? []} />
? searchedMembers.map((userId) => <ProjectMemberListItem key={userId} userId={userId} />)
: null}
{searchedMembers.length === 0 && ( {searchedMembers.length === 0 && (
<h4 className="text-sm mt-16 text-center text-custom-text-400">No matching members</h4> <h4 className="text-sm mt-16 text-center text-custom-text-400">No matching members</h4>
)} )}

View File

@@ -0,0 +1,141 @@
import Link from "next/link";
import { Controller, useForm } from "react-hook-form";
import { Trash2 } from "lucide-react";
import { Disclosure } from "@headlessui/react";
import { IUser, IWorkspaceMember } from "@plane/types";
import { CustomSelect, PopoverMenu, TOAST_TYPE, setToast } from "@plane/ui";
import { EUserProjectRoles } from "@/constants/project";
import { EUserWorkspaceRoles, ROLE } from "@/constants/workspace";
import { useMember } from "@/hooks/store";
export interface RowData {
member: IWorkspaceMember;
role: EUserWorkspaceRoles;
}
type NameProps = {
rowData: RowData;
workspaceSlug: string;
isAdmin: boolean;
currentUser: IUser | undefined;
setRemoveMemberModal: (rowData: RowData) => void;
};
type AccountTypeProps = {
rowData: RowData;
currentProjectRole: EUserProjectRoles | undefined;
workspaceSlug: string;
projectId: string;
};
export const NameColumn: React.FC<NameProps> = (props: any) => {
const { rowData, workspaceSlug, isAdmin, currentUser, setRemoveMemberModal } = props;
return (
<Disclosure>
{({}) => (
<div className="relative group">
<div className="flex items-center gap-x-4 gap-y-2 w-72 justify-between">
<div className="flex items-center gap-x-4 gap-y-2 flex-1">
{rowData.member.avatar && rowData.member.avatar.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${rowData.member.id}`}>
<span className="relative flex h-6 w-6 items-center justify-center rounded-full p-4 capitalize text-white">
<img
src={rowData.member.avatar}
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
alt={rowData.member.display_name || rowData.member.email}
/>
</span>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${rowData.member.id}`}>
<span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-gray-700 p-4 capitalize text-white">
{(rowData.member.email ?? rowData.member.display_name ?? "?")[0]}
</span>
</Link>
)}
{rowData.member.first_name} {rowData.member.last_name}
</div>
{(isAdmin || rowData.member?.id === currentUser?.id) && (
<PopoverMenu
data={[""]}
keyExtractor={(item) => item}
popoverClassName="justify-end"
buttonClassName="outline-none origin-center rotate-90 size-8 aspect-square flex-shrink-0 grid place-items-center opacity-0 group-hover:opacity-100 transition-opacity"
render={() => (
<div
className="flex items-center gap-x-3 cursor-pointer"
onClick={() => setRemoveMemberModal(rowData)}
>
<Trash2 className="size-3.5 align-middle" />
{rowData.member?.id === currentUser?.id ? "Leave " : "Remove "}
</div>
)}
/>
)}
</div>
</div>
)}
</Disclosure>
);
};
export const AccountTypeColumn: React.FC<AccountTypeProps> = (props) => {
const { rowData, currentProjectRole, projectId, workspaceSlug } = props;
// form info
const {
control,
formState: { errors },
} = useForm();
const {
project: { updateMember },
} = useMember();
return rowData.role === EUserWorkspaceRoles.ADMIN || currentProjectRole !== EUserProjectRoles.ADMIN ? (
<div className="w-32 flex ">
<span>{ROLE[rowData.role as keyof typeof ROLE]}</span>
</div>
) : (
<Controller
name="role"
control={control}
rules={{ required: "Role is required." }}
render={({ field: { value } }) => (
<CustomSelect
value={value}
onChange={(value: EUserProjectRoles) => {
if (!workspaceSlug) return;
updateMember(workspaceSlug.toString(), projectId.toString(), rowData.member.id, {
role: value as unknown as EUserProjectRoles, // Cast value to unknown first, then to EUserWorkspaceRoles
}).catch((err) => {
console.log(err, "err");
const error = err.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: errorString ?? "An error occurred while updating member role. Please try again.",
});
});
}}
label={
<div className="flex ">
<span>{ROLE[rowData.role as keyof typeof ROLE]}</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"
optionsClassName="w-full"
input
>
{Object.keys(ROLE).map((item) => (
<CustomSelect.Option key={item} value={item as unknown as EUserProjectRoles}>
{ROLE[item as unknown as keyof typeof ROLE]}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
);
};

View File

@@ -79,7 +79,7 @@ export const WorkspaceInvitationsListItem: FC<Props> = observer((props) => {
}} }}
onSubmit={handleRemoveInvitation} onSubmit={handleRemoveInvitation}
/> />
<div className="group flex items-center justify-between px-3 py-4 hover:bg-custom-background-90"> <div className="group flex items-center justify-between px-3 py-4 hover:bg-custom-background-90 w-full">
<div className="flex items-center gap-x-4 gap-y-2"> <div className="flex items-center gap-x-4 gap-y-2">
<span className="relative flex h-10 w-10 items-center justify-center rounded bg-gray-700 p-4 capitalize text-white"> <span className="relative flex h-10 w-10 items-center justify-center rounded bg-gray-700 p-4 capitalize text-white">
{(invitationDetails.email ?? "?")[0]} {(invitationDetails.email ?? "?")[0]}
@@ -137,17 +137,19 @@ export const WorkspaceInvitationsListItem: FC<Props> = observer((props) => {
); );
})} })}
</CustomSelect> </CustomSelect>
<Tooltip tooltipContent="Remove member" disabled={!isAdmin} isMobile={isMobile}> {isAdmin && (
<button <Tooltip tooltipContent="Remove member" disabled={!isAdmin} isMobile={isMobile}>
type="button" <button
onClick={() => setRemoveMemberModal(true)} type="button"
className={`pointer-events-none opacity-0 ${ onClick={() => setRemoveMemberModal(true)}
isAdmin ? "group-hover:pointer-events-auto group-hover:opacity-100" : "" className={`pointer-events-none opacity-0 ${
}`} isAdmin ? "group-hover:pointer-events-auto group-hover:opacity-100" : ""
> }`}
<XCircle className="h-3.5 w-3.5 text-red-500" strokeWidth={2} /> >
</button> <XCircle className="h-3.5 w-3.5 text-red-500" strokeWidth={2} />
</Tooltip> </button>
</Tooltip>
)}
</div> </div>
</div> </div>
</> </>

View File

@@ -0,0 +1,141 @@
import Link from "next/link";
import { Controller, useForm } from "react-hook-form";
import { Trash2 } from "lucide-react";
import { Disclosure } from "@headlessui/react";
import { IUser, IWorkspaceMember } from "@plane/types";
import { CustomSelect, PopoverMenu, TOAST_TYPE, setToast } from "@plane/ui";
import { EUserProjectRoles } from "@/constants/project";
import { EUserWorkspaceRoles, ROLE } from "@/constants/workspace";
import { useMember } from "@/hooks/store";
export interface RowData {
member: IWorkspaceMember;
role: EUserWorkspaceRoles;
}
type NameProps = {
rowData: RowData;
workspaceSlug: string;
isAdmin: boolean;
currentUser: IUser | undefined;
setRemoveMemberModal: (rowData: RowData) => void;
};
type AccountTypeProps = {
rowData: RowData;
currentWorkspaceRole: EUserWorkspaceRoles | undefined;
workspaceSlug: string;
};
export const NameColumn: React.FC<NameProps> = (props: any) => {
const { rowData, workspaceSlug, isAdmin, currentUser, setRemoveMemberModal } = props;
return (
<Disclosure>
{({}) => (
<div className="relative group">
<div className="flex items-center gap-x-4 gap-y-2 w-72 justify-between">
<div className="flex items-center gap-x-4 gap-y-2 flex-1">
{rowData.member.avatar && rowData.member.avatar.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${rowData.member.id}`}>
<span className="relative flex h-6 w-6 items-center justify-center rounded-full p-4 capitalize text-white">
<img
src={rowData.member.avatar}
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
alt={rowData.member.display_name || rowData.member.email}
/>
</span>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${rowData.member.id}`}>
<span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-gray-700 p-4 capitalize text-white">
{(rowData.member.email ?? rowData.member.display_name ?? "?")[0]}
</span>
</Link>
)}
{rowData.member.first_name} {rowData.member.last_name}
</div>
{(isAdmin || rowData.member?.id === currentUser?.id) && (
<PopoverMenu
data={[""]}
keyExtractor={(item) => item}
popoverClassName="justify-end"
buttonClassName="outline-none origin-center rotate-90 size-8 aspect-square flex-shrink-0 grid place-items-center opacity-0 group-hover:opacity-100 transition-opacity"
render={() => (
<div
className="flex items-center gap-x-3 cursor-pointer"
onClick={() => setRemoveMemberModal(rowData)}
>
<Trash2 className="size-3.5 align-middle" />{" "}
{rowData.member?.id === currentUser?.id ? "Leave " : "Remove "}
</div>
)}
/>
)}
</div>
</div>
)}
</Disclosure>
);
};
export const AccountTypeColumn: React.FC<AccountTypeProps> = (props) => {
const { rowData, currentWorkspaceRole, workspaceSlug } = props;
// form info
const {
control,
formState: { errors },
} = useForm();
const {
workspace: { updateMember },
} = useMember();
return rowData.role === EUserWorkspaceRoles.ADMIN || currentWorkspaceRole !== EUserWorkspaceRoles.ADMIN ? (
<div className="w-32 flex ">
<span>{ROLE[rowData.role as keyof typeof ROLE]}</span>
</div>
) : (
<Controller
name="role"
control={control}
rules={{ required: "Role is required." }}
render={({ field: { value } }) => (
<CustomSelect
value={value}
onChange={(value: EUserProjectRoles) => {
console.log({ value, workspaceSlug }, "onChange");
if (!workspaceSlug) return;
updateMember(workspaceSlug.toString(), rowData.member.id, {
role: value as unknown as EUserWorkspaceRoles, // Cast value to unknown first, then to EUserWorkspaceRoles
}).catch((err) => {
console.log(err, "err");
const error = err.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: errorString ?? "An error occurred while updating member role. Please try again.",
});
});
}}
label={
<div className="flex ">
<span>{ROLE[rowData.role as keyof typeof ROLE]}</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"
optionsClassName="w-full"
input
>
{Object.keys(ROLE).map((item) => (
<CustomSelect.Option key={item} value={item as unknown as EUserProjectRoles}>
{ROLE[item as unknown as keyof typeof ROLE]}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
);
};

View File

@@ -1,48 +1,40 @@
"use client"; "use client";
import { FC, useState } from "react"; import { FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
// lucide icons
import { ChevronDown, Dot, XCircle } from "lucide-react";
// ui // ui
import { CustomSelect, TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; import { IWorkspaceMember } from "@plane/types";
import { TOAST_TYPE, Table, setToast } from "@plane/ui";
// components // components
import { ConfirmWorkspaceMemberRemove } from "@/components/workspace"; import { ConfirmWorkspaceMemberRemove } from "@/components/workspace";
// constants // constants
import { WORKSPACE_MEMBER_LEAVE } from "@/constants/event-tracker"; import { WORKSPACE_MEMBER_LEAVE } from "@/constants/event-tracker";
import { EUserWorkspaceRoles, ROLE } from "@/constants/workspace";
// hooks // hooks
import { useEventTracker, useMember, useUser } from "@/hooks/store"; import { useEventTracker, useMember, useUser } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
import useMemberColumns from "@/plane-web/components/workspace/settings/useMemberColumns";
type Props = { type Props = {
memberId: string; memberDetails: (IWorkspaceMember | null)[];
}; };
export const WorkspaceMembersListItem: FC<Props> = observer((props) => { export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
const { memberId } = props; const { memberDetails } = props;
// states const { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal } = useMemberColumns();
const [removeMemberModal, setRemoveMemberModal] = useState(false);
// router // router
const router = useAppRouter(); const router = useAppRouter();
const { workspaceSlug } = useParams();
// store hooks // store hooks
const { const {
// currentUser, membership: { leaveWorkspace },
// currentUserSettings,
membership: { currentWorkspaceRole, leaveWorkspace },
} = useUser(); } = useUser();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { const {
workspace: { updateMember, removeMemberFromWorkspace, getWorkspaceMemberDetails }, workspace: { removeMemberFromWorkspace },
} = useMember(); } = useMember();
const { captureEvent } = useEventTracker(); const { captureEvent } = useEventTracker();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
// derived values // derived values
const memberDetails = getWorkspaceMemberDetails(memberId);
const handleLeaveWorkspace = async () => { const handleLeaveWorkspace = async () => {
if (!workspaceSlug || !currentUser) return; if (!workspaceSlug || !currentUser) return;
@@ -64,10 +56,10 @@ export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
); );
}; };
const handleRemoveMember = async () => { const handleRemoveMember = async (memberId: string) => {
if (!workspaceSlug || !memberDetails) return; if (!workspaceSlug || !memberId) return;
await removeMemberFromWorkspace(workspaceSlug.toString(), memberDetails.member.id).catch((err) => await removeMemberFromWorkspace(workspaceSlug.toString(), memberId).catch((err) =>
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: "Error!",
@@ -76,143 +68,41 @@ export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
); );
}; };
const handleRemove = async () => { const handleRemove = async (memberId: string) => {
if (memberDetails?.member.id === currentUser?.id) await handleLeaveWorkspace(); if (memberId === currentUser?.id) await handleLeaveWorkspace();
else await handleRemoveMember(); else await handleRemoveMember(memberId);
}; };
if (!memberDetails) return null;
// is the member current logged in user // is the member current logged in user
const isCurrentUser = memberDetails?.member.id === currentUser?.id; // const isCurrentUser = memberDetails?.member.id === currentUser?.id;
// is the current logged in user admin // is the current logged in user admin
const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
// role change access- // role change access-
// 1. user cannot change their own role // 1. user cannot change their own role
// 2. only admin or member can change role // 2. only admin or member can change role
// 3. user cannot change role of higher role // 3. user cannot change role of higher role
const hasRoleChangeAccess =
currentWorkspaceRole &&
!isCurrentUser &&
[EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER].includes(currentWorkspaceRole) &&
memberDetails.role <= currentWorkspaceRole;
return ( return (
<> <>
<ConfirmWorkspaceMemberRemove {removeMemberModal && (
isOpen={removeMemberModal} <ConfirmWorkspaceMemberRemove
onClose={() => setRemoveMemberModal(false)} isOpen={removeMemberModal.member.id.length > 0}
userDetails={{ onClose={() => setRemoveMemberModal(null)}
id: memberDetails.member.id, userDetails={{
display_name: `${memberDetails.member.display_name}`, id: removeMemberModal.member.id,
}} display_name: removeMemberModal.member.display_name || "",
onSubmit={handleRemove} }}
onSubmit={() => handleRemove(removeMemberModal.member.id)}
/>
)}
<Table
columns={columns}
data={memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []}
keyExtractor={(rowData) => rowData?.member.id ?? ""}
thClassName="text-left font-medium divide-x-0 border-b border-t divide-custom-border-200"
tBodyClassName="divide-y-0"
tBodyTrClassName="divide-x-0"
tHeadTrClassName="divide-x-0"
/> />
<div className="group w-full flex items-center justify-between px-3 py-4 hover:bg-custom-background-90">
<div className="flex w-full items-center gap-x-4 gap-y-2">
{memberDetails.member.avatar && memberDetails.member.avatar.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${memberDetails.member.id}`}>
<span className="relative flex h-10 w-10 items-center justify-center rounded p-4 capitalize text-white">
<img
src={memberDetails.member.avatar}
className="absolute left-0 top-0 h-full w-full rounded object-cover"
alt={memberDetails.member.display_name || memberDetails.member.email}
/>
</span>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${memberDetails.member.id}`}>
<span className="relative flex h-10 w-10 items-center justify-center rounded bg-gray-700 p-4 capitalize text-white">
{(memberDetails.member.email ?? memberDetails.member.display_name ?? "?")[0]}
</span>
</Link>
)}
<div className="w-full flex items-center justify-between">
<div className="truncate">
<Link href={`/${workspaceSlug}/profile/${memberDetails.member.id}`} className="truncate">
<div className="w-full truncate">
<span className="text-sm font-medium truncate">
{memberDetails.member.first_name} {memberDetails.member.last_name}
</span>
</div>
</Link>
<div className="flex flex-col sm:flex-row items-start sm:items-center truncate">
<p className="text-xs text-custom-text-300">{memberDetails.member.display_name}</p>
{isAdmin && (
<>
<Dot height={16} width={16} className="text-custom-text-300 hidden sm:block" />
<p className="text-xs text-custom-text-300 line-clamp-1 truncate">{memberDetails.member.email}</p>
</>
)}
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-2 text-xs">
<CustomSelect
customButton={
<div className="item-center flex gap-1 rounded px-2 py-0.5">
<span
className={`flex items-center rounded text-xs font-medium ${
hasRoleChangeAccess ? "" : "text-custom-sidebar-text-400"
}`}
>
{ROLE[memberDetails.role]}
</span>
{hasRoleChangeAccess && (
<span className="grid place-items-center">
<ChevronDown className="h-3 w-3" />
</span>
)}
</div>
}
value={memberDetails.role}
onChange={(value: EUserWorkspaceRoles) => {
if (!workspaceSlug || !value) return;
updateMember(workspaceSlug.toString(), memberDetails.member.id, {
role: value,
}).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "An error occurred while updating member role. Please try again.",
});
});
}}
disabled={!hasRoleChangeAccess}
placement="bottom-end"
>
{Object.keys(ROLE).map((key) => {
if (currentWorkspaceRole && currentWorkspaceRole !== 20 && currentWorkspaceRole < parseInt(key))
return null;
return (
<CustomSelect.Option key={key} value={parseInt(key, 10)}>
<>{ROLE[parseInt(key) as keyof typeof ROLE]}</>
</CustomSelect.Option>
);
})}
</CustomSelect>
<Tooltip
isMobile={isMobile}
tooltipContent={isCurrentUser ? "Leave workspace" : "Remove member"}
disabled={!isAdmin && !isCurrentUser}
>
<button
type="button"
onClick={() => setRemoveMemberModal(true)}
className={
isAdmin || isCurrentUser
? "pointer-events-none md:opacity-0 group-hover:pointer-events-auto md:group-hover:opacity-100"
: "pointer-events-none hidden md:opacity-0 md:block"
}
>
<XCircle className="h-3.5 w-3.5 text-red-500" strokeWidth={2} />
</button>
</Tooltip>
</div>
</div>
</div>
</div>
</> </>
); );
}); });

View File

@@ -1,15 +1,20 @@
import { FC } from "react"; import { FC, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import useSWR from "swr"; import useSWR from "swr";
// components import { ChevronDown } from "lucide-react";
import { Disclosure } from "@headlessui/react";
import { Collapsible } from "@plane/ui";
import { CountChip } from "@/components/common";
import { MembersSettingsLoader } from "@/components/ui"; import { MembersSettingsLoader } from "@/components/ui";
import { WorkspaceInvitationsListItem, WorkspaceMembersListItem } from "@/components/workspace"; import { WorkspaceInvitationsListItem, WorkspaceMembersListItem } from "@/components/workspace";
// hooks // hooks
import { useMember } from "@/hooks/store"; import { useMember } from "@/hooks/store";
export const WorkspaceMembersList: FC<{ searchQuery: string }> = observer((props) => { export const WorkspaceMembersList: FC<{ searchQuery: string; isAdmin: boolean }> = observer((props) => {
const { searchQuery } = props; const { searchQuery, isAdmin } = props;
const [showPendingInvites, setShowPendingInvites] = useState<boolean>(false);
// router // router
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
// store hooks // store hooks
@@ -21,6 +26,7 @@ export const WorkspaceMembersList: FC<{ searchQuery: string }> = observer((props
getSearchedWorkspaceMemberIds, getSearchedWorkspaceMemberIds,
workspaceMemberInvitationIds, workspaceMemberInvitationIds,
getSearchedWorkspaceInvitationIds, getSearchedWorkspaceInvitationIds,
getWorkspaceMemberDetails,
}, },
} = useMember(); } = useMember();
// fetching workspace invitations // fetching workspace invitations
@@ -39,20 +45,44 @@ export const WorkspaceMembersList: FC<{ searchQuery: string }> = observer((props
// derived values // derived values
const searchedMemberIds = getSearchedWorkspaceMemberIds(searchQuery); const searchedMemberIds = getSearchedWorkspaceMemberIds(searchQuery);
const searchedInvitationsIds = getSearchedWorkspaceInvitationIds(searchQuery); const searchedInvitationsIds = getSearchedWorkspaceInvitationIds(searchQuery);
const memberDetails = searchedMemberIds?.map((memberId) => getWorkspaceMemberDetails(memberId));
return ( return (
<div className="divide-y-[0.5px] divide-custom-border-100"> <>
{searchedInvitationsIds && searchedInvitationsIds.length > 0 <div className="divide-y-[0.5px] divide-custom-border-100 overflow-scroll ">
? searchedInvitationsIds?.map((invitationId) => ( <WorkspaceMembersListItem memberDetails={memberDetails ?? []} />
<WorkspaceInvitationsListItem key={invitationId} invitationId={invitationId} /> {searchedInvitationsIds?.length === 0 && searchedMemberIds?.length === 0 && (
)) <h4 className="mt-16 text-center text-sm text-custom-text-400">No matching members</h4>
: null} )}
{searchedMemberIds && searchedMemberIds.length > 0 </div>
? searchedMemberIds?.map((memberId) => <WorkspaceMembersListItem key={memberId} memberId={memberId} />) {isAdmin && (
: null} <Collapsible
{searchedInvitationsIds?.length === 0 && searchedMemberIds?.length === 0 && ( isOpen={showPendingInvites}
<h4 className="mt-16 text-center text-sm text-custom-text-400">No matching members</h4> onToggle={() => setShowPendingInvites((prev) => !prev)}
buttonClassName="w-full"
title={
<div className="flex w-full items-center justify-between pt-4">
<div className="flex">
<h4 className="text-xl font-medium pt-2 pb-2">Pending invites</h4>
{searchedInvitationsIds && (
<CountChip count={searchedInvitationsIds.length} className="h-5 m-auto ml-2" />
)}
</div>{" "}
<ChevronDown className={`h-5 w-5 transition-all ${showPendingInvites ? "rotate-180" : ""}`} />
</div>
}
>
<Disclosure.Panel>
<div className="ml-auto items-center gap-1.5 rounded-md bg-custom-background-100 py-1.5">
{searchedInvitationsIds && searchedInvitationsIds.length > 0
? searchedInvitationsIds?.map((invitationId) => (
<WorkspaceInvitationsListItem key={invitationId} invitationId={invitationId} />
))
: null}
</div>
</Disclosure.Panel>
</Collapsible>
)} )}
</div> </>
); );
}); });

View File

@@ -15,7 +15,7 @@ import { IUserStore } from "@/store/user";
import { CoreRootStore } from "../root.store"; import { CoreRootStore } from "../root.store";
import { IMemberRootStore } from "."; import { IMemberRootStore } from ".";
interface IProjectMemberDetails { export interface IProjectMemberDetails {
id: string; id: string;
member: IUserLite; member: IUserLite;
role: EUserProjectRoles; role: EUserProjectRoles;

View File

@@ -196,7 +196,7 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
await this.workspaceService.fetchWorkspaceMembers(workspaceSlug).then((response) => { await this.workspaceService.fetchWorkspaceMembers(workspaceSlug).then((response) => {
runInAction(() => { runInAction(() => {
response.forEach((member) => { response.forEach((member) => {
set(this.memberRoot?.memberMap, member.member.id, member.member); set(this.memberRoot?.memberMap, member.member.id, { ...member.member, joining_date: member.created_at });
set(this.workspaceMemberMap, [workspaceSlug, member.member.id], { set(this.workspaceMemberMap, [workspaceSlug, member.member.id], {
id: member.id, id: member.id,
member: member.member.id, member: member.member.id,

View File

@@ -0,0 +1 @@
export * from "ce/components/projects/settings/useProjectColumns";

View File

@@ -0,0 +1 @@
export * from "ce/components/workspace/settings/useMemberColumns";