[Dashboard Usability] Conditionally auto focus on title input in panel settings flyout (#173777)

Closes https://github.com/elastic/kibana/issues/170786

## Summary

Currently, in order to change a panel title, the most efficient way to
do this (assuming the dashboard is already in edit mode) is to click on
the panel title -> click on the title input in the flyout -> click save
at the bottom of the flyout. Notice that this process currently takes
**three** clicks, which can start to add up if you need to change
multiple titles in one session - so, in order to remove one click from
this process, I've made it so that the title input is **auto focused**
on when opening the settings flyout through the panel title (and not
when it is opened from the context menu).

> [!NOTE]
> I chose to make this auto-focus behaviour conditional on how the
flyout was opened because, from an a11y perspective, it can be jarring
to have your focus taken out of the natural element order (as noted in
the [EUI
docs](https://eui.elastic.co/#/layout/popover#setting-an-initial-focus)).
It feels natural that, in order to change the panel title, you click on
it - so, auto focusing on the title input makes sense, even when using
keyboard controls. However, if you are opening the settings flyout via
the context menu, it is **less likely** that the goal is to change the
title - so, forcing the focus could feel unnatural in this case.


I added tests for this new auto focus behaviour and, since I was
interested in learning a bit more about RTL, I also added a few other
tests for the dashboard panel components. As part of this, I migrated a
few functional tests to unit tests, since this is a faster and more
reliable way to test certain rendering conditionals.

### Video


229c1303-c81d-46b8-a567-76885361d9fa


### 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
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Hannah Mudge 2024-01-04 14:39:44 -07:00 committed by GitHub
parent 46a58541fa
commit 133016850f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 605 additions and 267 deletions

View file

@ -6,20 +6,23 @@
* Side Public License, v 1.
*/
import { isNil } from 'lodash';
import classNames from 'classnames';
import { distinct, map } from 'rxjs';
import React, { ReactNode, useEffect, useMemo, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, htmlIdGenerator } from '@elastic/eui';
import classNames from 'classnames';
import { isNil } from 'lodash';
import React, { ReactNode, useEffect, useMemo, useState } from 'react';
import { distinct, map } from 'rxjs';
import { UI_SETTINGS } from '@kbn/data-plugin/common';
import { PanelLoader } from '@kbn/panel-loader';
import { core, embeddableStart, inspector } from '../kibana_services';
import { EmbeddableErrorHandler, EmbeddableOutput, ViewMode } from '../lib';
import { EmbeddablePanelError } from './embeddable_panel_error';
import {
EditPanelAction,
RemovePanelAction,
InspectPanelAction,
CustomizePanelAction,
EditPanelAction,
InspectPanelAction,
RemovePanelAction,
} from './panel_actions';
import { EmbeddablePanelHeader } from './panel_header/embeddable_panel_header';
import {
EmbeddablePhase,
EmbeddablePhaseEvent,
@ -30,10 +33,6 @@ import {
useSelectFromEmbeddableInput,
useSelectFromEmbeddableOutput,
} from './use_select_from_embeddable';
import { EmbeddablePanelError } from './embeddable_panel_error';
import { core, embeddableStart, inspector } from '../kibana_services';
import { ViewMode, EmbeddableErrorHandler, EmbeddableOutput } from '../lib';
import { EmbeddablePanelHeader } from './panel_header/embeddable_panel_header';
const getEventStatus = (output: EmbeddableOutput): EmbeddablePhase => {
if (!isNil(output.error)) {
@ -61,8 +60,6 @@ export const EmbeddablePanel = (panelProps: UnwrappedEmbeddablePanelProps) => {
* bypass the trigger registry.
*/
const universalActions = useMemo<PanelUniversalActions>(() => {
const commonlyUsedRanges = core.uiSettings.get(UI_SETTINGS.TIMEPICKER_QUICK_RANGES);
const dateFormat = core.uiSettings.get(UI_SETTINGS.DATE_FORMAT);
const stateTransfer = embeddableStart.getStateTransfer();
const editPanel = new EditPanelAction(
embeddableStart.getEmbeddableFactory,
@ -71,13 +68,7 @@ export const EmbeddablePanel = (panelProps: UnwrappedEmbeddablePanelProps) => {
);
const actions: PanelUniversalActions = {
customizePanel: new CustomizePanelAction(
core.overlays,
core.theme,
editPanel,
commonlyUsedRanges,
dateFormat
),
customizePanel: new CustomizePanelAction(editPanel),
removePanel: new RemovePanelAction(),
editPanel,
};

View file

@ -8,8 +8,8 @@
import type { TimeRange } from '@kbn/es-query';
import { TimeRangeInput } from './customize_panel_action';
import { Embeddable, IContainer, ContainerInput } from '../../..';
import { TimeRangeInput } from './time_range_helpers';
interface ContainerTimeRangeInput extends ContainerInput<TimeRangeInput> {
timeRange: TimeRange;

View file

@ -6,16 +6,13 @@
* Side Public License, v 1.
*/
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { overlayServiceMock } from '@kbn/core-overlays-browser-mocks';
import {
TimeRangeEmbeddable,
TimeRangeContainer,
TimeRangeEmbeddable,
TIME_RANGE_EMBEDDABLE,
} from '../../../lib/test_samples/embeddables';
import { CustomTimeRangeBadge } from './custom_time_range_badge';
import { EditPanelAction } from '../edit_panel_action/edit_panel_action';
import { CustomTimeRangeBadge } from './custom_time_range_badge';
const editPanelAction = {
execute: jest.fn(),
@ -42,13 +39,7 @@ test(`badge is not compatible with embeddable that inherits from parent`, async
const child = container.getChild<TimeRangeEmbeddable>('1');
const compatible = await new CustomTimeRangeBadge(
overlayServiceMock.createStartContract(),
themeServiceMock.createStartContract(),
editPanelAction,
[],
'MM YYYY'
).isCompatible({
const compatible = await new CustomTimeRangeBadge(editPanelAction, 'MM YYYY').isCompatible({
embeddable: child,
});
expect(compatible).toBe(false);
@ -76,13 +67,7 @@ test(`badge is compatible with embeddable that has custom time range`, async ()
const child = container.getChild<TimeRangeEmbeddable>('1');
const compatible = await new CustomTimeRangeBadge(
overlayServiceMock.createStartContract(),
themeServiceMock.createStartContract(),
editPanelAction,
[],
'MM YYYY'
).isCompatible({
const compatible = await new CustomTimeRangeBadge(editPanelAction, 'MM YYYY').isCompatible({
embeddable: child,
});
expect(compatible).toBe(true);
@ -109,13 +94,7 @@ test('Attempting to execute on incompatible embeddable throws an error', async (
const child = container.getChild<TimeRangeEmbeddable>('1');
const badge = await new CustomTimeRangeBadge(
overlayServiceMock.createStartContract(),
themeServiceMock.createStartContract(),
editPanelAction,
[],
'MM YYYY'
);
const badge = await new CustomTimeRangeBadge(editPanelAction, 'MM YYYY');
async function check() {
await badge.execute({ embeddable: child });

View file

@ -11,9 +11,10 @@ import { PrettyDuration } from '@elastic/eui';
import { renderToString } from 'react-dom/server';
import { Action } from '@kbn/ui-actions-plugin/public';
import { Embeddable } from '../../..';
import { EditPanelAction, Embeddable } from '../../..';
import { doesInheritTimeRange } from './does_inherit_time_range';
import { TimeRangeInput, hasTimeRange, CustomizePanelAction } from './customize_panel_action';
import { CustomizePanelAction } from './customize_panel_action';
import { hasTimeRange, TimeRangeInput } from './time_range_helpers';
export const CUSTOM_TIME_RANGE_BADGE = 'CUSTOM_TIME_RANGE_BADGE';
@ -29,6 +30,13 @@ export class CustomTimeRangeBadge
public readonly id = CUSTOM_TIME_RANGE_BADGE;
public order = 7;
constructor(
protected readonly editPanel: EditPanelAction,
protected readonly dateFormat?: string
) {
super(editPanel);
}
public getDisplayName({ embeddable }: TimeBadgeActionContext) {
return renderToString(
<PrettyDuration

View file

@ -6,27 +6,24 @@
* Side Public License, v 1.
*/
import { overlayServiceMock } from '@kbn/core-overlays-browser-mocks';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { Container, isErrorEmbeddable } from '../../..';
import { CustomizePanelAction } from './customize_panel_action';
import {
ContactCardEmbeddable,
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
} from '../../../lib/test_samples/embeddables/contact_card/contact_card_embeddable';
import {
CONTACT_CARD_EMBEDDABLE,
ContactCardEmbeddableFactory,
CONTACT_CARD_EMBEDDABLE,
} from '../../../lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory';
import { HelloWorldContainer } from '../../../lib/test_samples/embeddables/hello_world_container';
import { embeddablePluginMock } from '../../../mocks';
import { EditPanelAction } from '../edit_panel_action/edit_panel_action';
import { CustomizePanelAction } from './customize_panel_action';
import * as openCustomizePanel from './open_customize_panel';
let container: Container;
let embeddable: ContactCardEmbeddable;
const overlays = overlayServiceMock.createStartContract();
const theme = themeServiceMock.createStartContract();
const editPanelActionMock = { execute: jest.fn() } as unknown as EditPanelAction;
function createHelloWorldContainer(input = { id: '123', panels: {} }) {
@ -59,9 +56,9 @@ beforeAll(async () => {
});
test('execute should open flyout', async () => {
const customizePanelAction = new CustomizePanelAction(overlays, theme, editPanelActionMock);
const spy = jest.spyOn(overlays, 'openFlyout');
await customizePanelAction.execute({ embeddable });
const customizePanelAction = new CustomizePanelAction(editPanelActionMock);
const spy = jest.spyOn(openCustomizePanel, 'openCustomizePanelFlyout');
await customizePanelAction.execute({ embeddable });
expect(spy).toHaveBeenCalled();
});

View file

@ -6,48 +6,16 @@
* Side Public License, v 1.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { TimeRange } from '@kbn/es-query';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { OverlayStart, ThemeServiceStart } from '@kbn/core/public';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { core } from '../../../kibana_services';
import {
IEmbeddable,
Embeddable,
EmbeddableInput,
EmbeddableOutput,
EditPanelAction,
} from '../../..';
import { ViewMode, CommonlyUsedRange } from '../../../lib/types';
import { tracksOverlays } from '../track_overlays';
import { CustomizePanelEditor } from './customize_panel_editor';
import { EditPanelAction, Embeddable, IEmbeddable } from '../../..';
import { ViewMode } from '../../../lib/types';
import { openCustomizePanelFlyout } from './open_customize_panel';
import { isTimeRangeCompatible, TimeRangeInput } from './time_range_helpers';
export const ACTION_CUSTOMIZE_PANEL = 'ACTION_CUSTOMIZE_PANEL';
const VISUALIZE_EMBEDDABLE_TYPE = 'visualization';
type VisualizeEmbeddable = IEmbeddable<{ id: string }, EmbeddableOutput & { visTypeName: string }>;
function isVisualizeEmbeddable(
embeddable: IEmbeddable | VisualizeEmbeddable
): embeddable is VisualizeEmbeddable {
return embeddable.type === VISUALIZE_EMBEDDABLE_TYPE;
}
export interface TimeRangeInput extends EmbeddableInput {
timeRange: TimeRange;
}
export function hasTimeRange(
embeddable: IEmbeddable | Embeddable<TimeRangeInput>
): embeddable is Embeddable<TimeRangeInput> {
return (embeddable as Embeddable<TimeRangeInput>).getInput().timeRange !== undefined;
}
export interface CustomizePanelActionContext {
embeddable: IEmbeddable | Embeddable<TimeRangeInput>;
}
@ -57,35 +25,7 @@ export class CustomizePanelAction implements Action<CustomizePanelActionContext>
public id = ACTION_CUSTOMIZE_PANEL;
public order = 40;
constructor(
protected readonly overlays: OverlayStart,
protected readonly theme: ThemeServiceStart,
protected readonly editPanel: EditPanelAction,
protected readonly commonlyUsedRanges?: CommonlyUsedRange[],
protected readonly dateFormat?: string
) {}
protected isTimeRangeCompatible({ embeddable }: CustomizePanelActionContext): boolean {
const isInputControl =
isVisualizeEmbeddable(embeddable) &&
(embeddable as VisualizeEmbeddable).getOutput().visTypeName === 'input_control_vis';
const isMarkdown =
isVisualizeEmbeddable(embeddable) &&
(embeddable as VisualizeEmbeddable).getOutput().visTypeName === 'markdown';
const isImage = embeddable.type === 'image';
const isNavigation = embeddable.type === 'navigation';
return Boolean(
embeddable &&
hasTimeRange(embeddable) &&
!isInputControl &&
!isMarkdown &&
!isImage &&
!isNavigation
);
}
constructor(protected readonly editPanel: EditPanelAction) {}
public getDisplayName({ embeddable }: CustomizePanelActionContext): string {
return i18n.translate('embeddableApi.customizePanel.action.displayName', {
@ -100,7 +40,7 @@ export class CustomizePanelAction implements Action<CustomizePanelActionContext>
public async isCompatible({ embeddable }: CustomizePanelActionContext) {
// It should be possible to customize just the time range in View mode
return (
embeddable.getInput().viewMode === ViewMode.EDIT || this.isTimeRangeCompatible({ embeddable })
embeddable.getInput().viewMode === ViewMode.EDIT || isTimeRangeCompatible({ embeddable })
);
}
@ -109,46 +49,6 @@ export class CustomizePanelAction implements Action<CustomizePanelActionContext>
if (!isCompatible) {
throw new IncompatibleActionError();
}
// send the overlay ref to the root embeddable if it is capable of tracking overlays
const rootEmbeddable = embeddable.getRoot();
const overlayTracker = tracksOverlays(rootEmbeddable) ? rootEmbeddable : undefined;
const { Provider: KibanaReactContextProvider } = createKibanaReactContext({
uiSettings: core.uiSettings,
});
const onEdit = () => {
this.editPanel.execute({ embeddable });
};
const handle = this.overlays.openFlyout(
toMountPoint(
<KibanaReactContextProvider>
<CustomizePanelEditor
embeddable={embeddable}
timeRangeCompatible={this.isTimeRangeCompatible({ embeddable })}
dateFormat={this.dateFormat}
commonlyUsedRanges={this.commonlyUsedRanges}
onClose={() => {
if (overlayTracker) overlayTracker.clearOverlays();
handle.close();
}}
onEdit={onEdit}
/>
</KibanaReactContextProvider>,
{ theme: this.theme, i18n: core.i18n }
),
{
size: 's',
'data-test-subj': 'customizePanel',
onClose: (overlayRef) => {
if (overlayTracker) overlayTracker.clearOverlays();
overlayRef.close();
},
maxWidth: true,
}
);
overlayTracker?.openOverlay(handle);
openCustomizePanelFlyout({ editPanel: this.editPanel, embeddable });
}
}

View file

@ -0,0 +1,200 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react';
import { Filter } from '@kbn/es-query';
import { ViewMode } from '../../../../common';
import { FilterableEmbeddable, IEmbeddable } from '../../../lib';
import {
ContactCardEmbeddable,
ContactCardEmbeddableFactory,
ContactCardEmbeddableInput,
} from '../../../lib/test_samples';
import { EditPanelAction } from '../edit_panel_action/edit_panel_action';
import { CustomizePanelAction } from './customize_panel_action';
import { CustomizePanelEditor } from './customize_panel_editor';
const editPanelActionMock = { execute: jest.fn() } as unknown as EditPanelAction;
const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
const customizePanelAction = new CustomizePanelAction(editPanelActionMock);
customizePanelAction.execute = jest.fn();
const DEFAULT_PANEL_TITLE = 'Panel title';
const createEmbeddable = async (
initialInput?: Partial<ContactCardEmbeddableInput>
): Promise<ContactCardEmbeddable> => {
return await mockEmbeddableFactory.create({
id: '20',
firstName: 'Bilbo',
lastName: 'Baggins',
title: DEFAULT_PANEL_TITLE,
...initialInput,
});
};
const DEFAULT_PROPS = {
timeRangeCompatible: true,
onClose: jest.fn(),
onEdit: jest.fn(),
};
describe('panel title / description', () => {
test('does not render if in view mode', async () => {
const mockEmbeddable = await createEmbeddable({ viewMode: ViewMode.VIEW });
render(
<IntlProvider locale="en">
<CustomizePanelEditor {...DEFAULT_PROPS} embeddable={mockEmbeddable} />
</IntlProvider>
);
const customizePanelForm = await screen.findByTestId('customizePanelForm');
const titleDescriptionComponent = screen.queryByTestId('customEmbeddableTitleComponent');
expect(customizePanelForm).not.toContainElement(titleDescriptionComponent);
});
test('title input receives focus when `focusOnTitle` is `true`', async () => {
const mockEmbeddable = await createEmbeddable({ viewMode: ViewMode.EDIT });
render(
<IntlProvider locale="en">
<CustomizePanelEditor {...DEFAULT_PROPS} embeddable={mockEmbeddable} focusOnTitle={true} />
</IntlProvider>
);
const customTitleComponent = await screen.findByTestId('customEmbeddablePanelTitleInput');
expect(customTitleComponent).toHaveFocus();
});
test('title input does not receive focus when `focusOnTitle` is `false`', async () => {
const mockEmbeddable = await createEmbeddable({ viewMode: ViewMode.EDIT });
render(
<IntlProvider locale="en">
<CustomizePanelEditor {...DEFAULT_PROPS} embeddable={mockEmbeddable} focusOnTitle={false} />
</IntlProvider>
);
const customTitleComponent = await screen.findByTestId('customEmbeddablePanelTitleInput');
expect(customTitleComponent).not.toHaveFocus();
});
});
describe('custom time picker', () => {
test('renders custom time picker if embeddable supports it', async () => {
const mockEmbeddable = await createEmbeddable({ viewMode: ViewMode.EDIT });
render(
<IntlProvider locale="en">
<CustomizePanelEditor
{...DEFAULT_PROPS}
embeddable={mockEmbeddable}
timeRangeCompatible={true}
/>
</IntlProvider>
);
const customTimeRangeComponent = await screen.findByTestId('customizePanelTimeRangeDatePicker');
expect(customTimeRangeComponent).toBeDefined();
});
test('does not render custom time picker if embeddable does not support it', async () => {
const mockEmbeddable = await createEmbeddable({ viewMode: ViewMode.EDIT });
render(
<IntlProvider locale="en">
<CustomizePanelEditor
{...DEFAULT_PROPS}
embeddable={mockEmbeddable}
timeRangeCompatible={false}
/>
</IntlProvider>
);
const customizePanelForm = await screen.findByTestId('customizePanelForm');
const customTimeRangeComponent = screen.queryByTestId('customizePanelTimeRangeDatePicker');
expect(customizePanelForm).not.toContainElement(customTimeRangeComponent);
});
test('does not render filters and/or query info if embeddable does not support it', async () => {
const mockEmbeddable = await createEmbeddable({
viewMode: ViewMode.EDIT,
});
render(
<IntlProvider locale="en">
<CustomizePanelEditor
{...DEFAULT_PROPS}
embeddable={mockEmbeddable as unknown as IEmbeddable}
timeRangeCompatible={false}
/>
</IntlProvider>
);
const customizePanelForm = await screen.findByTestId('customizePanelForm');
const customPanelQuery = screen.queryByTestId('panelCustomQueryRow');
expect(customizePanelForm).not.toContainElement(customPanelQuery);
const customPanelFilters = screen.queryByTestId('panelCustomFiltersRow');
expect(customizePanelForm).not.toContainElement(customPanelFilters);
});
describe('filterable embeddable', () => {
test('renders custom filters, if provided', async () => {
const mockEmbeddable: FilterableEmbeddable = (await createEmbeddable({
viewMode: ViewMode.EDIT,
})) as unknown as FilterableEmbeddable;
mockEmbeddable.getFilters = jest.fn().mockResolvedValue([
{
meta: {},
query: {},
$state: {},
},
] as Filter[]);
mockEmbeddable.getQuery = jest.fn().mockResolvedValue({});
render(
<IntlProvider locale="en">
<CustomizePanelEditor
{...DEFAULT_PROPS}
embeddable={mockEmbeddable as unknown as IEmbeddable}
timeRangeCompatible={false}
/>
</IntlProvider>
);
await waitFor(() => {
expect(screen.getByTestId('euiSkeletonLoadingAriaWrapper')).toBeInTheDocument();
});
const customPanelQuery = await screen.findByTestId('panelCustomFiltersRow');
expect(customPanelQuery).toBeInTheDocument();
});
test('renders a custom query, if provided', async () => {
const mockEmbeddable: FilterableEmbeddable = (await createEmbeddable({
viewMode: ViewMode.EDIT,
})) as unknown as FilterableEmbeddable;
mockEmbeddable.getFilters = jest.fn().mockResolvedValue([]);
mockEmbeddable.getQuery = jest.fn().mockResolvedValue({ query: 'field : value' });
render(
<IntlProvider locale="en">
<CustomizePanelEditor
{...DEFAULT_PROPS}
embeddable={mockEmbeddable as unknown as IEmbeddable}
timeRangeCompatible={false}
/>
</IntlProvider>
);
await waitFor(() => {
expect(screen.getByTestId('euiSkeletonLoadingAriaWrapper')).toBeInTheDocument();
});
const customPanelQuery = await screen.findByTestId('customPanelQuery');
expect(customPanelQuery).toHaveTextContent('field : value');
});
});
});

View file

@ -6,40 +6,40 @@
* Side Public License, v 1.
*/
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import {
EuiFormRow,
EuiFieldText,
EuiSwitch,
EuiFlyoutHeader,
EuiTitle,
EuiFlyoutBody,
EuiForm,
EuiTextArea,
EuiFlyoutFooter,
EuiButtonEmpty,
EuiButton,
EuiButtonEmpty,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiSuperDatePicker,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiForm,
EuiFormRow,
EuiSpacer,
EuiSuperDatePicker,
EuiSwitch,
EuiTextArea,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { TimeRange } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { TimeRangeInput } from './customize_panel_action';
import {
CommonlyUsedRange,
Embeddable,
IEmbeddable,
isFilterableEmbeddable,
ViewMode,
} from '../../../lib';
import { canInheritTimeRange } from './can_inherit_time_range';
import { doesInheritTimeRange } from './does_inherit_time_range';
import {
IEmbeddable,
Embeddable,
CommonlyUsedRange,
ViewMode,
isFilterableEmbeddable,
} from '../../../lib';
import { FiltersDetails } from './filters_details';
import { TimeRangeInput } from './time_range_helpers';
type PanelSettings = {
title?: string;
@ -55,10 +55,11 @@ interface CustomizePanelProps {
commonlyUsedRanges?: CommonlyUsedRange[];
onClose: () => void;
onEdit: () => void;
focusOnTitle?: boolean;
}
export const CustomizePanelEditor = (props: CustomizePanelProps) => {
const { onClose, embeddable, dateFormat, timeRangeCompatible, onEdit } = props;
const { onClose, embeddable, dateFormat, timeRangeCompatible, onEdit, focusOnTitle } = props;
const editMode = embeddable.getInput().viewMode === ViewMode.EDIT;
const [hideTitle, setHideTitle] = useState(embeddable.getInput().hidePanelTitles);
const [panelDescription, setPanelDescription] = useState(
@ -75,6 +76,13 @@ export const CustomizePanelEditor = (props: CustomizePanelProps) => {
? (embeddable as Embeddable<TimeRangeInput>).getInput().timeRange
: undefined
);
const initialFocusRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (focusOnTitle && initialFocusRef.current) {
initialFocusRef.current.focus();
}
}, [initialFocusRef, focusOnTitle]);
const commonlyUsedRangesForDatePicker = props.commonlyUsedRanges
? props.commonlyUsedRanges.map(
@ -108,7 +116,7 @@ export const CustomizePanelEditor = (props: CustomizePanelProps) => {
if (!editMode) return null;
return (
<>
<div data-test-subj="customEmbeddableTitleComponent">
<EuiFormRow>
<EuiSwitch
checked={!hideTitle}
@ -154,6 +162,7 @@ export const CustomizePanelEditor = (props: CustomizePanelProps) => {
}
>
<EuiFieldText
inputRef={initialFocusRef}
id="panelTitleInput"
className="panelTitleInputText"
data-test-subj="customEmbeddablePanelTitleInput"
@ -219,7 +228,7 @@ export const CustomizePanelEditor = (props: CustomizePanelProps) => {
)}
/>
</EuiFormRow>
</>
</div>
);
};
@ -292,7 +301,7 @@ export const CustomizePanelEditor = (props: CustomizePanelProps) => {
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiForm>
<EuiForm data-test-subj="customizePanelForm">
{renderCustomTitleComponent()}
{renderCustomTimeRangeComponent()}
{renderFilterDetails()}

View file

@ -6,8 +6,8 @@
* Side Public License, v 1.
*/
import { Embeddable, IContainer, ContainerInput } from '../../../lib';
import { TimeRangeInput } from './customize_panel_action';
import { ContainerInput, Embeddable, IContainer } from '../../../lib';
import { TimeRangeInput } from './time_range_helpers';
export function doesInheritTimeRange(embeddable: Embeddable<TimeRangeInput>) {
if (!embeddable.parent) {

View file

@ -89,6 +89,7 @@ export function FiltersDetails({ embeddable, editMode, onEdit }: FiltersDetailsP
<EuiSkeletonText isLoading={isLoading} lines={3}>
{queryString !== '' && (
<EuiFormRow
data-test-subj="panelCustomQueryRow"
label={filterDetailsActionStrings.getQueryTitle()}
display="rowCompressed"
labelAppend={
@ -113,6 +114,7 @@ export function FiltersDetails({ embeddable, editMode, onEdit }: FiltersDetailsP
}
>
<EuiCodeBlock
data-test-subj="customPanelQuery"
language={queryLanguage}
paddingSize="s"
fontSize="s"
@ -125,6 +127,7 @@ export function FiltersDetails({ embeddable, editMode, onEdit }: FiltersDetailsP
)}
{filters.length > 0 && (
<EuiFormRow
data-test-subj="panelCustomFiltersRow"
label={filterDetailsActionStrings.getFiltersTitle()}
labelAppend={
editMode && !disableEditbutton ? (

View file

@ -0,0 +1,75 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { UI_SETTINGS } from '@kbn/data-plugin/common';
import { core } from '../../../kibana_services';
import { Embeddable, IEmbeddable } from '../../../lib';
import { EditPanelAction } from '../edit_panel_action/edit_panel_action';
import { tracksOverlays } from '../track_overlays';
import { CustomizePanelEditor } from './customize_panel_editor';
import { isTimeRangeCompatible, TimeRangeInput } from './time_range_helpers';
export const openCustomizePanelFlyout = ({
editPanel,
focusOnTitle,
embeddable,
}: {
editPanel: EditPanelAction;
focusOnTitle?: boolean;
embeddable: IEmbeddable | Embeddable<TimeRangeInput>;
}) => {
// send the overlay ref to the root embeddable if it is capable of tracking overlays
const rootEmbeddable = embeddable.getRoot();
const overlayTracker = tracksOverlays(rootEmbeddable) ? rootEmbeddable : undefined;
const commonlyUsedRanges = core.uiSettings.get(UI_SETTINGS.TIMEPICKER_QUICK_RANGES);
const dateFormat = core.uiSettings.get(UI_SETTINGS.DATE_FORMAT);
const { Provider: KibanaReactContextProvider } = createKibanaReactContext({
uiSettings: core.uiSettings,
});
const onEdit = () => {
editPanel.execute({ embeddable });
};
const handle = core.overlays.openFlyout(
toMountPoint(
<KibanaReactContextProvider>
<CustomizePanelEditor
focusOnTitle={focusOnTitle}
embeddable={embeddable}
timeRangeCompatible={isTimeRangeCompatible({ embeddable })}
dateFormat={dateFormat}
commonlyUsedRanges={commonlyUsedRanges}
onClose={() => {
if (overlayTracker) overlayTracker.clearOverlays();
handle.close();
}}
onEdit={onEdit}
/>
</KibanaReactContextProvider>,
{ theme: core.theme, i18n: core.i18n }
),
{
size: 's',
'data-test-subj': 'customizePanel',
onClose: (overlayRef) => {
if (overlayTracker) overlayTracker.clearOverlays();
overlayRef.close();
},
maxWidth: true,
}
);
overlayTracker?.openOverlay(handle);
};

View file

@ -0,0 +1,53 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { TimeRange } from '@kbn/es-query';
import { Embeddable, EmbeddableInput, EmbeddableOutput, IEmbeddable } from '../../../lib';
const VISUALIZE_EMBEDDABLE_TYPE = 'visualization';
type VisualizeEmbeddable = IEmbeddable<{ id: string }, EmbeddableOutput & { visTypeName: string }>;
function isVisualizeEmbeddable(
embeddable: IEmbeddable | VisualizeEmbeddable
): embeddable is VisualizeEmbeddable {
return embeddable.type === VISUALIZE_EMBEDDABLE_TYPE;
}
export interface TimeRangeInput extends EmbeddableInput {
timeRange: TimeRange;
}
export function hasTimeRange(
embeddable: IEmbeddable | Embeddable<TimeRangeInput>
): embeddable is Embeddable<TimeRangeInput> {
return (embeddable as Embeddable<TimeRangeInput>).getInput().timeRange !== undefined;
}
export function isTimeRangeCompatible({ embeddable }: { embeddable: IEmbeddable }): boolean {
const isInputControl =
isVisualizeEmbeddable(embeddable) &&
(embeddable as VisualizeEmbeddable).getOutput().visTypeName === 'input_control_vis';
const isMarkdown =
isVisualizeEmbeddable(embeddable) &&
(embeddable as VisualizeEmbeddable).getOutput().visTypeName === 'markdown';
const isImage = embeddable.type === 'image';
const isNavigation = embeddable.type === 'navigation';
return Boolean(
embeddable &&
hasTimeRange(embeddable) &&
!isInputControl &&
!isMarkdown &&
!isImage &&
!isNavigation
);
}

View file

@ -0,0 +1,171 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { applicationServiceMock } from '@kbn/core-application-browser-mocks';
import userEvent from '@testing-library/user-event';
import { ViewMode } from '../../../common';
import {
ContactCardEmbeddable,
ContactCardEmbeddableFactory,
ContactCardEmbeddableInput,
} from '../../lib/test_samples';
import { EditPanelAction } from '../panel_actions';
import * as openCustomizePanel from '../panel_actions/customize_panel_action/open_customize_panel';
import { EmbeddablePanelHeader } from './embeddable_panel_header';
const getEmbeddableFactory = jest.fn();
const application = applicationServiceMock.createStartContract();
const editPanelAction = new EditPanelAction(getEmbeddableFactory, application);
const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
editPanelAction.execute = jest.fn();
const DEFAULT_PANEL_TITLE = 'Panel title';
const createEmbeddable = async (
initialInput?: Partial<ContactCardEmbeddableInput>
): Promise<ContactCardEmbeddable> => {
return await mockEmbeddableFactory.create({
id: '20',
firstName: 'Bilbo',
lastName: 'Baggins',
title: DEFAULT_PANEL_TITLE,
...initialInput,
});
};
describe('view mode', () => {
test('renders as expected', async () => {
const mockEmbeddable = await createEmbeddable({ viewMode: ViewMode.VIEW });
render(
<EmbeddablePanelHeader
embeddable={mockEmbeddable}
headerId={'headerId'}
universalActions={{}}
/>
);
const titleComponent = await screen.findByTestId('dashboardPanelTitle');
expect(titleComponent).toHaveTextContent(DEFAULT_PANEL_TITLE);
});
test('renders tooltip + icon when description provided', async () => {
const mockEmbeddable = await createEmbeddable({ viewMode: ViewMode.VIEW });
mockEmbeddable.updateOutput({ description: 'This is a description ' });
render(
<EmbeddablePanelHeader
embeddable={mockEmbeddable}
headerId={'headerId'}
universalActions={{}}
/>
);
expect(await screen.findByTestId('embeddablePanelTooltipAnchor')).toBeInTheDocument();
expect(await screen.findByTestId('embeddablePanelTitleDescriptionIcon')).toBeInTheDocument();
});
test('blank titles are hidden in view mode', async () => {
const mockEmbeddable = await createEmbeddable({ title: '', viewMode: ViewMode.VIEW });
render(
<EmbeddablePanelHeader
embeddable={mockEmbeddable}
headerId={'headerId'}
universalActions={{}}
/>
);
const header = await screen.findByTestId('embeddablePanelHeading');
const titleComponent = screen.queryByTestId('dashboardPanelTitle');
expect(header).not.toContainElement(titleComponent);
});
test('hiding an individual panel title hides it in view mode', async () => {
const mockEmbeddable = await createEmbeddable({
viewMode: ViewMode.VIEW,
hidePanelTitles: true,
});
render(
<EmbeddablePanelHeader
embeddable={mockEmbeddable}
headerId={'headerId'}
universalActions={{}}
/>
);
const header = await screen.findByTestId('embeddablePanelHeading');
const titleComponent = screen.queryByTestId('dashboardPanelTitle');
expect(header).not.toContainElement(titleComponent);
});
});
describe('edit mode', () => {
test('renders as expected', async () => {
const mockEmbeddable = await createEmbeddable({ viewMode: ViewMode.EDIT });
render(
<EmbeddablePanelHeader
embeddable={mockEmbeddable}
headerId={'headerId'}
universalActions={{}}
/>
);
const titleComponent = await screen.findByTestId('dashboardPanelTitle');
expect(titleComponent).toHaveTextContent(DEFAULT_PANEL_TITLE);
});
test('blank titles render [No title] in edit mode', async () => {
const mockEmbeddable = await createEmbeddable({ title: '', viewMode: ViewMode.EDIT });
render(
<EmbeddablePanelHeader
embeddable={mockEmbeddable}
headerId={'headerId'}
universalActions={{ editPanel: editPanelAction }}
/>
);
const titleComponent = await screen.findByTestId('embeddablePanelTitleInner');
expect(titleComponent).toHaveTextContent('[No Title]');
});
test('hiding an individual panel title renders **only** the context menu button in edit mode', async () => {
const mockEmbeddable = await createEmbeddable({
viewMode: ViewMode.EDIT,
hidePanelTitles: true,
});
render(
<EmbeddablePanelHeader
embeddable={mockEmbeddable}
headerId={'headerId'}
universalActions={{ editPanel: editPanelAction }}
/>
);
const titleComponent = await screen.findByTestId('embeddablePanelHeading-');
const innerTitleComponent = await screen.findByTestId('embeddablePanelTitleInner');
expect(innerTitleComponent).toBeEmptyDOMElement();
const menuComponent = await screen.findByTestId('embeddablePanelToggleMenuIcon');
expect(titleComponent).toContainElement(menuComponent);
});
test('clicking title opens customize panel flyout', async () => {
const mockEmbeddable = await createEmbeddable({ viewMode: ViewMode.EDIT });
render(
<EmbeddablePanelHeader
embeddable={mockEmbeddable}
headerId={'headerId'}
universalActions={{ editPanel: editPanelAction }}
/>
);
const titleComponent = await screen.findByTestId('embeddablePanelTitleLink');
const spy = jest.spyOn(openCustomizePanel, 'openCustomizePanelFlyout');
userEvent.click(titleComponent);
expect(spy).toHaveBeenCalled();
});
});

View file

@ -85,7 +85,7 @@ export const EmbeddablePanelHeader = ({
if (!showPanelBar) {
return (
<div className={headerClasses}>
<div data-test-subj={`embeddablePanelHeading`} className={headerClasses}>
{embeddablePanelContextMenu}
{ariaLabelElement}
</div>
@ -104,7 +104,7 @@ export const EmbeddablePanelHeader = ({
hideTitle={hideTitle}
embeddable={embeddable}
description={description}
customizePanelAction={universalActions.customizePanel}
editPanelAction={universalActions.editPanel}
/>
{showBadges && badgeComponents}
</h2>

View file

@ -11,21 +11,22 @@ import React, { useMemo } from 'react';
import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui';
import { IEmbeddable, ViewMode } from '../../lib';
import { CustomizePanelAction } from '../panel_actions';
import { getEditTitleAriaLabel, placeholderTitle } from '../embeddable_panel_strings';
import { EditPanelAction } from '../panel_actions';
import { openCustomizePanelFlyout } from '../panel_actions/customize_panel_action/open_customize_panel';
export const EmbeddablePanelTitle = ({
viewMode,
hideTitle,
embeddable,
description,
customizePanelAction,
editPanelAction,
}: {
hideTitle?: boolean;
viewMode?: ViewMode;
description?: string;
embeddable: IEmbeddable;
customizePanelAction?: CustomizePanelAction;
editPanelAction?: EditPanelAction;
}) => {
const title = embeddable.getTitle();
@ -39,32 +40,44 @@ export const EmbeddablePanelTitle = ({
if (viewMode === ViewMode.VIEW) {
return <span className={titleClassNames}>{title}</span>;
}
if (customizePanelAction) {
if (editPanelAction) {
return (
<EuiLink
color="text"
className={titleClassNames}
aria-label={getEditTitleAriaLabel(title)}
data-test-subj={'embeddablePanelTitleLink'}
onClick={() => customizePanelAction.execute({ embeddable })}
onClick={() =>
openCustomizePanelFlyout({
editPanel: editPanelAction,
embeddable,
focusOnTitle: true,
})
}
>
{title || placeholderTitle}
</EuiLink>
);
}
return null;
}, [customizePanelAction, embeddable, title, viewMode, hideTitle]);
}, [editPanelAction, embeddable, title, viewMode, hideTitle]);
const titleComponentWithDescription = useMemo(() => {
if (!description) return <span className="embPanel__titleInner">{titleComponent}</span>;
if (!description)
return (
<span className="embPanel__titleInner" data-test-subj="embeddablePanelTitleInner">
{titleComponent}
</span>
);
return (
<EuiToolTip
content={description}
delay="regular"
position="top"
anchorClassName="embPanel__titleTooltipAnchor"
anchorProps={{ 'data-test-subj': 'embeddablePanelTooltipAnchor' }}
>
<span className="embPanel__titleInner">
<span className="embPanel__titleInner" data-test-subj="embeddablePanelTitleInner">
{titleComponent}{' '}
<EuiIcon
type="iInCircle"

View file

@ -146,10 +146,9 @@ export class EmbeddablePublicPlugin implements Plugin<EmbeddableSetup, Embeddabl
});
const { uiActions } = deps;
const { overlays, theme, uiSettings } = core;
const { uiSettings } = core;
const dateFormat = uiSettings.get(UI_SETTINGS.DATE_FORMAT);
const commonlyUsedRanges = uiSettings.get(UI_SETTINGS.TIMEPICKER_QUICK_RANGES);
this.appListSubscription = core.application.applications$.subscribe((appList) => {
this.appList = appList;
@ -168,13 +167,7 @@ export class EmbeddablePublicPlugin implements Plugin<EmbeddableSetup, Embeddabl
this.stateTransferService
);
const timeRangeBadge = new CustomTimeRangeBadge(
overlays,
theme,
editPanel,
commonlyUsedRanges,
dateFormat
);
const timeRangeBadge = new CustomTimeRangeBadge(editPanel, dateFormat);
uiActions.addTriggerAction(PANEL_BADGE_TRIGGER, timeRangeBadge);

View file

@ -21,8 +21,6 @@
"@kbn/std",
"@kbn/expressions-plugin",
"@kbn/data-plugin",
"@kbn/core-overlays-browser-mocks",
"@kbn/core-theme-browser-mocks",
"@kbn/saved-objects-management-plugin",
"@kbn/saved-objects-tagging-oss-plugin",
"@kbn/saved-objects-finder-plugin",
@ -36,6 +34,7 @@
"@kbn/data-views-plugin",
"@kbn/search-errors",
"@kbn/panel-loader",
"@kbn/core-application-browser-mocks",
],
"exclude": ["target/**/*"]
}

View file

@ -15,7 +15,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const dashboardPanelActions = getService('dashboardPanelActions');
const dashboardBadgeActions = getService('dashboardBadgeActions');
const dashboardCustomizePanel = getService('dashboardCustomizePanel');
const dashboardAddPanel = getService('dashboardAddPanel');
const PageObjects = getPageObjects([
'common',
'dashboard',
@ -87,19 +86,5 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await testSubjects.exists('xyVisChart')).to.be(true);
});
});
describe('embeddable that does not support time', () => {
it('should not show custom time picker in flyout', async () => {
await dashboardPanelActions.removePanel();
await PageObjects.dashboard.waitForRenderComplete();
await dashboardAddPanel.clickMarkdownQuickButton();
await PageObjects.visEditor.setMarkdownTxt('I am timeless!');
await PageObjects.visEditor.clickGo();
await PageObjects.visualize.saveVisualizationAndReturn();
await PageObjects.dashboard.clickQuickSave();
await dashboardPanelActions.customizePanel();
await dashboardCustomizePanel.expectMissingCustomTimeRange();
});
});
});
}

View file

@ -78,44 +78,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(panelTitle).to.equal(EMPTY_TITLE);
await PageObjects.dashboard.clearUnsavedChanges();
});
it('blank titles are hidden in view mode', async () => {
await PageObjects.dashboard.clickCancelOutOfEditMode();
const titleVisibility = (await PageObjects.dashboard.getVisibilityOfPanelTitles())[0];
expect(titleVisibility).to.be(false);
});
it('custom titles are visible in view mode', async () => {
await PageObjects.dashboard.switchToEditMode();
await dashboardPanelActions.customizePanel();
await dashboardCustomizePanel.setCustomPanelTitle(CUSTOM_TITLE);
await dashboardCustomizePanel.clickSaveButton();
await PageObjects.dashboard.clickQuickSave();
await PageObjects.dashboard.clickCancelOutOfEditMode();
const titleVisibility = (await PageObjects.dashboard.getVisibilityOfPanelTitles())[0];
expect(titleVisibility).to.be(true);
});
it('hiding an individual panel title hides it in view mode', async () => {
await PageObjects.dashboard.switchToEditMode();
await dashboardPanelActions.customizePanel();
await dashboardCustomizePanel.clickToggleHidePanelTitle();
await dashboardCustomizePanel.clickSaveButton();
await PageObjects.dashboard.clickQuickSave();
await PageObjects.dashboard.clickCancelOutOfEditMode();
const titleVisibility = (await PageObjects.dashboard.getVisibilityOfPanelTitles())[0];
expect(titleVisibility).to.be(false);
// undo the previous hide panel toggle (i.e. make the panel visible) to keep state consistent
await PageObjects.dashboard.switchToEditMode();
await dashboardPanelActions.customizePanel();
await dashboardCustomizePanel.clickToggleHidePanelTitle();
await dashboardCustomizePanel.clickSaveButton();
await PageObjects.dashboard.clickQuickSave();
});
});
describe('by reference', () => {