Skip to content

Commit

Permalink
[Discover] Enhance flyout customization to update content (elastic#16…
Browse files Browse the repository at this point in the history
…9634)

## 📓 Summary

Closes elastic#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>
  • Loading branch information
2 people authored and awahab07 committed Oct 31, 2023
1 parent 30b80dd commit 1002130
Show file tree
Hide file tree
Showing 5 changed files with 195 additions and 71 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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',
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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>,
},
{
id: 'action-item-2',
enabled: true,
Content: () => <EuiFlexItem data-test-subj="customActionItem2">Action 2</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 },
},
]),
};
};

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 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({});

const { component } = await mountComponent({});
findTestSubject(component, 'addColumn').simulate('click');
findTestSubject(component, 'removeColumn').simulate('click');
findTestSubject(component, 'addFilter').simulate('click');

const singleDocumentView = findTestSubject(component, 'docTableRowAction');
expect(singleDocumentView.length).toBeFalsy();
expect(props.onAddColumn).toHaveBeenCalled();
expect(props.onRemoveColumn).toHaveBeenCalled();
expect(services.toastNotifications.addSuccess).toHaveBeenCalledTimes(2);
expect(props.onFilter).toHaveBeenCalled();
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -103,6 +106,7 @@ export function DiscoverGridFlyout({
);

const { flyoutActions } = useFlyoutActions({
actions: flyoutCustomization?.actions,
dataView,
rowIndex: hit.raw._index,
rowId: hit.raw._id,
Expand All @@ -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
Expand All @@ -136,7 +211,6 @@ export function DiscoverGridFlyout({
})}
</h2>
</EuiTitle>

<EuiSpacer size="s" />
<EuiFlexGroup responsive={false} gutterSize="s" alignItems="center">
{!isPlainRecord &&
Expand All @@ -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>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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>;
}
2 changes: 1 addition & 1 deletion tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -1725,6 +1725,6 @@
"@kbn/ambient-ui-types",
"@kbn/ambient-common-types",
"@kbn/ambient-storybook-types"
]
],
}
}

0 comments on commit 1002130

Please sign in to comment.