From bf75ce84eb793091cd561f30c1d4586ef7467237 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 10 May 2026 11:39:45 +0300 Subject: [PATCH] =?UTF-8?q?UI=20-=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E?= =?UTF-8?q?=D0=95=D0=9A=D0=A2=D0=9D=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C?= =?UTF-8?q?=D0=A3=D0=9D=D0=98=D0=9A=D0=90=D0=A6=D0=98=D0=AF:=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=B4=D0=B0=D1=80=D1=82=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B?= =?UTF-8?q?=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2?= =?UTF-8?q?=20workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/settings/useMemberColumns.tsx | 70 ++++-- .../workspace/settings/member-columns.tsx | 205 +++++++++--------- .../workspace/settings/members-list-item.tsx | 3 +- plane-src/apps/web/styles/globals.css | 21 ++ plane-src/packages/ui/src/tables/table.tsx | 11 +- plane-src/packages/ui/src/tables/types.ts | 10 +- 6 files changed, 191 insertions(+), 129 deletions(-) diff --git a/plane-src/apps/web/ce/components/workspace/settings/useMemberColumns.tsx b/plane-src/apps/web/ce/components/workspace/settings/useMemberColumns.tsx index bf0d188..4533bb3 100644 --- a/plane-src/apps/web/ce/components/workspace/settings/useMemberColumns.tsx +++ b/plane-src/apps/web/ce/components/workspace/settings/useMemberColumns.tsx @@ -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 = { + 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(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) => { 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: () => ( - +
+ +
), tdRender: (rowData: RowData) => ( { key: "Display name", content: t("workspace_settings.settings.members.details.display_name"), tdRender: (rowData: RowData) => ( -
{rowData.member.display_name}
+
+ {rowData.member.display_name} +
), thRender: () => ( { key: "Email address", content: t("workspace_settings.settings.members.details.email_address"), tdRender: (rowData: RowData) => ( -
{rowData.member.email}
+
+ {rowData.member.email} +
), thRender: () => ( { { key: "Authentication", - content: t("workspace_settings.settings.members.details.authentication"), + content:
Вход
, tdRender: (rowData: RowData) => { if (isSuspended(rowData)) return null; const loginMedium = rowData.member.last_login_medium; if (!loginMedium) return null; - return
{LOGIN_MEDIUM_LABELS[loginMedium]}
; + return
{getLoginMediumLabel(loginMedium)}
; }, }, @@ -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 :
{renderFormattedDate(rowData?.member?.joining_date)}
, + isSuspended(rowData) ? null : ( +
{renderFormattedDate(rowData?.member?.joining_date)}
+ ), thRender: () => ( { /> ), }, + { + key: "Actions", + content: Действия, + tdRender: (rowData: RowData) => ( + + ), + }, ]; return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal }; }; diff --git a/plane-src/apps/web/core/components/workspace/settings/member-columns.tsx b/plane-src/apps/web/core/components/workspace/settings/member-columns.tsx index 5534a2a..febc4a0 100644 --- a/plane-src/apps/web/core/components/workspace/settings/member-columns.tsx +++ b/plane-src/apps/web/core/components/workspace/settings/member-columns.tsx @@ -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.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 ( - - {() => ( -
-
-
- {isSuspended ? ( -
- -
- ) : avatar_url && avatar_url.trim() !== "" ? ( - - - {display_name - - - ) : ( - - - {(email ?? display_name ?? "?")[0]} - - - )} - - {first_name} {last_name} - -
- - {!isSuspended && (isAdmin || 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)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setRemoveMemberModal(rowData); - } - }} - data-ph-element={MEMBER_TRACKER_ELEMENTS.WORKSPACE_MEMBER_TABLE_CONTEXT_MENU} - > - {id === currentUser?.id ? "Leave " : "Remove "} -
- )} - /> - )} -
+
+ {isSuspended ? ( +
+
+ ) : avatar_url && avatar_url.trim() !== "" ? ( + + + {display_name + + + ) : ( + + + {(email ?? display_name ?? "?")[0]} + + )} - + {fullName} +
+ ); +} + +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 ( +
+ {canRemoveMember && ( + + )} +
); } 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 ? ( -
+
- Suspended + Заблокирован
) : isRoleNonEditable ? ( -
- {ROLE[rowData.role]} +
+ {roleLabel}
) : ( - ( - ({ - 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; + ({ + 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={ -
- {ROLE[rowData.role]} -
+ 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 }) => ( +
+ {roleLabel} + +
)} + menuButtonWrapperClassName="flex rounded-[1.25rem] border-0 outline-none" + placement="bottom-end" /> )} diff --git a/plane-src/apps/web/core/components/workspace/settings/members-list-item.tsx b/plane-src/apps/web/core/components/workspace/settings/members-list-item.tsx index cdb179f..ad44e31 100644 --- a/plane-src/apps/web/core/components/workspace/settings/members-list-item.tsx +++ b/plane-src/apps/web/core/components/workspace/settings/members-list-item.tsx @@ -91,7 +91,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt if (isEmpty(columns)) return ; return ( -
+
{removeMemberModal && ( 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" diff --git a/plane-src/apps/web/styles/globals.css b/plane-src/apps/web/styles/globals.css index 40fc544..7bcd12d 100644 --- a/plane-src/apps/web/styles/globals.css +++ b/plane-src/apps/web/styles/globals.css @@ -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; diff --git a/plane-src/packages/ui/src/tables/table.tsx b/plane-src/packages/ui/src/tables/table.tsx index 04ebaab..1749860 100644 --- a/plane-src/packages/ui/src/tables/table.tsx +++ b/plane-src/packages/ui/src/tables/table.tsx @@ -29,7 +29,7 @@ export function Table(props: TTableData) { {columns.map((column) => ( - + {(column?.thRender && column?.thRender()) || column.content} ))} @@ -42,7 +42,14 @@ export function Table(props: TTableData) { className={cn("divide-x divide-subtle text-13 text-secondary", tBodyTrClassName)} > {columns.map((column) => ( - + {column.tdRender(item)} ))} diff --git a/plane-src/packages/ui/src/tables/types.ts b/plane-src/packages/ui/src/tables/types.ts index b43882f..476af55 100644 --- a/plane-src/packages/ui/src/tables/types.ts +++ b/plane-src/packages/ui/src/tables/types.ts @@ -4,11 +4,15 @@ * See the LICENSE file for details. */ +import type { ReactNode } from "react"; + export type TTableColumn = { 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 = {