UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: стандартизация таблицы участников workspace
This commit is contained in:
parent
3dd99491a4
commit
bf75ce84eb
|
|
@ -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: () => (
|
||||
<MemberHeaderColumn
|
||||
property="full_name"
|
||||
displayFilters={filters}
|
||||
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
|
||||
/>
|
||||
<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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue