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 { useState } from "react";
import { useParams } from "next/navigation"; 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 { useTranslation } from "@plane/i18n";
import { renderFormattedDate } from "@plane/utils"; import { renderFormattedDate } from "@plane/utils";
import { MemberHeaderColumn } from "@/components/project/member-header-column"; import { MemberHeaderColumn } from "@/components/project/member-header-column";
import type { RowData } from "@/components/workspace/settings/member-columns"; 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 { useMember } from "@/hooks/store/use-member";
import { useUser, useUserPermissions } from "@/hooks/store/user"; import { useUser, useUserPermissions } from "@/hooks/store/user";
import type { IMemberFilters } from "@/store/member/utils"; 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 = () => { export const useMemberColumns = () => {
// states // states
const [removeMemberModal, setRemoveMemberModal] = useState<RowData | null>(null); const [removeMemberModal, setRemoveMemberModal] = useState<RowData | null>(null);
@ -34,8 +54,6 @@ export const useMemberColumns = () => {
// derived values // derived values
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const isSuspended = (rowData: RowData) => rowData.is_active === false;
// handlers // handlers
const handleDisplayFilterUpdate = (filterUpdates: Partial<IMemberFilters>) => { const handleDisplayFilterUpdate = (filterUpdates: Partial<IMemberFilters>) => {
updateFilters(filterUpdates); updateFilters(filterUpdates);
@ -45,13 +63,16 @@ export const useMemberColumns = () => {
{ {
key: "Full name", key: "Full name",
content: t("workspace_settings.settings.members.details.full_name"), content: t("workspace_settings.settings.members.details.full_name"),
thClassName: "text-left", thClassName: stickyNameHeaderClassName,
tdClassName: stickyNameCellClassName,
thRender: () => ( thRender: () => (
<div className="w-max min-w-[8.5rem] pr-3">
<MemberHeaderColumn <MemberHeaderColumn
property="full_name" property="full_name"
displayFilters={filters} displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate} handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/> />
</div>
), ),
tdRender: (rowData: RowData) => ( tdRender: (rowData: RowData) => (
<NameColumn <NameColumn
@ -68,7 +89,9 @@ export const useMemberColumns = () => {
key: "Display name", key: "Display name",
content: t("workspace_settings.settings.members.details.display_name"), content: t("workspace_settings.settings.members.details.display_name"),
tdRender: (rowData: RowData) => ( 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: () => ( thRender: () => (
<MemberHeaderColumn <MemberHeaderColumn
@ -83,7 +106,9 @@ export const useMemberColumns = () => {
key: "Email address", key: "Email address",
content: t("workspace_settings.settings.members.details.email_address"), content: t("workspace_settings.settings.members.details.email_address"),
tdRender: (rowData: RowData) => ( 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: () => ( thRender: () => (
<MemberHeaderColumn <MemberHeaderColumn
@ -109,12 +134,12 @@ export const useMemberColumns = () => {
{ {
key: "Authentication", key: "Authentication",
content: t("workspace_settings.settings.members.details.authentication"), content: <div className="min-w-[5.5rem] pr-3">Вход</div>,
tdRender: (rowData: RowData) => { tdRender: (rowData: RowData) => {
if (isSuspended(rowData)) return null; if (isSuspended(rowData)) return null;
const loginMedium = rowData.member.last_login_medium; const loginMedium = rowData.member.last_login_medium;
if (!loginMedium) return null; 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", key: "Joining date",
content: t("workspace_settings.settings.members.details.joining_date"), content: t("workspace_settings.settings.members.details.joining_date"),
tdRender: (rowData: RowData) => 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: () => ( thRender: () => (
<MemberHeaderColumn <MemberHeaderColumn
property="joining_date" 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 }; return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal };
}; };

View File

@ -6,19 +6,15 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { Controller, useForm } from "react-hook-form";
import { Disclosure } from "@headlessui/react";
// plane imports // plane imports
import { ROLE, EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants"; import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
import { TrashIcon, SuspendedUserIcon } from "@plane/propel/icons"; import { ChevronDownIcon, TrashIcon, SuspendedUserIcon } from "@plane/propel/icons";
import { Pill, EPillVariant, EPillSize } from "@plane/propel/pill"; import { Pill, EPillVariant, EPillSize } from "@plane/propel/pill";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IUser, IWorkspaceMember } from "@plane/types"; import type { IUser, IWorkspaceMember } from "@plane/types";
// plane ui
import { PopoverMenu } from "@plane/ui";
// helpers // helpers
import { getFileURL } from "@plane/utils"; import { cn, getFileURL } from "@plane/utils";
import { SelectionDropdown } from "@/components/common/selection-dropdown"; import { SelectionDropdown } from "@/components/common/selection-dropdown";
// hooks // hooks
import { useMember } from "@/hooks/store/use-member"; import { useMember } from "@/hooks/store/use-member";
@ -43,18 +39,26 @@ type AccountTypeProps = {
workspaceSlug: string; 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) { export function NameColumn(props: NameProps) {
const { rowData, workspaceSlug, isAdmin, currentUser, setRemoveMemberModal } = props; const { rowData, workspaceSlug } = props;
// derived values // derived values
const { avatar_url, display_name, email, first_name, id, last_name } = rowData.member; const { avatar_url, display_name, email, first_name, id, last_name } = rowData.member;
const isSuspended = rowData.is_active === false; const isSuspended = rowData.is_active === false;
const fullName = [first_name, last_name].filter(Boolean).join(" ") || display_name || email;
return ( return (
<Disclosure> <div className="flex w-max min-w-[8.5rem] items-center gap-x-2 gap-y-2 pr-3">
{() => (
<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 ? ( {isSuspended ? (
<div className="rounded-full bg-layer-1"> <div className="rounded-full bg-layer-1">
<SuspendedUserIcon className="size-6 text-placeholder" /> <SuspendedUserIcon className="size-6 text-placeholder" />
@ -76,50 +80,39 @@ export function NameColumn(props: NameProps) {
</span> </span>
</Link> </Link>
)} )}
<span className={isSuspended ? "text-placeholder" : ""}> <span className={cn(isSuspended ? "text-placeholder" : "")}>{fullName}</span>
{first_name} {last_name}
</span>
</div> </div>
);
}
{!isSuspended && (isAdmin || id === currentUser?.id) && ( export function WorkspaceMemberActionsColumn(props: NameProps) {
<PopoverMenu const { rowData, isAdmin, currentUser, setRemoveMemberModal } = props;
data={[""]} const { display_name, email, id } = rowData.member;
keyExtractor={(item) => item} const isSuspended = rowData.is_active === false;
popoverClassName="justify-end" const canRemoveMember = !isSuspended && (isAdmin || id === currentUser?.id);
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" const isCurrentUser = id === currentUser?.id;
render={() => ( const label = isCurrentUser ? "Покинуть workspace" : `Удалить ${display_name || email}`;
<div
role="button" return (
tabIndex={0} <div className="flex w-12 justify-end">
className="flex cursor-pointer items-center gap-x-3" {canRemoveMember && (
onClick={() => setRemoveMemberModal(rowData)} <button
onKeyDown={(e) => { aria-label={label}
if (e.key === "Enter" || e.key === " ") { className="nodedc-external-icon-button"
e.preventDefault();
setRemoveMemberModal(rowData);
}
}}
data-ph-element={MEMBER_TRACKER_ELEMENTS.WORKSPACE_MEMBER_TABLE_CONTEXT_MENU} 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 "} <TrashIcon className="h-3.5 w-3.5" />
</div> </button>
)}
/>
)} )}
</div> </div>
</div>
)}
</Disclosure>
); );
} }
export const AccountTypeColumn = observer(function AccountTypeColumn(props: AccountTypeProps) { export const AccountTypeColumn = observer(function AccountTypeColumn(props: AccountTypeProps) {
const { rowData, workspaceSlug } = props; const { rowData, workspaceSlug } = props;
// form info
const {
control,
formState: { errors },
} = useForm();
// store hooks // store hooks
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
@ -133,56 +126,52 @@ export const AccountTypeColumn = observer(function AccountTypeColumn(props: Acco
const isAdminRole = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const isAdminRole = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const isRoleNonEditable = isCurrentUser || !isAdminRole; const isRoleNonEditable = isCurrentUser || !isAdminRole;
const isSuspended = rowData.is_active === false; const isSuspended = rowData.is_active === false;
const roleLabel = getWorkspaceRoleLabel(rowData.role);
return ( return (
<> <>
{isSuspended ? ( {isSuspended ? (
<div className="flex w-32"> <div className="flex min-w-[9rem]">
<Pill variant={EPillVariant.DEFAULT} size={EPillSize.SM} className="border-none"> <Pill variant={EPillVariant.DEFAULT} size={EPillSize.SM} className="border-none">
Suspended Заблокирован
</Pill> </Pill>
</div> </div>
) : isRoleNonEditable ? ( ) : isRoleNonEditable ? (
<div className="flex w-32"> <div className="nodedc-settings-chip flex min-h-10 min-w-[9rem] items-center px-4 py-2 text-caption-sm-medium">
<span>{ROLE[rowData.role]}</span> <span className="truncate">{roleLabel}</span>
</div> </div>
) : ( ) : (
<Controller
name="role"
control={control}
rules={{ required: "Role is required." }}
render={({ field: { value } }) => (
<SelectionDropdown <SelectionDropdown
options={Object.keys(ROLE).map((item) => ({ dropdownClassName="!p-2"
key: item, dropdownContentClassName="!w-44"
title: ROLE[item as unknown as keyof typeof ROLE], options={WORKSPACE_ROLE_OPTIONS.map((role) => ({
isChecked: String(value) === item, key: String(role),
title: getWorkspaceRoleLabel(role),
isChecked: rowData.role === role,
onClick: async () => { onClick: async () => {
if (!workspaceSlug) return; if (!workspaceSlug || rowData.role === role) return;
try { try {
await updateMember(workspaceSlug.toString(), rowData.member.id, { await updateMember(workspaceSlug.toString(), rowData.member.id, { role });
role: item as unknown as EUserPermissions,
});
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { error?: string | string[] }; const error = err as { error?: string | string[] };
const errorString = Array.isArray(error?.error) ? error.error[0] : error?.error; const errorString = Array.isArray(error?.error) ? error.error[0] : error?.error;
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: "Ошибка",
message: errorString ?? "An error occurred while updating member role. Please try again.", message: errorString ?? "Не удалось обновить роль участника. Попробуйте ещё раз.",
}); });
} }
}, },
}))} }))}
menuButton={ menuButton={({ open }) => (
<div className="flex"> <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>{ROLE[rowData.role]}</span> <span className="truncate">{roleLabel}</span>
<ChevronDownIcon className={cn("h-3 w-3 transition-transform", open ? "rotate-180" : "")} />
</div> </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 />; if (isEmpty(columns)) return <MembersLayoutLoader />;
return ( 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 && ( {removeMemberModal && (
<ConfirmWorkspaceMemberRemove <ConfirmWorkspaceMemberRemove
isOpen={removeMemberModal.member.id.length > 0} 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[] (memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as unknown as RowData[]
} }
keyExtractor={(rowData) => rowData?.member.id ?? ""} 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" tHeadClassName="border-b border-white/6"
thClassName="text-left font-medium divide-x-0 text-placeholder" thClassName="text-left font-medium divide-x-0 text-placeholder"
tBodyClassName="divide-y-0" tBodyClassName="divide-y-0"

View File

@ -2094,6 +2094,27 @@
-webkit-backdrop-filter: blur(18px); -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 { .nodedc-settings-sidebar-shell {
border: 0 !important; border: 0 !important;
outline: none !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)}> <thead className={cn("divide-y divide-subtle", tHeadClassName)}>
<tr className={cn("divide-x divide-subtle text-13 text-primary", tHeadTrClassName)}> <tr className={cn("divide-x divide-subtle text-13 text-primary", tHeadTrClassName)}>
{columns.map((column) => ( {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} {(column?.thRender && column?.thRender()) || column.content}
</th> </th>
))} ))}
@ -42,7 +42,14 @@ export function Table<T>(props: TTableData<T>) {
className={cn("divide-x divide-subtle text-13 text-secondary", tBodyTrClassName)} className={cn("divide-x divide-subtle text-13 text-secondary", tBodyTrClassName)}
> >
{columns.map((column) => ( {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)} {column.tdRender(item)}
</td> </td>
))} ))}

View File

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