UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: стандартизация таблицы участников workspace

This commit is contained in:
DCCONSTRUCTIONS
2026-05-10 11:39:45 +03:00
parent 3dd99491a4
commit bf75ce84eb
6 changed files with 191 additions and 129 deletions
@@ -6,19 +6,15 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form";
import { Disclosure } from "@headlessui/react";
// plane imports
import { ROLE, EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
import { TrashIcon, SuspendedUserIcon } from "@plane/propel/icons";
import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
import { ChevronDownIcon, TrashIcon, SuspendedUserIcon } from "@plane/propel/icons";
import { Pill, EPillVariant, EPillSize } from "@plane/propel/pill";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IUser, IWorkspaceMember } from "@plane/types";
// plane ui
import { PopoverMenu } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
import { cn, getFileURL } from "@plane/utils";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
// hooks
import { useMember } from "@/hooks/store/use-member";
@@ -43,83 +39,80 @@ type AccountTypeProps = {
workspaceSlug: string;
};
const WORKSPACE_ROLE_LABELS: Record<EUserPermissions, string> = {
[EUserPermissions.GUEST]: "Гость",
[EUserPermissions.MEMBER]: "Участник",
[EUserPermissions.ADMIN]: "Админ",
};
const WORKSPACE_ROLE_OPTIONS = [EUserPermissions.GUEST, EUserPermissions.MEMBER, EUserPermissions.ADMIN];
export const getWorkspaceRoleLabel = (role: EUserPermissions | undefined) =>
role ? (WORKSPACE_ROLE_LABELS[role] ?? "Не назначено") : "Не назначено";
export function NameColumn(props: NameProps) {
const { rowData, workspaceSlug, isAdmin, currentUser, setRemoveMemberModal } = props;
const { rowData, workspaceSlug } = props;
// derived values
const { avatar_url, display_name, email, first_name, id, last_name } = rowData.member;
const isSuspended = rowData.is_active === false;
const fullName = [first_name, last_name].filter(Boolean).join(" ") || display_name || email;
return (
<Disclosure>
{() => (
<div className="group relative">
<div className="flex w-72 items-center justify-between gap-x-4 gap-y-2">
<div className="flex flex-1 items-center gap-x-2 gap-y-2">
{isSuspended ? (
<div className="rounded-full bg-layer-1">
<SuspendedUserIcon className="size-6 text-placeholder" />
</div>
) : avatar_url && avatar_url.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-6 items-center justify-center rounded-full text-on-color capitalize">
<img
src={getFileURL(avatar_url)}
className="absolute top-0 left-0 h-full w-full rounded-full object-cover"
alt={display_name || email}
/>
</span>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-6 items-center justify-center rounded-full bg-layer-3 text-11 text-tertiary capitalize">
{(email ?? display_name ?? "?")[0]}
</span>
</Link>
)}
<span className={isSuspended ? "text-placeholder" : ""}>
{first_name} {last_name}
</span>
</div>
{!isSuspended && (isAdmin || 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
role="button"
tabIndex={0}
className="flex cursor-pointer items-center gap-x-3"
onClick={() => setRemoveMemberModal(rowData)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setRemoveMemberModal(rowData);
}
}}
data-ph-element={MEMBER_TRACKER_ELEMENTS.WORKSPACE_MEMBER_TABLE_CONTEXT_MENU}
>
<TrashIcon className="size-3.5 align-middle" /> {id === currentUser?.id ? "Leave " : "Remove "}
</div>
)}
/>
)}
</div>
<div className="flex w-max min-w-[8.5rem] items-center gap-x-2 gap-y-2 pr-3">
{isSuspended ? (
<div className="rounded-full bg-layer-1">
<SuspendedUserIcon className="size-6 text-placeholder" />
</div>
) : avatar_url && avatar_url.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-6 items-center justify-center rounded-full text-on-color capitalize">
<img
src={getFileURL(avatar_url)}
className="absolute top-0 left-0 h-full w-full rounded-full object-cover"
alt={display_name || email}
/>
</span>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-6 items-center justify-center rounded-full bg-layer-3 text-11 text-tertiary capitalize">
{(email ?? display_name ?? "?")[0]}
</span>
</Link>
)}
</Disclosure>
<span className={cn(isSuspended ? "text-placeholder" : "")}>{fullName}</span>
</div>
);
}
export function WorkspaceMemberActionsColumn(props: NameProps) {
const { rowData, isAdmin, currentUser, setRemoveMemberModal } = props;
const { display_name, email, id } = rowData.member;
const isSuspended = rowData.is_active === false;
const canRemoveMember = !isSuspended && (isAdmin || id === currentUser?.id);
const isCurrentUser = id === currentUser?.id;
const label = isCurrentUser ? "Покинуть workspace" : `Удалить ${display_name || email}`;
return (
<div className="flex w-12 justify-end">
{canRemoveMember && (
<button
aria-label={label}
className="nodedc-external-icon-button"
data-ph-element={MEMBER_TRACKER_ELEMENTS.WORKSPACE_MEMBER_TABLE_CONTEXT_MENU}
title={label}
type="button"
onClick={() => setRemoveMemberModal(rowData)}
>
<TrashIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
export const AccountTypeColumn = observer(function AccountTypeColumn(props: AccountTypeProps) {
const { rowData, workspaceSlug } = props;
// form info
const {
control,
formState: { errors },
} = useForm();
// store hooks
const { allowPermissions } = useUserPermissions();
@@ -133,56 +126,52 @@ export const AccountTypeColumn = observer(function AccountTypeColumn(props: Acco
const isAdminRole = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const isRoleNonEditable = isCurrentUser || !isAdminRole;
const isSuspended = rowData.is_active === false;
const roleLabel = getWorkspaceRoleLabel(rowData.role);
return (
<>
{isSuspended ? (
<div className="flex w-32">
<div className="flex min-w-[9rem]">
<Pill variant={EPillVariant.DEFAULT} size={EPillSize.SM} className="border-none">
Suspended
Заблокирован
</Pill>
</div>
) : isRoleNonEditable ? (
<div className="flex w-32">
<span>{ROLE[rowData.role]}</span>
<div className="nodedc-settings-chip flex min-h-10 min-w-[9rem] items-center px-4 py-2 text-caption-sm-medium">
<span className="truncate">{roleLabel}</span>
</div>
) : (
<Controller
name="role"
control={control}
rules={{ required: "Role is required." }}
render={({ field: { value } }) => (
<SelectionDropdown
options={Object.keys(ROLE).map((item) => ({
key: item,
title: ROLE[item as unknown as keyof typeof ROLE],
isChecked: String(value) === item,
onClick: async () => {
if (!workspaceSlug) return;
try {
await updateMember(workspaceSlug.toString(), rowData.member.id, {
role: item as unknown as EUserPermissions,
});
} catch (err: unknown) {
const error = err as { error?: string | string[] };
const errorString = Array.isArray(error?.error) ? error.error[0] : error?.error;
<SelectionDropdown
dropdownClassName="!p-2"
dropdownContentClassName="!w-44"
options={WORKSPACE_ROLE_OPTIONS.map((role) => ({
key: String(role),
title: getWorkspaceRoleLabel(role),
isChecked: rowData.role === role,
onClick: async () => {
if (!workspaceSlug || rowData.role === role) return;
try {
await updateMember(workspaceSlug.toString(), rowData.member.id, { role });
} catch (err: unknown) {
const error = err as { error?: string | string[] };
const errorString = Array.isArray(error?.error) ? error.error[0] : error?.error;
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: errorString ?? "An error occurred while updating member role. Please try again.",
});
}
},
}))}
menuButton={
<div className="flex">
<span>{ROLE[rowData.role]}</span>
</div>
setToast({
type: TOAST_TYPE.ERROR,
title: "Ошибка",
message: errorString ?? "Не удалось обновить роль участника. Попробуйте ещё раз.",
});
}
menuButtonWrapperClassName={`w-32 rounded-md p-0 !justify-start !px-0 hover:bg-surface-1 ${errors.role ? "border-danger-strong" : "border-none"}`}
/>
},
}))}
menuButton={({ open }) => (
<div className="nodedc-settings-chip flex min-h-10 min-w-[9rem] items-center justify-between gap-2 px-4 py-2 text-caption-sm-medium">
<span className="truncate">{roleLabel}</span>
<ChevronDownIcon className={cn("h-3 w-3 transition-transform", open ? "rotate-180" : "")} />
</div>
)}
menuButtonWrapperClassName="flex rounded-[1.25rem] border-0 outline-none"
placement="bottom-end"
/>
)}
</>
@@ -91,7 +91,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
if (isEmpty(columns)) return <MembersLayoutLoader />;
return (
<div className="grid overflow-hidden rounded-[1.2rem]">
<div className="horizontal-scrollbar scrollbar-sm w-full overflow-x-auto overflow-y-hidden rounded-[1.2rem]">
{removeMemberModal && (
<ConfirmWorkspaceMemberRemove
isOpen={removeMemberModal.member.id.length > 0}
@@ -109,6 +109,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
(memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as unknown as RowData[]
}
keyExtractor={(rowData) => rowData?.member.id ?? ""}
tableClassName="nodedc-settings-table-surface w-max table-auto border-separate border-spacing-0 overflow-visible"
tHeadClassName="border-b border-white/6"
thClassName="text-left font-medium divide-x-0 text-placeholder"
tBodyClassName="divide-y-0"