[Discover] Enhance flyout customization to update content (#169634)

## 📓 Summary

Closes #169394 

This PR extends the `flyout` customization extension point to support
updating/replacing the content shown in the document flyout.
A consumer would need to show/hide/highlight details related to the
expanded document, and it might also want to control whether the default
content (currently only the UnifiedDocViewer) is shown/hidden. Finally,
it could be necessary to perform imperative actions such as
adding/removing columns or filter.

To get this flexibility at the moment of customizing the content, the
existing extension point takes now a `Content` component, where some
props are injected.

```
export interface FlyoutContentProps {
  actions: {
    setFilter?: DocViewFilterFn;
    addColumn: (column: string) => void;
    removeColumn: (column: string) => void;
  };
  doc: DataTableRecord;
  renderDefaultContent: () => React.ReactNode;
}
```
N.B. `renderDefaultContent` is passed as a function instead of a React
element to avoid its creation in the Discover flyout in case the
consumer doesn't want to display it.

Here is a usage example of the new extension point property.

```
customizations.set({
  id: 'flyout',
  Content: ({ actions, doc, renderDefaultContent }) => {
    return (
      <Panel>
        <HighlightComponent timestamp={doc.flattened['@timestamp']} />
        <Columns onAddColumn={actions.addColumns} onAddColumn={actions.removeColumn} />
        <Filters onFilter={actions.setFilter} />
        {renderDefaultContent()}
      </Panel>
    );
  },
});
```

---------

Co-authored-by: Marco Antonio Ghiani <marcoantonio.ghiani@elastic.co>
This commit is contained in:
Marco Antonio Ghiani 2023-10-27 11:38:00 +02:00 committed by GitHub
parent 227d7acae7
commit 002ac40dc1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 195 additions and 71 deletions

View file

@ -8,6 +8,7 @@
import React from 'react';
import { findTestSubject } from '@elastic/eui/lib/test';
import { EuiFlexItem } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import type { Query, AggregateQuery } from '@kbn/es-query';
import { DiscoverGridFlyout, DiscoverGridFlyoutProps } from './discover_grid_flyout';
@ -24,7 +25,6 @@ import { ReactWrapper } from 'enzyme';
import { setUnifiedDocViewerServices } from '@kbn/unified-doc-viewer-plugin/public/plugin';
import { mockUnifiedDocViewerServices } from '@kbn/unified-doc-viewer-plugin/public/__mocks__';
import { FlyoutCustomization, useDiscoverCustomization } from '../../customizations';
import { EuiFlexItem } from '@elastic/eui';
const mockFlyoutCustomization: FlyoutCustomization = {
id: 'flyout',
@ -69,6 +69,9 @@ describe('Discover flyout', function () {
},
contextLocator: { getRedirectUrl: jest.fn(() => 'mock-context-redirect-url') },
singleDocLocator: { getRedirectUrl: jest.fn(() => 'mock-doc-redirect-url') },
toastNotifications: {
addSuccess: jest.fn(),
},
} as unknown as DiscoverServices;
setUnifiedDocViewerServices(mockUnifiedDocViewerServices);
@ -103,11 +106,12 @@ describe('Discover flyout', function () {
const component = mountWithIntl(<Proxy {...props} />);
await waitNextUpdate(component);
return { component, props };
return { component, props, services };
};
beforeEach(() => {
mockFlyoutCustomization.actions.defaultActions = undefined;
mockFlyoutCustomization.Content = undefined;
jest.clearAllMocks();
(useDiscoverCustomization as jest.Mock).mockImplementation(() => mockFlyoutCustomization);
@ -226,44 +230,98 @@ describe('Discover flyout', function () {
expect(flyoutTitle.text()).toBe('Expanded row');
});
describe('when customizations actions exists', () => {
it('should display actions added by getActionItems', async () => {
mockFlyoutCustomization.actions = {
getActionItems: jest.fn(() => [
{
id: 'action-item-1',
enabled: true,
Content: () => <EuiFlexItem data-test-subj="customActionItem1">Action 1</EuiFlexItem>,
describe('with applied customizations', () => {
describe('when actions are customized', () => {
it('should display actions added by getActionItems', async () => {
mockFlyoutCustomization.actions = {
getActionItems: jest.fn(() => [
{
id: 'action-item-1',
enabled: true,
Content: () => <EuiFlexItem data-test-subj="customActionItem1">Action 1</EuiFlexItem>,
},
{
id: 'action-item-2',
enabled: true,
Content: () => <EuiFlexItem data-test-subj="customActionItem2">Action 2</EuiFlexItem>,
},
]),
};
const { component } = await mountComponent({});
const action1 = findTestSubject(component, 'customActionItem1');
const action2 = findTestSubject(component, 'customActionItem2');
expect(action1.text()).toBe('Action 1');
expect(action2.text()).toBe('Action 2');
});
it('should allow disabling default actions', async () => {
mockFlyoutCustomization.actions = {
defaultActions: {
viewSingleDocument: { disabled: true },
viewSurroundingDocument: { disabled: true },
},
{
id: 'action-item-2',
enabled: true,
Content: () => <EuiFlexItem data-test-subj="customActionItem2">Action 2</EuiFlexItem>,
},
]),
};
};
const { component } = await mountComponent({});
const { component } = await mountComponent({});
const action1 = findTestSubject(component, 'customActionItem1');
const action2 = findTestSubject(component, 'customActionItem2');
expect(action1.text()).toBe('Action 1');
expect(action2.text()).toBe('Action 2');
const singleDocumentView = findTestSubject(component, 'docTableRowAction');
expect(singleDocumentView.length).toBeFalsy();
});
});
it('should allow disabling default actions', async () => {
mockFlyoutCustomization.actions = {
defaultActions: {
viewSingleDocument: { disabled: true },
viewSurroundingDocument: { disabled: true },
},
};
describe('when content is customized', () => {
it('should display the component passed to the Content customization', async () => {
mockFlyoutCustomization.Content = () => (
<span data-test-subj="flyoutCustomContent">Custom content</span>
);
const { component } = await mountComponent({});
const { component } = await mountComponent({});
const singleDocumentView = findTestSubject(component, 'docTableRowAction');
expect(singleDocumentView.length).toBeFalsy();
const customContent = findTestSubject(component, 'flyoutCustomContent');
expect(customContent.text()).toBe('Custom content');
});
it('should provide a doc property to display details about the current document overview', async () => {
mockFlyoutCustomization.Content = ({ doc }) => {
return (
<span data-test-subj="flyoutCustomContent">{doc.flattened.message as string}</span>
);
};
const { component } = await mountComponent({});
const customContent = findTestSubject(component, 'flyoutCustomContent');
expect(customContent.text()).toBe('test1');
});
it('should provide an actions prop collection to optionally update the grid content', async () => {
mockFlyoutCustomization.Content = ({ actions }) => (
<>
<button data-test-subj="addColumn" onClick={() => actions.addColumn('message')} />
<button data-test-subj="removeColumn" onClick={() => actions.removeColumn('message')} />
<button
data-test-subj="addFilter"
onClick={() => actions.addFilter?.('_exists_', 'message', '+')}
/>
</>
);
const { component, props, services } = await mountComponent({});
findTestSubject(component, 'addColumn').simulate('click');
findTestSubject(component, 'removeColumn').simulate('click');
findTestSubject(component, 'addFilter').simulate('click');
expect(props.onAddColumn).toHaveBeenCalled();
expect(props.onRemoveColumn).toHaveBeenCalled();
expect(services.toastNotifications.addSuccess).toHaveBeenCalledTimes(2);
expect(props.onFilter).toHaveBeenCalled();
});
});
});
});

View file

@ -28,6 +28,7 @@ import { UnifiedDocViewer } from '@kbn/unified-doc-viewer-plugin/public';
import { useDiscoverServices } from '../../hooks/use_discover_services';
import { isTextBasedQuery } from '../../application/main/utils/is_text_based_query';
import { useFlyoutActions } from './use_flyout_actions';
import { useDiscoverCustomization } from '../../customizations';
export interface DiscoverGridFlyoutProps {
savedSearchId?: string;
@ -69,6 +70,8 @@ export function DiscoverGridFlyout({
setExpandedDoc,
}: DiscoverGridFlyoutProps) {
const services = useDiscoverServices();
const flyoutCustomization = useDiscoverCustomization('flyout');
const isPlainRecord = isTextBasedQuery(query);
// Get actual hit with updated highlighted searches
const actualHit = useMemo(() => hits?.find(({ id }) => id === hit?.id) || hit, [hit, hits]);
@ -103,6 +106,7 @@ export function DiscoverGridFlyout({
);
const { flyoutActions } = useFlyoutActions({
actions: flyoutCustomization?.actions,
dataView,
rowIndex: hit.raw._index,
rowId: hit.raw._id,
@ -111,6 +115,77 @@ export function DiscoverGridFlyout({
savedSearchId,
});
const addColumn = useCallback(
(columnName: string) => {
onAddColumn(columnName);
services.toastNotifications.addSuccess(
i18n.translate('discover.grid.flyout.toastColumnAdded', {
defaultMessage: `Column '{columnName}' was added`,
values: { columnName },
})
);
},
[onAddColumn, services.toastNotifications]
);
const removeColumn = useCallback(
(columnName: string) => {
onRemoveColumn(columnName);
services.toastNotifications.addSuccess(
i18n.translate('discover.grid.flyout.toastColumnRemoved', {
defaultMessage: `Column '{columnName}' was removed`,
values: { columnName },
})
);
},
[onRemoveColumn, services.toastNotifications]
);
const renderDefaultContent = useCallback(
() => (
<UnifiedDocViewer
columns={columns}
columnTypes={columnTypes}
dataView={dataView}
filter={onFilter}
hit={actualHit}
onAddColumn={addColumn}
onRemoveColumn={removeColumn}
textBasedHits={isPlainRecord ? hits : undefined}
/>
),
[
actualHit,
addColumn,
columns,
columnTypes,
dataView,
hits,
isPlainRecord,
onFilter,
removeColumn,
]
);
const contentActions = useMemo(
() => ({
addFilter: onFilter,
addColumn,
removeColumn,
}),
[onFilter, addColumn, removeColumn]
);
const bodyContent = flyoutCustomization?.Content ? (
<flyoutCustomization.Content
actions={contentActions}
doc={actualHit}
renderDefaultContent={renderDefaultContent}
/>
) : (
renderDefaultContent()
);
return (
<EuiPortal>
<EuiFlyout
@ -136,7 +211,6 @@ export function DiscoverGridFlyout({
})}
</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup responsive={false} gutterSize="s" alignItems="center">
{!isPlainRecord &&
@ -158,34 +232,7 @@ export function DiscoverGridFlyout({
)}
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<UnifiedDocViewer
hit={actualHit}
columns={columns}
columnTypes={columnTypes}
dataView={dataView}
filter={onFilter}
onRemoveColumn={(columnName: string) => {
onRemoveColumn(columnName);
services.toastNotifications.addSuccess(
i18n.translate('discover.grid.flyout.toastColumnRemoved', {
defaultMessage: `Column '{columnName}' was removed`,
values: { columnName },
})
);
}}
onAddColumn={(columnName: string) => {
onAddColumn(columnName);
services.toastNotifications.addSuccess(
i18n.translate('discover.grid.flyout.toastColumnAdded', {
defaultMessage: `Column '{columnName}' was added`,
values: { columnName },
})
);
}}
textBasedHits={isPlainRecord ? hits : undefined}
/>
</EuiFlyoutBody>
<EuiFlyoutBody>{bodyContent}</EuiFlyoutBody>
</EuiFlyout>
</EuiPortal>
);

View file

@ -16,9 +16,13 @@ import {
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useDiscoverCustomization } from '../../customizations';
import { FlyoutCustomization } from '../../customizations';
import { UseNavigationProps, useNavigationProps } from '../../hooks/use_navigation_props';
interface UseFlyoutActionsParams extends UseNavigationProps {
actions?: FlyoutCustomization['actions'];
}
interface FlyoutActionProps {
onClick: React.MouseEventHandler<Element>;
href: string;
@ -30,18 +34,16 @@ const staticViewDocumentItem = {
Content: () => <ViewDocument />,
};
export const useFlyoutActions = (navigationProps: UseNavigationProps) => {
const { dataView } = navigationProps;
export const useFlyoutActions = ({ actions, ...props }: UseFlyoutActionsParams) => {
const { dataView } = props;
const { singleDocHref, contextViewHref, onOpenSingleDoc, onOpenContextView } =
useNavigationProps(navigationProps);
const flyoutCustomization = useDiscoverCustomization('flyout');
useNavigationProps(props);
const {
viewSingleDocument = { disabled: false },
viewSurroundingDocument = { disabled: false },
} = flyoutCustomization?.actions?.defaultActions ?? {};
const customActions = [...(flyoutCustomization?.actions?.getActionItems?.() ?? [])];
} = actions?.defaultActions ?? {};
const customActions = [...(actions?.getActionItems?.() ?? [])];
const flyoutActions = [
{

View file

@ -6,6 +6,10 @@
* Side Public License, v 1.
*/
import type { DataTableRecord } from '@kbn/discover-utils/types';
import type { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import React, { type ComponentType } from 'react';
export interface FlyoutDefaultActionItem {
disabled?: boolean;
}
@ -21,10 +25,23 @@ export interface FlyoutActionItem {
enabled: boolean;
}
export interface FlyoutContentActions {
addFilter?: DocViewFilterFn;
addColumn: (column: string) => void;
removeColumn: (column: string) => void;
}
export interface FlyoutContentProps {
actions: FlyoutContentActions;
doc: DataTableRecord;
renderDefaultContent: () => React.ReactNode;
}
export interface FlyoutCustomization {
id: 'flyout';
actions: {
defaultActions?: FlyoutDefaultActions;
getActionItems?: () => FlyoutActionItem[];
};
Content?: ComponentType<FlyoutContentProps>;
}

View file

@ -1725,6 +1725,6 @@
"@kbn/ambient-ui-types",
"@kbn/ambient-common-types",
"@kbn/ambient-storybook-types"
]
],
}
}