mirror of
https://github.com/makeplane/plane.git
synced 2025-12-23 15:19:37 +01:00
[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:
299
packages/types/src/workspace.d.ts
vendored
299
packages/types/src/workspace.d.ts
vendored
@@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 & {
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
75
web/ce/components/projects/settings/useProjectColumns.tsx
Normal file
75
web/ce/components/projects/settings/useProjectColumns.tsx
Normal 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;
|
||||||
69
web/ce/components/workspace/settings/useMemberColumns.tsx
Normal file
69
web/ce/components/workspace/settings/useMemberColumns.tsx
Normal 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;
|
||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
141
web/core/components/project/settings/member-columns.tsx
Normal file
141
web/core/components/project/settings/member-columns.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
141
web/core/components/workspace/settings/member-columns.tsx
Normal file
141
web/core/components/workspace/settings/member-columns.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "ce/components/projects/settings/useProjectColumns";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "ce/components/workspace/settings/useMemberColumns";
|
||||||
Reference in New Issue
Block a user