[Discover Tabs] Don't allow to duplicate a tab when tabs limit is reached (#214772)

## Summary

This PR is a follow up for https://github.com/elastic/kibana/pull/213106
to hide Duplicate menu item when the max tabs limit is already reached.

## Testing

Two options are possible:

1. start Storybook with `yarn storybook unified_tabs` and navigate to
`http://localhost:9001`.
2. start Kibana with `yarn start --run-examples`. Then navigate to the
Unified Tabs example plugin
`http://localhost:5601/app/unifiedTabsExamples`.


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Julia Rechkunova 2025-03-19 10:25:58 +01:00 committed by GitHub
parent 4f9c54f91b
commit d764bd91f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 121 additions and 18 deletions

View file

@ -98,7 +98,7 @@ export const UnifiedTabsExampleApp: React.FC<UnifiedTabsExampleAppProps> = ({
<div className="eui-fullHeight">
<UnifiedTabs
initialItems={initialItems}
maxItemsCount={20}
maxItemsCount={25}
onChanged={() => {}}
createItem={getNewTabDefaultProps}
renderContent={({ label }) => {

View file

@ -101,16 +101,17 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
const getTabMenuItems = useMemo(() => {
return getTabMenuItemsFn({
tabsState: state,
maxItemsCount,
onDuplicate: (item) => {
const newItem = createItem();
newItem.label = `${item.label} (copy)`;
changeState((prevState) => insertTabAfter(prevState, newItem, item));
changeState((prevState) => insertTabAfter(prevState, newItem, item, maxItemsCount));
},
onCloseOtherTabs: (item) => changeState((prevState) => closeOtherTabs(prevState, item)),
onCloseTabsToTheRight: (item) =>
changeState((prevState) => closeTabsToTheRight(prevState, item)),
});
}, [changeState, createItem, state]);
}, [changeState, createItem, state, maxItemsCount]);
return (
<EuiFlexGroup

View file

@ -0,0 +1,76 @@
/*
* 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 { getTabMenuItemsFn } from './get_tab_menu_items';
import { getNewTabPropsForIndex } from '../hooks/use_new_tab_props';
import type { TabMenuItem } from '../types';
const items = Array.from({ length: 5 }).map((_, i) => getNewTabPropsForIndex(i));
const mapMenuItem = (item: TabMenuItem) => {
if (item === 'divider') {
return 'divider';
}
return item.name;
};
describe('getTabMenuItemsFn', () => {
it('returns correct menu items for a single tab', () => {
const getTabMenuItems = getTabMenuItemsFn({
tabsState: { items: [items[0]], selectedItem: items[0] },
maxItemsCount: 10,
onDuplicate: jest.fn(),
onCloseOtherTabs: jest.fn(),
onCloseTabsToTheRight: jest.fn(),
});
const menuItems = getTabMenuItems(items[0]);
expect(menuItems.map(mapMenuItem)).toEqual(['duplicate']);
});
it('returns correct menu items for many tabs', () => {
const getTabMenuItems = getTabMenuItemsFn({
tabsState: { items, selectedItem: items[0] },
maxItemsCount: 10,
onDuplicate: jest.fn(),
onCloseOtherTabs: jest.fn(),
onCloseTabsToTheRight: jest.fn(),
});
const menuItems = getTabMenuItems(items[0]);
expect(menuItems.map(mapMenuItem)).toEqual([
'duplicate',
'divider',
'closeOtherTabs',
'closeTabsToTheRight',
]);
});
it('returns correct menu items when max limit is reached', () => {
const getTabMenuItems = getTabMenuItemsFn({
tabsState: { items, selectedItem: items[0] },
maxItemsCount: items.length,
onDuplicate: jest.fn(),
onCloseOtherTabs: jest.fn(),
onCloseTabsToTheRight: jest.fn(),
});
const menuItems = getTabMenuItems(items[2]);
expect(menuItems.map(mapMenuItem)).toEqual(['closeOtherTabs', 'closeTabsToTheRight']);
});
it('returns correct menu items for the last item of many tabs', () => {
const getTabMenuItems = getTabMenuItemsFn({
tabsState: { items, selectedItem: items[0] },
maxItemsCount: 10,
onDuplicate: jest.fn(),
onCloseOtherTabs: jest.fn(),
onCloseTabsToTheRight: jest.fn(),
});
const menuItems = getTabMenuItems(items[items.length - 1]);
expect(menuItems.map(mapMenuItem)).toEqual(['duplicate', 'divider', 'closeOtherTabs']);
});
});

View file

@ -34,6 +34,7 @@ const getTabMenuItem = ({
export interface GetTabMenuItemsFnProps {
tabsState: TabsState;
maxItemsCount: number | undefined;
onDuplicate: (item: TabItem) => void;
onCloseOtherTabs: (item: TabItem) => void;
onCloseTabsToTheRight: (item: TabItem) => void;
@ -41,6 +42,7 @@ export interface GetTabMenuItemsFnProps {
export const getTabMenuItemsFn = ({
tabsState,
maxItemsCount,
onDuplicate,
onCloseOtherTabs,
onCloseTabsToTheRight,
@ -68,19 +70,25 @@ export const getTabMenuItemsFn = ({
onClick: onCloseTabsToTheRight,
});
const items: TabMenuItem[] = [
getTabMenuItem({
item,
name: 'duplicate',
label: i18n.translate('unifiedTabs.tabMenu.duplicateMenuItem', {
defaultMessage: 'Duplicate',
}),
onClick: onDuplicate,
}),
];
const items: TabMenuItem[] = [];
if (!maxItemsCount || tabsState.items.length < maxItemsCount) {
items.push(
getTabMenuItem({
item,
name: 'duplicate',
label: i18n.translate('unifiedTabs.tabMenu.duplicateMenuItem', {
defaultMessage: 'Duplicate',
}),
onClick: onDuplicate,
})
);
}
if (closeOtherTabsItem || closeTabsToTheRightItem) {
items.push(DividerMenuItem);
if (items.length > 0) {
items.push(DividerMenuItem);
}
if (closeOtherTabsItem) {
items.push(closeOtherTabsItem);

View file

@ -120,17 +120,27 @@ describe('manage_tabs', () => {
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]);
const nextState = insertTabAfter(prevState, newItem, items[2], undefined);
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('should not insert a tab if the limit is reached', () => {
const maxItemsCount = items.length;
const newItem = { id: 'tab-5', label: 'Tab 5' };
const prevState = { items, selectedItem: items[0] };
const nextState = insertTabAfter(prevState, newItem, items[2], maxItemsCount);
expect(nextState.items).toBe(items);
expect(nextState.selectedItem).toBe(items[0]);
});
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]);
const nextState = insertTabAfter(prevState, newItem, items[items.length - 1], 100);
expect(nextState.items).not.toBe(items);
expect(nextState.items).toEqual([items[0], items[1], items[2], items[3], items[4], newItem]);

View file

@ -25,7 +25,7 @@ export const isLastTab = ({ items }: TabsState, item: TabItem): boolean => {
export const addTab = (
{ items, selectedItem }: TabsState,
item: TabItem,
maxItemsCount?: number
maxItemsCount: number | undefined
): TabsState => {
if (maxItemsCount && items.length >= maxItemsCount) {
return {
@ -79,8 +79,16 @@ export const closeTab = ({ items, selectedItem }: TabsState, item: TabItem): Tab
export const insertTabAfter = (
{ items, selectedItem }: TabsState,
item: TabItem,
insertAfterItem: TabItem
insertAfterItem: TabItem,
maxItemsCount: number | undefined
): TabsState => {
if (maxItemsCount && items.length >= maxItemsCount) {
return {
items,
selectedItem,
};
}
const insertAfterIndex = items.findIndex((i) => i.id === insertAfterItem.id);
if (insertAfterIndex === -1) {