diff --git a/src/platform/packages/shared/kbn-unified-tabs/README.md b/src/platform/packages/shared/kbn-unified-tabs/README.md index 461b6e0fe834f..f9006b60cbe59 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/README.md +++ b/src/platform/packages/shared/kbn-unified-tabs/README.md @@ -5,7 +5,7 @@ Tabs bar components. ## Storybook Run the following command: -`NODE_OPTIONS="--openssl-legacy-provider" node scripts/storybook unified_tabs`. +`yarn storybook unified_tabs`. ## Example plugin diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.test.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.test.tsx index f344e8603a946..d7e62077cafa0 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.test.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.test.tsx @@ -47,4 +47,37 @@ describe('Tab', () => { expect(onClose).toHaveBeenCalled(); expect(onSelect).toHaveBeenCalledTimes(1); }); + + it('can render tab menu items', async () => { + const mockClick = jest.fn(); + const getTabMenuItems = jest.fn(() => [ + { + 'data-test-subj': 'test-subj', + name: 'test-name', + label: 'test-label', + onClick: mockClick, + }, + ]); + + render( + + ); + + const tabMenuButton = screen.getByTestId(`unifiedTabs_tabMenuBtn_${tabItem.id}`); + tabMenuButton.click(); + + expect(getTabMenuItems).toHaveBeenCalledWith(tabItem); + + const menuItem = screen.getByTestId('test-subj'); + menuItem.click(); + expect(mockClick).toHaveBeenCalledTimes(1); + expect(getTabMenuItems).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx index 32418db39f1f4..01822dcd1ed4d 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab/tab.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { MouseEvent, useCallback } from 'react'; +import React, { MouseEvent, useCallback, useRef } from 'react'; import { i18n } from '@kbn/i18n'; import { css } from '@emotion/react'; import { @@ -18,19 +18,29 @@ import { EuiThemeComputed, useEuiTheme, } from '@elastic/eui'; +import { TabMenu } from '../tab_menu'; import { getTabAttributes } from '../../utils/get_tab_attributes'; -import type { TabItem } from '../../types'; +import type { TabItem, GetTabMenuItems } from '../../types'; export interface TabProps { item: TabItem; isSelected: boolean; tabContentId: string; + getTabMenuItems?: GetTabMenuItems; onSelect: (item: TabItem) => void; - onClose: (item: TabItem) => void; + onClose: ((item: TabItem) => void) | undefined; } -export const Tab: React.FC = ({ item, isSelected, tabContentId, onSelect, onClose }) => { +export const Tab: React.FC = ({ + item, + isSelected, + tabContentId, + getTabMenuItems, + onSelect, + onClose, +}) => { const { euiTheme } = useEuiTheme(); + const containerRef = useRef(); const tabContainerDataTestSubj = `unifiedTabs_tab_${item.id}`; const closeButtonLabel = i18n.translate('unifiedTabs.closeTabButton', { @@ -51,23 +61,24 @@ export const Tab: React.FC = ({ item, isSelected, tabContentId, onSele const onCloseEvent = useCallback( (event: MouseEvent) => { event.stopPropagation(); - onClose(item); + onClose?.(item); }, [onClose, item] ); const onClickEvent = useCallback( (event: MouseEvent) => { - if (event.currentTarget.getAttribute('data-test-subj') === tabContainerDataTestSubj) { + if (event.currentTarget === containerRef.current) { // if user presses on the space around the buttons, we should still trigger the onSelectEvent onSelectEvent(event); } }, - [onSelectEvent, tabContainerDataTestSubj] + [onSelectEvent] ); return ( = ({ item, isSelected, tabContentId, onSele {item.label} - - + + + {!!getTabMenuItems && ( + + + + )} + {!!onClose && ( + + + + )} + ); @@ -111,8 +133,7 @@ function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) { border-right: ${euiTheme.border.thin}; border-color: ${euiTheme.colors.lightShade}; height: ${euiTheme.size.xl}; - padding-left: ${euiTheme.size.m}; - padding-right: ${euiTheme.size.xs}; + padding-inline: ${euiTheme.size.xs}; min-width: 96px; max-width: 280px; @@ -120,13 +141,14 @@ function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) { color: ${isSelected ? euiTheme.colors.text : euiTheme.colors.subduedText}; transition: background-color ${euiTheme.animation.fast}; - .unifiedTabs__closeTabBtn { + .unifiedTabs__tabActions { opacity: 0; transition: opacity ${euiTheme.animation.fast}; } - &:hover { - .unifiedTabs__closeTabBtn { + &:hover, + &:focus-within { + .unifiedTabs__tabActions { opacity: 1; } } @@ -149,9 +171,10 @@ function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) { function getTabButtonCss(euiTheme: EuiThemeComputed) { return css` width: 100%; + min-height: 100%; min-width: 0; flex-grow: 1; - padding-right: ${euiTheme.size.xs}; + padding-inline: ${euiTheme.size.xs}; text-align: left; color: inherit; border: none; diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab_menu/index.ts b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab_menu/index.ts new file mode 100644 index 0000000000000..bb842a4444504 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab_menu/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { TabMenu, type TabMenuProps } from './tab_menu'; diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab_menu/tab_menu.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab_menu/tab_menu.tsx new file mode 100644 index 0000000000000..c9fe45bcfc5e8 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab_menu/tab_menu.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButtonIcon, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiHorizontalRule, + EuiPopover, + useGeneratedHtmlId, +} from '@elastic/eui'; +import type { TabItem, GetTabMenuItems } from '../../types'; + +export interface TabMenuProps { + item: TabItem; + getTabMenuItems: GetTabMenuItems; +} + +export const TabMenu: React.FC = ({ item, getTabMenuItems }) => { + const [isPopoverOpen, setPopover] = useState(false); + const contextMenuPopoverId = useGeneratedHtmlId(); + + const menuButtonLabel = i18n.translate('unifiedTabs.tabMenuButton', { + defaultMessage: 'Actions', + }); + + const closePopover = useCallback(() => { + setPopover(false); + }, [setPopover]); + + const panelItems = useMemo(() => { + const itemConfigs = getTabMenuItems(item); + + return itemConfigs.map((itemConfig, index) => { + if (itemConfig === 'divider') { + return ; + } + + return ( + { + itemConfig.onClick(); + closePopover(); + }} + > + {itemConfig.label} + + ); + }); + }, [item, getTabMenuItems, closePopover]); + + return ( + setPopover((prev) => !prev)} + /> + } + > + + + ); +}; diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx index 5f35fbb2c753f..66d9aa1d19a2d 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabbed_content/tabbed_content.tsx @@ -7,10 +7,19 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { htmlIdGenerator, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { TabsBar } from '../tabs_bar'; import { getTabAttributes } from '../../utils/get_tab_attributes'; +import { getTabMenuItemsFn } from '../../utils/get_tab_menu_items'; +import { + addTab, + closeTab, + selectTab, + insertTabAfter, + closeOtherTabs, + closeTabsToTheRight, +} from '../../utils/manage_tabs'; import { TabItem } from '../../types'; export interface TabbedContentProps { @@ -44,55 +53,55 @@ export const TabbedContent: React.FC = ({ }; }); const { items, selectedItem } = state; + const stateRef = React.useRef(); + stateRef.current = state; const changeState = useCallback( (getNextState: (prevState: TabbedContentState) => TabbedContentState) => { - _setState((prevState) => { - const nextState = getNextState(prevState); - onChanged(nextState); - return nextState; - }); + if (!stateRef.current) { + return; + } + + const nextState = getNextState(stateRef.current); + _setState(nextState); + onChanged(nextState); }, [_setState, onChanged] ); const onSelect = useCallback( (item: TabItem) => { - changeState((prevState) => ({ - ...prevState, - selectedItem: item, - })); + changeState((prevState) => selectTab(prevState, item)); }, [changeState] ); const onClose = useCallback( (item: TabItem) => { - changeState((prevState) => { - const nextItems = prevState.items.filter((prevItem) => prevItem.id !== item.id); - // TODO: better selection logic - const nextSelectedItem = nextItems.length ? nextItems[nextItems.length - 1] : null; - - return { - items: nextItems, - selectedItem: - prevState.selectedItem?.id !== item.id ? prevState.selectedItem : nextSelectedItem, - }; - }); + changeState((prevState) => closeTab(prevState, item)); }, [changeState] ); const onAdd = useCallback(() => { const newItem = createItem(); - changeState((prevState) => { - return { - items: [...prevState.items, newItem], - selectedItem: newItem, - }; - }); + changeState((prevState) => addTab(prevState, newItem)); }, [changeState, createItem]); + const getTabMenuItems = useMemo(() => { + return getTabMenuItemsFn({ + tabsState: state, + onDuplicate: (item) => { + const newItem = createItem(); + newItem.label = `${item.label} (copy)`; + changeState((prevState) => insertTabAfter(prevState, newItem, item)); + }, + onCloseOtherTabs: (item) => changeState((prevState) => closeOtherTabs(prevState, item)), + onCloseTabsToTheRight: (item) => + changeState((prevState) => closeTabsToTheRight(prevState, item)), + }); + }, [changeState, createItem, state]); + return ( = ({ items={items} selectedItem={selectedItem} tabContentId={tabContentId} + getTabMenuItems={getTabMenuItems} onAdd={onAdd} onSelect={onSelect} onClose={onClose} diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.tsx index 0ba5439106aa1..0448a43d204a1 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.tsx @@ -11,22 +11,23 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { css } from '@emotion/react'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; -import { Tab } from '../tab'; +import { Tab, type TabProps } from '../tab'; import type { TabItem } from '../../types'; -export interface TabsBarProps { +export type TabsBarProps = Pick< + TabProps, + 'getTabMenuItems' | 'onSelect' | 'onClose' | 'tabContentId' +> & { items: TabItem[]; selectedItem: TabItem | null; - tabContentId: string; onAdd: () => void; - onSelect: (item: TabItem) => void; - onClose: (item: TabItem) => void; -} +}; export const TabsBar: React.FC = ({ items, selectedItem, tabContentId, + getTabMenuItems, onAdd, onSelect, onClose, @@ -56,8 +57,9 @@ export const TabsBar: React.FC = ({ item={item} isSelected={selectedItem?.id === item.id} tabContentId={tabContentId} + getTabMenuItems={getTabMenuItems} onSelect={onSelect} - onClose={onClose} + onClose={items.length > 1 ? onClose : undefined} // prevents closing the last tab /> ))} diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/types.ts b/src/platform/packages/shared/kbn-unified-tabs/src/types.ts index 16c76b660efa0..f1d0d35fca5fa 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/types.ts +++ b/src/platform/packages/shared/kbn-unified-tabs/src/types.ts @@ -11,3 +11,13 @@ export interface TabItem { id: string; label: string; } + +export interface TabMenuItemWithClick { + 'data-test-subj': string; + name: string; + label: string; + onClick: () => void; +} +export type TabMenuItem = TabMenuItemWithClick | 'divider'; + +export type GetTabMenuItems = (item: TabItem) => TabMenuItem[]; diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/utils/get_tab_menu_items.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/utils/get_tab_menu_items.tsx new file mode 100644 index 0000000000000..b89d6b463b564 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-tabs/src/utils/get_tab_menu_items.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { i18n } from '@kbn/i18n'; +import { TabItem, GetTabMenuItems, TabMenuItemWithClick, TabMenuItem } from '../types'; +import { isLastTab, hasSingleTab, type TabsState } from './manage_tabs'; + +const DividerMenuItem = 'divider'; + +interface TabMenuItemProps { + name: string; + label: string; + item: TabItem; + onClick: (item: TabItem) => void; +} + +const getTabMenuItem = ({ + name, + label, + item, + onClick, +}: TabMenuItemProps): TabMenuItemWithClick => ({ + 'data-test-subj': `unifiedTabs_tabMenuItem_${name}`, + name, + label, + onClick: () => onClick(item), +}); + +export interface GetTabMenuItemsFnProps { + tabsState: TabsState; + onDuplicate: (item: TabItem) => void; + onCloseOtherTabs: (item: TabItem) => void; + onCloseTabsToTheRight: (item: TabItem) => void; +} + +export const getTabMenuItemsFn = ({ + tabsState, + onDuplicate, + onCloseOtherTabs, + onCloseTabsToTheRight, +}: GetTabMenuItemsFnProps): GetTabMenuItems => { + return (item) => { + const closeOtherTabsItem = hasSingleTab(tabsState) + ? null + : getTabMenuItem({ + item, + name: 'closeOtherTabs', + label: i18n.translate('unifiedTabs.tabMenu.closeOtherTabsMenuItem', { + defaultMessage: 'Close other tabs', + }), + onClick: onCloseOtherTabs, + }); + + const closeTabsToTheRightItem = isLastTab(tabsState, item) + ? null + : getTabMenuItem({ + item, + name: 'closeTabsToTheRight', + label: i18n.translate('unifiedTabs.tabMenu.closeTabsToTheRightMenuItem', { + defaultMessage: 'Close tabs to the right', + }), + onClick: onCloseTabsToTheRight, + }); + + const items: TabMenuItem[] = [ + getTabMenuItem({ + item, + name: 'duplicate', + label: i18n.translate('unifiedTabs.tabMenu.duplicateMenuItem', { + defaultMessage: 'Duplicate', + }), + onClick: onDuplicate, + }), + ]; + + if (closeOtherTabsItem || closeTabsToTheRightItem) { + items.push(DividerMenuItem); + + if (closeOtherTabsItem) { + items.push(closeOtherTabsItem); + } + + if (closeTabsToTheRightItem) { + items.push(closeTabsToTheRightItem); + } + } + + return items; + }; +}; diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.test.ts b/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.test.ts new file mode 100644 index 0000000000000..422c3354ecbf5 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.test.ts @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + hasSingleTab, + isLastTab, + addTab, + selectTab, + insertTabAfter, + closeTab, + closeOtherTabs, + closeTabsToTheRight, +} from './manage_tabs'; + +const items = Array.from({ length: 5 }).map((_, i) => ({ + id: `tab-${i}`, + label: `Tab ${i}`, +})); + +describe('manage_tabs', () => { + describe('hasSingleTab', () => { + it('returns true if there is only one tab', () => { + expect(hasSingleTab({ items: [items[0]], selectedItem: null })).toBe(true); + }); + + it('returns false if there is more than one tab', () => { + expect(hasSingleTab({ items, selectedItem: null })).toBe(false); + }); + }); + + describe('isLastTab', () => { + it('returns true if the item is the last tab', () => { + expect(isLastTab({ items, selectedItem: null }, items[4])).toBe(true); + }); + + it('returns true if the item is the only tab', () => { + expect(isLastTab({ items: [items[0]], selectedItem: null }, items[0])).toBe(true); + }); + + it('returns false if the item is not the last tab', () => { + expect(isLastTab({ items, selectedItem: null }, items[3])).toBe(false); + }); + + it('returns false if the item is not from the list', () => { + expect(isLastTab({ items: [items[0], items[1]], selectedItem: null }, items[3])).toBe(false); + }); + }); + + describe('addTab', () => { + it('adds a tab', () => { + const newItem = { id: 'tab-5', label: 'Tab 5' }; + const prevState = { items, selectedItem: items[0] }; + const nextState = addTab(prevState, newItem); + + expect(nextState.items).not.toBe(items); + expect(nextState.items).toEqual([...items, newItem]); + expect(nextState.selectedItem).toBe(newItem); + }); + }); + + describe('selectTab', () => { + it('selects a tab', () => { + const prevState = { items, selectedItem: items[0] }; + const nextState = selectTab(prevState, items[1]); + + expect(nextState.items).toBe(items); + expect(nextState.selectedItem).toBe(items[1]); + }); + + it("skips selecting a tab if it's not from the list", () => { + const limitedItems = [items[0], items[1]]; + const prevState = { items: limitedItems, selectedItem: items[0] }; + const nextState = selectTab(prevState, items[2]); + + expect(nextState.items).toBe(limitedItems); + expect(nextState.selectedItem).toBe(items[0]); + }); + }); + + describe('insertTabAfter', () => { + it('inserts a tab after another tab', () => { + const newItem = { id: 'tab-5', label: 'Tab 5' }; + const prevState = { items, selectedItem: items[0] }; + const nextState = insertTabAfter(prevState, newItem, items[2]); + + expect(nextState.items).not.toBe(items); + expect(nextState.items).toEqual([items[0], items[1], items[2], newItem, items[3], items[4]]); + expect(nextState.selectedItem).toBe(newItem); + }); + + it('inserts a tab after the last tab', () => { + const newItem = { id: 'tab-5', label: 'Tab 5' }; + const prevState = { items, selectedItem: items[0] }; + const nextState = insertTabAfter(prevState, newItem, items[items.length - 1]); + + expect(nextState.items).not.toBe(items); + expect(nextState.items).toEqual([items[0], items[1], items[2], items[3], items[4], newItem]); + expect(nextState.selectedItem).toBe(newItem); + }); + }); + + describe('closeTab', () => { + it('closes a tab from the middle', () => { + const prevState = { items, selectedItem: items[0] }; + const nextState = closeTab(prevState, items[2]); + + expect(nextState.items).not.toBe(items); + expect(nextState.items).toEqual([items[0], items[1], items[3], items[4]]); + expect(nextState.selectedItem).toBe(items[0]); + }); + + it('closes the first tab', () => { + const prevState = { items, selectedItem: items[0] }; + const nextState = closeTab(prevState, items[0]); + + expect(nextState.items).not.toBe(items); + expect(nextState.items).toEqual([items[1], items[2], items[3], items[4]]); + expect(nextState.selectedItem).toBe(items[1]); + }); + + it('closes the last tab', () => { + const prevState = { items, selectedItem: items[4] }; + const nextState = closeTab(prevState, items[items.length - 1]); + + expect(nextState.items).not.toBe(items); + expect(nextState.items).toEqual([items[0], items[1], items[2], items[3]]); + expect(nextState.selectedItem).toBe(items[3]); + }); + + it('closes the selected tab', () => { + const prevState = { items, selectedItem: items[2] }; + const nextState = closeTab(prevState, items[2]); + + expect(nextState.items).not.toBe(items); + expect(nextState.items).toEqual([items[0], items[1], items[3], items[4]]); + expect(nextState.selectedItem).toBe(items[3]); + }); + + it("skips closing a tab if it's not from the list", () => { + const limitedItems = [items[0], items[1]]; + const prevState = { items: limitedItems, selectedItem: items[0] }; + const nextState = closeTab(prevState, items[2]); + + expect(nextState.items).toBe(limitedItems); + expect(nextState.selectedItem).toBe(items[0]); + }); + }); + + describe('closeOtherTabs', () => { + it('closes other tabs', () => { + const prevState = { items, selectedItem: items[1] }; + const nextState = closeOtherTabs(prevState, items[2]); + + expect(nextState.items).not.toBe(items); + expect(nextState.items).toEqual([items[2]]); + expect(nextState.selectedItem).toBe(items[2]); + }); + + it('closes other tabs and keeps the only tab', () => { + const limitedItems = [items[2]]; + const prevState = { items: limitedItems, selectedItem: items[2] }; + const nextState = closeOtherTabs(prevState, items[2]); + + expect(nextState.items).not.toBe(limitedItems); + expect(nextState.items).toEqual(limitedItems); + expect(nextState.selectedItem).toBe(items[2]); + }); + }); + + describe('closeTabsToTheRight', () => { + it('closes tabs to the right from the middle', () => { + const prevState = { items, selectedItem: items[1] }; + const nextState = closeTabsToTheRight(prevState, items[2]); + + expect(nextState.items).not.toBe(items); + expect(nextState.items).toEqual([items[0], items[1], items[2]]); + expect(nextState.selectedItem).toBe(items[1]); + }); + + it('closes tabs to the right from the beginning', () => { + const limitedItems = [items[0], items[1], items[2]]; + const prevState = { items: limitedItems, selectedItem: items[0] }; + const nextState = closeTabsToTheRight(prevState, items[0]); + + expect(nextState.items).not.toBe(limitedItems); + expect(nextState.items).toEqual([items[0]]); + expect(nextState.selectedItem).toBe(items[0]); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.ts b/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.ts new file mode 100644 index 0000000000000..b95ff4bfbfce8 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-tabs/src/utils/manage_tabs.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { TabItem } from '../types'; + +export interface TabsState { + items: TabItem[]; + selectedItem: TabItem | null; +} + +export const hasSingleTab = ({ items }: TabsState): boolean => { + return items.length === 1; +}; + +export const isLastTab = ({ items }: TabsState, item: TabItem): boolean => { + return items[items.length - 1].id === item.id; +}; + +export const addTab = ({ items }: TabsState, item: TabItem): TabsState => { + return { + items: [...items, item], + selectedItem: item, + }; +}; + +export const selectTab = ({ items, selectedItem }: TabsState, item: TabItem): TabsState => { + return { + items, + selectedItem: items.find((i) => i.id === item.id) || selectedItem, + }; +}; + +export const closeTab = ({ items, selectedItem }: TabsState, item: TabItem): TabsState => { + const itemIndex = items.findIndex((i) => i.id === item.id); + + if (itemIndex === -1) { + return { + items, + selectedItem, + }; + } + + const nextItems = [...items]; + nextItems.splice(itemIndex, 1); + + if (selectedItem?.id !== item.id) { + return { + items: nextItems, + selectedItem, + }; + } + + const nextSelectedIndex = itemIndex === items.length - 1 ? itemIndex - 1 : itemIndex; + const nextSelectedItem = nextItems[nextSelectedIndex] || null; + + return { + items: nextItems, + selectedItem: nextSelectedItem, + }; +}; + +export const insertTabAfter = ( + { items, selectedItem }: TabsState, + item: TabItem, + insertAfterItem: TabItem +): TabsState => { + const insertAfterIndex = items.findIndex((i) => i.id === insertAfterItem.id); + + if (insertAfterIndex === -1) { + return { + items, + selectedItem, + }; + } + + const nextItems = [...items]; + const insertIndex = insertAfterIndex + 1; + + if (insertIndex === nextItems.length) { + nextItems.push(item); + } else { + nextItems.splice(insertIndex, 0, item); + } + + return { + items: nextItems, + selectedItem: item, + }; +}; + +export const closeOtherTabs = (_: TabsState, item: TabItem): TabsState => { + return { + items: [item], + selectedItem: item, + }; +}; + +export const closeTabsToTheRight = ( + { items, selectedItem }: TabsState, + item: TabItem +): TabsState => { + const itemIndex = items.findIndex((i) => i.id === item.id); + + if (itemIndex === -1 || itemIndex === items.length - 1) { + return { + items, + selectedItem, + }; + } + + const nextItems = items.slice(0, itemIndex + 1); + + return { + items: nextItems, + selectedItem, + }; +};