From fff27c60e4d961920fcd677129b36a0235a8135c Mon Sep 17 00:00:00 2001 From: Akshita Goyal <36129505+gakshita@users.noreply.github.com> Date: Thu, 18 Jul 2024 13:02:22 +0530 Subject: [PATCH] [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 --- packages/types/src/workspace.d.ts | 299 +++++++++--------- packages/ui/src/dropdowns/custom-select.tsx | 15 +- packages/ui/src/popovers/popover-menu.tsx | 2 + packages/ui/src/popovers/popover.tsx | 3 +- packages/ui/src/popovers/types.ts | 1 + packages/ui/src/tables/table.tsx | 2 +- .../(projects)/settings/members/page.tsx | 7 +- .../projects/settings/useProjectColumns.tsx | 75 +++++ .../workspace/settings/useMemberColumns.tsx | 69 ++++ .../components/project/member-list-item.tsx | 183 +++-------- web/core/components/project/member-list.tsx | 37 ++- .../project/settings/member-columns.tsx | 141 +++++++++ .../settings/invitations-list-item.tsx | 26 +- .../workspace/settings/member-columns.tsx | 141 +++++++++ .../workspace/settings/members-list-item.tsx | 180 ++--------- .../workspace/settings/members-list.tsx | 62 +++- web/core/store/member/project-member.store.ts | 2 +- .../store/member/workspace-member.store.ts | 2 +- .../projects/settings/useProjectColumns.tsx | 1 + .../workspace/settings/useMemberColumns.tsx | 1 + 20 files changed, 758 insertions(+), 491 deletions(-) create mode 100644 web/ce/components/projects/settings/useProjectColumns.tsx create mode 100644 web/ce/components/workspace/settings/useMemberColumns.tsx create mode 100644 web/core/components/project/settings/member-columns.tsx create mode 100644 web/core/components/workspace/settings/member-columns.tsx create mode 100644 web/ee/components/projects/settings/useProjectColumns.tsx create mode 100644 web/ee/components/workspace/settings/useMemberColumns.tsx diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index 4e40009e1..20674bcc6 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -1,192 +1,199 @@ -import { EUserWorkspaceRoles } from "@/constants/workspace"; +import {EUserWorkspaceRoles} from "@/constants/workspace"; import type { - IProjectMember, - IUser, - IUserLite, - IWorkspaceViewProps, + IProjectMember, + IUser, + IUserLite, + IWorkspaceViewProps, } from "@plane/types"; export interface IWorkspace { - readonly id: string; - readonly owner: IUser; - readonly created_at: Date; - readonly updated_at: Date; - name: string; - url: string; - logo: string | null; - slug: string; - readonly total_members: number; - readonly slug: string; - readonly created_by: string; - readonly updated_by: string; - organization_size: string; - total_issues: number; + readonly id: string; + readonly owner: IUser; + readonly created_at: Date; + readonly updated_at: Date; + name: string; + url: string; + logo: string | null; + slug: string; + readonly total_members: number; + readonly slug: string; + readonly created_by: string; + readonly updated_by: string; + organization_size: string; + total_issues: number; } export interface IWorkspaceLite { - readonly id: string; - name: string; - slug: string; + readonly id: string; + name: string; + slug: string; } export interface IWorkspaceMemberInvitation { - accepted: boolean; - email: string; - id: string; - message: string; - responded_at: Date; - role: EUserWorkspaceRoles; - token: string; - workspace: { - id: string; - logo: string; - name: string; - slug: string; - }; + accepted: boolean; + email: string; + id: string; + message: string; + responded_at: Date; + role: EUserWorkspaceRoles; + token: string; + workspace: { + id: string; + logo: string; + name: string; + slug: string; + }; } export interface IWorkspaceBulkInviteFormData { - emails: { email: string; role: EUserWorkspaceRoles }[]; + emails: {email: string; role: EUserWorkspaceRoles}[]; } export type Properties = { - assignee: boolean; - start_date: boolean; - due_date: boolean; - labels: boolean; - key: boolean; - priority: boolean; - state: boolean; - sub_issue_count: boolean; - link: boolean; - attachment_count: boolean; - estimate: boolean; - created_on: boolean; - updated_on: boolean; + assignee: boolean; + start_date: boolean; + due_date: boolean; + labels: boolean; + key: boolean; + priority: boolean; + state: boolean; + sub_issue_count: boolean; + link: boolean; + attachment_count: boolean; + estimate: boolean; + created_on: boolean; + updated_on: boolean; }; export interface IWorkspaceMember { - id: string; - member: IUserLite; - role: EUserWorkspaceRoles; + id: string; + member: IUserLite; + role: EUserWorkspaceRoles; + created_at: string; + avatar?: string; + email?: string; + first_name?: string; + last_name?: string; + joining_date: string; + display_name?: string; } export interface IWorkspaceMemberMe { - company_role: string | null; - created_at: Date; - created_by: string; - default_props: IWorkspaceViewProps; - id: string; - member: string; - role: EUserWorkspaceRoles; - updated_at: Date; - updated_by: string; - view_props: IWorkspaceViewProps; - workspace: string; + company_role: string | null; + created_at: Date; + created_by: string; + default_props: IWorkspaceViewProps; + id: string; + member: string; + role: EUserWorkspaceRoles; + updated_at: Date; + updated_by: string; + view_props: IWorkspaceViewProps; + workspace: string; } export interface ILastActiveWorkspaceDetails { - workspace_details: IWorkspace; - project_details?: IProjectMember[]; + workspace_details: IWorkspace; + project_details?: IProjectMember[]; } export interface IWorkspaceDefaultSearchResult { - id: string; - name: string; - project_id: string; - project__identifier: string; - workspace__slug: string; + id: string; + name: string; + project_id: string; + project__identifier: string; + workspace__slug: string; } export interface IWorkspaceSearchResult { - id: string; - name: string; - slug: string; + id: string; + name: string; + slug: string; } export interface IWorkspaceIssueSearchResult { - id: string; - name: string; - project__identifier: string; - project_id: string; - sequence_id: number; - workspace__slug: string; + id: string; + name: string; + project__identifier: string; + project_id: string; + sequence_id: number; + workspace__slug: string; } export interface IWorkspacePageSearchResult { - id: string; - name: string; - project_ids: string[]; - project__identifiers: string[]; - workspace__slug: string; + id: string; + name: string; + project_ids: string[]; + project__identifiers: string[]; + workspace__slug: string; } export interface IWorkspaceProjectSearchResult { - id: string; - identifier: string; - name: string; - workspace__slug: string; + id: string; + identifier: string; + name: string; + workspace__slug: string; } export interface IWorkspaceSearchResults { - results: { - workspace: IWorkspaceSearchResult[]; - project: IWorkspaceProjectSearchResult[]; - issue: IWorkspaceIssueSearchResult[]; - cycle: IWorkspaceDefaultSearchResult[]; - module: IWorkspaceDefaultSearchResult[]; - issue_view: IWorkspaceDefaultSearchResult[]; - page: IWorkspacePageSearchResult[]; - }; + results: { + workspace: IWorkspaceSearchResult[]; + project: IWorkspaceProjectSearchResult[]; + issue: IWorkspaceIssueSearchResult[]; + cycle: IWorkspaceDefaultSearchResult[]; + module: IWorkspaceDefaultSearchResult[]; + issue_view: IWorkspaceDefaultSearchResult[]; + page: IWorkspacePageSearchResult[]; + }; } export interface IProductUpdateResponse { - url: string; - assets_url: string; - upload_url: string; - html_url: string; - id: number; - author: { - login: string; - id: string; - node_id: string; - avatar_url: string; - gravatar_id: ""; - url: string; - html_url: string; - followers_url: string; - following_url: string; - gists_url: string; - starred_url: string; - subscriptions_url: string; - organizations_url: string; - repos_url: string; - events_url: string; - received_events_url: string; - type: string; - site_admin: false; - }; - node_id: string; - tag_name: string; - target_commitish: string; - name: string; - draft: boolean; - prerelease: true; - created_at: string; - published_at: string; - assets: []; - tarball_url: string; - zipball_url: string; - body: string; - reactions: { - url: string; - total_count: number; - "+1": number; - "-1": number; - laugh: number; - hooray: number; - confused: number; - heart: number; - rocket: number; - eyes: number; - }; + url: string; + assets_url: string; + upload_url: string; + html_url: string; + id: number; + author: { + login: string; + id: string; + node_id: string; + avatar_url: string; + gravatar_id: ""; + url: string; + html_url: string; + followers_url: string; + following_url: string; + gists_url: string; + starred_url: string; + subscriptions_url: string; + organizations_url: string; + repos_url: string; + events_url: string; + received_events_url: string; + type: string; + site_admin: false; + }; + node_id: string; + tag_name: string; + target_commitish: string; + name: string; + draft: boolean; + prerelease: true; + created_at: string; + published_at: string; + assets: []; + tarball_url: string; + zipball_url: string; + body: string; + reactions: { + url: string; + total_count: number; + "+1": number; + "-1": number; + laugh: number; + hooray: number; + confused: number; + heart: number; + rocket: number; + eyes: number; + }; } diff --git a/packages/ui/src/dropdowns/custom-select.tsx b/packages/ui/src/dropdowns/custom-select.tsx index 4c4009b1b..671991c84 100644 --- a/packages/ui/src/dropdowns/custom-select.tsx +++ b/packages/ui/src/dropdowns/custom-select.tsx @@ -82,11 +82,16 @@ const CustomSelect = (props: ICustomSelectProps) => { )} - + ); diff --git a/web/ce/components/projects/settings/useProjectColumns.tsx b/web/ce/components/projects/settings/useProjectColumns.tsx new file mode 100644 index 000000000..031b425e8 --- /dev/null +++ b/web/ce/components/projects/settings/useProjectColumns.tsx @@ -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(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) => ( + + ), + }, + { + key: "Display Name", + content: "Display Name", + tdRender: (rowData: RowData) =>
{rowData.member.display_name}
, + }, + + { + key: "Account Type", + content: "Account Type", + tdRender: (rowData: RowData) => ( + + ), + }, + { + key: "Joining Date", + content: "Joining Date", + tdRender: (rowData: RowData) =>
{getFormattedDate(rowData.member.joining_date)}
, + }, + ]; + return { columns, workspaceSlug, projectId, removeMemberModal, setRemoveMemberModal }; +}; + +export default useProjectColumns; diff --git a/web/ce/components/workspace/settings/useMemberColumns.tsx b/web/ce/components/workspace/settings/useMemberColumns.tsx new file mode 100644 index 000000000..c2941a633 --- /dev/null +++ b/web/ce/components/workspace/settings/useMemberColumns.tsx @@ -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(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) => ( + + ), + }, + { + key: "Display Name", + content: "Display Name", + tdRender: (rowData: RowData) =>
{rowData.member.display_name}
, + }, + + { + key: "Account Type", + content: "Account Type", + tdRender: (rowData: RowData) => ( + + ), + }, + + { + key: "Joining Date", + content: "Joining Date", + tdRender: (rowData: RowData) =>
{getFormattedDate(rowData.member.joining_date)}
, + }, + ]; + return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal }; +}; + +export default useMemberColumns; diff --git a/web/core/components/project/member-list-item.tsx b/web/core/components/project/member-list-item.tsx index 59e295bd7..886b0776c 100644 --- a/web/core/components/project/member-list-item.tsx +++ b/web/core/components/project/member-list-item.tsx @@ -1,54 +1,45 @@ "use client"; -import { useState } from "react"; import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -// icons -import { ChevronDown, Dot, XCircle } from "lucide-react"; -// ui -import { CustomSelect, TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; + +import { TOAST_TYPE, Table, setToast } from "@plane/ui"; // components import { ConfirmProjectMemberRemove } from "@/components/project"; // constants import { PROJECT_MEMBER_LEAVE } from "@/constants/event-tracker"; -import { EUserProjectRoles } from "@/constants/project"; -import { ROLE } from "@/constants/workspace"; + // hooks import { useEventTracker, useMember, useProject, useUser } from "@/hooks/store"; 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 = { - userId: string; + memberDetails: (IProjectMemberDetails | null)[]; }; export const ProjectMemberListItem: React.FC = observer((props) => { - const { userId } = props; - // states - const [removeMemberModal, setRemoveMemberModal] = useState(false); + const { memberDetails } = props; + const { columns, workspaceSlug, projectId, removeMemberModal, setRemoveMemberModal } = useProjectColumns(); + // router const router = useAppRouter(); - const { workspaceSlug, projectId } = useParams(); // store hooks const { - membership: { currentProjectRole, leaveProject }, + membership: { leaveProject }, } = useUser(); const { data: currentUser } = useUser(); const { fetchProjects } = useProject(); const { - project: { removeMemberFromProject, getProjectMemberDetails, updateMember }, + project: { removeMemberFromProject }, } = useMember(); const { captureEvent } = useEventTracker(); - const { isMobile } = usePlatformOS(); - // derived values - const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN; - const userDetails = getProjectMemberDetails(userId); + // const { isMobile } = usePlatformOS(); - const handleRemove = async () => { - if (!workspaceSlug || !projectId || !userDetails) return; + const handleRemove = async (memberId: string) => { + if (!workspaceSlug || !projectId || !memberId) return; - if (userDetails.member?.id === currentUser?.id) { + if (memberId === currentUser?.id) { await leaveProject(workspaceSlug.toString(), projectId.toString()) .then(async () => { captureEvent(PROJECT_MEMBER_LEAVE, { @@ -58,7 +49,7 @@ export const ProjectMemberListItem: React.FC = observer((props) => { await fetchProjects(workspaceSlug.toString()); router.push(`/${workspaceSlug}/projects`); }) - .catch((err: any) => + .catch((err) => setToast({ type: TOAST_TYPE.ERROR, title: "Error!", @@ -66,130 +57,36 @@ export const ProjectMemberListItem: React.FC = observer((props) => { }) ); } else - await removeMemberFromProject(workspaceSlug.toString(), projectId.toString(), userDetails.member?.id).catch( - (err) => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: err?.error || "Something went wrong. Please try again.", - }) + await removeMemberFromProject(workspaceSlug.toString(), projectId.toString(), memberId).catch((err) => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + 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 ( <> - setRemoveMemberModal(false)} - data={userDetails.member} - onSubmit={handleRemove} + {removeMemberModal && ( + setRemoveMemberModal(null)} + data={{ id: removeMemberModal.member.id, display_name: removeMemberModal.member.display_name || "" }} + onSubmit={() => handleRemove(removeMemberModal.member.id)} + /> + )} + 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" /> -
-
- {userDetails.member?.avatar && userDetails.member?.avatar !== "" ? ( - - - {userDetails.member?.display_name - - - ) : ( - - - {(userDetails.member?.display_name ?? userDetails.member?.email ?? "?")[0]} - - - )} - -
- - - {userDetails.member?.first_name} {userDetails.member?.last_name} - - -
-

{userDetails.member?.display_name}

- {isAdmin && ( - <> - -

{userDetails.member?.email}

- - )} -
-
-
- -
- - - {ROLE[userDetails.role]} - - {userDetails.member?.id !== currentUser?.id && ( - - - - )} -
- } - 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 ( - - <>{ROLE[parseInt(key) as keyof typeof ROLE]} - - ); - })} - - {(isAdmin || userDetails.member?.id === currentUser?.id) && ( - - - - )} -
- ); }); diff --git a/web/core/components/project/member-list.tsx b/web/core/components/project/member-list.tsx index 2a36ca058..8ff6ed284 100644 --- a/web/core/components/project/member-list.tsx +++ b/web/core/components/project/member-list.tsx @@ -9,7 +9,8 @@ import { Button } from "@plane/ui"; import { ProjectMemberListItem, SendProjectInvitationModal } from "@/components/project"; // 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(() => { // states @@ -20,7 +21,9 @@ export const ProjectMemberList: React.FC = observer(() => { const { project: { projectMemberIds, getProjectMemberDetails }, } = useMember(); - + const { + membership: { currentProjectRole }, + } = useUser(); const searchedMembers = (projectMemberIds ?? []).filter((userId) => { const memberDetails = getProjectMemberDetails(userId); @@ -31,12 +34,13 @@ export const ProjectMemberList: React.FC = observer(() => { return displayName?.includes(searchQuery.toLowerCase()) || fullName.includes(searchQuery.toLowerCase()); }); + const memberDetails = searchedMembers?.map((memberId) => getProjectMemberDetails(memberId)); return ( <> setInviteModal(false)} /> -
+

Members

@@ -48,23 +52,24 @@ export const ProjectMemberList: React.FC = observer(() => { onChange={(e) => setSearchQuery(e.target.value)} />
- + {currentProjectRole === EUserProjectRoles.ADMIN && ( + + )}
{!projectMemberIds ? ( ) : ( -
- {projectMemberIds.length > 0 - ? searchedMembers.map((userId) => ) - : null} +
+ + {searchedMembers.length === 0 && (

No matching members

)} diff --git a/web/core/components/project/settings/member-columns.tsx b/web/core/components/project/settings/member-columns.tsx new file mode 100644 index 000000000..835f13cfa --- /dev/null +++ b/web/core/components/project/settings/member-columns.tsx @@ -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 = (props: any) => { + const { rowData, workspaceSlug, isAdmin, currentUser, setRemoveMemberModal } = props; + return ( + + {({}) => ( +
+
+
+ {rowData.member.avatar && rowData.member.avatar.trim() !== "" ? ( + + + {rowData.member.display_name + + + ) : ( + + + {(rowData.member.email ?? rowData.member.display_name ?? "?")[0]} + + + )} + {rowData.member.first_name} {rowData.member.last_name} +
+ + {(isAdmin || rowData.member?.id === currentUser?.id) && ( + 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={() => ( +
setRemoveMemberModal(rowData)} + > + + {rowData.member?.id === currentUser?.id ? "Leave " : "Remove "} +
+ )} + /> + )} +
+
+ )} +
+ ); +}; + +export const AccountTypeColumn: React.FC = (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 ? ( +
+ {ROLE[rowData.role as keyof typeof ROLE]} +
+ ) : ( + ( + { + 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={ +
+ {ROLE[rowData.role as keyof typeof ROLE]} +
+ } + 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) => ( + + {ROLE[item as unknown as keyof typeof ROLE]} + + ))} +
+ )} + /> + ); +}; diff --git a/web/core/components/workspace/settings/invitations-list-item.tsx b/web/core/components/workspace/settings/invitations-list-item.tsx index 8027e1a75..13b742e1c 100644 --- a/web/core/components/workspace/settings/invitations-list-item.tsx +++ b/web/core/components/workspace/settings/invitations-list-item.tsx @@ -79,7 +79,7 @@ export const WorkspaceInvitationsListItem: FC = observer((props) => { }} onSubmit={handleRemoveInvitation} /> -
+
{(invitationDetails.email ?? "?")[0]} @@ -137,17 +137,19 @@ export const WorkspaceInvitationsListItem: FC = observer((props) => { ); })} - - - + {isAdmin && ( + + + + )}
diff --git a/web/core/components/workspace/settings/member-columns.tsx b/web/core/components/workspace/settings/member-columns.tsx new file mode 100644 index 000000000..d8b52cb9d --- /dev/null +++ b/web/core/components/workspace/settings/member-columns.tsx @@ -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 = (props: any) => { + const { rowData, workspaceSlug, isAdmin, currentUser, setRemoveMemberModal } = props; + return ( + + {({}) => ( +
+
+
+ {rowData.member.avatar && rowData.member.avatar.trim() !== "" ? ( + + + {rowData.member.display_name + + + ) : ( + + + {(rowData.member.email ?? rowData.member.display_name ?? "?")[0]} + + + )} + {rowData.member.first_name} {rowData.member.last_name} +
+ + {(isAdmin || rowData.member?.id === currentUser?.id) && ( + 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={() => ( +
setRemoveMemberModal(rowData)} + > + {" "} + {rowData.member?.id === currentUser?.id ? "Leave " : "Remove "} +
+ )} + /> + )} +
+
+ )} +
+ ); +}; + +export const AccountTypeColumn: React.FC = (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 ? ( +
+ {ROLE[rowData.role as keyof typeof ROLE]} +
+ ) : ( + ( + { + 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={ +
+ {ROLE[rowData.role as keyof typeof ROLE]} +
+ } + 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) => ( + + {ROLE[item as unknown as keyof typeof ROLE]} + + ))} +
+ )} + /> + ); +}; diff --git a/web/core/components/workspace/settings/members-list-item.tsx b/web/core/components/workspace/settings/members-list-item.tsx index ef37b9a2b..e877eea59 100644 --- a/web/core/components/workspace/settings/members-list-item.tsx +++ b/web/core/components/workspace/settings/members-list-item.tsx @@ -1,48 +1,40 @@ "use client"; -import { FC, useState } from "react"; +import { FC } from "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 -import { CustomSelect, TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; +import { IWorkspaceMember } from "@plane/types"; +import { TOAST_TYPE, Table, setToast } from "@plane/ui"; // components import { ConfirmWorkspaceMemberRemove } from "@/components/workspace"; // constants import { WORKSPACE_MEMBER_LEAVE } from "@/constants/event-tracker"; -import { EUserWorkspaceRoles, ROLE } from "@/constants/workspace"; // hooks import { useEventTracker, useMember, useUser } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; +import useMemberColumns from "@/plane-web/components/workspace/settings/useMemberColumns"; type Props = { - memberId: string; + memberDetails: (IWorkspaceMember | null)[]; }; export const WorkspaceMembersListItem: FC = observer((props) => { - const { memberId } = props; - // states - const [removeMemberModal, setRemoveMemberModal] = useState(false); + const { memberDetails } = props; + const { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal } = useMemberColumns(); // router const router = useAppRouter(); - const { workspaceSlug } = useParams(); // store hooks const { - // currentUser, - // currentUserSettings, - membership: { currentWorkspaceRole, leaveWorkspace }, + membership: { leaveWorkspace }, } = useUser(); const { data: currentUser } = useUser(); const { - workspace: { updateMember, removeMemberFromWorkspace, getWorkspaceMemberDetails }, + workspace: { removeMemberFromWorkspace }, } = useMember(); const { captureEvent } = useEventTracker(); const { isMobile } = usePlatformOS(); // derived values - const memberDetails = getWorkspaceMemberDetails(memberId); const handleLeaveWorkspace = async () => { if (!workspaceSlug || !currentUser) return; @@ -64,10 +56,10 @@ export const WorkspaceMembersListItem: FC = observer((props) => { ); }; - const handleRemoveMember = async () => { - if (!workspaceSlug || !memberDetails) return; + const handleRemoveMember = async (memberId: string) => { + if (!workspaceSlug || !memberId) return; - await removeMemberFromWorkspace(workspaceSlug.toString(), memberDetails.member.id).catch((err) => + await removeMemberFromWorkspace(workspaceSlug.toString(), memberId).catch((err) => setToast({ type: TOAST_TYPE.ERROR, title: "Error!", @@ -76,143 +68,41 @@ export const WorkspaceMembersListItem: FC = observer((props) => { ); }; - const handleRemove = async () => { - if (memberDetails?.member.id === currentUser?.id) await handleLeaveWorkspace(); - else await handleRemoveMember(); + const handleRemove = async (memberId: string) => { + if (memberId === currentUser?.id) await handleLeaveWorkspace(); + else await handleRemoveMember(memberId); }; - if (!memberDetails) return null; - // 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 - const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN; // role change access- // 1. user cannot change their own role // 2. only admin or member can change role // 3. user cannot change role of higher role - const hasRoleChangeAccess = - currentWorkspaceRole && - !isCurrentUser && - [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER].includes(currentWorkspaceRole) && - memberDetails.role <= currentWorkspaceRole; return ( <> - setRemoveMemberModal(false)} - userDetails={{ - id: memberDetails.member.id, - display_name: `${memberDetails.member.display_name}`, - }} - onSubmit={handleRemove} + {removeMemberModal && ( + 0} + onClose={() => setRemoveMemberModal(null)} + userDetails={{ + id: removeMemberModal.member.id, + display_name: removeMemberModal.member.display_name || "", + }} + onSubmit={() => handleRemove(removeMemberModal.member.id)} + /> + )} +
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" /> -
-
- {memberDetails.member.avatar && memberDetails.member.avatar.trim() !== "" ? ( - - - {memberDetails.member.display_name - - - ) : ( - - - {(memberDetails.member.email ?? memberDetails.member.display_name ?? "?")[0]} - - - )} -
-
- -
- - {memberDetails.member.first_name} {memberDetails.member.last_name} - -
- -
-

{memberDetails.member.display_name}

- {isAdmin && ( - <> - -

{memberDetails.member.email}

- - )} -
-
-
- - - {ROLE[memberDetails.role]} - - {hasRoleChangeAccess && ( - - - - )} -
- } - 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 ( - - <>{ROLE[parseInt(key) as keyof typeof ROLE]} - - ); - })} - - - - -
-
-
- ); }); diff --git a/web/core/components/workspace/settings/members-list.tsx b/web/core/components/workspace/settings/members-list.tsx index 2f85b5c96..1becc38e6 100644 --- a/web/core/components/workspace/settings/members-list.tsx +++ b/web/core/components/workspace/settings/members-list.tsx @@ -1,15 +1,20 @@ -import { FC } from "react"; +import { FC, useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; 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 { WorkspaceInvitationsListItem, WorkspaceMembersListItem } from "@/components/workspace"; // hooks import { useMember } from "@/hooks/store"; -export const WorkspaceMembersList: FC<{ searchQuery: string }> = observer((props) => { - const { searchQuery } = props; +export const WorkspaceMembersList: FC<{ searchQuery: string; isAdmin: boolean }> = observer((props) => { + const { searchQuery, isAdmin } = props; + const [showPendingInvites, setShowPendingInvites] = useState(false); + // router const { workspaceSlug } = useParams(); // store hooks @@ -21,6 +26,7 @@ export const WorkspaceMembersList: FC<{ searchQuery: string }> = observer((props getSearchedWorkspaceMemberIds, workspaceMemberInvitationIds, getSearchedWorkspaceInvitationIds, + getWorkspaceMemberDetails, }, } = useMember(); // fetching workspace invitations @@ -39,20 +45,44 @@ export const WorkspaceMembersList: FC<{ searchQuery: string }> = observer((props // derived values const searchedMemberIds = getSearchedWorkspaceMemberIds(searchQuery); const searchedInvitationsIds = getSearchedWorkspaceInvitationIds(searchQuery); + const memberDetails = searchedMemberIds?.map((memberId) => getWorkspaceMemberDetails(memberId)); return ( -
- {searchedInvitationsIds && searchedInvitationsIds.length > 0 - ? searchedInvitationsIds?.map((invitationId) => ( - - )) - : null} - {searchedMemberIds && searchedMemberIds.length > 0 - ? searchedMemberIds?.map((memberId) => ) - : null} - {searchedInvitationsIds?.length === 0 && searchedMemberIds?.length === 0 && ( -

No matching members

+ <> +
+ + {searchedInvitationsIds?.length === 0 && searchedMemberIds?.length === 0 && ( +

No matching members

+ )} +
+ {isAdmin && ( + setShowPendingInvites((prev) => !prev)} + buttonClassName="w-full" + title={ +
+
+

Pending invites

+ {searchedInvitationsIds && ( + + )} +
{" "} + +
+ } + > + +
+ {searchedInvitationsIds && searchedInvitationsIds.length > 0 + ? searchedInvitationsIds?.map((invitationId) => ( + + )) + : null} +
+
+
)} -
+ ); }); diff --git a/web/core/store/member/project-member.store.ts b/web/core/store/member/project-member.store.ts index dff9435dc..6a2c870a0 100644 --- a/web/core/store/member/project-member.store.ts +++ b/web/core/store/member/project-member.store.ts @@ -15,7 +15,7 @@ import { IUserStore } from "@/store/user"; import { CoreRootStore } from "../root.store"; import { IMemberRootStore } from "."; -interface IProjectMemberDetails { +export interface IProjectMemberDetails { id: string; member: IUserLite; role: EUserProjectRoles; diff --git a/web/core/store/member/workspace-member.store.ts b/web/core/store/member/workspace-member.store.ts index 8c7e68aa2..1550b9552 100644 --- a/web/core/store/member/workspace-member.store.ts +++ b/web/core/store/member/workspace-member.store.ts @@ -196,7 +196,7 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore { await this.workspaceService.fetchWorkspaceMembers(workspaceSlug).then((response) => { runInAction(() => { 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], { id: member.id, member: member.member.id, diff --git a/web/ee/components/projects/settings/useProjectColumns.tsx b/web/ee/components/projects/settings/useProjectColumns.tsx new file mode 100644 index 000000000..2fa541758 --- /dev/null +++ b/web/ee/components/projects/settings/useProjectColumns.tsx @@ -0,0 +1 @@ +export * from "ce/components/projects/settings/useProjectColumns"; diff --git a/web/ee/components/workspace/settings/useMemberColumns.tsx b/web/ee/components/workspace/settings/useMemberColumns.tsx new file mode 100644 index 000000000..4d0ca711a --- /dev/null +++ b/web/ee/components/workspace/settings/useMemberColumns.tsx @@ -0,0 +1 @@ +export * from "ce/components/workspace/settings/useMemberColumns";