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

View File

@ -6,16 +6,36 @@
import { useState } from "react";
import { useParams } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel, LOGIN_MEDIUM_LABELS } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { renderFormattedDate } from "@plane/utils";
import { MemberHeaderColumn } from "@/components/project/member-header-column";
import type { RowData } from "@/components/workspace/settings/member-columns";
import { AccountTypeColumn, NameColumn } from "@/components/workspace/settings/member-columns";
import {
AccountTypeColumn,
NameColumn,
WorkspaceMemberActionsColumn,
} from "@/components/workspace/settings/member-columns";
import { useMember } from "@/hooks/store/use-member";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import type { IMemberFilters } from "@/store/member/utils";
const NODEDC_LOGIN_MEDIUM_LABELS: Record<string, string> = {
email: "Почта",
"magic-code": "Код",
github: "GitHub",
gitlab: "GitLab",
google: "Google",
gitea: "Gitea",
};
const getLoginMediumLabel = (loginMedium: string) => NODEDC_LOGIN_MEDIUM_LABELS[loginMedium] ?? loginMedium;
const isSuspended = (rowData: RowData) => rowData.is_active === false;
const stickyNameHeaderClassName = "nodedc-settings-table-sticky left-0 z-20 min-w-max";
const stickyNameCellClassName = "nodedc-settings-table-sticky left-0 z-10 min-w-max";
export const useMemberColumns = () => {
// states
const [removeMemberModal, setRemoveMemberModal] = useState<RowData | null>(null);
@ -34,8 +54,6 @@ export const useMemberColumns = () => {
// derived values
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const isSuspended = (rowData: RowData) => rowData.is_active === false;
// handlers
const handleDisplayFilterUpdate = (filterUpdates: Partial<IMemberFilters>) => {
updateFilters(filterUpdates);
@ -45,13 +63,16 @@ export const useMemberColumns = () => {
{
key: "Full name",
content: t("workspace_settings.settings.members.details.full_name"),
thClassName: "text-left",
thClassName: stickyNameHeaderClassName,
tdClassName: stickyNameCellClassName,
thRender: () => (
<div className="w-max min-w-[8.5rem] pr-3">
<MemberHeaderColumn
property="full_name"
displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
</div>
),
tdRender: (rowData: RowData) => (
<NameColumn
@ -68,7 +89,9 @@ export const useMemberColumns = () => {
key: "Display name",
content: t("workspace_settings.settings.members.details.display_name"),
tdRender: (rowData: RowData) => (
<div className={`w-32 ${isSuspended(rowData) ? "text-placeholder" : ""}`}>{rowData.member.display_name}</div>
<div className={`min-w-[7.5rem] pr-3 ${isSuspended(rowData) ? "text-placeholder" : ""}`}>
{rowData.member.display_name}
</div>
),
thRender: () => (
<MemberHeaderColumn
@ -83,7 +106,9 @@ export const useMemberColumns = () => {
key: "Email address",
content: t("workspace_settings.settings.members.details.email_address"),
tdRender: (rowData: RowData) => (
<div className={`w-48 truncate ${isSuspended(rowData) ? "text-placeholder" : ""}`}>{rowData.member.email}</div>
<div className={`min-w-[10.5rem] pr-3 ${isSuspended(rowData) ? "text-placeholder" : ""}`}>
{rowData.member.email}
</div>
),
thRender: () => (
<MemberHeaderColumn
@ -109,12 +134,12 @@ export const useMemberColumns = () => {
{
key: "Authentication",
content: t("workspace_settings.settings.members.details.authentication"),
content: <div className="min-w-[5.5rem] pr-3">Вход</div>,
tdRender: (rowData: RowData) => {
if (isSuspended(rowData)) return null;
const loginMedium = rowData.member.last_login_medium;
if (!loginMedium) return null;
return <div>{LOGIN_MEDIUM_LABELS[loginMedium]}</div>;
return <div className="min-w-[5.5rem] pr-3">{getLoginMediumLabel(loginMedium)}</div>;
},
},
@ -122,7 +147,9 @@ export const useMemberColumns = () => {
key: "Joining date",
content: t("workspace_settings.settings.members.details.joining_date"),
tdRender: (rowData: RowData) =>
isSuspended(rowData) ? null : <div>{renderFormattedDate(rowData?.member?.joining_date)}</div>,
isSuspended(rowData) ? null : (
<div className="min-w-[7rem] pr-3">{renderFormattedDate(rowData?.member?.joining_date)}</div>
),
thRender: () => (
<MemberHeaderColumn
property="joining_date"
@ -131,6 +158,19 @@ export const useMemberColumns = () => {
/>
),
},
{
key: "Actions",
content: <span className="sr-only">Действия</span>,
tdRender: (rowData: RowData) => (
<WorkspaceMemberActionsColumn
rowData={rowData}
workspaceSlug={workspaceSlug}
isAdmin={isAdmin}
currentUser={currentUser}
setRemoveMemberModal={setRemoveMemberModal}
/>
),
},
];
return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal };
};

View File

@ -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,18 +39,26 @@ 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">
<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" />
@ -76,50 +80,39 @@ export function NameColumn(props: NameProps) {
</span>
</Link>
)}
<span className={isSuspended ? "text-placeholder" : ""}>
{first_name} {last_name}
</span>
<span className={cn(isSuspended ? "text-placeholder" : "")}>{fullName}</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);
);
}
}}
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="size-3.5 align-middle" /> {id === currentUser?.id ? "Leave " : "Remove "}
</div>
)}
/>
<TrashIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
)}
</Disclosure>
);
}
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,
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) return;
if (!workspaceSlug || rowData.role === role) return;
try {
await updateMember(workspaceSlug.toString(), rowData.member.id, {
role: item as unknown as EUserPermissions,
});
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.",
title: "Ошибка",
message: errorString ?? "Не удалось обновить роль участника. Попробуйте ещё раз.",
});
}
},
}))}
menuButton={
<div className="flex">
<span>{ROLE[rowData.role]}</span>
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={`w-32 rounded-md p-0 !justify-start !px-0 hover:bg-surface-1 ${errors.role ? "border-danger-strong" : "border-none"}`}
/>
)}
menuButtonWrapperClassName="flex rounded-[1.25rem] border-0 outline-none"
placement="bottom-end"
/>
)}
</>

View File

@ -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"

View File

@ -2094,6 +2094,27 @@
-webkit-backdrop-filter: blur(18px);
}
.nodedc-settings-table-surface,
.nodedc-settings-table-sticky {
background: rgb(36, 36, 38) !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
.nodedc-settings-table-sticky {
position: sticky;
}
.nodedc-settings-table-sticky::after {
content: "";
position: absolute;
top: 0;
right: -1px;
bottom: 0;
width: 1px;
background: rgba(255, 255, 255, 0.045);
}
.nodedc-settings-sidebar-shell {
border: 0 !important;
outline: none !important;

View File

@ -29,7 +29,7 @@ export function Table<T>(props: TTableData<T>) {
<thead className={cn("divide-y divide-subtle", tHeadClassName)}>
<tr className={cn("divide-x divide-subtle text-13 text-primary", tHeadTrClassName)}>
{columns.map((column) => (
<th key={column.key} className={cn("px-2.5 py-2", thClassName)}>
<th key={column.key} className={cn("px-2.5 py-2", thClassName, column.thClassName)}>
{(column?.thRender && column?.thRender()) || column.content}
</th>
))}
@ -42,7 +42,14 @@ export function Table<T>(props: TTableData<T>) {
className={cn("divide-x divide-subtle text-13 text-secondary", tBodyTrClassName)}
>
{columns.map((column) => (
<td key={`${column.key}-${keyExtractor(item)}`} className={cn("px-2.5 py-2", tdClassName)}>
<td
key={`${column.key}-${keyExtractor(item)}`}
className={cn(
"px-2.5 py-2",
tdClassName,
typeof column.tdClassName === "function" ? column.tdClassName(item) : column.tdClassName
)}
>
{column.tdRender(item)}
</td>
))}

View File

@ -4,11 +4,15 @@
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
export type TTableColumn<T> = {
key: string;
content: string;
thRender?: () => React.ReactNode;
tdRender: (rowData: T) => React.ReactNode;
content: ReactNode;
thClassName?: string;
thRender?: () => ReactNode;
tdClassName?: string | ((rowData: T) => string);
tdRender: (rowData: T) => ReactNode;
};
export type TTableData<T> = {