mirror of
https://github.com/elastic/kibana.git
synced 2025-04-18 23:21:39 -04:00
[Discover Session][Tabs] Tab preview (#214090)
- Closes https://github.com/elastic/kibana/issues/214554 ## Summary This PR: - adds TabPreview component, which is visible when you hover over a particular tab - adds tests for TabPreview component About TabPreview component - EUI doesn't have a component, which would suit for our needs, hence a custom component activated on hover - TabPreview should activate (with 500ms of delay) after hovering over a whole tab - It should hide when we click action button in the tab or we open editing name mode - It shouldn't appear on hover when we have tab context menu open or we're editing a title - For now the data inside is mocked (besides of title), so you can see random queries and statuses each time you hover over the tab - Preview should not overflow the screen if there are a lot of tabs and they're "touching" right side of the screen https://github.com/user-attachments/assets/da0a47dd-b594-4c20-b76c-49e6889f3814 ## 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 --------- Co-authored-by: Julia Rechkunova <julia.rechkunova@elastic.co> Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
38de01504b
commit
748d54ba91
14 changed files with 462 additions and 36 deletions
|
@ -25,9 +25,33 @@ import type { DataView } from '@kbn/data-views-plugin/public';
|
|||
import type { DataViewField } from '@kbn/data-views-plugin/public';
|
||||
import type { DataViewPickerProps } from '@kbn/unified-search-plugin/public';
|
||||
import { type TabItem, UnifiedTabs, useNewTabProps } from '@kbn/unified-tabs';
|
||||
import { type TabPreviewData, TabStatus } from '@kbn/unified-tabs';
|
||||
import { PLUGIN_ID, PLUGIN_NAME } from '../common';
|
||||
import { FieldListSidebar, FieldListSidebarProps } from './field_list_sidebar';
|
||||
|
||||
// TODO: replace with real data when ready
|
||||
const TAB_CONTENT_MOCK: TabPreviewData[] = [
|
||||
{
|
||||
query: {
|
||||
esql: 'FROM logs-* | FIND ?findText | WHERE host.name == ?hostName AND log.level == ?logLevel',
|
||||
},
|
||||
status: TabStatus.SUCCESS,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
esql: 'FROM logs-* | FIND ?findText | WHERE host.name == ?hostName AND log.level == ?logLevel',
|
||||
},
|
||||
status: TabStatus.RUNNING,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
language: 'kql',
|
||||
query: 'agent.name : "activemq-integrations-5f6677988-hjp58"',
|
||||
},
|
||||
status: TabStatus.ERROR,
|
||||
},
|
||||
];
|
||||
|
||||
interface UnifiedTabsExampleAppProps {
|
||||
services: FieldListSidebarProps['services'];
|
||||
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
|
||||
|
@ -102,6 +126,9 @@ export const UnifiedTabsExampleApp: React.FC<UnifiedTabsExampleAppProps> = ({
|
|||
services={services}
|
||||
onChanged={() => {}}
|
||||
createItem={getNewTabDefaultProps}
|
||||
getPreviewData={
|
||||
() => TAB_CONTENT_MOCK[Math.floor(Math.random() * TAB_CONTENT_MOCK.length)] // TODO change mock to real data when ready
|
||||
}
|
||||
renderContent={({ label }) => {
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="none">
|
||||
|
|
|
@ -7,7 +7,8 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export type { TabItem } from './src/types';
|
||||
export type { TabItem, TabPreviewData } from './src/types';
|
||||
export { TabStatus } from './src/types';
|
||||
export {
|
||||
TabbedContent as UnifiedTabs,
|
||||
type TabbedContentProps as UnifiedTabsProps,
|
||||
|
|
|
@ -12,6 +12,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { Tab } from './tab';
|
||||
import { MAX_TAB_WIDTH, MIN_TAB_WIDTH } from '../../constants';
|
||||
import { TabStatus } from '../../types';
|
||||
import { servicesMock } from '../../../__mocks__/services';
|
||||
|
||||
const tabItem = {
|
||||
|
@ -28,6 +29,13 @@ const tabsSizeConfig = {
|
|||
regularTabMinWidth: MIN_TAB_WIDTH,
|
||||
};
|
||||
|
||||
const previewQuery = {
|
||||
query: {
|
||||
esql: 'SELECT * FROM table',
|
||||
},
|
||||
status: TabStatus.SUCCESS,
|
||||
};
|
||||
|
||||
describe('Tab', () => {
|
||||
it('renders tab', async () => {
|
||||
const onLabelEdited = jest.fn();
|
||||
|
@ -44,10 +52,13 @@ describe('Tab', () => {
|
|||
onLabelEdited={onLabelEdited}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
tabPreviewData={previewQuery}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(tabItem.label)).toBeInTheDocument();
|
||||
const tabButton = screen.getByTestId(tabButtonTestSubj);
|
||||
expect(tabButton).toBeInTheDocument();
|
||||
expect(tabButton).toHaveTextContent(tabItem.label);
|
||||
|
||||
const tab = screen.getByRole('tab');
|
||||
expect(tab).toHaveAttribute('id', `tab-${tabItem.id}`);
|
||||
|
@ -56,7 +67,6 @@ describe('Tab', () => {
|
|||
expect(onSelect).toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
const tabButton = screen.getByTestId(tabButtonTestSubj);
|
||||
tabButton.click();
|
||||
expect(onSelect).toHaveBeenCalledTimes(2);
|
||||
|
||||
|
@ -88,6 +98,7 @@ describe('Tab', () => {
|
|||
onLabelEdited={jest.fn()}
|
||||
onSelect={jest.fn()}
|
||||
onClose={jest.fn()}
|
||||
tabPreviewData={previewQuery}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -117,11 +128,12 @@ describe('Tab', () => {
|
|||
onLabelEdited={onLabelEdited}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
tabPreviewData={previewQuery}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId(tabButtonTestSubj)).toBeInTheDocument();
|
||||
await userEvent.dblClick(screen.getByText(tabItem.label));
|
||||
await userEvent.dblClick(screen.getByTestId(tabButtonTestSubj));
|
||||
expect(onSelect).toHaveBeenCalled();
|
||||
expect(screen.queryByTestId(tabButtonTestSubj)).not.toBeInTheDocument();
|
||||
|
||||
|
@ -151,11 +163,12 @@ describe('Tab', () => {
|
|||
onLabelEdited={onLabelEdited}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
tabPreviewData={previewQuery}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId(tabButtonTestSubj)).toBeInTheDocument();
|
||||
await userEvent.dblClick(screen.getByText(tabItem.label));
|
||||
await userEvent.dblClick(screen.getByTestId(tabButtonTestSubj));
|
||||
expect(onSelect).toHaveBeenCalled();
|
||||
expect(screen.queryByTestId(tabButtonTestSubj)).not.toBeInTheDocument();
|
||||
|
||||
|
|
|
@ -15,14 +15,21 @@ import {
|
|||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiText,
|
||||
EuiThemeComputed,
|
||||
useEuiTheme,
|
||||
type EuiThemeComputed,
|
||||
} from '@elastic/eui';
|
||||
import { TabMenu } from '../tab_menu';
|
||||
import { EditTabLabel, type EditTabLabelProps } from './edit_tab_label';
|
||||
import { getTabAttributes } from '../../utils/get_tab_attributes';
|
||||
import type { TabItem, TabsSizeConfig, GetTabMenuItems, TabsServices } from '../../types';
|
||||
import type {
|
||||
TabItem,
|
||||
TabsSizeConfig,
|
||||
GetTabMenuItems,
|
||||
TabsServices,
|
||||
TabPreviewData,
|
||||
} from '../../types';
|
||||
import { TabWithBackground } from '../tabs_visual_glue_to_header/tab_with_background';
|
||||
import { TabPreview } from '../tab_preview';
|
||||
|
||||
export interface TabProps {
|
||||
item: TabItem;
|
||||
|
@ -34,6 +41,7 @@ export interface TabProps {
|
|||
onLabelEdited: EditTabLabelProps['onLabelEdited'];
|
||||
onSelect: (item: TabItem) => Promise<void>;
|
||||
onClose: ((item: TabItem) => Promise<void>) | undefined;
|
||||
tabPreviewData: TabPreviewData;
|
||||
}
|
||||
|
||||
export const Tab: React.FC<TabProps> = (props) => {
|
||||
|
@ -47,10 +55,13 @@ export const Tab: React.FC<TabProps> = (props) => {
|
|||
onLabelEdited,
|
||||
onSelect,
|
||||
onClose,
|
||||
tabPreviewData,
|
||||
} = props;
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const tabRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isInlineEditActive, setIsInlineEditActive] = useState<boolean>(false);
|
||||
const [showPreview, setShowPreview] = useState<boolean>(false);
|
||||
const [isActionPopoverOpen, setActionPopover] = useState<boolean>(false);
|
||||
|
||||
const closeButtonLabel = i18n.translate('unifiedTabs.closeTabButton', {
|
||||
defaultMessage: 'Close session',
|
||||
|
@ -60,9 +71,12 @@ export const Tab: React.FC<TabProps> = (props) => {
|
|||
defaultMessage: 'Click to select or double-click to edit session name',
|
||||
});
|
||||
|
||||
const hidePreview = () => setShowPreview(false);
|
||||
|
||||
const onSelectEvent = useCallback(
|
||||
async (event: MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
hidePreview();
|
||||
|
||||
if (!isSelected) {
|
||||
await onSelect(item);
|
||||
|
@ -89,6 +103,11 @@ export const Tab: React.FC<TabProps> = (props) => {
|
|||
[onSelectEvent]
|
||||
);
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
setIsInlineEditActive(true);
|
||||
hidePreview();
|
||||
}, []);
|
||||
|
||||
const mainTabContent = (
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
|
@ -111,10 +130,9 @@ export const Tab: React.FC<TabProps> = (props) => {
|
|||
css={getTabButtonCss(euiTheme)}
|
||||
className="unifiedTabs__tabBtn"
|
||||
data-test-subj={`unifiedTabs_selectTabBtn_${item.id}`}
|
||||
title={item.label}
|
||||
type="button"
|
||||
onClick={onSelectEvent}
|
||||
onDoubleClick={() => setIsInlineEditActive(true)}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<EuiText color="inherit" size="s" css={getTabLabelCss(euiTheme)}>
|
||||
{item.label}
|
||||
|
@ -124,7 +142,12 @@ export const Tab: React.FC<TabProps> = (props) => {
|
|||
<EuiFlexGroup responsive={false} direction="row" gutterSize="none">
|
||||
{!!getTabMenuItems && (
|
||||
<EuiFlexItem grow={false} className="unifiedTabs__tabMenuBtn">
|
||||
<TabMenu item={item} getTabMenuItems={getTabMenuItems} />
|
||||
<TabMenu
|
||||
item={item}
|
||||
getTabMenuItems={getTabMenuItems}
|
||||
isPopoverOpen={isActionPopoverOpen}
|
||||
setPopover={setActionPopover}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{!!onClose && (
|
||||
|
@ -148,18 +171,26 @@ export const Tab: React.FC<TabProps> = (props) => {
|
|||
);
|
||||
|
||||
return (
|
||||
<TabWithBackground
|
||||
{...getTabAttributes(item, tabContentId)}
|
||||
ref={tabRef}
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
data-test-subj={`unifiedTabs_tab_${item.id}`}
|
||||
isSelected={isSelected}
|
||||
services={services}
|
||||
onClick={onClickEvent}
|
||||
<TabPreview
|
||||
showPreview={showPreview}
|
||||
setShowPreview={setShowPreview}
|
||||
stopPreviewOnHover={isInlineEditActive || isActionPopoverOpen}
|
||||
tabPreviewData={tabPreviewData}
|
||||
tabItem={item}
|
||||
>
|
||||
{mainTabContent}
|
||||
</TabWithBackground>
|
||||
<TabWithBackground
|
||||
{...getTabAttributes(item, tabContentId)}
|
||||
ref={tabRef}
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
data-test-subj={`unifiedTabs_tab_${item.id}`}
|
||||
isSelected={isSelected}
|
||||
services={services}
|
||||
onClick={onClickEvent}
|
||||
>
|
||||
{mainTabContent}
|
||||
</TabWithBackground>
|
||||
</TabPreview>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
|
@ -22,10 +22,16 @@ import type { TabItem, GetTabMenuItems } from '../../types';
|
|||
export interface TabMenuProps {
|
||||
item: TabItem;
|
||||
getTabMenuItems: GetTabMenuItems;
|
||||
isPopoverOpen: boolean;
|
||||
setPopover: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const TabMenu: React.FC<TabMenuProps> = ({ item, getTabMenuItems }) => {
|
||||
const [isPopoverOpen, setPopover] = useState<boolean>(false);
|
||||
export const TabMenu: React.FC<TabMenuProps> = ({
|
||||
item,
|
||||
getTabMenuItems,
|
||||
isPopoverOpen,
|
||||
setPopover,
|
||||
}) => {
|
||||
const contextMenuPopoverId = useGeneratedHtmlId();
|
||||
|
||||
const menuButtonLabel = i18n.translate('unifiedTabs.tabMenuButton', {
|
||||
|
|
|
@ -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 { TabPreview } from './tab_preview';
|
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
* 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 from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { TabPreview } from './tab_preview';
|
||||
import type { TabPreviewData, TabItem } from '../../types';
|
||||
import { TabStatus } from '../../types';
|
||||
|
||||
const tabPreviewData: TabPreviewData = {
|
||||
query: {
|
||||
esql: 'SELECT * FROM table',
|
||||
},
|
||||
status: TabStatus.SUCCESS,
|
||||
};
|
||||
|
||||
const tabItem: TabItem = {
|
||||
id: 'test-id',
|
||||
label: 'test-label',
|
||||
};
|
||||
|
||||
const previewTestSubj = `unifiedTabs_tabPreview_${tabItem.id}`;
|
||||
|
||||
describe('TabPreview', () => {
|
||||
it('should call setShowPreview when mouse enters and change opacity after a delay', async () => {
|
||||
const setShowPreview = jest.fn();
|
||||
|
||||
render(
|
||||
<TabPreview
|
||||
showPreview={false}
|
||||
setShowPreview={setShowPreview}
|
||||
tabPreviewData={tabPreviewData}
|
||||
tabItem={tabItem}
|
||||
stopPreviewOnHover={false}
|
||||
previewDelay={0}
|
||||
>
|
||||
<span>Tab Example</span>
|
||||
</TabPreview>
|
||||
);
|
||||
|
||||
const previewContainer = screen.getByTestId(previewTestSubj);
|
||||
expect(previewContainer).toBeInTheDocument();
|
||||
expect(previewContainer).toHaveStyle('opacity: 0');
|
||||
|
||||
const tabElement = screen.getByText('Tab Example');
|
||||
fireEvent.mouseEnter(tabElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setShowPreview).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call setShowPreview when mouse leaves', async () => {
|
||||
const setShowPreview = jest.fn();
|
||||
|
||||
render(
|
||||
<TabPreview
|
||||
showPreview={true}
|
||||
setShowPreview={setShowPreview}
|
||||
tabPreviewData={tabPreviewData}
|
||||
tabItem={tabItem}
|
||||
stopPreviewOnHover={false}
|
||||
>
|
||||
<span>Tab Example</span>
|
||||
</TabPreview>
|
||||
);
|
||||
|
||||
const previewContainer = screen.getByTestId(previewTestSubj);
|
||||
expect(previewContainer).toBeInTheDocument();
|
||||
expect(previewContainer).toHaveStyle('opacity: 1');
|
||||
|
||||
const tabElement = screen.getByText('Tab Example');
|
||||
fireEvent.mouseLeave(tabElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setShowPreview).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call setShowPreview when stopPreviewOnHover is true', async () => {
|
||||
const setShowPreview = jest.fn();
|
||||
|
||||
render(
|
||||
<TabPreview
|
||||
showPreview={false}
|
||||
setShowPreview={setShowPreview}
|
||||
tabPreviewData={tabPreviewData}
|
||||
tabItem={tabItem}
|
||||
stopPreviewOnHover={true}
|
||||
>
|
||||
<span>Tab Example</span>
|
||||
</TabPreview>
|
||||
);
|
||||
|
||||
const tabElement = screen.getByText('Tab Example');
|
||||
fireEvent.mouseEnter(tabElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setShowPreview).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,202 @@
|
|||
/*
|
||||
* 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, useEffect, useState, useRef } from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import useEvent from 'react-use/lib/useEvent';
|
||||
import {
|
||||
EuiSplitPanel,
|
||||
EuiHealth,
|
||||
keys,
|
||||
useEuiTheme,
|
||||
EuiCodeBlock,
|
||||
EuiFlexItem,
|
||||
EuiFlexGroup,
|
||||
EuiLoadingSpinner,
|
||||
EuiPortal,
|
||||
EuiText,
|
||||
type EuiThemeComputed,
|
||||
} from '@elastic/eui';
|
||||
import { isOfAggregateQueryType } from '@kbn/es-query';
|
||||
|
||||
import { PREVIEW_WIDTH } from '../../constants';
|
||||
import type { TabPreviewData, TabItem } from '../../types';
|
||||
import { TabStatus } from '../../types';
|
||||
|
||||
interface TabPreviewProps {
|
||||
children: React.ReactNode;
|
||||
showPreview: boolean;
|
||||
setShowPreview: (show: boolean) => void;
|
||||
tabItem: TabItem;
|
||||
tabPreviewData: TabPreviewData;
|
||||
stopPreviewOnHover?: boolean;
|
||||
previewDelay?: number;
|
||||
}
|
||||
|
||||
const getQueryLanguage = (tabPreviewData: TabPreviewData) => {
|
||||
if (isOfAggregateQueryType(tabPreviewData.query)) {
|
||||
return 'esql';
|
||||
}
|
||||
|
||||
return tabPreviewData.query.language;
|
||||
};
|
||||
|
||||
const getPreviewQuery = (tabPreviewData: TabPreviewData) => {
|
||||
if (isOfAggregateQueryType(tabPreviewData.query)) {
|
||||
return tabPreviewData.query.esql;
|
||||
}
|
||||
|
||||
return typeof tabPreviewData.query.query === 'string' ? tabPreviewData.query.query : '';
|
||||
};
|
||||
|
||||
export const TabPreview: React.FC<TabPreviewProps> = ({
|
||||
children,
|
||||
showPreview,
|
||||
setShowPreview,
|
||||
tabItem,
|
||||
tabPreviewData,
|
||||
stopPreviewOnHover,
|
||||
previewDelay = 500,
|
||||
}) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const [previewTimer, setPreviewTimer] = useState<NodeJS.Timeout | null>(null);
|
||||
const tabRef = useRef<HTMLSpanElement>(null);
|
||||
const [tabPosition, setTabPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
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;
|
||||
|
||||
// Calculate left position based on screen edge constraints
|
||||
let leftPosition = rect.left + window.scrollX;
|
||||
|
||||
if (wouldExtendBeyondRight) {
|
||||
// Align right edge of preview with right edge of window
|
||||
leftPosition = windowWidth - PREVIEW_WIDTH + window.scrollX;
|
||||
}
|
||||
|
||||
setTabPosition({
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: leftPosition,
|
||||
});
|
||||
}
|
||||
}, [showPreview]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === keys.ESCAPE) {
|
||||
setShowPreview(false);
|
||||
}
|
||||
},
|
||||
[setShowPreview]
|
||||
);
|
||||
|
||||
useEvent('keydown', onKeyDown);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewTimer) {
|
||||
clearTimeout(previewTimer);
|
||||
}
|
||||
};
|
||||
}, [previewTimer]);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (stopPreviewOnHover) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setShowPreview(true);
|
||||
}, previewDelay);
|
||||
|
||||
setPreviewTimer(timer);
|
||||
}, [previewDelay, setShowPreview, stopPreviewOnHover]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (previewTimer) {
|
||||
clearTimeout(previewTimer);
|
||||
setPreviewTimer(null);
|
||||
}
|
||||
|
||||
setShowPreview(false);
|
||||
}, [previewTimer, setShowPreview]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} ref={tabRef}>
|
||||
{children}
|
||||
</span>
|
||||
<EuiPortal>
|
||||
<EuiSplitPanel.Outer
|
||||
grow
|
||||
css={getPreviewContainerCss(euiTheme, showPreview, tabPosition)}
|
||||
data-test-subj={`unifiedTabs_tabPreview_${tabItem.id}`}
|
||||
>
|
||||
<EuiSplitPanel.Inner paddingSize="none" css={getSplitPanelCss(euiTheme)}>
|
||||
<EuiCodeBlock
|
||||
language={getQueryLanguage(tabPreviewData)}
|
||||
transparentBackground
|
||||
paddingSize="none"
|
||||
>
|
||||
{getPreviewQuery(tabPreviewData)}
|
||||
</EuiCodeBlock>
|
||||
</EuiSplitPanel.Inner>
|
||||
<EuiSplitPanel.Inner
|
||||
grow={false}
|
||||
color="subdued"
|
||||
paddingSize="none"
|
||||
css={getSplitPanelCss(euiTheme)}
|
||||
>
|
||||
{tabPreviewData.status === TabStatus.RUNNING ? (
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLoadingSpinner />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiText>{tabItem.label}</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
) : (
|
||||
<EuiHealth color={tabPreviewData.status} textSize="m">
|
||||
{tabItem.label}
|
||||
</EuiHealth>
|
||||
)}
|
||||
</EuiSplitPanel.Inner>
|
||||
</EuiSplitPanel.Outer>
|
||||
</EuiPortal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getPreviewContainerCss = (
|
||||
euiTheme: EuiThemeComputed,
|
||||
showPreview: boolean,
|
||||
tabPosition: { top: number; left: number }
|
||||
) => {
|
||||
return css`
|
||||
position: absolute;
|
||||
top: ${tabPosition.top}px;
|
||||
left: ${tabPosition.left}px;
|
||||
z-index: 10000;
|
||||
margin-top: ${euiTheme.size.xs};
|
||||
width: ${PREVIEW_WIDTH}px;
|
||||
opacity: ${showPreview ? 1 : 0};
|
||||
transition: opacity ${euiTheme.animation.normal} ease;
|
||||
`;
|
||||
};
|
||||
|
||||
const getSplitPanelCss = (euiTheme: EuiThemeComputed) => {
|
||||
return css`
|
||||
padding-inline: ${euiTheme.size.base};
|
||||
padding-block: ${euiTheme.size.s};
|
||||
`;
|
||||
};
|
|
@ -21,7 +21,7 @@ import {
|
|||
closeOtherTabs,
|
||||
closeTabsToTheRight,
|
||||
} from '../../utils/manage_tabs';
|
||||
import type { TabItem, TabsServices } from '../../types';
|
||||
import type { TabItem, TabsServices, TabPreviewData } from '../../types';
|
||||
|
||||
export interface TabbedContentProps extends Pick<TabsBarProps, 'maxItemsCount'> {
|
||||
initialItems: TabItem[];
|
||||
|
@ -31,6 +31,7 @@ export interface TabbedContentProps extends Pick<TabsBarProps, 'maxItemsCount'>
|
|||
renderContent: (selectedItem: TabItem) => React.ReactNode;
|
||||
createItem: () => TabItem;
|
||||
onChanged: (state: TabbedContentState) => void;
|
||||
getPreviewData: (item: TabItem) => TabPreviewData;
|
||||
}
|
||||
|
||||
export interface TabbedContentState {
|
||||
|
@ -46,6 +47,7 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
|
|||
renderContent,
|
||||
createItem,
|
||||
onChanged,
|
||||
getPreviewData,
|
||||
}) => {
|
||||
const [tabContentId] = useState(() => htmlIdGenerator()());
|
||||
const [state, _setState] = useState<TabbedContentState>(() => {
|
||||
|
@ -134,6 +136,7 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
|
|||
onLabelEdited={onLabelEdited}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
getPreviewData={getPreviewData}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{selectedItem ? (
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TabsBar } from './tabs_bar';
|
||||
import { TabStatus } from '../../types';
|
||||
import { servicesMock } from '../../../__mocks__/services';
|
||||
|
||||
const items = Array.from({ length: 5 }).map((_, i) => ({
|
||||
|
@ -25,9 +26,17 @@ describe('TabsBar', () => {
|
|||
const onSelect = jest.fn();
|
||||
const onLabelEdited = jest.fn();
|
||||
const onClose = jest.fn();
|
||||
const getPreviewData = jest.fn();
|
||||
|
||||
const selectedItem = items[0];
|
||||
|
||||
getPreviewData.mockReturnValue({
|
||||
query: {
|
||||
esql: 'SELECT * FROM table',
|
||||
},
|
||||
status: TabStatus.SUCCESS,
|
||||
});
|
||||
|
||||
render(
|
||||
<TabsBar
|
||||
tabContentId={tabContentId}
|
||||
|
@ -38,6 +47,7 @@ describe('TabsBar', () => {
|
|||
onLabelEdited={onLabelEdited}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
getPreviewData={getPreviewData}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -46,7 +56,9 @@ describe('TabsBar', () => {
|
|||
|
||||
items.forEach((tabItem, index) => {
|
||||
const tab = tabs[index];
|
||||
expect(screen.getByText(tabItem.label)).toBeInTheDocument();
|
||||
const tabButton = screen.getByTestId(`unifiedTabs_selectTabBtn_${tabItem.id}`);
|
||||
expect(tabButton).toBeInTheDocument();
|
||||
expect(tabButton).toHaveTextContent(tabItem.label);
|
||||
expect(tab).toHaveAttribute('id', `tab-${tabItem.id}`);
|
||||
expect(tab).toHaveAttribute('aria-controls', tabContentId);
|
||||
expect(tab).toHaveAttribute(
|
||||
|
@ -55,7 +67,7 @@ describe('TabsBar', () => {
|
|||
);
|
||||
});
|
||||
|
||||
const tab = screen.getByText(items[1].label);
|
||||
const tab = screen.getByTestId(`unifiedTabs_selectTabBtn_${items[1].id}`);
|
||||
tab.click();
|
||||
expect(onSelect).toHaveBeenCalled();
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n';
|
|||
import { css } from '@emotion/react';
|
||||
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
|
||||
import { Tab, type TabProps } from '../tab';
|
||||
import type { TabItem, TabsServices } from '../../types';
|
||||
import type { TabItem, TabsServices, TabPreviewData } from '../../types';
|
||||
import { getTabIdAttribute } from '../../utils/get_tab_attributes';
|
||||
import { useResponsiveTabs } from '../../hooks/use_responsive_tabs';
|
||||
import { TabsBarWithBackground } from '../tabs_visual_glue_to_header/tabs_bar_with_background';
|
||||
|
@ -30,6 +30,7 @@ export type TabsBarProps = Pick<
|
|||
maxItemsCount?: number;
|
||||
services: TabsServices;
|
||||
onAdd: () => Promise<void>;
|
||||
getPreviewData: (item: TabItem) => TabPreviewData;
|
||||
};
|
||||
|
||||
export const TabsBar: React.FC<TabsBarProps> = ({
|
||||
|
@ -43,6 +44,7 @@ export const TabsBar: React.FC<TabsBarProps> = ({
|
|||
onLabelEdited,
|
||||
onSelect,
|
||||
onClose,
|
||||
getPreviewData,
|
||||
}) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const [tabsContainerWithPlusElement, setTabsContainerWithPlusElement] =
|
||||
|
@ -107,6 +109,7 @@ export const TabsBar: React.FC<TabsBarProps> = ({
|
|||
onLabelEdited={onLabelEdited}
|
||||
onSelect={onSelect}
|
||||
onClose={items.length > 1 ? onClose : undefined} // prevents closing the last tab
|
||||
tabPreviewData={getPreviewData(item)}
|
||||
/>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -11,3 +11,5 @@ export const MAX_TAB_LABEL_LENGTH = 500;
|
|||
|
||||
export const MAX_TAB_WIDTH = 280;
|
||||
export const MIN_TAB_WIDTH = 96;
|
||||
|
||||
export const PREVIEW_WIDTH = 280;
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { AggregateQuery, Query } from '@kbn/es-query';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
|
||||
export interface TabItem {
|
||||
|
@ -28,6 +29,19 @@ export interface TabsSizeConfig {
|
|||
// TODO: extend with possibly different sizes for pinned tabs
|
||||
}
|
||||
|
||||
// TODO status value for now matches EuiHealth colors for mocking simplicity, adjust when real data is available
|
||||
export enum TabStatus {
|
||||
SUCCESS = 'success',
|
||||
RUNNING = 'running',
|
||||
ERROR = 'danger',
|
||||
}
|
||||
|
||||
// TODO adjust interface when real data is available, this currently types TAB_CONTENT_MOCK
|
||||
export interface TabPreviewData {
|
||||
query: AggregateQuery | Query;
|
||||
status: TabStatus;
|
||||
}
|
||||
|
||||
export type TabMenuItem = TabMenuItemWithClick | 'divider';
|
||||
|
||||
export type GetTabMenuItems = (item: TabItem) => TabMenuItem[];
|
||||
|
|
|
@ -4,12 +4,6 @@
|
|||
"outDir": "target/types"
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/i18n",
|
||||
"@kbn/core-chrome-browser",
|
||||
"@kbn/core",
|
||||
]
|
||||
"exclude": ["target/**/*"],
|
||||
"kbn_references": ["@kbn/i18n", "@kbn/es-query", "@kbn/core-chrome-browser", "@kbn/core"]
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue