From e9a46f885ffb433267a677b49c227e096a482827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D1=80=D0=BE=D0=BB=D0=B5=D0=B2=20=D0=94=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=BB?= <105650840+pan1caisreal@users.noreply.github.com> Date: Thu, 8 Aug 2024 10:41:11 +0300 Subject: [PATCH] =?UTF-8?q?feat(UIKIT-1607,Profile):=20=D0=94=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B0=D0=B4=D0=B0=D0=BF?= =?UTF-8?q?=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BC=D0=B5=D0=BD=D1=8E=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8F=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=BC=D0=BE=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2=20(#1076?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Andrey Potyomkin --- .../DashboardLayout.stories.tsx | 185 ++++++-- .../DashboardLayout/Header/Header.stories.tsx | 153 ++++--- .../DashboardLayout/Header/Header.test.tsx | 86 ++++ .../src/DashboardLayout/Header/Header.tsx | 15 +- .../src/DashboardLayout/Header/styles.tsx | 23 +- .../DashboardLayout/Header/useLogic/index.ts | 1 + .../Header/useLogic/useLogic.ts | 19 + .../src/Profile/MenuList/MenuList.tsx | 71 +++ .../components/src/Profile/MenuList/index.ts | 1 + .../components/src/Profile/MenuList/styles.ts | 18 + .../src/Profile/Profile.stories.tsx | 415 ++++++++---------- .../components/src/Profile/Profile.test.tsx | 168 +++++++ packages/components/src/Profile/Profile.tsx | 112 ++++- 13 files changed, 933 insertions(+), 334 deletions(-) create mode 100644 packages/components/src/DashboardLayout/Header/Header.test.tsx create mode 100644 packages/components/src/DashboardLayout/Header/useLogic/index.ts create mode 100644 packages/components/src/DashboardLayout/Header/useLogic/useLogic.ts create mode 100644 packages/components/src/Profile/MenuList/MenuList.tsx create mode 100644 packages/components/src/Profile/MenuList/index.ts create mode 100644 packages/components/src/Profile/MenuList/styles.ts create mode 100644 packages/components/src/Profile/Profile.test.tsx diff --git a/packages/components/src/DashboardLayout/DashboardLayout.stories.tsx b/packages/components/src/DashboardLayout/DashboardLayout.stories.tsx index 6d992e446..84464cb81 100644 --- a/packages/components/src/DashboardLayout/DashboardLayout.stories.tsx +++ b/packages/components/src/DashboardLayout/DashboardLayout.stories.tsx @@ -1,23 +1,16 @@ import { type MouseEvent, type ReactElement, forwardRef } from 'react'; -import { Box } from '@mui/material'; import { AddOutlineMd, CompanyOutlineMd, ProfileOutlineMd, - QuitOutlineMd, } from '@astral/icons'; import { type Meta } from '@storybook/react'; -import { Divider } from '../Divider'; -import { ListItemIcon } from '../ListItemIcon'; -import { ListItemText } from '../ListItemText'; -import { Menu } from '../Menu'; -import { MenuItem } from '../MenuItem'; import { ProductSwitcher } from '../ProductSwitcher'; import { Placeholder } from '../Placeholder'; import { PageLayout } from '../PageLayout'; import { handleGetProducts } from '../ProductSwitcher/ProductSwitcher.stub'; -import { styled } from '../styles/styled'; +import { styled } from '../styles'; import { DashboardLayout } from './DashboardLayout'; import { SidebarButton } from './Sidebar'; @@ -90,16 +83,164 @@ const DashboardLayoutWrapper = styled.div` `; export const Example = () => { + const menuList = [ + { + icon: , + title: 'Мой профиль', + onClick: () => console.log('Мой профиль'), + }, + ]; + + return ( + + + { + return ; + }} + product={{ + name: 'Астрал.ЭДО', + logo() { + return ; + }, + }} + profile={{ + displayName: 'Григорьев Виталий', + annotation: 'vitatiy_grig@mail.ru', + avatar: { + alt: 'Григорьев Виталий', + children: 'ГВ', + }, + menuList: menuList, + exitButton: { onClick: () => console.log('Выход') }, + }} + /> + }> + Добавить документ + + } + menu={{ + items: [ + [ + 'documents', + { + icon: , + text: 'Документы', + items: [ + [ + 'incoming-documents', + { + text: 'Входящие документы', + active: true, + component: forwardRef((props, ref) => { + return ( + + ); + }), + }, + ], + [ + 'outgoing-documents', + { + text: 'Исходящие документы', + active: false, + component: forwardRef((props, ref) => { + return ( + + ); + }), + }, + ], + ], + }, + ], + [ + 'counterparties', + { + icon: , + text: 'Контрагенты', + items: [ + [ + 'invitations', + { + text: 'Приглашения', + active: false, + component: forwardRef((props, ref) => { + return ( + + ); + }), + }, + ], + ], + }, + ], + [ + 'organizations', + { + icon: , + text: 'Мои организации', + active: true, + component: forwardRef((props, ref) => { + return ( + + ); + }), + }, + ], + ], + }} + /> + + , + }, + ], + secondary: [ + { + text: 'Кнопка', + }, + ], + }, + }} + content={{ + children: , + isPaddingDisabled: false, + }} + /> + + + + ); +}; + +export const ExitButton = () => { return ( { - return ( - - - - ); + return ; }} product={{ name: 'Астрал.ЭДО', @@ -114,23 +255,7 @@ export const Example = () => { alt: 'Григорьев Виталий', children: 'ГВ', }, - menu: (props) => ( - - - - - - Мой профиль - - - - - - - Выйти - - - ), + exitButton: { onClick: () => console.log('Выход') }, }} /> , + title: 'Мой профиль', + onClick: () => console.log('Мой профиль'), + }, +]; + export const Interaction: Story = { render: (args) => { return ( @@ -69,11 +76,7 @@ export const Interaction: Story = { }, args: { productSwitcher: () => { - return ( - - - - ); + return ; }, product: { name: 'Астрал.ЭДО', @@ -88,23 +91,8 @@ export const Interaction: Story = { alt: 'Григорьев Виталий', children: 'ГВ', }, - menu: (props) => ( - - - - - - Мой профиль - - - - - - - Выйти - - - ), + menuList: FAKE_MENU_LIST, + exitButton: { onClick: () => console.log('Выход') }, }, }, parameters: { @@ -115,16 +103,20 @@ export const Interaction: Story = { }; export const Example = () => { + const menuList = [ + { + icon: , + title: 'Мой профиль', + onClick: () => console.log('Мой профиль'), + }, + ]; + return ( { - return ( - - - - ); + return ; }} product={{ name: 'Астрал.ЭДО', @@ -139,23 +131,40 @@ export const Example = () => { alt: 'Григорьев Виталий', children: 'ГВ', }, - menu: (props) => ( - - - - - - Мой профиль - - - - - - - Выйти - - - ), + menuList: menuList, + exitButton: { onClick: () => console.log('Выход') }, + }} + /> + + + + ); +}; + +export const ExitButton = () => { + const onExitClick = () => console.log('Выход'); + + return ( + + + { + return ; + }} + product={{ + name: 'Астрал.ЭДО', + logo() { + return ; + }, + }} + profile={{ + displayName: 'Григорьев Виталий', + annotation: 'vitatiy_grig@mail.ru', + avatar: { + alt: 'Григорьев Виталий', + children: 'ГВ', + }, + exitButton: { onClick: onExitClick }, }} /> @@ -188,11 +197,7 @@ export const ProductSwitcherProps = () => { { - return ( - - - - ); + return ; }} product={{ name: 'Астрал.ЭДО', @@ -208,6 +213,42 @@ export const ProductSwitcherProps = () => { }; export const Profile = () => { + const menuList = [ + { + icon: , + title: 'Мой профиль', + onClick: () => console.log('Мой профиль'), + }, + ]; + + return ( + + + ; + }, + }} + profile={{ + displayName: 'Григорьев Виталий', + annotation: 'vitatiy_grig@mail.ru', + avatar: { + alt: 'Григорьев Виталий', + children: 'ГВ', + }, + menuList: menuList, + exitButton: { onClick: () => console.log('Выход') }, + }} + /> + + + + ); +}; + +export const ProfileCustomMenu = () => { return ( diff --git a/packages/components/src/DashboardLayout/Header/Header.test.tsx b/packages/components/src/DashboardLayout/Header/Header.test.tsx new file mode 100644 index 000000000..cb5838e11 --- /dev/null +++ b/packages/components/src/DashboardLayout/Header/Header.test.tsx @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderWithTheme, screen } from '@astral/tests'; + +import DashboardLayout from '../DashboardLayout'; + +describe('Header', () => { + it('Profile отображается', () => { + const fakeDisplayName = 'Григорьев Виталий'; + + renderWithTheme( + + ; + }, + }} + profile={{ + displayName: fakeDisplayName, + annotation: 'vitatiy_grig@mail.ru', + avatar: { + alt: 'Григорьев Виталий', + children: 'ГВ', + }, + }} + /> + , + ); + + const profile = screen.getByText(fakeDisplayName); + + expect(profile).toBeVisible(); + }); + + it('ExitButton не отображается на desktop версии', () => { + const fakeDisplayName = 'Григорьев Виталий'; + + const onClickSpy = vi.fn; + + renderWithTheme( + + ; + }, + }} + profile={{ + displayName: fakeDisplayName, + annotation: 'vitatiy_grig@mail.ru', + avatar: { + alt: 'Григорьев Виталий', + children: 'ГВ', + }, + exitButton: { onClick: onClickSpy }, + }} + /> + , + ); + + const exitButton = screen.getByTitle('Выход'); + + expect(exitButton).not.toBeVisible(); + }); + + it('Product отображается', () => { + renderWithTheme( + + ; + }, + }} + /> + , + ); + + const product = screen.getByText('Астрал.ЭДО'); + + expect(product).toBeVisible(); + }); +}); diff --git a/packages/components/src/DashboardLayout/Header/Header.tsx b/packages/components/src/DashboardLayout/Header/Header.tsx index 909f19862..f474e4c56 100644 --- a/packages/components/src/DashboardLayout/Header/Header.tsx +++ b/packages/components/src/DashboardLayout/Header/Header.tsx @@ -4,6 +4,7 @@ import { forwardRef, useContext, } from 'react'; +import { QuitOutlineMd } from '@astral/icons'; import { useViewportType } from '../../hooks/useViewportType'; import { DashboardSidebarContext } from '../../DashboardSidebarProvider'; @@ -13,11 +14,13 @@ import { type ProfileProps } from '../../Profile'; import { SidebarToggler } from '../SidebarToggler'; import { + ExitButton, HeaderRoot, HeaderSection, ProfileWrapper, SidebarTogglerWrapper, } from './styles'; +import { useLogic } from './useLogic'; export type HeaderProps = { product: ProductProps; @@ -34,6 +37,8 @@ export const Header = forwardRef((props, ref) => { children, } = props; + const { isShowExitButton, isShowProfile } = useLogic(props); + const { collapsedIn, onToggleSidebar } = useContext(DashboardSidebarContext); const { isMobile } = useViewportType(); @@ -57,10 +62,18 @@ export const Header = forwardRef((props, ref) => { {children} {profile && ( - + )} + + + ); diff --git a/packages/components/src/DashboardLayout/Header/styles.tsx b/packages/components/src/DashboardLayout/Header/styles.tsx index 69cdc90eb..f4cdb0c3b 100644 --- a/packages/components/src/DashboardLayout/Header/styles.tsx +++ b/packages/components/src/DashboardLayout/Header/styles.tsx @@ -1,4 +1,5 @@ import { styled } from '../../styles'; +import { IconButton } from '../../IconButton'; export const HeaderRoot = styled.header` z-index: ${({ theme }) => theme.zIndex.appBar}; @@ -39,11 +40,29 @@ export const HeaderSection = styled.div` } `; -export const ProfileWrapper = styled.div` +export const ProfileWrapper = styled('div', { + shouldForwardProp: (prop) => !['$isShow'].includes(prop), +})<{ + $isShow: boolean; +}>` display: contents; ${({ theme }) => theme.breakpoints.down('sm')} { - display: block; + display: ${({ $isShow }) => ($isShow ? 'block' : 'none')}; + + margin-left: auto; + } +`; + +export const ExitButton = styled(IconButton, { + shouldForwardProp: (prop) => !['$isShow'].includes(prop), +})<{ + $isShow: boolean; +}>` + display: none; + + ${({ theme }) => theme.breakpoints.down('sm')} { + display: ${({ $isShow }) => (!$isShow ? 'none' : 'flex')}; margin-left: auto; } diff --git a/packages/components/src/DashboardLayout/Header/useLogic/index.ts b/packages/components/src/DashboardLayout/Header/useLogic/index.ts new file mode 100644 index 000000000..51786a09c --- /dev/null +++ b/packages/components/src/DashboardLayout/Header/useLogic/index.ts @@ -0,0 +1 @@ +export * from './useLogic'; diff --git a/packages/components/src/DashboardLayout/Header/useLogic/useLogic.ts b/packages/components/src/DashboardLayout/Header/useLogic/useLogic.ts new file mode 100644 index 000000000..2f7e833a7 --- /dev/null +++ b/packages/components/src/DashboardLayout/Header/useLogic/useLogic.ts @@ -0,0 +1,19 @@ +import { type HeaderProps } from '../Header'; +import { useViewportType } from '../../../hooks/useViewportType'; + +type UseLogicParams = HeaderProps; + +export const useLogic = ({ profile }: UseLogicParams) => { + const { isMobile } = useViewportType(); + + const isShowProfile = + (Boolean(profile) && !isMobile) || + (Boolean(profile?.menu || profile?.menuList) && isMobile); + + const isShowExitButton = + isMobile && + !Boolean(profile?.menu || profile?.menuList) && + Boolean(profile?.exitButton); + + return { isShowExitButton, isShowProfile }; +}; diff --git a/packages/components/src/Profile/MenuList/MenuList.tsx b/packages/components/src/Profile/MenuList/MenuList.tsx new file mode 100644 index 000000000..5af6de2c5 --- /dev/null +++ b/packages/components/src/Profile/MenuList/MenuList.tsx @@ -0,0 +1,71 @@ +import { type MenuProps as MuiMenuProps } from '@mui/material'; +import { QuitOutlineMd } from '@astral/icons'; + +import { type ProfileMenuItemData } from '../Profile'; +import { type WithoutEmotionSpecific } from '../../types'; +import { useViewportType } from '../../hooks/useViewportType'; +import { BottomDrawer } from '../../BottomDrawer'; +import { MenuItem as StyledMenuItem } from '../../MenuItem'; +import { ListItemIcon } from '../../ListItemIcon'; +import { ListItemText } from '../../ListItemText'; +import { OverflowTypography } from '../../OverflowTypography'; +import { Divider } from '../../Divider'; +import { MenuList as StyledMenuList } from '../../MenuList'; + +import { ExitMenuItem, StyledMenu } from './styles'; + +type MenuListProps = WithoutEmotionSpecific & { + menuList?: Array; + exitButton?: { onClick: () => void }; +}; + +/** + * Компонент для рендера menu с помощью массива данных + */ +export const MenuList = (props: MenuListProps) => { + const { open, onClose, menuList, exitButton, ...restProps } = props; + + const { isMobile } = useViewportType(); + const renderMenuList = () => ( + <> + {menuList?.map(({ render, icon, title, onClick }) => + render ? ( + render({ icon, title, onClick }) + ) : ( + + {icon} + + {title} + + + ), + )} + {exitButton && } + + + + + + Выйти + + + + ); + + if (isMobile) { + return ( + + {renderMenuList()} + + ); + } + + return ( + + {renderMenuList()} + + ); +}; diff --git a/packages/components/src/Profile/MenuList/index.ts b/packages/components/src/Profile/MenuList/index.ts new file mode 100644 index 000000000..81a5e2830 --- /dev/null +++ b/packages/components/src/Profile/MenuList/index.ts @@ -0,0 +1 @@ +export * from './MenuList'; diff --git a/packages/components/src/Profile/MenuList/styles.ts b/packages/components/src/Profile/MenuList/styles.ts new file mode 100644 index 000000000..694b40bc7 --- /dev/null +++ b/packages/components/src/Profile/MenuList/styles.ts @@ -0,0 +1,18 @@ +import { menuClasses } from '@mui/material'; + +import { styled } from '../../styles'; +import { Menu } from '../../Menu'; +import { MenuItem } from '../../MenuItem'; + +export const StyledMenu = styled(Menu)` + & .${menuClasses.paper} { + min-width: 200px; + max-width: 300px; + } +`; + +export const ExitMenuItem = styled(MenuItem, { + shouldForwardProp: (prop) => !['$exitButton'].includes(prop), +})<{ $exitButton: boolean }>` + display: ${({ $exitButton }) => $exitButton === false && 'none'}; +`; diff --git a/packages/components/src/Profile/Profile.stories.tsx b/packages/components/src/Profile/Profile.stories.tsx index 5961c3426..07ad180c6 100644 --- a/packages/components/src/Profile/Profile.stories.tsx +++ b/packages/components/src/Profile/Profile.stories.tsx @@ -1,5 +1,4 @@ import { type Meta, type StoryObj } from '@storybook/react'; -import { Box, Stack } from '@mui/material'; import { CompanyOutlineMd, ProfileOutlineMd, @@ -13,8 +12,10 @@ import { MenuItem } from '../MenuItem'; import { Divider } from '../Divider'; import { ListItemText } from '../ListItemText'; import { OverflowTypography } from '../OverflowTypography'; +import { Typography } from '../Typography'; +import { IconButton } from '../IconButton'; -import { Profile } from './Profile'; +import { Profile, type ProfileMenuItemData } from './Profile'; /** * ### [Figma](https://www.figma.com/design/3ghN4WjSgkKx5rETR64jqh/Sirius-Design-System-(%D0%90%D0%9A%D0%A2%D0%A3%D0%90%D0%9B%D0%AC%D0%9D%D0%9E)?node-id=17119-17523) @@ -28,6 +29,14 @@ const meta: Meta = { export default meta; +const FAKE_MENU_LIST = [ + { + icon: , + title: 'Мой профиль', + onClick: () => console.log('Мой профиль'), + }, +]; + type Story = StoryObj; export const Interaction: Story = { @@ -38,43 +47,7 @@ export const Interaction: Story = { alt: 'Иванов Иван', children: 'ИИ', }, - menu: (props) => ( - - - - - - - Мой профиль - - - - - - - - Мои организации - - - - - - - - Настройки - - - - - - - - - Выйти - - - - ), + menuList: FAKE_MENU_LIST, }, parameters: { docs: { @@ -84,204 +57,202 @@ export const Interaction: Story = { }; export const Example = () => { + const menuList = [ + { + icon: , + title: 'Мой профиль', + onClick: () => console.log('Мой профиль'), + }, + { icon: , title: 'Мои организации' }, + { icon: , title: 'Настройки' }, + ]; + + return ; +}; + +/** + * prop ```menu``` кастомный рендер menu. Перекрывает menuList и exitButton + */ + +export const CustomMenu = () => { + return ( + ( + + + + + + + Мой профиль + + + + + + + + Мои организации + + + + + + + + Настройки + + + + + + + + + Выйти + + + + )} + /> + ); +}; + +/** + * prop ```exitButton``` объект, который добавляет кнопку выхода и действие на нее + */ + +export const ExitButton = () => { + const menuList = [ + { + icon: , + title: 'Мой профиль', + onClick: () => console.log('Мой профиль'), + }, + { icon: , title: 'Мои организации' }, + { icon: , title: 'Настройки' }, + ]; + const exitButton = { + onClick: () => console.log('Выход'), + }; + + return ( + + ); +}; + +/** + * prop ```render``` позволяет задать свое отображение для MenuItem + */ + +export const RenderCustomItem = () => { + const renderItem: ProfileMenuItemData['render'] = ({ title, icon }) => ( + + + {title} + + {icon} + + ); + const menuList = [ + { + icon: , + title: 'Мой профиль', + onClick: () => console.log('Мой профиль'), + render: renderItem, + }, + { + icon: , + title: 'Мои организации', + render: renderItem, + }, + { icon: , title: 'Настройки' }, + ]; + + const exitButton = { + onClick: () => console.log('Выход'), + }; + return ( - - ( - - - - - - - Мой профиль - - - - - - - - Мои организации - - - - - - - - Настройки - - - - - - - - - Выйти - - - - )} - /> - + ); }; export const WithAvatar = () => { + const menuList = [ + { + icon: , + title: 'Мой профиль', + onClick: () => console.log('Мой профиль'), + }, + { icon: , title: 'Мои организации' }, + { icon: , title: 'Настройки' }, + ]; + return ( - - ( - - - - - - - Мой профиль - - - - - - - - Мои организации - - - - - - - - Настройки - - - - - - - - - Выйти - - - - )} - /> - + ); }; export const WithAnnotation = () => { + const menuList = [ + { + icon: , + title: 'Мой профиль', + onClick: () => console.log('Мой профиль'), + }, + { icon: , title: 'Мои организации' }, + { icon: , title: 'Настройки' }, + ]; + return ( - - ( - - - - - - - Мой профиль - - - - - - - - Мои организации - - - - - - - - Настройки - - - - - - - - - Выйти - - - - )} - /> - + ); }; export const TotalOverflow = () => { - return ( - - - ( - - - - - - - Мой профиль - - - - - - - - - Мои организации с излишним количеством текста - - - - - - - + const menuList = [ + { + icon: , + title: 'Мой профиль', + onClick: () => console.log('Мой профиль'), + }, + { + icon: , + title: 'Мои организации с излишним количеством текста', + }, + { icon: , title: 'Настройки' }, + ]; - - Настройки - - - - - - - - - Выйти - - - - )} - /> - - + return ( + ); }; diff --git a/packages/components/src/Profile/Profile.test.tsx b/packages/components/src/Profile/Profile.test.tsx new file mode 100644 index 000000000..6fcd5f768 --- /dev/null +++ b/packages/components/src/Profile/Profile.test.tsx @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + CompanyOutlineMd, + ProfileOutlineMd, + SettingsFillMd, +} from '@astral/icons'; +import { renderWithTheme, screen, userEvents } from '@astral/tests'; + +import { MenuItem } from '../MenuItem'; +import { ListItemIcon } from '../ListItemIcon'; +import { ListItemText } from '../ListItemText'; +import { OverflowTypography } from '../OverflowTypography'; +import { Menu } from '../Menu'; +import { Typography } from '../Typography'; +import { IconButton } from '../IconButton'; + +import Profile, { type ProfileMenuItemData } from './Profile'; + +describe('Profile', () => { + const FAKE_MENU_LIST = [ + { + icon: , + title: 'Мой профиль', + onClick: () => console.log('Мой профиль'), + }, + { icon: , title: 'Мои организации' }, + { icon: , title: 'Настройки' }, + ]; + + it('Profile отображается', () => { + const displayName = 'Григорьев Виталий'; + + renderWithTheme(); + + const profile = screen.getByText(displayName); + + expect(profile).toBeVisible(); + }); + + it('Menu отображается при клике на Profile, если передан menuList', async () => { + const fakeDisplayName = 'Григорьев Виталий'; + + renderWithTheme( + , + ); + + const profile = screen.getByText(fakeDisplayName); + + await userEvents.click(profile); + + const menuItem = await screen.findByText('Мой профиль'); + + expect(menuItem).toBeVisible(); + }); + + it('Menu отображается при клике на Profile, если передан menu', async () => { + const fakeDisplayName = 'Григорьев Виталий'; + + renderWithTheme( + ( + + + + + + + Мой профиль + + + + )} + />, + ); + + const profile = screen.getByText(fakeDisplayName); + + await userEvents.click(profile); + + const menuItem = await screen.findByText('Мой профиль'); + + expect(menuItem).toBeVisible(); + }); + + it('Аннотация отображается', () => { + const fakeDisplayName = 'Григорьев Виталий'; + const fakeAnnotation = 'vitatiy_grig@mail.ru'; + + renderWithTheme( + , + ); + + const annotation = screen.getByText(fakeAnnotation); + + expect(annotation).toBeVisible(); + }); + + it('Render применяется к элементам меню', async () => { + const fakeDisplayName = 'Григорьев Виталий'; + const renderItem: ProfileMenuItemData['render'] = ({ title, icon }) => ( + + + #{title} + + {icon} + + ); + + const fakeMenuList = [ + { + icon: , + title: 'Мой профиль', + onClick: () => console.log('Мой профиль'), + render: renderItem, + }, + ]; + + renderWithTheme( + , + ); + + const profile = screen.getByText(fakeDisplayName); + + await userEvents.click(profile); + + const customElement = await screen.findByText('#Мой профиль'); + + expect(customElement).toBeVisible(); + }); + + it('Кнопка exit отображается если передан exitButton', async () => { + const fakeDisplayName = 'Григорьев Виталий'; + + const onClickSpy = vi.fn; + + renderWithTheme( + , + ); + + const profile = screen.getByText(fakeDisplayName); + + await userEvents.click(profile); + + const exitButton = await screen.findByText('Выйти'); + + expect(exitButton).toBeVisible(); + }); + + it('Кнопка exit не отображается если не передан exitButton', async () => { + const fakeDisplayName = 'Григорьев Виталий'; + + renderWithTheme( + , + ); + + const profile = screen.getByText(fakeDisplayName); + + await userEvents.click(profile); + + const exitButton = screen.queryByRole('button', { name: 'Выйти' }); + + expect(exitButton).toBeNull(); + }); +}); diff --git a/packages/components/src/Profile/Profile.tsx b/packages/components/src/Profile/Profile.tsx index f190df154..8325048a0 100644 --- a/packages/components/src/Profile/Profile.tsx +++ b/packages/components/src/Profile/Profile.tsx @@ -1,4 +1,10 @@ -import { type PropsWithChildren, forwardRef } from 'react'; +import { + type FunctionComponent, + type PropsWithChildren, + type ReactNode, + forwardRef, + useEffect, +} from 'react'; import { type AvatarProps, ClickAwayListener, @@ -10,6 +16,7 @@ import { Chevron } from '../Chevron'; import { useViewportType } from '../hooks/useViewportType'; import { type WithoutEmotionSpecific } from '../types'; +import { MenuList } from './MenuList'; import { ProfileAnnotation, ProfileAvatar, @@ -19,6 +26,24 @@ import { ProfileUser, } from './styles'; +export type ProfileMenuItemData = { + icon: ReactNode; + title: ReactNode; + onClick?: () => void; + /** + * @example + * const renderItem: ProfileMenuItemData['render'] = ({ title, icon }) => ( + * + * + * {title} + * + * {icon} + * + * ); + */ + render?: FunctionComponent>; +}; + export type ProfileProps = { /** * Имя профиля @@ -33,20 +58,43 @@ export type ProfileProps = { */ avatar?: AvatarProps; /** - * Выпадающее меню + * Кастомный рендер menu. Перекрывает menuList и exitButton */ - menu: ( + menu?: ( props: PropsWithChildren>, ) => JSX.Element; + /** + * Рендер menu через массив данных. Перекрывает menu и может использоваться с exitButton + */ + menuList?: Array; + /** + * Отображение кнопки выхода и действие на нее + */ + exitButton?: { onClick: () => void }; }; export const Profile = forwardRef( (props, ref) => { - const { displayName, annotation, avatar = {}, menu: Menu } = props; + const { + displayName, + annotation, + avatar = {}, + menu: Menu, + menuList, + exitButton, + } = props; const { open, anchorRef, handleOpenMenu, handleCloseMenu } = useMenu(); const { isMobile } = useViewportType(); + useEffect(() => { + if (!Menu && !menuList && !exitButton) { + console.error( + 'Profile должен иметь один из следующих props: menu, menuList, exitButton', + ); + } + }, []); + return ( <> @@ -66,25 +114,43 @@ export const Profile = forwardRef( {!isMobile && } - + {Menu ? ( + + ) : ( + + )} ); },