From 19cf54f8a59a691a5bb5d93d609e1b6a8af032cc 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: Fri, 23 Aug 2024 17:01:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(UIKIT-375,NewDataGrid):=20=D0=A0=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D0=BF=D1=80=D0=B8=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D0=B9=20(#1100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ooops_o_O <47597207+mfrolov89@users.noreply.github.com> --- .cspell-ignore | 1 + .../src/ActionCell/ActionCell.stories.tsx | 6 +- .../src/ActionCell/ActionCell.test.tsx | 2 +- .../components/src/ActionCell/ActionCell.tsx | 9 +- packages/components/src/ActionCell/types.ts | 17 +- .../src/ActionCell/useLogic/useLogic.ts | 8 +- .../NewActionCell/MainAction/MainAction.tsx | 95 ++++++++ .../src/NewActionCell/MainAction/index.ts | 1 + .../NewActionCell/NewActionCell.stories.tsx | 206 ++++++++++++++++++ .../src/NewActionCell/NewActionCell.test.tsx | 141 ++++++++++++ .../src/NewActionCell/NewActionCell.tsx | 68 ++++++ .../SecondaryAction/SecondaryAction.tsx | 55 +++++ .../NewActionCell/SecondaryAction/index.ts | 1 + .../components/src/NewActionCell/index.ts | 1 + .../components/src/NewActionCell/styles.ts | 6 + .../components/src/NewActionCell/types.ts | 99 +++++++++ .../src/NewActionCell/useLogic/index.ts | 1 + .../src/NewActionCell/useLogic/useLogic.ts | 53 +++++ .../components/src/NewDataGrid/Body/Body.tsx | 22 +- .../src/NewDataGrid/NewDataGrid.stories.tsx | 173 +++++++++++++-- .../src/NewDataGrid/NewDataGrid.test.tsx | 87 ++++++++ .../components/src/NewDataGrid/Row/Row.tsx | 17 +- .../NewDataGrid/Row/RowContext/RowContext.ts | 14 ++ .../RowContext/RowProvider/RowProvider.tsx | 35 +++ .../Row/RowContext/RowProvider/index.ts | 1 + .../src/NewDataGrid/Row/RowContext/index.ts | 3 + .../components/src/NewDataGrid/Row/index.ts | 2 + .../src/NewDataGrid/Row/useLogic/useLogic.ts | 23 +- packages/components/src/NewDataGrid/index.ts | 4 + packages/components/src/NewDataGrid/types.ts | 16 ++ .../NewDataGridInfinite.stories.tsx | 182 +++++++++++++++- .../NewDataGridInfinite.test.tsx | 115 ++++++++++ .../NewDataGridInfinite.tsx | 43 ++-- packages/components/src/Tooltip/Tooltip.tsx | 37 ++-- packages/components/src/index.ts | 6 + 35 files changed, 1447 insertions(+), 103 deletions(-) create mode 100644 packages/components/src/NewActionCell/MainAction/MainAction.tsx create mode 100644 packages/components/src/NewActionCell/MainAction/index.ts create mode 100644 packages/components/src/NewActionCell/NewActionCell.stories.tsx create mode 100644 packages/components/src/NewActionCell/NewActionCell.test.tsx create mode 100644 packages/components/src/NewActionCell/NewActionCell.tsx create mode 100644 packages/components/src/NewActionCell/SecondaryAction/SecondaryAction.tsx create mode 100644 packages/components/src/NewActionCell/SecondaryAction/index.ts create mode 100644 packages/components/src/NewActionCell/index.ts create mode 100644 packages/components/src/NewActionCell/styles.ts create mode 100644 packages/components/src/NewActionCell/types.ts create mode 100644 packages/components/src/NewActionCell/useLogic/index.ts create mode 100644 packages/components/src/NewActionCell/useLogic/useLogic.ts create mode 100644 packages/components/src/NewDataGrid/Row/RowContext/RowContext.ts create mode 100644 packages/components/src/NewDataGrid/Row/RowContext/RowProvider/RowProvider.tsx create mode 100644 packages/components/src/NewDataGrid/Row/RowContext/RowProvider/index.ts create mode 100644 packages/components/src/NewDataGrid/Row/RowContext/index.ts diff --git a/.cspell-ignore b/.cspell-ignore index 7ccd6630d..eeb84e1bc 100644 --- a/.cspell-ignore +++ b/.cspell-ignore @@ -88,6 +88,7 @@ mobx monokai navpanes nextjs +newactioncell newdatagrid newdatagridinfinite noopener diff --git a/packages/components/src/ActionCell/ActionCell.stories.tsx b/packages/components/src/ActionCell/ActionCell.stories.tsx index 0fb031b96..f1a9f348a 100644 --- a/packages/components/src/ActionCell/ActionCell.stories.tsx +++ b/packages/components/src/ActionCell/ActionCell.stories.tsx @@ -12,6 +12,10 @@ import { type DataGridColumns, NewDataGrid } from '../NewDataGrid'; import { ActionCell, type Actions } from './ActionCell'; /** + * **❗️❗️❗️ Компонент устарел и больше не будет поддерживаться.** + * **Используйте [NewActionCell](/docs/components-newactioncell--docs) + * Причина отказа от поддержки: ActionCell не работает с контекстом NewDataGrid, и не позволяет + * управлять состояниями строки при взаимодействии с действиями * ### [Figma]() * ### [Guide]() */ @@ -203,13 +207,13 @@ export const BlockingOperations = () => { }, [deleteLoading, saveLoading]); const FAKE_ACTIONS: Actions = { - isBlockingOperation: true, main: [ { icon: , name: 'Удалить', onClick: () => setDeleteLoading((prevState) => !prevState), loading: deleteLoading, + isBlockingOperation: true, }, { icon: , diff --git a/packages/components/src/ActionCell/ActionCell.test.tsx b/packages/components/src/ActionCell/ActionCell.test.tsx index 87d6f9d00..40adf9872 100644 --- a/packages/components/src/ActionCell/ActionCell.test.tsx +++ b/packages/components/src/ActionCell/ActionCell.test.tsx @@ -116,12 +116,12 @@ describe('ActionCell', () => { it('Кнопки заблокированы, при isBlockingOperation=true и loading=true', async () => { const fakeAction: Actions = { - isBlockingOperation: true, main: [ { icon: , name: 'Удалить', loading: true, + isBlockingOperation: true, }, ], secondary: [ diff --git a/packages/components/src/ActionCell/ActionCell.tsx b/packages/components/src/ActionCell/ActionCell.tsx index 1716ad9aa..08424011c 100644 --- a/packages/components/src/ActionCell/ActionCell.tsx +++ b/packages/components/src/ActionCell/ActionCell.tsx @@ -15,10 +15,6 @@ export type Actions = { * Второстепенные действия */ secondary?: SecondaryActionKind[]; - /** - * Если true, блокирует взаимодействие с actions, если одна из них имеет состояние loading - */ - isBlockingOperation?: boolean; }; export type ActionsCellProps = { @@ -41,6 +37,11 @@ const TOOLTIP_PLACEMENT: Record = { secondaryAction: 'left', }; +/** + * @deprecated + * Используйте NewActionCell. Причина отказа от поддержки: ActionCell не работает с контекстом NewDataGrid, и не позволяет + * управлять состояниями строки при взаимодействии с действиями + */ export const ActionCell = (props: ActionsCellProps) => { const { isSecondaryActionsAvailable, diff --git a/packages/components/src/ActionCell/types.ts b/packages/components/src/ActionCell/types.ts index a86074529..59442d454 100644 --- a/packages/components/src/ActionCell/types.ts +++ b/packages/components/src/ActionCell/types.ts @@ -7,7 +7,7 @@ import type { IconButtonProps } from '../IconButton'; export type SecondaryActionKind = MenuItemProps & SingleAction & { /** - * Причина дизейбла + * Причина блокировки */ disabledReason?: TooltipProps['title']; }; @@ -17,6 +17,7 @@ export type NestedAction = MenuItemProps & { * Обработчик действия */ onClick?: (row: T) => void; + /** * Название действия */ @@ -25,25 +26,34 @@ export type NestedAction = MenuItemProps & { export type SingleAction = { /** - * Причина дизейбла + * Причина блокировки */ disabledReason?: TooltipProps['title']; + /** * Иконка действия */ icon?: ReactNode; + /** * Обработчик действия */ onClick?: (row: T) => void; + /** * Название действия */ name: string; + /** * Флаг показа выпадающего списка при клике */ nested?: false; + + /** + * Если true, блокирует взаимодействие с actions + */ + isBlockingOperation?: boolean; }; export type MultipleAction = MenuItemProps & { @@ -51,14 +61,17 @@ export type MultipleAction = MenuItemProps & { * Иконка действия */ icon: ReactNode; + /** * Список действий для выпадающего списка */ actions: Array>; + /** * Флаг показа выпадающего списка при клике */ nested: true; + /** * Название действия */ diff --git a/packages/components/src/ActionCell/useLogic/useLogic.ts b/packages/components/src/ActionCell/useLogic/useLogic.ts index 6fa2617a0..59aac35aa 100644 --- a/packages/components/src/ActionCell/useLogic/useLogic.ts +++ b/packages/components/src/ActionCell/useLogic/useLogic.ts @@ -9,14 +9,14 @@ export const useLogic = ({ actions, row, }: UseLogicParams) => { - const { main, secondary, isBlockingOperation = false } = actions; + const { main, secondary } = actions; - const isLoading = main.some((action) => { + const isDisabledAction = main.some((action) => { if ('actions' in action) { return false; } - return action?.loading; + return action?.isBlockingOperation && action?.loading; }); const handleActionClick = useCallback( @@ -37,8 +37,6 @@ export const useLogic = ({ const isSecondaryActionsAvailable = secondary && secondary.length >= 1; - const isDisabledAction = isLoading && isBlockingOperation; - return { isSecondaryActionsAvailable, handleActionClick, diff --git a/packages/components/src/NewActionCell/MainAction/MainAction.tsx b/packages/components/src/NewActionCell/MainAction/MainAction.tsx new file mode 100644 index 000000000..89d7c0d63 --- /dev/null +++ b/packages/components/src/NewActionCell/MainAction/MainAction.tsx @@ -0,0 +1,95 @@ +import type { ActionCellHandler, MainActionKind } from '../types'; +import { Tooltip, type TooltipProps } from '../../Tooltip'; +import { IconDropdownButton } from '../../IconDropdownButton'; +import { MenuItem } from '../../MenuItem'; +import { IconButton } from '../../IconButton'; + +type MainActionProps = { + /** + * Основные действия + */ + action: MainActionKind; + /** + * Обработчик клика на действие + */ + onActionClick: ActionCellHandler; + /** + * Если true, action не доступен + */ + isDisabled?: boolean; + /** + * Положение тултипа + */ + tooltipPlacement?: TooltipProps['placement']; +}; + +export const MainAction = ({ + action, + onActionClick, + isDisabled, + tooltipPlacement, +}: MainActionProps) => { + if ('actions' in action) { + const { disabled, icon, name, disabledReason, actions } = action; + + return ( + + + {actions.map( + ({ name: nestedActionName, onClick: onClickNested, ...props }) => ( + + {nestedActionName} + + ), + )} + + + ); + } + + const { + onClick, + name, + icon, + disabledReason, + disabled, + loading, + isBlockingOperation, + loadingNote, + ...actions + } = action; + + const title = !loading && (disabledReason || name); + + return ( + + + {icon} + + + ); +}; diff --git a/packages/components/src/NewActionCell/MainAction/index.ts b/packages/components/src/NewActionCell/MainAction/index.ts new file mode 100644 index 000000000..c8c76228c --- /dev/null +++ b/packages/components/src/NewActionCell/MainAction/index.ts @@ -0,0 +1 @@ +export * from './MainAction'; diff --git a/packages/components/src/NewActionCell/NewActionCell.stories.tsx b/packages/components/src/NewActionCell/NewActionCell.stories.tsx new file mode 100644 index 000000000..ff48ca407 --- /dev/null +++ b/packages/components/src/NewActionCell/NewActionCell.stories.tsx @@ -0,0 +1,206 @@ +import { type Meta, type StoryObj } from '@storybook/react'; +import { BinOutlineMd, EditOutlineMd, SaveOutlineMd } from '@astral/icons'; +import { useEffect, useState } from 'react'; + +import { type DataGridColumns, NewDataGrid } from '../NewDataGrid'; + +import { type Actions, NewActionCell } from './NewActionCell'; + +/** + * NewActionCell предназначен для использования в компонентах NewDataGrid и NewDataGridInfinite. + * Работает с контекстом NewDataGrid и позволяет управлять состояниями строки при взаимодействии с действиями + * + * ### [Figma]() + * ### [Guide]() + */ + +const meta: Meta = { + title: 'Components/NewActionCell', + component: NewActionCell, +}; + +export default meta; + +type DataType = { + id: string; + documentName: string; +}; + +const FAKE_ACTIONS = { + main: [ + { + icon: , + name: 'Редактировать', + onClick: () => { + console.log('Редактировать'); + }, + }, + { + icon: , + name: 'Удалить', + onClick: () => { + console.log('Удалить'); + }, + }, + ], + secondary: [ + { + name: 'Подписать', + onClick: () => { + console.log('Подписать'); + }, + }, + ], +}; + +const FAKE_DATA = [ + { + id: '1', + documentName: 'Документ 1', + }, + { + id: '2', + documentName: 'Документ 2', + }, + { + id: '3', + documentName: 'Документ 3', + }, +]; + +type Story = StoryObj; + +export const Interaction: Story = { + args: { + actions: FAKE_ACTIONS, + }, + parameters: { + docs: { + disable: true, + }, + }, +}; + +export const Example = () => { + const columns: DataGridColumns[] = [ + { + field: 'documentName', + label: 'Документ', + sortable: false, + }, + { + label: 'Действия', + sortable: false, + width: '120px', + align: 'right', + renderCell: (row) => , + }, + ]; + + return ( + {}} + /> + ); +}; + +export const LoaderActions = () => { + type DataTypeActions = { + id: string; + actions?: object; + }; + + const [deleteLoading, setDeleteLoading] = useState(false); + const [saveLoading, setSaveLoading] = useState(false); + + useEffect(() => { + if (deleteLoading) { + setTimeout(() => { + setDeleteLoading(false); + }, 1500); + } + + if (saveLoading) { + setTimeout(() => { + setSaveLoading(false); + }, 1500); + } + }, [deleteLoading, saveLoading]); + + const fakeActions: Actions = { + main: [ + { + icon: , + name: 'Удалить', + onClick: () => setDeleteLoading((prevState) => !prevState), + loading: deleteLoading, + }, + { + icon: , + name: 'Сохранить', + loading: saveLoading, + onClick: () => setSaveLoading((prevState) => !prevState), + }, + ], + }; + + const fakeData: DataTypeActions = { + id: '123456789', + }; + + return ; +}; + +export const BlockingOperations = () => { + type DataTypeActions = { + id: string; + actions?: object; + }; + + const [deleteLoading, setDeleteLoading] = useState(false); + const [saveLoading, setSaveLoading] = useState(false); + + useEffect(() => { + if (deleteLoading) { + setTimeout(() => { + setDeleteLoading(false); + }, 1500); + } + + if (saveLoading) { + setTimeout(() => { + setSaveLoading(false); + }, 1500); + } + }, [deleteLoading, saveLoading]); + + const fakeActions: Actions = { + main: [ + { + icon: , + name: 'Удалить', + onClick: () => setDeleteLoading((prevState) => !prevState), + loading: deleteLoading, + isBlockingOperation: true, + }, + { + icon: , + name: 'Сохранить', + onClick: () => setSaveLoading((prevState) => !prevState), + loading: saveLoading, + }, + ], + secondary: [ + { name: 'Редактировать', onClick: () => console.log('secondary 1') }, + ], + }; + + const fakeData: DataTypeActions = { + id: '123456789', + }; + + return ; +}; diff --git a/packages/components/src/NewActionCell/NewActionCell.test.tsx b/packages/components/src/NewActionCell/NewActionCell.test.tsx new file mode 100644 index 000000000..af6d0368a --- /dev/null +++ b/packages/components/src/NewActionCell/NewActionCell.test.tsx @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderWithTheme, screen, userEvents } from '@astral/tests'; + +import { type Actions, NewActionCell } from './NewActionCell'; + +type DataTypeActions = { + id: string; + actions?: object; +}; + +const FAKE_DATA: DataTypeActions = { + id: '123456789', +}; + +describe('NewActionCell', () => { + it('Основные действия отображаются', () => { + const fakeAction: Actions = { + main: [ + { + icon: , + name: 'Удалить', + }, + ], + }; + + renderWithTheme(); + + const button = screen.getByRole('button'); + + expect(button).toBeVisible(); + }); + + it('Второстепенные действия отображаются', async () => { + const fakeAction: Actions = { + main: [], + secondary: [ + { + name: 'Удалить', + }, + { + name: 'Сохранить', + }, + ], + }; + + renderWithTheme(); + + const button = screen.getByRole('button'); + + await userEvents.click(button); + + const element = await screen.findByText('Сохранить'); + + expect(element).toBeVisible(); + }); + + it('OnClick вызывается при нажатии на основные действия', async () => { + const onClickSpy = vi.fn(); + const fakeAction: Actions = { + main: [ + { + icon: , + name: 'Удалить', + onClick: onClickSpy, + }, + ], + }; + + renderWithTheme(); + + const button = screen.getByRole('button'); + + await userEvents.click(button); + expect(onClickSpy).toBeCalled(); + }); + + it('Кнопка блокируется, если disabled=true', () => { + const fakeAction: Actions = { + main: [ + { + icon: , + name: 'Удалить', + disabled: true, + }, + ], + }; + + renderWithTheme(); + + const button = screen.getByRole('button'); + + expect(button).toBeDisabled(); + }); + + it('Tooltip отображается для основных действий', async () => { + const fakeAction: Actions = { + main: [ + { + icon: , + name: 'Удалить', + }, + ], + }; + + renderWithTheme(); + + const button = screen.getByRole('button'); + + await userEvents.hover(button); + + const tooltip = await screen.findByRole('tooltip'); + + expect(tooltip).toBeVisible(); + expect(tooltip).toHaveTextContent('Удалить'); + }); + + it('Кнопки заблокированы, при isBlockingOperation=true и loading=true', async () => { + const fakeAction: Actions = { + main: [ + { + icon: , + name: 'Удалить', + loading: true, + isBlockingOperation: true, + }, + ], + secondary: [ + { + icon: , + name: 'Отправить', + }, + ], + }; + + renderWithTheme(); + + const buttons = screen.getAllByRole('button'); + + buttons.forEach((button) => expect(button).toBeDisabled()); + }); +}); diff --git a/packages/components/src/NewActionCell/NewActionCell.tsx b/packages/components/src/NewActionCell/NewActionCell.tsx new file mode 100644 index 000000000..00133eb7b --- /dev/null +++ b/packages/components/src/NewActionCell/NewActionCell.tsx @@ -0,0 +1,68 @@ +import { SecondaryActions } from '../ActionCell/SecondaryAction'; +import { type TooltipProps } from '../Tooltip'; + +import type { MainActionKind, SecondaryActionKind } from './types'; +import { useLogic } from './useLogic'; +import { Wrapper } from './styles'; +import { MainAction } from './MainAction'; + +export type Actions = { + /** + * Основные действия + */ + main: MainActionKind[]; + + /** + * Вторичные действия + */ + secondary?: SecondaryActionKind[]; +}; + +export type ActionCellProps = { + /** + * Действия + */ + actions: Actions; + + /** + * Данные строки + */ + row: TRow; +}; + +const TOOLTIP_PLACEMENT: Record = { + mainAction: 'top', + secondaryAction: 'left', +}; + +export const NewActionCell = (props: ActionCellProps) => { + const { isDisabledAction, handleWrapperClick, handleActionClick } = + useLogic(props); + + const { actions } = props; + const { main, secondary } = actions; + + return ( + + {main.map((action) => { + return ( + + ); + })} + {secondary && ( + + )} + + ); +}; diff --git a/packages/components/src/NewActionCell/SecondaryAction/SecondaryAction.tsx b/packages/components/src/NewActionCell/SecondaryAction/SecondaryAction.tsx new file mode 100644 index 000000000..9b489b65e --- /dev/null +++ b/packages/components/src/NewActionCell/SecondaryAction/SecondaryAction.tsx @@ -0,0 +1,55 @@ +import { DotsVOutlineMd } from '@astral/icons'; + +import type { ActionCellHandler, SecondaryActionKind } from '../types'; +import { type TooltipProps } from '../../Tooltip'; +import { IconDropdownButton } from '../../IconDropdownButton'; +import { MenuItem } from '../../MenuItem'; + +type SecondaryActionProps = { + /** + * Вторичные действия + */ + actions: SecondaryActionKind[]; + /** + * Обработчик нажатия на действие + */ + onActionClick: ActionCellHandler; + /** + * Если true, action не доступен + */ + isDisabled?: boolean; + /** + * Положение тултипа + */ + tooltipPlacement?: TooltipProps['placement']; +}; + +export const SecondaryAct = ({ + actions, + onActionClick, + tooltipPlacement, + isDisabled, +}: SecondaryActionProps) => { + return ( + } + variant="text" + disabled={isDisabled} + > + {actions.map((action) => { + const { onClick, name } = action; + + return ( + + {name} + + ); + })} + + ); +}; diff --git a/packages/components/src/NewActionCell/SecondaryAction/index.ts b/packages/components/src/NewActionCell/SecondaryAction/index.ts new file mode 100644 index 000000000..4a598569d --- /dev/null +++ b/packages/components/src/NewActionCell/SecondaryAction/index.ts @@ -0,0 +1 @@ +export * from './SecondaryAction'; diff --git a/packages/components/src/NewActionCell/index.ts b/packages/components/src/NewActionCell/index.ts new file mode 100644 index 000000000..00e048eb8 --- /dev/null +++ b/packages/components/src/NewActionCell/index.ts @@ -0,0 +1 @@ +export * from './NewActionCell'; diff --git a/packages/components/src/NewActionCell/styles.ts b/packages/components/src/NewActionCell/styles.ts new file mode 100644 index 000000000..606ecc789 --- /dev/null +++ b/packages/components/src/NewActionCell/styles.ts @@ -0,0 +1,6 @@ +import { styled } from '../styles'; + +export const Wrapper = styled.div` + display: inline-flex; + align-items: center; +`; diff --git a/packages/components/src/NewActionCell/types.ts b/packages/components/src/NewActionCell/types.ts new file mode 100644 index 000000000..342dcffcd --- /dev/null +++ b/packages/components/src/NewActionCell/types.ts @@ -0,0 +1,99 @@ +import type { MouseEventHandler, ReactNode } from 'react'; + +import type { MenuItemProps } from '../MenuItem'; +import type { IconButtonProps } from '../IconButton'; + +export type NestedAction = MenuItemProps & { + /** + * Обработчик действия + */ + onClick?: (row: TAction) => void; + /** + * Название действия + */ + name: string; +}; + +export type SingleAction = { + /** + * Причина блокировки действия + */ + disabledReason?: string; + /** + * Иконка действия + */ + icon?: ReactNode; + /** + * Обработчик действия + */ + onClick?: (row: TAction) => void; + /** + * Название действия + */ + name: string; + /** + * Флаг показа выпадающего списка при клике + */ + nested?: false; + /** + * Если true, блокирует взаимодействие с actions + */ + isBlockingOperation?: boolean; + /** + * Причина блокировки строки во время загрузки + */ + loadingNote?: string; +}; + +export type MultipleAction = MenuItemProps & { + /** + * Иконка действия + */ + icon: ReactNode; + /** + * Список действий для выпадающего списка + */ + actions: Array>; + /** + * Флаг показа выпадающего списка при клике + */ + nested: true; + /** + * Название действия + */ + name: string; + /** + * Причина блокировки строки во время загрузки + */ + loadingNote?: string; + /** + * Если true, блокирует взаимодействие с actions + */ + isBlockingOperation?: boolean; + /** + * Если true, происходит загрузка + */ + loading?: boolean; +}; + +export type ActionCellHandler = ( + onClick: SingleAction['onClick'] | NestedAction['onClick'], +) => + | MouseEventHandler + | undefined; + +export type MainActionKind = + | (IconButtonProps & SingleAction) + | MultipleAction; + +export type SecondaryActionKind = MenuItemProps & + SingleAction & { + /** + * Причина блокировки действия + */ + disabledReason?: string; + /** + * Если true, происходит загрузка + */ + loading?: boolean; + }; diff --git a/packages/components/src/NewActionCell/useLogic/index.ts b/packages/components/src/NewActionCell/useLogic/index.ts new file mode 100644 index 000000000..51786a09c --- /dev/null +++ b/packages/components/src/NewActionCell/useLogic/index.ts @@ -0,0 +1 @@ +export * from './useLogic'; diff --git a/packages/components/src/NewActionCell/useLogic/useLogic.ts b/packages/components/src/NewActionCell/useLogic/useLogic.ts new file mode 100644 index 000000000..53ad0b10c --- /dev/null +++ b/packages/components/src/NewActionCell/useLogic/useLogic.ts @@ -0,0 +1,53 @@ +import { + type MouseEventHandler, + useCallback, + useContext, + useEffect, +} from 'react'; + +import { RowContext } from '../../NewDataGrid'; +import { type ActionCellProps } from '../NewActionCell'; +import type { NestedAction, SingleAction } from '../types'; + +type UseLogicParams = ActionCellProps; + +export const useLogic = ({ + row, + actions, +}: UseLogicParams) => { + const { main, secondary } = actions; + + const { addDisabledRow, removeDisabledRow } = useContext(RowContext); + + const blockingAction = [...main, ...(secondary || [])].find( + (action) => action.isBlockingOperation && action.loading, + ); + + const isDisabledAction = Boolean(blockingAction); + + useEffect(() => { + if (blockingAction) { + return addDisabledRow(blockingAction?.loadingNote); + } + + removeDisabledRow(); + }, [blockingAction]); + + const handleActionClick = useCallback( + ( + onClick: + | SingleAction['onClick'] + | NestedAction['onClick'], + ) => + () => { + onClick?.(row); + }, + [row], + ); + + const handleWrapperClick: MouseEventHandler = (event) => { + event.stopPropagation(); + }; + + return { isDisabledAction, handleActionClick, handleWrapperClick }; +}; diff --git a/packages/components/src/NewDataGrid/Body/Body.tsx b/packages/components/src/NewDataGrid/Body/Body.tsx index 3081a9a47..14b094395 100644 --- a/packages/components/src/NewDataGrid/Body/Body.tsx +++ b/packages/components/src/NewDataGrid/Body/Body.tsx @@ -4,6 +4,7 @@ import { ConfigContext } from '../../ConfigProvider'; import { ContentState } from '../../ContentState'; import { DataGridContextProvider } from '../DataGridContext'; import { Row } from '../Row'; +import { RowContextProvider } from '../Row/RowContext'; import type { CellValue, DataGridColumns, DataGridRowOptions } from '../types'; import { useLogic } from './useLogic'; @@ -134,16 +135,17 @@ export const Body = >( const rowId = (row as TData)[keyId] as string; return ( - } - {...rowProps} - /> + + } + {...rowProps} + /> + ); }); }, [rows, keyId, selectedRows, rowProps]); diff --git a/packages/components/src/NewDataGrid/NewDataGrid.stories.tsx b/packages/components/src/NewDataGrid/NewDataGrid.stories.tsx index 572c4ed64..0fdc92611 100644 --- a/packages/components/src/NewDataGrid/NewDataGrid.stories.tsx +++ b/packages/components/src/NewDataGrid/NewDataGrid.stories.tsx @@ -1,11 +1,20 @@ -import { BinOutlineMd, EyeFillMd, SendOutlineMd } from '@astral/icons'; +import { type ChangeEvent, useEffect, useMemo, useState } from 'react'; import { type Meta } from '@storybook/react'; -import { type ChangeEvent, useEffect, useState } from 'react'; +import { + BinOutlineMd, + EditOutlineMd, + EyeFillMd, + SendOutlineMd, +} from '@astral/icons'; import errorIllustration from '../../../ui/illustrations/error.svg'; -import { ActionCell, type Actions } from '../ActionCell'; import { DataGridPagination } from '../DataGridPagination'; import { ConfigProvider } from '../ConfigProvider'; +import { + type ActionCellProps, + type Actions, + NewActionCell, +} from '../NewActionCell'; import { NewDataGrid } from './NewDataGrid'; import type { @@ -61,6 +70,7 @@ const FAKE_ACTIONS: Actions = { icon: , name: 'Просмотреть', onClick: () => console.log('main'), + isBlockingOperation: true, }, { icon: , @@ -102,11 +112,78 @@ const FAKE_COLUMNS: DataGridColumns[] = [ align: 'center', width: '120px', renderCell: (row) => { - return ; + return ; }, }, ]; +type FakeActionCellProps = Pick, 'row'>; + +const FakeActionCell = ({ row }: FakeActionCellProps) => { + const [isEditing, setIsEditing] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isSigning, setIsSigning] = useState(false); + + const handleEdit = () => { + setIsEditing(true); + }; + + const handleDelete = () => { + setIsDeleting(true); + }; + + const handleSign = () => { + setIsSigning(true); + }; + + useEffect(() => { + if (isEditing) { + setTimeout(() => setIsEditing(false), 1500); + } + + if (isDeleting) { + setTimeout(() => setIsDeleting(false), 1500); + } + + if (isSigning) { + setTimeout(() => setIsSigning(false), 1500); + } + }, [isEditing, isDeleting, isSigning]); + + const fakeActions = useMemo( + () => ({ + main: [ + { + icon: , + name: 'Редактировать', + loading: isEditing, + onClick: handleEdit, + }, + { + icon: , + name: 'Удалить', + loading: isDeleting, + loadingNote: 'Происходит удаление', + isBlockingOperation: true, + onClick: handleDelete, + }, + ], + secondary: [ + { + name: 'Подписать', + loading: isSigning, + loadingNote: 'Происходит подписание', + isBlockingOperation: true, + onClick: handleSign, + }, + ], + }), + [isEditing, isDeleting, isSigning], + ); + + return ; +}; + /** * DataGrid без пагинации */ @@ -506,7 +583,7 @@ export const WithDisabledRow = () => { { field: 'actions', renderCell: (row) => { - return ; + return ; }, }, ]); @@ -575,7 +652,7 @@ export const DisabledLastCell = () => { { field: 'actions', renderCell: (row) => { - return ; + return ; }, }, ]); @@ -628,6 +705,72 @@ export const DisabledLastCell = () => { ); }; +export const ActionsDataGrid = () => { + const fakeColumns: DataGridColumns[] = [ + { + field: 'documentName', + label: 'Наименование документа', + sortable: true, + }, + { + field: 'recipient', + label: 'Получатель', + sortable: true, + }, + { + field: 'createDate', + label: 'Дата создания', + sortable: true, + format: ({ createDate }) => new Date(createDate).toLocaleDateString(), + }, + { + field: 'actions', + label: 'Действия', + sortable: false, + align: 'center', + width: '120px', + renderCell: (row) => { + return ; + }, + }, + ]; + + const columns = makeColumns(fakeColumns); + + const fakeData: DataGridRowWithOptions[] = [ + { + id: '123456789', + documentName: 'Договор №12345678', + recipient: 'ПАО "Первый завод"', + createDate: makeRandomDate(), + }, + ...makeDataList(FAKE_DATA_TEMPLATE), + ]; + + const [loading, setLoading] = useState(true); + const [slicedData, setSlicedData] = useState([]); + + useEffect(() => { + setTimeout(() => { + setSlicedData(fakeData.slice(0, 10)); + setLoading(false); + }, 1500); + }, []); + + const handleRowClick = (row: DataType) => console.log('row clicked', row); + + return ( + + keyId="id" + rows={slicedData} + columns={columns} + onRowClick={handleRowClick} + isLoading={loading} + onRetry={() => {}} + /> + ); +}; + export const EmptyCellValue = () => { type DataTypeEmptyCell = { id: string; @@ -698,7 +841,7 @@ export const Tree = () => { { field: 'actions', renderCell: (row) => { - return ; + return ; }, }, ]); @@ -753,7 +896,7 @@ export const TreeWithInitialExpanded = () => { { field: 'actions', renderCell: (row) => { - return ; + return ; }, }, ]); @@ -810,7 +953,7 @@ export const TreeWithExpandedLevel = () => { { field: 'actions', renderCell: (row) => { - return ; + return ; }, }, ]); @@ -869,7 +1012,7 @@ export const TreeWithInitialVisibleChildrenCount = () => { { field: 'actions', renderCell: (row) => { - return ; + return ; }, }, ]); @@ -927,7 +1070,7 @@ export const TreeWithCheckbox = () => { { field: 'actions', renderCell: (row) => { - return ; + return ; }, }, ]); @@ -1070,7 +1213,7 @@ export const TreeWithOverrideColumns = () => { { field: 'actions', renderCell: (row) => { - return ; + return ; }, }, ]); @@ -1095,7 +1238,7 @@ export const TreeWithOverrideColumns = () => { field: 'actions', renderCell: (row) => { return ( - { field: 'actions', renderCell: (row) => { return ( - { { field: 'actions', renderCell: (row) => { - return ; + return ; }, }, ]); diff --git a/packages/components/src/NewDataGrid/NewDataGrid.test.tsx b/packages/components/src/NewDataGrid/NewDataGrid.test.tsx index b2d9f2126..510cd287e 100644 --- a/packages/components/src/NewDataGrid/NewDataGrid.test.tsx +++ b/packages/components/src/NewDataGrid/NewDataGrid.test.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { BinOutlineMd } from '@astral/icons'; import { ActionCell } from '../ActionCell'; +import { NewActionCell } from '../NewActionCell'; import { NewDataGrid } from './NewDataGrid'; import type { DataGridColumns, DataGridSort } from './types'; @@ -397,6 +398,92 @@ describe('NewDataGrid', () => { expect(onClickSpy).not.toHaveBeenCalled(); }); + it('Строка не доступна для взаимодействия, если loading=true и isBlockingOperation=true', async () => { + const onClickSpy = vi.fn(); + + const FAKE_ACTIONS = { + main: [ + { + name: 'Удалить', + loading: true, + isBlockingOperation: true, + }, + ], + }; + + renderWithTheme( + ( + + ), + }, + ]} + onRetry={() => {}} + />, + ); + + await userEvents.click(screen.getByText('Vasya')); + expect(onClickSpy).not.toHaveBeenCalled(); + }); + + it('Tooltip c причиной блокировки при загрузке отображается, если loading=true и isBlockingOperation=true', async () => { + const onClickSpy = vi.fn(); + + const fakeLoadingNote = 'Происходит удаление'; + const FAKE_ACTIONS = { + main: [ + { + name: 'Удалить', + loading: true, + loadingNote: fakeLoadingNote, + isBlockingOperation: true, + }, + ], + }; + + renderWithTheme( + ( + + ), + }, + ]} + onRetry={() => {}} + />, + ); + + const row = screen.getByLabelText(fakeLoadingNote); + + await userEvents.hover(row); + + const tooltip = await screen.findByRole('tooltip'); + + expect(tooltip).toBeVisible(); + expect(tooltip).toHaveTextContent(fakeLoadingNote); + }); + it('Последняя ячейка доступна для взаимодействия, если isDisabled=true и isDisabledLastCell=false', async () => { const onDeleteSpy = vi.fn(); diff --git a/packages/components/src/NewDataGrid/Row/Row.tsx b/packages/components/src/NewDataGrid/Row/Row.tsx index 3545eab42..775397d6c 100644 --- a/packages/components/src/NewDataGrid/Row/Row.tsx +++ b/packages/components/src/NewDataGrid/Row/Row.tsx @@ -118,6 +118,7 @@ export const Row = >( rowProps, tooltipProps, nestedChildrenProps, + disabled, } = useLogic(props); const { @@ -139,15 +140,11 @@ export const Row = >( onSelectRow, onRowClick, // В этот rest-оператор попадают специфичные пропсы (атрибуты) virtuoso - // Необходимы для NewDataGrigInfinite + // Необходимы для NewDataGridInfinite ...selfProps } = props; - const { - isDisabled, - isDisabledLastCell = true, - isNotSelectable, - } = options || {}; + const { isDisabledLastCell = true, isNotSelectable } = options || {}; const renderStartAdornment = () => { if (!nestedChildren?.length && !isSelectable) { @@ -166,7 +163,7 @@ export const Row = >( {isSelectable && !isNotSelectable && ( event.stopPropagation()} > @@ -185,7 +182,7 @@ export const Row = >( const cellId = `${rowId}-${index}`; const isDisabledCell = checkIsDisabled( - isDisabled, + disabled, availableCellsByIndex, index, ); @@ -204,7 +201,7 @@ export const Row = >( /> ); }); - }, [isOpen, columns]); + }, [isOpen, columns, disabled]); const renderRow = useCallback( ({ @@ -256,7 +253,7 @@ export const Row = >( {renderCells()} diff --git a/packages/components/src/NewDataGrid/Row/RowContext/RowContext.ts b/packages/components/src/NewDataGrid/Row/RowContext/RowContext.ts new file mode 100644 index 000000000..2ef3c3221 --- /dev/null +++ b/packages/components/src/NewDataGrid/Row/RowContext/RowContext.ts @@ -0,0 +1,14 @@ +import { createContext } from 'react'; + +export type RowContextProps = { + isDisabled: boolean; + disabledReason?: string; + addDisabledRow: (disabledReason?: string) => void; + removeDisabledRow: () => void; +}; + +export const RowContext = createContext({ + isDisabled: false, + addDisabledRow: () => {}, + removeDisabledRow: () => {}, +}); diff --git a/packages/components/src/NewDataGrid/Row/RowContext/RowProvider/RowProvider.tsx b/packages/components/src/NewDataGrid/Row/RowContext/RowProvider/RowProvider.tsx new file mode 100644 index 000000000..881670607 --- /dev/null +++ b/packages/components/src/NewDataGrid/Row/RowContext/RowProvider/RowProvider.tsx @@ -0,0 +1,35 @@ +import { type ReactNode, useState } from 'react'; + +import { RowContext } from '../RowContext'; + +type RowContextProviderProps = { + children: ReactNode; +}; + +export const RowContextProvider = ({ children }: RowContextProviderProps) => { + const [isDisabled, setDisabled] = useState(false); + const [disabledReason, setDisabledReason] = useState(); + + const addDisabledRow = (reason?: string) => { + setDisabled(true); + setDisabledReason(reason); + }; + + const removeDisabledRow = () => { + setDisabled(false); + setDisabledReason(undefined); + }; + + return ( + + {children} + + ); +}; diff --git a/packages/components/src/NewDataGrid/Row/RowContext/RowProvider/index.ts b/packages/components/src/NewDataGrid/Row/RowContext/RowProvider/index.ts new file mode 100644 index 000000000..ab88deb86 --- /dev/null +++ b/packages/components/src/NewDataGrid/Row/RowContext/RowProvider/index.ts @@ -0,0 +1 @@ +export * from './RowProvider'; diff --git a/packages/components/src/NewDataGrid/Row/RowContext/index.ts b/packages/components/src/NewDataGrid/Row/RowContext/index.ts new file mode 100644 index 000000000..58c587892 --- /dev/null +++ b/packages/components/src/NewDataGrid/Row/RowContext/index.ts @@ -0,0 +1,3 @@ +export * from './RowContext'; + +export * from './RowProvider'; diff --git a/packages/components/src/NewDataGrid/Row/index.ts b/packages/components/src/NewDataGrid/Row/index.ts index 7a86ee8cd..c2a2319c5 100644 --- a/packages/components/src/NewDataGrid/Row/index.ts +++ b/packages/components/src/NewDataGrid/Row/index.ts @@ -1 +1,3 @@ export * from './Row'; + +export * from './RowContext'; diff --git a/packages/components/src/NewDataGrid/Row/useLogic/useLogic.ts b/packages/components/src/NewDataGrid/Row/useLogic/useLogic.ts index cad54707a..3e5eb5d41 100644 --- a/packages/components/src/NewDataGrid/Row/useLogic/useLogic.ts +++ b/packages/components/src/NewDataGrid/Row/useLogic/useLogic.ts @@ -11,6 +11,7 @@ import { DataGridContext } from '../../DataGridContext'; import type { CellValue } from '../../types'; import { DISABLE_ROW_ATTR } from '../constants'; import { type RowProps } from '../Row'; +import { RowContext } from '../RowContext'; import { mergeColumnsOptions } from './utils'; @@ -33,13 +34,19 @@ export const useLogic = >({ const isDefaultExpanded = isInitialExpanded && level <= expandedLevel - 1; const { checkIsOpened, toggleOpenItems } = useContext(DataGridContext); + const { isDisabled, disabledReason } = useContext(RowContext); const [isVisibleTooltip, setVisibleTooltip] = useState(false); - const { isDisabled, disabledReason } = options || {}; - const rowId = row[keyId] as string; + const { + isDisabled: isExternalDisabled, + disabledReason: externalDisabledReason, + } = options || {}; + + const disabled = isDisabled || isExternalDisabled; + useEffect(() => { if (isDefaultExpanded) { toggleOpenItems(rowId); @@ -70,11 +77,10 @@ export const useLogic = >({ setVisibleTooltip(true); } }; - const handleCloseTooltip = () => setVisibleTooltip(false); const handleMouseMove = (event: MouseEvent) => { - if (!isDisabled) { + if (!disabled) { return; } @@ -87,7 +93,7 @@ export const useLogic = >({ }; const handleRowClick = () => { - if (isDisabled) { + if (disabled) { return undefined; } @@ -98,22 +104,23 @@ export const useLogic = >({ isOpen, childrenColumns, rowId, + disabled, handleToggle, rowProps: { - $isHovered: Boolean(!isDisabled && onRowClick), + $isHovered: Boolean(!disabled && onRowClick), $isSelected: activeRowId === rowId, onClick: handleRowClick, onMouseMove: handleMouseMove, }, tooltipProps: { open: isVisibleTooltip, - title: isDisabled && disabledReason, + title: isDisabled ? externalDisabledReason || disabledReason : undefined, onOpen: handleOpenTooltip, onClose: handleCloseTooltip, }, checkboxProps: { checked: isChecked, - disabled: isDisabled, + disabled: disabled, onChange: onSelectRow(row), }, nestedChildrenProps: { diff --git a/packages/components/src/NewDataGrid/index.ts b/packages/components/src/NewDataGrid/index.ts index 348007685..9685eca83 100644 --- a/packages/components/src/NewDataGrid/index.ts +++ b/packages/components/src/NewDataGrid/index.ts @@ -1,5 +1,9 @@ export * from './NewDataGrid'; +export * from './Head'; + +export * from './Row'; + export * from './constants'; export * from './types'; diff --git a/packages/components/src/NewDataGrid/types.ts b/packages/components/src/NewDataGrid/types.ts index 8191f8e0a..26e6e26bb 100644 --- a/packages/components/src/NewDataGrid/types.ts +++ b/packages/components/src/NewDataGrid/types.ts @@ -1,5 +1,10 @@ import { type CSSProperties, type ReactNode } from 'react'; +import { + type MainActionKind, + type SecondaryActionKind, +} from '../ActionCell/types'; + import type { SortStates } from './enums'; export type AlignVariant = 'left' | 'center' | 'right'; @@ -8,6 +13,17 @@ export type SortState = `${SortStates}`; export type RenderCell = (params: Data) => ReactNode; +export type Actions = { + /** + * Основные действия + */ + main: MainActionKind[]; + /** + * Второстепенные действия + */ + secondary?: SecondaryActionKind[]; +}; + export type CellValue = unknown; export type DataGridSort = { diff --git a/packages/components/src/NewDataGridInfinite/NewDataGridInfinite.stories.tsx b/packages/components/src/NewDataGridInfinite/NewDataGridInfinite.stories.tsx index ae1d9b829..1260e7e57 100644 --- a/packages/components/src/NewDataGridInfinite/NewDataGridInfinite.stories.tsx +++ b/packages/components/src/NewDataGridInfinite/NewDataGridInfinite.stories.tsx @@ -1,21 +1,31 @@ -import { useEffect, useState } from 'react'; -import { EyeFillMd, SendOutlineMd } from '@astral/icons'; +import { useEffect, useMemo, useState } from 'react'; +import { + BinOutlineMd, + EditOutlineMd, + EyeFillMd, + SendOutlineMd, +} from '@astral/icons'; import { type Meta } from '@storybook/react'; import errorIllustration from '../../../ui/illustrations/error.svg'; import noDataIllustration from '../../../ui/illustrations/no-data.svg'; import { ConfigProvider } from '../ConfigProvider'; -import { ActionCell, type Actions } from '../ActionCell'; +import { + type ActionCellProps, + type Actions, + NewActionCell, +} from '../NewActionCell'; import type { DataGridColumns, DataGridRowWithOptions } from '../NewDataGrid'; import { styled } from '../styles'; +import { makeColumns } from '../NewDataGrid/faker'; -import { NewDataGridInfinite } from './NewDataGridInfinite'; import { makeDataList, makeDataListWithTree, makeRandomDate } from './faker'; +import { NewDataGridInfinite } from './NewDataGridInfinite'; /** * Таблица с бесконечным скроллом построенная на css grid * - * NewDataGridInfinite обладает тем же функционалом что и [NewDataGrid](/docs/components-newdatagrid--docs) + * NewDataGridInfinite обладает тем же функционалом что и [NewDataGrid](/docs/components-newdatagrid--docs) * ### [Figma](https://www.figma.com/file/3ghN4WjSgkKx5rETR64jqh/Sirius-Design-System-(%D0%90%D0%9A%D0%A2%D0%A3%D0%90%D0%9B%D0%AC%D0%9D%D0%9E)?type=design&node-id=12407-146186&mode=design&t=sBor9IJ3F3TqLcos-0) * ### [Guide]() */ @@ -83,11 +93,78 @@ const FAKE_COLUMNS: DataGridColumns[] = [ align: 'center', width: '120px', renderCell: (row) => { - return ; + return ; }, }, ]; +type FakeActionCellProps = Pick, 'row'>; + +const FakeActionCell = ({ row }: FakeActionCellProps) => { + const [isEditing, setIsEditing] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isSigning, setIsSigning] = useState(false); + + const handleEdit = () => { + setIsEditing(true); + }; + + const handleDelete = () => { + setIsDeleting(true); + }; + + const handleSign = () => { + setIsSigning(true); + }; + + useEffect(() => { + if (isEditing) { + setTimeout(() => setIsEditing(false), 1500); + } + + if (isDeleting) { + setTimeout(() => setIsDeleting(false), 1500); + } + + if (isSigning) { + setTimeout(() => setIsSigning(false), 1500); + } + }, [isEditing, isDeleting, isSigning]); + + const fakeActions = useMemo( + () => ({ + main: [ + { + icon: , + name: 'Редактировать', + loading: isEditing, + onClick: handleEdit, + }, + { + icon: , + name: 'Удалить', + loading: isDeleting, + loadingNote: 'Происходит удаление', + isBlockingOperation: true, + onClick: handleDelete, + }, + ], + secondary: [ + { + name: 'Подписать', + loading: isSigning, + loadingNote: 'Происходит подписание', + isBlockingOperation: true, + onClick: handleSign, + }, + ], + }), + [isEditing, isDeleting, isSigning], + ); + + return ; +}; + const DataGridInfiniteWrapper = styled.div` width: 100%; height: 400px; @@ -153,6 +230,95 @@ export const Example = () => { ); }; +export const ActionsDataGrid = () => { + const fakeColumns: DataGridColumns[] = [ + { + field: 'documentName', + label: 'Наименование документа', + sortable: true, + }, + { + field: 'recipient', + label: 'Получатель', + sortable: true, + }, + { + field: 'createDate', + label: 'Дата создания', + sortable: true, + format: ({ createDate }) => new Date(createDate).toLocaleDateString(), + }, + { + field: 'actions', + label: 'Действия', + sortable: false, + align: 'center', + width: '120px', + renderCell: (row) => { + return ; + }, + }, + ]; + + const columns = makeColumns(fakeColumns); + + const fakeData: DataGridRowWithOptions[] = [ + { + id: '123456789', + documentName: 'Договор №12345678', + recipient: 'ПАО "Первый завод"', + createDate: makeRandomDate(), + }, + ...makeDataList(9), + ]; + + const [isLoading, setLoading] = useState(true); + const [slicedData, setSlicedData] = useState([]); + const [isEndReached, setIsEndReached] = useState(false); + + const incrementData = () => { + setLoading(true); + + setTimeout(() => { + setSlicedData((prevData) => [...prevData, ...makeDataList(10)]); + setIsEndReached(true); + setLoading(false); + }, 1500); + }; + + useEffect(() => { + setTimeout(() => { + setSlicedData(fakeData.slice(0, 10)); + setLoading(false); + }, 1500); + }, []); + + const handleRowClick = (row: DataType) => console.log('row clicked', row); + + return ( + + + + keyId="id" + rows={slicedData} + isLoading={isLoading} + isEndReached={isEndReached} + columns={columns} + onEndReached={incrementData} + onRowClick={handleRowClick} + onRetry={() => {}} + /> + + + ); +}; + export const WithTree = () => { const columns = FAKE_COLUMNS; const fakeData = [ @@ -326,7 +492,7 @@ export const TreeWithOverrideColumns = () => { field: 'actions', renderCell: (row) => { return ( - { field: 'actions', renderCell: (row) => { return ( - { expect(onClickSpy).not.toHaveBeenCalled(); }); + it('Строка не доступна для взаимодействия, если loading=true и isBlockingOperation=true', async () => { + const onClickSpy = vi.fn(); + + const FAKE_ACTIONS = { + main: [ + { + name: 'Удалить', + loading: true, + loadingNote: 'Происходит удаление', + isBlockingOperation: true, + }, + ], + }; + + render( + + + ( + + ), + }, + ]} + onRetry={() => {}} + onRowClick={onClickSpy} + /> + + , + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + await userEvents.click(screen.getByText('Vasya')); + expect(onClickSpy).not.toHaveBeenCalled(); + }); + + it('Tooltip c причиной блокировки при загрузке отображается, если loading=true и isBlockingOperation=true', async () => { + const onClickSpy = vi.fn(); + + const fakeLoadingNote = 'Происходит удаление'; + + const FAKE_ACTIONS = { + main: [ + { + name: 'Удалить', + loading: true, + loadingNote: fakeLoadingNote, + isBlockingOperation: true, + }, + ], + }; + + render( + + + ( + + ), + }, + ]} + onRetry={() => {}} + onRowClick={onClickSpy} + /> + + , + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + const row = screen.getByLabelText(fakeLoadingNote); + + await userEvents.hover(row); + + const tooltip = await screen.findByRole('tooltip'); + + expect(tooltip).toBeVisible(); + expect(tooltip).toHaveTextContent(fakeLoadingNote); + }); + it('Последняя ячейка доступна для взаимодействия, если isDisabled=true и isDisabledLastCell=false', async () => { const onDeleteSpy = vi.fn(); diff --git a/packages/components/src/NewDataGridInfinite/NewDataGridInfinite.tsx b/packages/components/src/NewDataGridInfinite/NewDataGridInfinite.tsx index 79af8ac51..9e5610a60 100644 --- a/packages/components/src/NewDataGridInfinite/NewDataGridInfinite.tsx +++ b/packages/components/src/NewDataGridInfinite/NewDataGridInfinite.tsx @@ -9,12 +9,13 @@ import { Container, type DataGridRow, EXPANDED_LEVEL_BY_DEFAULT, + Head, INITIAL_OPENED_NESTED_CHILDREN_COUNT_BY_DEFAULT, type NewDataGridProps, + Row, + RowContextProvider, } from '../NewDataGrid'; import { DataGridContextProvider } from '../NewDataGrid/DataGridContext'; -import { Head } from '../NewDataGrid/Head'; -import { Row } from '../NewDataGrid/Row'; import { ScrollToTopButton } from '../ScrollToTopButton'; import { OVERSCAN_COUNT } from './constants'; @@ -136,23 +137,27 @@ export const NewDataGridInfinite = < const { children: nestedChildren, options, ...row } = item; return ( - } - isInitialExpanded={isInitialExpanded} - expandedLevel={expandedLevel} - initialVisibleChildrenCount={initialVisibleChildrenCount} - emptyCellValue={emptyCellValue} - onRowClick={onRowClick} - /> + + } + isInitialExpanded={isInitialExpanded} + expandedLevel={expandedLevel} + initialVisibleChildrenCount={ + initialVisibleChildrenCount + } + emptyCellValue={emptyCellValue} + onRowClick={onRowClick} + /> + ); }, EmptyPlaceholder: () => <>{noDataPlaceholder || }, diff --git a/packages/components/src/Tooltip/Tooltip.tsx b/packages/components/src/Tooltip/Tooltip.tsx index a70a66452..16757f321 100644 --- a/packages/components/src/Tooltip/Tooltip.tsx +++ b/packages/components/src/Tooltip/Tooltip.tsx @@ -12,6 +12,7 @@ export type TooltipProps = WithoutEmotionSpecific & { * Размер тултипа */ size?: TooltipSize; + /** * При значении false оборачивает компонент в div. По-умолчанию true * Это позволяет показывать тултипы на задизейбленных компонентах @@ -31,25 +32,21 @@ export const Tooltip = forwardRef( ...restProps } = props; - if (title) { - return ( - - {withoutContainer ? ( - children - ) : ( - {children} - )} - - ); - } - - return children; + return ( + + {withoutContainer ? ( + children + ) : ( + {children} + )} + + ); }, ); diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 94e29e9e2..6dbb8741e 100755 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -166,6 +166,12 @@ export * from './MenuList'; export * from './NavMenu'; +export { + NewActionCell, + type Actions as NewActions, + type ActionCellProps as NewActionCellProps, +} from './NewActionCell'; + export { NewDataGrid, type NewDataGridProps,