diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/__stories__/tabs.stories.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/__stories__/tabs.stories.tsx index 809eaedd32187..fe36a3c4727a3 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/__stories__/tabs.stories.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/__stories__/tabs.stories.tsx @@ -43,7 +43,7 @@ const TabbedContentTemplate: StoryFn = (args) => { {...args} items={managedItems} selectedItemId={managedSelectedItemId} - recentlyClosedItems={[]} + recentlyClosedItems={args.recentlyClosedItems} createItem={getNewTabDefaultProps} getPreviewData={getPreviewDataMock} services={servicesMock} @@ -94,5 +94,12 @@ export const WithMultipleTabs: StoryObj = { }, ], selectedItemId: '3', + recentlyClosedItems: [ + { + id: '4', + label: 'Closed Tab', + closedAt: 123456789, + }, + ], }, }; diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab_preview/tab_preview.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab_preview/tab_preview.tsx index aa56fbc0cba13..0a90cff8bbc34 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tab_preview/tab_preview.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tab_preview/tab_preview.tsx @@ -37,6 +37,7 @@ export interface TabPreviewProps { previewData: TabPreviewData; stopPreviewOnHover?: boolean; previewDelay?: number; + position?: 'bottom' | 'left'; } const getQueryLanguage = (tabPreviewData: TabPreviewData) => { @@ -62,6 +63,7 @@ export const TabPreview: React.FC = ({ tabItem, previewData, stopPreviewOnHover, + position = 'bottom', previewDelay = 1250, // as "long" EuiToolTip delay }) => { const { euiTheme } = useEuiTheme(); @@ -73,27 +75,42 @@ export const TabPreview: React.FC = ({ useEffect(() => { if (showPreview && tabRef.current) { const rect = tabRef.current.getBoundingClientRect(); - const windowWidth = window.innerWidth; - // Check if preview would extend beyond right edge - const wouldExtendBeyondRight = rect.left + PREVIEW_WIDTH > windowWidth; + setTabPreviewData(previewData); - // Calculate left position based on screen edge constraints - let leftPosition = rect.left + window.scrollX; + if (position === 'left') { + // Position to the left of the element + let leftPosition = rect.left + window.scrollX - PREVIEW_WIDTH - euiTheme.base - 30; // extra 30 to push it off the EUI selectable menu + const topPosition = rect.top + window.scrollY - euiTheme.base / 2; - if (wouldExtendBeyondRight) { - // Align right edge of preview with right edge of window - leftPosition = windowWidth - PREVIEW_WIDTH + window.scrollX; - } + // Ensure preview doesn't go off left edge + if (leftPosition < window.scrollX) { + leftPosition = window.scrollX + euiTheme.base; + } - setTabPreviewData(previewData); + setTabPosition({ + top: topPosition, + left: leftPosition, + }); + } else { + // Position below the element (default) + let leftPosition = rect.left + window.scrollX; - setTabPosition({ - top: rect.bottom + window.scrollY + euiTheme.base / 2, - left: leftPosition, - }); + // Check if preview would extend beyond right edge + const wouldExtendBeyondRight = rect.left + PREVIEW_WIDTH > window.innerWidth; + + if (wouldExtendBeyondRight) { + // Align right edge of preview with right edge of window + leftPosition = window.innerWidth - PREVIEW_WIDTH + window.scrollX; + } + + setTabPosition({ + top: rect.bottom + window.scrollY + euiTheme.base / 2, + left: leftPosition, + }); + } } - }, [showPreview, previewData, tabItem, euiTheme.base]); + }, [showPreview, previewData, tabItem, euiTheme.base, position]); const onKeyDown = useCallback( (event: KeyboardEvent) => { 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 fd8d0729a29c1..4435cf586b21d 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 @@ -24,7 +24,13 @@ import { closeOtherTabs, closeTabsToTheRight, } from '../../utils/manage_tabs'; -import type { TabItem, TabsServices, TabPreviewData, TabsEBTEvent } from '../../types'; +import type { + TabItem, + TabsServices, + TabPreviewData, + TabsEBTEvent, + RecentlyClosedTabItem, +} from '../../types'; import { TabsEventName } from '../../types'; import { getNextTabNumber } from '../../utils/get_next_tab_number'; import { MAX_ITEMS_COUNT, TAB_SWITCH_DEBOUNCE_MS } from '../../constants'; @@ -43,7 +49,7 @@ export interface TabbedContentProps > { items: TabItem[]; selectedItemId?: string; - recentlyClosedItems: TabItem[]; + recentlyClosedItems: RecentlyClosedTabItem[]; 'data-test-subj'?: string; services: TabsServices; hideTabsBar?: boolean; diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.test.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.test.tsx index f58870bffd52c..605c0193aa395 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.test.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar/tabs_bar.test.tsx @@ -21,6 +21,7 @@ const items = Array.from({ length: 5 }).map((_, i) => ({ const recentlyClosedItems = Array.from({ length: 3 }).map((_, i) => ({ id: `closed-tab-${i}`, label: `Closed Tab ${i}`, + closedAt: 0, })); const tabContentId = 'test-content-id'; 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 f058f5406d50b..a9a64fe43fc14 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 @@ -31,7 +31,7 @@ import { keys, } from '@elastic/eui'; import { Tab, type TabProps } from '../tab'; -import type { TabItem, TabsServices, TabsEBTEvent } from '../../types'; +import type { TabItem, TabsServices, TabsEBTEvent, RecentlyClosedTabItem } from '../../types'; import { TabsEventName } from '../../types'; import { getTabIdAttribute } from '../../utils/get_tab_attributes'; import { useResponsiveTabs } from '../../hooks/use_responsive_tabs'; @@ -67,7 +67,7 @@ export type TabsBarProps = Pick< > & { items: TabItem[]; selectedItem: TabItem | null; - recentlyClosedItems: TabItem[]; + recentlyClosedItems: RecentlyClosedTabItem[]; unsavedItemIds?: string[]; maxItemsCount?: number; services: TabsServices; @@ -385,6 +385,7 @@ export const TabsBar = forwardRef( items={items} selectedItem={selectedItem} recentlyClosedItems={recentlyClosedItems} + getPreviewData={getPreviewData} onSelect={onSelect} onSelectRecentlyClosed={onSelectRecentlyClosed} onClearRecentlyClosed={onClearRecentlyClosed} diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar_menu/tabs_bar_menu.test.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar_menu/tabs_bar_menu.test.tsx index e2af4ffef5109..1a0a398ae9496 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar_menu/tabs_bar_menu.test.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar_menu/tabs_bar_menu.test.tsx @@ -11,6 +11,8 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { TabsBarMenu } from './tabs_bar_menu'; +import type { TabItem } from '../../types'; +import { TabStatus, type RecentlyClosedTabItem } from '../../types'; const mockTabs = [ { id: 'tab1', label: 'Tab 1' }, @@ -18,9 +20,9 @@ const mockTabs = [ { id: 'tab3', label: 'Tab 3' }, ]; -const mockRecentlyClosedTabs = [ - { id: 'closed1', label: 'Closed Tab 1' }, - { id: 'closed2', label: 'Closed Tab 2' }, +const mockRecentlyClosedTabs: RecentlyClosedTabItem[] = [ + { id: 'closed1', label: 'Closed Tab 1', closedAt: 0 }, + { id: 'closed2', label: 'Closed Tab 2', closedAt: 0 }, ]; const tabsBarMenuButtonTestId = 'unifiedTabs_tabsBarMenuButton'; @@ -122,7 +124,9 @@ describe('TabsBarMenu', () => { const menuButton = await screen.findByTestId(tabsBarMenuButtonTestId); await user.click(menuButton); - const closedTabOption = (await screen.findAllByTitle(mockRecentlyClosedTabs[0].label))[0]; + const closedTabOption = await screen.findByTestId( + `unifiedTabs_tabsMenu_recentlyClosedTab_${mockRecentlyClosedTabs[0].id}` + ); await user.click(closedTabOption); expect(mockOnSelectClosedTab).toHaveBeenCalledWith(mockRecentlyClosedTabs[0]); @@ -154,7 +158,7 @@ describe('TabsBarMenu', () => { const menuButton = await screen.findByTestId(tabsBarMenuButtonTestId); await user.click(menuButton); - const selectedTabOption = (await screen.findAllByTitle(mockTabs[0].label))[0]; + const selectedTabOption = (await screen.findAllByText(mockTabs[0].label))[0]; expect(selectedTabOption.closest('[aria-selected="true"]')).toBeInTheDocument(); }); @@ -178,4 +182,34 @@ describe('TabsBarMenu', () => { expect(await screen.findByText(/5 minutes ago/i)).toBeVisible(); expect(await screen.findByText(/10 minutes ago/i)).toBeVisible(); }); + + it('shows preview when callback is provided', async () => { + const user = userEvent.setup(); + const now = Date.now(); + const propsWithTimestamps = { + ...defaultProps, + getPreviewData: (item: TabItem) => ({ + title: `Preview of ${item.label}`, + query: { language: 'esql', query: 'SELECT * FROM table' }, + status: TabStatus.DEFAULT, + }), + recentlyClosedItems: [ + { id: 'closed1', label: 'Closed Tab 1', closedAt: now - 5 * 60 * 1000 }, // 5 minutes + { id: 'closed2', label: 'Closed Tab 2', closedAt: now - 10 * 60 * 1000 }, // 10 minutes + ], + }; + + render(); + + const menuButton = screen.getByTestId(tabsBarMenuButtonTestId); + await user.click(menuButton); + + expect(await screen.findByText('Recently closed')).toBeVisible(); + + // Hover over the closed tab item + await user.hover(screen.getByText('Closed Tab 1')); + + // Wait for the preview to appear + expect(await screen.findByText('Preview of Closed Tab 1')).toBeVisible(); + }); }); diff --git a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar_menu/tabs_bar_menu.tsx b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar_menu/tabs_bar_menu.tsx index f628055628530..e664a9ee934cf 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar_menu/tabs_bar_menu.tsx +++ b/src/platform/packages/shared/kbn-unified-tabs/src/components/tabs_bar_menu/tabs_bar_menu.tsx @@ -25,48 +25,56 @@ import { EuiButtonEmpty, EuiText, useEuiTheme, + EuiTextTruncate, } from '@elastic/eui'; +import type { RecentlyClosedTabItem, TabPreviewData } from '../../types'; import type { TabItem } from '../../types'; +import { TabPreview } from '../tab_preview'; interface OptionData { - closedAt?: moment.Moment; + // lowercase names because EUI applies these to the DOM and React warns against non-lowercase attributes + tabitem: RecentlyClosedTabItem | TabItem; + formattedtime?: string; } -type RecentlyClosedTabOption = EuiSelectableOption; - const getOpenedTabsList = ( tabItems: TabItem[], selectedTab: TabItem | null -): EuiSelectableOption[] => { +): EuiSelectableOption[] => { return tabItems.map((tab) => ({ label: tab.label, checked: selectedTab && tab.id === selectedTab.id ? 'on' : undefined, key: tab.id, + tabitem: tab, + title: undefined, // disable default title tooltip })); }; -const getRecentlyClosedTabsList = (tabItems: TabItem[]): RecentlyClosedTabOption[] => { +const getRecentlyClosedTabsList = ( + tabItems: RecentlyClosedTabItem[] +): Array> => { return tabItems.map((tab) => { - const closedAt = 'closedAt' in tab && tab.closedAt ? moment(tab.closedAt) : undefined; - const option = { + const momentClosedAt = moment(tab.closedAt); + const formattedTime = momentClosedAt?.isValid() ? momentClosedAt.fromNow() : ''; + + return { label: tab.label, - title: `${tab.label}${closedAt?.isValid() ? ` (${closedAt.format('LL LT')})` : ''}`, key: tab.id, 'data-test-subj': `unifiedTabs_tabsMenu_recentlyClosedTab_${tab.id}`, - data: { - closedAt, - }, + tabitem: tab, + formattedtime: formattedTime, + title: undefined, // disable default title tooltip }; - return option; }); }; export interface TabsBarMenuProps { items: TabItem[]; selectedItem: TabItem | null; - recentlyClosedItems: TabItem[]; + recentlyClosedItems: RecentlyClosedTabItem[]; + getPreviewData?: (item: TabItem) => TabPreviewData; onSelect: (item: TabItem) => Promise; - onSelectRecentlyClosed: (item: TabItem) => Promise; + onSelectRecentlyClosed: (item: RecentlyClosedTabItem) => Promise; onClearRecentlyClosed: () => void; } @@ -75,22 +83,29 @@ export const TabsBarMenu: React.FC = React.memo( items, selectedItem, recentlyClosedItems, + getPreviewData, onSelect, onSelectRecentlyClosed, onClearRecentlyClosed, }) => { const { euiTheme } = useEuiTheme(); - const openedTabsList = useMemo( + const openedTabsList: EuiSelectableOption[] = useMemo( () => getOpenedTabsList(items, selectedItem), [items, selectedItem] ); + + const [menuOpenCount, setMenuOpenCount] = useState(0); + const recentlyClosedTabsList = useMemo( () => getRecentlyClosedTabsList(recentlyClosedItems), - [recentlyClosedItems] + // disabling eslint rule because we want the formattedTimes to update whenever the popover opens + // eslint-disable-next-line react-hooks/exhaustive-deps + [recentlyClosedItems, menuOpenCount] ); const [isPopoverOpen, setPopover] = useState(false); + const [previewTabId, setPreviewTabId] = useState(null); const contextMenuPopoverId = useGeneratedHtmlId(); const menuButtonLabel = i18n.translate('unifiedTabs.tabsBarMenu.tabsBarMenuButton', { @@ -108,21 +123,43 @@ export const TabsBarMenu: React.FC = React.memo( }, } as Partial; - const renderRecentlyClosedOption = useCallback((option: EuiSelectableOption) => { - const closedAt = option?.closedAt; - const formattedTime = closedAt?.isValid() ? closedAt.fromNow() : ''; - - return ( - <> - {option.label} - {formattedTime && ( - - {formattedTime} - - )} - - ); - }, []); + const renderOption = useCallback( + (option: EuiSelectableOption) => { + const itemContents = ( + <> + {/* title set to undefined to disable default tooltip */} + + {option.formattedtime && ( + + {option.formattedtime} + + )} + + ); + + if (!getPreviewData) { + return itemContents; + } + + const previewData = getPreviewData(option.tabitem); + + return ( + + setPreviewTabId((prev) => (prev === option.key ? null : (option.key as string))) + } + tabItem={{ id: option.key as string, label: option.label }} + previewData={previewData} + previewDelay={0} + position="left" + > +
{itemContents}
+
+ ); + }, + [previewTabId, getPreviewData] + ); return ( = React.memo( color="text" data-test-subj="unifiedTabs_tabsBarMenuButton" iconType="arrowDown" - onClick={() => setPopover((prev) => !prev)} + onClick={() => { + setPopover((prev) => { + const willOpen = !prev; + if (willOpen) { + setMenuOpenCount((count) => count + 1); + } + return willOpen; + }); + }} /> } @@ -163,6 +208,7 @@ export const TabsBarMenu: React.FC = React.memo( closePopover(); } }} + renderOption={renderOption} singleSelection="always" listProps={selectableListProps} > @@ -190,7 +236,7 @@ export const TabsBarMenu: React.FC = React.memo( ...selectableListProps, rowHeight: parseInt(euiTheme.size.xxxl, 10), }} - renderOption={renderRecentlyClosedOption} + renderOption={renderOption} onChange={(newOptions) => { const clickedTabId = newOptions.find((option) => option.checked)?.key; const tabToNavigate = recentlyClosedItems.find((tab) => tab.id === clickedTabId); 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 d60067995eb8c..b77101bc12e3c 100644 --- a/src/platform/packages/shared/kbn-unified-tabs/src/types.ts +++ b/src/platform/packages/shared/kbn-unified-tabs/src/types.ts @@ -19,6 +19,10 @@ export interface TabItem { customMenuButton?: React.JSX.Element; } +export type RecentlyClosedTabItem = TabItem & { + closedAt: number; +}; + export interface TabsSizeConfig { isScrollable: boolean; regularTabMaxWidth: number; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_preview_data.ts b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_preview_data.ts index c2e16b54282ae..1241e4bf5fd55 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_preview_data.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_preview_data.ts @@ -22,25 +22,31 @@ import { selectTabRuntimeState, useInternalStateSelector, selectAllTabs, + selectRecentlyClosedTabs, } from '../../state_management/redux'; import { FetchStatus } from '../../../types'; +import type { RecentlyClosedTabState } from '../../state_management/redux/types'; export const usePreviewData = (runtimeStateManager: RuntimeStateManager) => { const allTabs = useInternalStateSelector(selectAllTabs); + const recentlyClosedTabs = useInternalStateSelector(selectRecentlyClosedTabs); const savedDataViews = useInternalStateSelector((state) => state.savedDataViews); const previewDataMap$ = useMemo( () => combineLatest( - allTabs.reduce>>((acc, tabState) => { - const tabId = tabState.id; - return { - ...acc, - [tabId]: getPreviewDataObservable(runtimeStateManager, tabState, savedDataViews), - }; - }, {}) + [...allTabs, ...recentlyClosedTabs].reduce>>( + (acc, tabState) => { + const tabId = tabState.id; + return { + ...acc, + [tabId]: getPreviewDataObservable(runtimeStateManager, tabState, savedDataViews), + }; + }, + {} + ) ), - [allTabs, runtimeStateManager, savedDataViews] + [allTabs, recentlyClosedTabs, runtimeStateManager, savedDataViews] ); const previewDataMap = useObservable(previewDataMap$); const getPreviewData = useCallback( @@ -135,9 +141,22 @@ const getPreviewTitle = ( const getPreviewDataObservable = ( runtimeStateManager: RuntimeStateManager, - tabState: TabState, + tabState: TabState | RecentlyClosedTabState, savedDataViews: DataViewListItem[] ) => { + if ('closedAt' in tabState) { + // Recently closed tab, no runtime state, no updates expected + const derivedDataViewName = getDataViewNameFromInitialInternalState( + tabState.initialInternalState, + savedDataViews + ); + return of({ + status: TabStatus.DEFAULT, + query: getPreviewQuery(tabState.appState.query, derivedDataViewName), + title: getPreviewTitle(tabState.appState.query, derivedDataViewName), + }); + } + const tabRuntimeState = selectTabRuntimeState(runtimeStateManager, tabState.id); return tabRuntimeState.stateContainer$.pipe(