[Lens][Visualizations] library annotation groups listing page (#157988)

This commit is contained in:
Drew Tate 2023-06-13 20:09:01 -05:00 committed by GitHub
parent 44bfd0c343
commit 6553ebbdd5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
176 changed files with 4818 additions and 2423 deletions

4
.github/CODEOWNERS vendored
View file

@ -88,7 +88,9 @@ src/plugins/console @elastic/platform-deployment-management
packages/content-management/content_editor @elastic/appex-sharedux
examples/content_management_examples @elastic/appex-sharedux
src/plugins/content_management @elastic/appex-sharedux
packages/content-management/table_list @elastic/appex-sharedux
packages/content-management/tabbed_table_list_view @elastic/appex-sharedux
packages/content-management/table_list_view @elastic/appex-sharedux
packages/content-management/table_list_view_table @elastic/appex-sharedux
packages/kbn-content-management-utils @elastic/kibana-data-discovery
examples/controls_example @elastic/kibana-presentation
src/plugins/controls @elastic/kibana-presentation

View file

@ -8,7 +8,7 @@
import React from 'react';
import { ContentClientProvider, type ContentClient } from '@kbn/content-management-plugin/public';
import { TableListViewKibanaProvider } from '@kbn/content-management-table-list';
import { TableListViewKibanaProvider } from '@kbn/content-management-table-list-view-table';
import type { CoreStart } from '@kbn/core/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { FormattedRelative, I18nProvider } from '@kbn/i18n-react';

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { TableListView, UserContentCommonSchema } from '@kbn/content-management-table-list';
import { TableListView, UserContentCommonSchema } from '@kbn/content-management-table-list-view';
import { useContentClient } from '@kbn/content-management-plugin/public';
import React from 'react';
import { SavedObjectsFindOptionsReference } from '@kbn/core-saved-objects-api-browser';
@ -51,7 +51,7 @@ export const MSearchTable = () => {
initialPageSize={50}
entityName={`ContentItem`}
entityNamePlural={`ContentItems`}
tableListTitle={`MSearch Demo`}
title={`MSearch Demo`}
urlStateEnabled={false}
emptyPrompt={<>No data found. Try to install some sample data first.</>}
onClickTitle={(item) => {

View file

@ -20,10 +20,13 @@
"@kbn/content-management-plugin",
"@kbn/core-application-browser",
"@kbn/shared-ux-link-redirect-app",
"@kbn/content-management-table-list",
"@kbn/content-management-table-list-view",
"@kbn/content-management-table-list-view-table",
"@kbn/kibana-react-plugin",
"@kbn/i18n-react",
"@kbn/saved-objects-tagging-oss-plugin",
"@kbn/core-saved-objects-api-browser",
"@kbn/content-management-table-list-view-table",
"@kbn/content-management-table-list-view",
]
}

View file

@ -189,7 +189,9 @@
"@kbn/content-management-content-editor": "link:packages/content-management/content_editor",
"@kbn/content-management-examples-plugin": "link:examples/content_management_examples",
"@kbn/content-management-plugin": "link:src/plugins/content_management",
"@kbn/content-management-table-list": "link:packages/content-management/table_list",
"@kbn/content-management-tabbed-table-list-view": "link:packages/content-management/tabbed_table_list_view",
"@kbn/content-management-table-list-view": "link:packages/content-management/table_list_view",
"@kbn/content-management-table-list-view-table": "link:packages/content-management/table_list_view_table",
"@kbn/content-management-utils": "link:packages/kbn-content-management-utils",
"@kbn/controls-example-plugin": "link:examples/controls_example",
"@kbn/controls-plugin": "link:src/plugins/controls",

View file

@ -0,0 +1,20 @@
---
id: sharedUX/contentManagement/TabbedTableListView
slug: /shared-ux/content-management/tabbed-table-list-view
title: Tabbed table list view
summary: A table to render user generated saved objects.
tags: ['shared-ux', 'content-management']
date: 2022-08-09
---
The `<TabbedTableListView />` renders an eui page to display a list of user content saved object.
**Uncomplete documentation**. Will be updated.
## API
TODO: https://github.com/elastic/kibana/issues/144402
## EUI Promotion Status
This component is not currently considered for promotion to EUI.

View file

@ -0,0 +1,11 @@
/*
* 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.
*/
export { TabbedTableListView, type TableListTab, type TableListTabParentProps } from './src';
export type { UserContentCommonSchema } from '@kbn/content-management-table-list-view-table';

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/packages/content-management/tabbed_table_list_view'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/content-management-tabbed-table-list-view",
"owner": "@elastic/appex-sharedux"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/content-management-tabbed-table-list-view",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
export {
TabbedTableListView,
type TableListTab,
type TableListTabParentProps,
} from './tabbed_table_list_view';

View file

@ -0,0 +1,128 @@
/*
* 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 { ReactWrapper, mount, shallow } from 'enzyme';
import {
TabbedTableListView,
TableListTabParentProps,
TableListTab,
} from './tabbed_table_list_view';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { EuiPageTemplate } from '@elastic/eui';
import { act } from 'react-dom/test-utils';
// Mock the necessary props for the component
const title = 'Test Title';
const description = 'Test Description';
const headingId = 'test-heading-id';
const children = <div>Test Children</div>;
const tableList1 = 'Test Table List 1';
const tableList2 = 'Test Table List 2';
const tabs: TableListTab[] = [
{
title: 'Tab 1',
id: 'tab-1',
getTableList: async (props: TableListTabParentProps) => <div>{tableList1}</div>,
},
{
title: 'Tab 2',
id: 'tab-2',
getTableList: async (props: TableListTabParentProps) => <div>{tableList2}</div>,
},
];
describe('TabbedTableListView', () => {
it('should render without errors', () => {
const wrapper = shallow(
<TabbedTableListView
title={title}
description={description}
headingId={headingId}
children={children}
tabs={tabs}
activeTabId={'tab-1'}
changeActiveTab={() => {}}
/>
);
expect(wrapper.exists()).toBe(true);
});
it('should render the correct title and description', () => {
const wrapper = shallow(
<TabbedTableListView
title={title}
description={description}
headingId={headingId}
children={children}
tabs={tabs}
activeTabId={'tab-1'}
changeActiveTab={() => {}}
/>
);
expect(wrapper.find(KibanaPageTemplate.Header).prop('pageTitle')).toMatchInlineSnapshot(`
<span
id="test-heading-id"
>
Test Title
</span>
`);
expect(wrapper.find(KibanaPageTemplate.Header).prop('description')).toContain(description);
});
it('should render the correct number of tabs', () => {
const wrapper = shallow(
<TabbedTableListView
title={title}
description={description}
headingId={headingId}
children={children}
tabs={tabs}
activeTabId={'tab-1'}
changeActiveTab={() => {}}
/>
);
expect(wrapper.find(EuiPageTemplate.Header).prop('tabs')).toHaveLength(2);
});
it('should switch tabs when props change', async () => {
const changeActiveTab = jest.fn();
let wrapper: ReactWrapper | undefined;
await act(async () => {
wrapper = mount(
<TabbedTableListView
title={title}
description={description}
headingId={headingId}
children={children}
tabs={tabs}
activeTabId={'tab-1'}
changeActiveTab={changeActiveTab}
/>
);
});
if (!wrapper) {
throw new Error("enzyme wrapper didn't initialize");
}
expect(wrapper.find(EuiPageTemplate.Section).text()).toContain(tableList1);
await act(async () => {
wrapper?.setProps({
activeTabId: 'tab-2',
});
});
expect(wrapper.find(EuiPageTemplate.Section).text()).toContain(tableList2);
});
});

View file

@ -0,0 +1,88 @@
/*
* 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 { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import React, { useCallback, useEffect, useState } from 'react';
import type {
TableListViewTableProps,
UserContentCommonSchema,
} from '@kbn/content-management-table-list-view-table';
import type { TableListViewProps } from '@kbn/content-management-table-list-view';
export type TableListTabParentProps<T extends UserContentCommonSchema = UserContentCommonSchema> =
Pick<TableListViewTableProps<T>, 'onFetchSuccess' | 'setPageDataTestSubject'>;
export interface TableListTab<T extends UserContentCommonSchema = UserContentCommonSchema> {
title: string;
id: string;
getTableList: (
propsFromParent: TableListTabParentProps<T>
) => Promise<React.ReactNode> | React.ReactNode;
}
type TabbedTableListViewProps = Pick<
TableListViewProps<UserContentCommonSchema>,
'title' | 'description' | 'headingId' | 'children'
> & { tabs: TableListTab[]; activeTabId: string; changeActiveTab: (id: string) => void };
export const TabbedTableListView = ({
title,
description,
headingId,
children,
tabs,
activeTabId,
changeActiveTab,
}: TabbedTableListViewProps) => {
const [hasInitialFetchReturned, setHasInitialFetchReturned] = useState(false);
const [pageDataTestSubject, setPageDataTestSubject] = useState<string>();
const getActiveTab = useCallback(
() => tabs.find((tab) => tab.id === activeTabId) ?? tabs[0],
[activeTabId, tabs]
);
const [tableList, setTableList] = useState<React.ReactNode>(null);
useEffect(() => {
async function loadTableList() {
const newTableList = await getActiveTab().getTableList({
onFetchSuccess: () => {
if (!hasInitialFetchReturned) {
setHasInitialFetchReturned(true);
}
},
setPageDataTestSubject,
});
setTableList(newTableList);
}
loadTableList();
}, [hasInitialFetchReturned, activeTabId, tabs, getActiveTab]);
return (
<KibanaPageTemplate panelled data-test-subj={pageDataTestSubject}>
<KibanaPageTemplate.Header
pageTitle={<span id={headingId}>{title}</span>}
description={description}
data-test-subj="top-nav"
tabs={tabs.map((tab) => ({
onClick: () => changeActiveTab(tab.id),
isSelected: tab.id === getActiveTab().id,
label: tab.title,
}))}
/>
<KibanaPageTemplate.Section aria-labelledby={hasInitialFetchReturned ? headingId : undefined}>
{/* Any children passed to the component */}
{children}
{tableList}
</KibanaPageTemplate.Section>
</KibanaPageTemplate>
);
};

View file

@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react",
"@kbn/ambient-ui-types",
"@kbn/ambient-storybook-types",
"@emotion/react/types/css-prop"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"kbn_references": [
"@kbn/content-management-table-list-view",
"@kbn/shared-ux-page-kibana-template",
"@kbn/content-management-table-list-view-table",
"@kbn/content-management-table-list-view",
],
"exclude": [
"target/**/*",
]
}

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
export { TableListView } from './src/table_list_view';
export type { TableListViewProps } from './src/table_list_view';
export type { UserContentCommonSchema } from '@kbn/content-management-table-list-view-table';

View file

@ -9,5 +9,5 @@
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/packages/content-management/table_list'],
roots: ['<rootDir>/packages/content-management/table_list_view'],
};

View file

@ -1,5 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/content-management-table-list",
"id": "@kbn/content-management-table-list-view",
"owner": "@elastic/appex-sharedux"
}

View file

@ -1,6 +1,6 @@
{
"name": "@kbn/content-management-table-list",
"name": "@kbn/content-management-table-list-view",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}
}

View file

@ -11,10 +11,16 @@ import Chance from 'chance';
import moment from 'moment';
import { action } from '@storybook/addon-actions';
import { Params, getStoryArgTypes, getStoryServices } from './mocks';
import { TableListView as Component, UserContentCommonSchema } from './table_list_view';
import { TableListViewProvider } from './services';
import {
TableListViewProvider,
UserContentCommonSchema,
} from '@kbn/content-management-table-list-view-table';
import {
Params,
getStoryArgTypes,
getStoryServices,
} from '@kbn/content-management-table-list-view-table/src/mocks';
import { TableListView as Component } from './table_list_view';
import mdx from '../README.mdx';

View file

@ -0,0 +1,131 @@
/*
* 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 { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import React, { ReactNode, useState } from 'react';
import {
TableListViewTable,
type TableListViewTableProps,
type UserContentCommonSchema,
} from '@kbn/content-management-table-list-view-table';
export type TableListViewProps<T extends UserContentCommonSchema = UserContentCommonSchema> = Pick<
TableListViewTableProps<T>,
| 'entityName'
| 'entityNamePlural'
| 'initialFilter'
| 'headingId'
| 'initialPageSize'
| 'listingLimit'
| 'urlStateEnabled'
| 'customTableColumn'
| 'emptyPrompt'
| 'findItems'
| 'createItem'
| 'editItem'
| 'deleteItems'
| 'getDetailViewLink'
| 'onClickTitle'
| 'id'
| 'rowItemActions'
| 'contentEditor'
| 'titleColumnName'
| 'withoutPageTemplateWrapper'
| 'showEditActionForItem'
> & {
title: string;
description?: string;
/**
* Additional actions (buttons) to be placed in the page header.
* @note only the first two values will be used.
*/
additionalRightSideActions?: ReactNode[];
children?: ReactNode | undefined;
};
export const TableListView = <T extends UserContentCommonSchema>({
title,
description,
entityName,
entityNamePlural,
initialFilter,
headingId,
initialPageSize,
listingLimit,
urlStateEnabled = true,
customTableColumn,
emptyPrompt,
findItems,
createItem,
editItem,
deleteItems,
getDetailViewLink,
onClickTitle,
rowItemActions,
id: listingId,
contentEditor,
children,
titleColumnName,
additionalRightSideActions,
withoutPageTemplateWrapper,
}: TableListViewProps<T>) => {
const PageTemplate = withoutPageTemplateWrapper
? (React.Fragment as unknown as typeof KibanaPageTemplate)
: KibanaPageTemplate;
const [hasInitialFetchReturned, setHasInitialFetchReturned] = useState(false);
const [pageDataTestSubject, setPageDataTestSubject] = useState<string>();
return (
<PageTemplate panelled data-test-subj={pageDataTestSubject}>
<KibanaPageTemplate.Header
pageTitle={<span id={headingId}>{title}</span>}
description={description}
rightSideItems={additionalRightSideActions?.slice(0, 2)}
data-test-subj="top-nav"
/>
<KibanaPageTemplate.Section aria-labelledby={hasInitialFetchReturned ? headingId : undefined}>
{/* Any children passed to the component */}
{children}
<TableListViewTable
tableCaption={title}
entityName={entityName}
entityNamePlural={entityNamePlural}
initialFilter={initialFilter}
headingId={headingId}
initialPageSize={initialPageSize}
listingLimit={listingLimit}
urlStateEnabled={urlStateEnabled}
customTableColumn={customTableColumn}
emptyPrompt={emptyPrompt}
findItems={findItems}
createItem={createItem}
editItem={editItem}
deleteItems={deleteItems}
rowItemActions={rowItemActions}
getDetailViewLink={getDetailViewLink}
onClickTitle={onClickTitle}
id={listingId}
contentEditor={contentEditor}
titleColumnName={titleColumnName}
withoutPageTemplateWrapper={withoutPageTemplateWrapper}
onFetchSuccess={() => {
if (!hasInitialFetchReturned) {
setHasInitialFetchReturned(true);
}
}}
setPageDataTestSubject={setPageDataTestSubject}
/>
</KibanaPageTemplate.Section>
</PageTemplate>
);
};
// eslint-disable-next-line import/no-default-export
export default TableListView;

View file

@ -0,0 +1,25 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react",
"@kbn/ambient-ui-types",
"@kbn/ambient-storybook-types",
"@emotion/react/types/css-prop"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"kbn_references": [
"@kbn/shared-ux-page-kibana-template",
"@kbn/content-management-table-list-view-table"
],
"exclude": [
"target/**/*",
]
}

View file

@ -0,0 +1,20 @@
---
id: sharedUX/contentManagement/TableListViewTable
slug: /shared-ux/content-management/table-list-view-table
title: Table list view
summary: A table to render user generated saved objects.
tags: ['shared-ux', 'content-management']
date: 2022-08-09
---
The `<TableListViewTable />` renders a list of user content saved object.
**Uncomplete documentation**. Will be updated.
## API
TODO: https://github.com/elastic/kibana/issues/144402
## EUI Promotion Status
This component is not currently considered for promotion to EUI.

View file

@ -6,7 +6,8 @@
* Side Public License, v 1.
*/
export { TableListView, TableListViewProvider, TableListViewKibanaProvider } from './src';
export { TableListViewTable, TableListViewProvider, TableListViewKibanaProvider } from './src';
export type { UserContentCommonSchema, TableListViewTableProps, RowActions } from './src';
export type { UserContentCommonSchema, RowActions } from './src';
export type { TableListViewKibanaDependencies } from './src/services';

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/packages/content-management/table_list_view_table'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/content-management-table-list-view-table",
"owner": "@elastic/appex-sharedux"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/content-management-table-list-view-table",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -8,7 +8,7 @@
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { Query } from '@elastic/eui';
import type { State, UserContentCommonSchema } from './table_list_view';
import type { State, UserContentCommonSchema } from './table_list_view_table';
/** Action to trigger a fetch of the table items */
export interface OnFetchItemsAction {

View file

@ -12,11 +12,11 @@ import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import type { Tag } from '../types';
import { useServices } from '../services';
import type { UserContentCommonSchema, Props as TableListViewProps } from '../table_list_view';
import type { UserContentCommonSchema, TableListViewTableProps } from '../table_list_view_table';
import { TagBadge } from './tag_badge';
type InheritedProps<T extends UserContentCommonSchema> = Pick<
TableListViewProps<T>,
TableListViewTableProps<T>,
'onClickTitle' | 'getDetailViewLink' | 'id'
>;
interface Props<T extends UserContentCommonSchema> extends InheritedProps<T> {

View file

@ -17,6 +17,7 @@ import {
SearchFilterConfig,
Direction,
Query,
Search,
type EuiTableSelectionType,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -25,9 +26,9 @@ import { useServices } from '../services';
import type { Action } from '../actions';
import type {
State as TableListViewState,
Props as TableListViewProps,
TableListViewTableProps,
UserContentCommonSchema,
} from '../table_list_view';
} from '../table_list_view_table';
import type { TableItemsRowActions } from '../types';
import { TableSortSelect } from './table_sort_select';
import { TagFilterPanel } from './tag_filter_panel';
@ -53,8 +54,9 @@ interface Props<T extends UserContentCommonSchema> extends State<T>, TagManageme
tableCaption: string;
tableColumns: Array<EuiBasicTableColumn<T>>;
hasUpdatedAtMetadata: boolean;
deleteItems: TableListViewProps<T>['deleteItems'];
deleteItems: TableListViewTableProps<T>['deleteItems'];
tableItemsRowActions: TableItemsRowActions;
renderCreateButton: () => React.ReactElement | undefined;
onSortChange: (column: SortColumnField, direction: Direction) => void;
onTableChange: (criteria: CriteriaWithPagination<T>) => void;
onTableSearchChange: (arg: { query: Query | null; queryText: string }) => void;
@ -76,6 +78,7 @@ export function Table<T extends UserContentCommonSchema>({
tagsToTableItemMap,
tableItemsRowActions,
deleteItems,
renderCreateButton,
tableCaption,
onTableChange,
onTableSearchChange,
@ -201,10 +204,11 @@ export function Table<T extends UserContentCommonSchema>({
return [tableSortSelectFilter, tagFilterPanel];
}, [tableSortSelectFilter, tagFilterPanel]);
const search = useMemo(() => {
const search = useMemo((): Search => {
return {
onChange: onTableSearchChange,
toolsLeft: renderToolsLeft(),
toolsRight: renderCreateButton(),
query: searchQuery.query ?? undefined,
box: {
incremental: true,
@ -212,7 +216,7 @@ export function Table<T extends UserContentCommonSchema>({
},
filters: searchFilters,
};
}, [onTableSearchChange, renderToolsLeft, searchFilters, searchQuery.query]);
}, [onTableSearchChange, renderCreateButton, renderToolsLeft, searchFilters, searchQuery.query]);
const noItemsMessage = (
<FormattedMessage

View file

@ -19,7 +19,7 @@ import {
} from '@elastic/eui';
import { css } from '@emotion/react';
import { State } from '../table_list_view';
import { State } from '../table_list_view_table';
type SortItem = EuiSelectableOption & {
column: SortColumnField;

View file

@ -6,13 +6,13 @@
* Side Public License, v 1.
*/
export { TableListView } from './table_list_view';
export { TableListViewTable } from './table_list_view_table';
export type {
Props as TableListViewProps,
TableListViewTableProps,
State as TableListViewState,
UserContentCommonSchema,
} from './table_list_view';
} from './table_list_view_table';
export { TableListViewProvider, TableListViewKibanaProvider } from './services';

View file

@ -82,7 +82,7 @@ export const getStoryServices = (params: Params, action: ActionFn = () => {}) =>
* consuming component stories.
*/
export const getStoryArgTypes = () => ({
tableListTitle: {
title: {
control: {
type: 'text',
},

View file

@ -5,7 +5,7 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { State, UserContentCommonSchema } from './table_list_view';
import type { State, UserContentCommonSchema } from './table_list_view_table';
import type { Action } from './actions';
export function getReducer<T extends UserContentCommonSchema>() {

View file

@ -18,10 +18,10 @@ import type { LocationDescriptor, History } from 'history';
import { WithServices } from './__jest__';
import { getTagList } from './mocks';
import {
TableListView,
Props as TableListViewProps,
UserContentCommonSchema,
} from './table_list_view';
TableListViewTable,
type TableListViewTableProps,
type UserContentCommonSchema,
} from './table_list_view_table';
const mockUseEffect = useEffect;
@ -49,18 +49,6 @@ interface Router {
};
}
const requiredProps: TableListViewProps = {
entityName: 'test',
entityNamePlural: 'tests',
listingLimit: 500,
initialFilter: '',
initialPageSize: 20,
tableListTitle: 'test title',
findItems: jest.fn().mockResolvedValue({ total: 0, hits: [] }),
getDetailViewLink: () => 'http://elastic.co',
urlStateEnabled: false,
};
const twoDaysAgo = new Date(new Date().setDate(new Date().getDate() - 2));
const twoDaysAgoToString = new Date(twoDaysAgo.getTime()).toDateString();
const yesterday = new Date(new Date().setDate(new Date().getDate() - 1));
@ -73,6 +61,20 @@ const getActions = (testBed: TestBed) => ({
});
describe('TableListView', () => {
const requiredProps: TableListViewTableProps = {
entityName: 'test',
entityNamePlural: 'tests',
listingLimit: 500,
initialFilter: '',
initialPageSize: 20,
findItems: jest.fn().mockResolvedValue({ total: 0, hits: [] }),
getDetailViewLink: () => 'http://elastic.co',
urlStateEnabled: false,
onFetchSuccess: () => {},
tableCaption: 'my caption',
setPageDataTestSubject: () => {},
};
beforeAll(() => {
jest.useFakeTimers({ legacyFakeTimers: true });
});
@ -81,8 +83,8 @@ describe('TableListView', () => {
jest.useRealTimers();
});
const setup = registerTestBed<string, TableListViewProps>(
WithServices<TableListViewProps>(TableListView),
const setup = registerTestBed<string, TableListViewTableProps>(
WithServices<TableListViewTableProps>(TableListViewTable),
{
defaultProps: { ...requiredProps },
memoryRouter: { wrapComponent: true },
@ -376,8 +378,8 @@ describe('TableListView', () => {
});
describe('column sorting', () => {
const setupColumnSorting = registerTestBed<string, TableListViewProps>(
WithServices<TableListViewProps>(TableListView, {
const setupColumnSorting = registerTestBed<string, TableListViewTableProps>(
WithServices<TableListViewTableProps>(TableListViewTable, {
TagList: getTagList({ references: [] }),
}),
{
@ -579,8 +581,8 @@ describe('TableListView', () => {
});
describe('content editor', () => {
const setupInspector = registerTestBed<string, TableListViewProps>(
WithServices<TableListViewProps>(TableListView),
const setupInspector = registerTestBed<string, TableListViewTableProps>(
WithServices<TableListViewTableProps>(TableListViewTable),
{
defaultProps: { ...requiredProps },
memoryRouter: { wrapComponent: true },
@ -630,8 +632,8 @@ describe('TableListView', () => {
});
describe('tag filtering', () => {
const setupTagFiltering = registerTestBed<string, TableListViewProps>(
WithServices<TableListViewProps>(TableListView, {
const setupTagFiltering = registerTestBed<string, TableListViewTableProps>(
WithServices<TableListViewTableProps>(TableListViewTable, {
getTagList: () => [
{ id: 'id-tag-1', name: 'tag-1', type: 'tag', description: '', color: '' },
{ id: 'id-tag-2', name: 'tag-2', type: 'tag', description: '', color: '' },
@ -782,8 +784,8 @@ describe('TableListView', () => {
describe('url state', () => {
let router: Router | undefined;
const setupTagFiltering = registerTestBed<string, TableListViewProps>(
WithServices<TableListViewProps>(TableListView, {
const setupTagFiltering = registerTestBed<string, TableListViewTableProps>(
WithServices<TableListViewTableProps>(TableListViewTable, {
getTagList: () => [
{ id: 'id-tag-1', name: 'tag-1', type: 'tag', description: '', color: '' },
{ id: 'id-tag-2', name: 'tag-2', type: 'tag', description: '', color: '' },
@ -1092,7 +1094,7 @@ describe('TableListView', () => {
},
];
const setupTest = async (props?: Partial<TableListViewProps>) => {
const setupTest = async (props?: Partial<TableListViewTableProps>) => {
let testBed: TestBed | undefined;
const deleteItems = jest.fn();
await act(async () => {
@ -1173,3 +1175,87 @@ describe('TableListView', () => {
});
});
});
describe('TableList', () => {
const requiredProps: TableListViewTableProps = {
entityName: 'test',
entityNamePlural: 'tests',
initialPageSize: 20,
listingLimit: 500,
findItems: jest.fn().mockResolvedValue({ total: 0, hits: [] }),
onFetchSuccess: jest.fn(),
tableCaption: 'test title',
getDetailViewLink: () => '',
setPageDataTestSubject: () => {},
};
const setup = registerTestBed<string, TableListViewTableProps>(
WithServices<TableListViewTableProps>(TableListViewTable),
{
defaultProps: { ...requiredProps, refreshListBouncer: false },
memoryRouter: { wrapComponent: true },
}
);
it('refreshes the list when the bouncer changes', async () => {
let testBed: TestBed;
const findItems = jest.fn().mockResolvedValue({ total: 0, hits: [] });
await act(async () => {
testBed = setup({ findItems });
});
const { component, table } = testBed!;
findItems.mockClear();
expect(findItems).not.toHaveBeenCalled();
const hits: UserContentCommonSchema[] = [
{
id: `item`,
type: 'dashboard',
updatedAt: 'some date',
attributes: {
title: `Updated title`,
},
references: [],
},
];
findItems.mockResolvedValue({ total: hits.length, hits });
await act(async () => {
component.setProps({
refreshListBouncer: true,
});
});
component.update();
expect(findItems).toHaveBeenCalledTimes(1);
const metadata = table.getMetaData('itemsInMemTable');
expect(metadata.tableCellsValues[0][0]).toBe('Updated title');
});
it('reports successful fetches', async () => {
const onFetchSuccess = jest.fn();
await act(async () => {
setup({ onFetchSuccess });
});
expect(onFetchSuccess).toHaveBeenCalled();
});
it('reports the page data test subject', async () => {
const setPageDataTestSubject = jest.fn();
act(() => {
setup({ setPageDataTestSubject });
});
expect(setPageDataTestSubject).toHaveBeenCalledWith('testLandingPage');
});
});

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { useReducer, useCallback, useEffect, useRef, useMemo, ReactNode } from 'react';
import React, { useReducer, useCallback, useEffect, useRef, useMemo } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import {
EuiBasicTableColumn,
@ -49,11 +49,11 @@ interface ContentEditorConfig
enabled?: boolean;
}
export interface Props<T extends UserContentCommonSchema = UserContentCommonSchema> {
export interface TableListViewTableProps<
T extends UserContentCommonSchema = UserContentCommonSchema
> {
entityName: string;
entityNamePlural: string;
tableListTitle: string;
tableListDescription?: string;
listingLimit: number;
initialFilter?: string;
initialPageSize: number;
@ -73,7 +73,6 @@ export interface Props<T extends UserContentCommonSchema = UserContentCommonSche
* Currently only the "delete" ite action can be disabled.
*/
rowItemActions?: (obj: T) => RowActions | undefined;
children?: ReactNode | undefined;
findItems(
searchQuery: string,
refs?: {
@ -99,11 +98,7 @@ export interface Props<T extends UserContentCommonSchema = UserContentCommonSche
* Name for the column containing the "title" value.
*/
titleColumnName?: string;
/**
* Additional actions (buttons) to be placed in the page header.
* @note only the first two values will be used.
*/
additionalRightSideActions?: ReactNode[];
/**
* This assumes the content is already wrapped in an outer PageTemplate component.
* @note Hack! This is being used as a workaround so that this page can be rendered in the Kibana management UI
@ -111,6 +106,11 @@ export interface Props<T extends UserContentCommonSchema = UserContentCommonSche
*/
withoutPageTemplateWrapper?: boolean;
contentEditor?: ContentEditorConfig;
tableCaption: string;
refreshListBouncer?: boolean;
onFetchSuccess: () => void;
setPageDataTestSubject: (subject: string) => void;
}
export interface State<T extends UserContentCommonSchema = UserContentCommonSchema> {
@ -242,9 +242,8 @@ const tableColumnMetadata = {
},
} as const;
function TableListViewComp<T extends UserContentCommonSchema>({
tableListTitle,
tableListDescription,
function TableListViewTableComp<T extends UserContentCommonSchema>({
tableCaption,
entityName,
entityNamePlural,
initialFilter: initialQuery,
@ -264,11 +263,16 @@ function TableListViewComp<T extends UserContentCommonSchema>({
onClickTitle,
id: listingId = 'userContent',
contentEditor = { enabled: false },
children,
titleColumnName,
additionalRightSideActions = [],
withoutPageTemplateWrapper,
}: Props<T>) {
onFetchSuccess,
refreshListBouncer,
setPageDataTestSubject,
}: TableListViewTableProps<T>) {
useEffect(() => {
setPageDataTestSubject(`${entityName}LandingPage`);
}, [entityName, setPageDataTestSubject]);
if (!getDetailViewLink && !onClickTitle) {
throw new Error(
`[TableListView] One o["getDetailViewLink" or "onClickTitle"] prop must be provided.`
@ -366,7 +370,6 @@ function TableListViewComp<T extends UserContentCommonSchema>({
const hasQuery = searchQuery.text !== '';
const hasNoItems = !isFetchingItems && items.length === 0 && !hasQuery;
const pageDataTestSubject = `${entityName}LandingPage`;
const showFetchError = Boolean(fetchError);
const showLimitError = !showFetchError && totalItems > listingLimit;
@ -397,6 +400,8 @@ function TableListViewComp<T extends UserContentCommonSchema>({
response,
},
});
onFetchSuccess();
}
} catch (err) {
dispatch({
@ -404,7 +409,11 @@ function TableListViewComp<T extends UserContentCommonSchema>({
data: err,
});
}
}, [searchQueryParser, findItems, searchQuery.text]);
}, [searchQueryParser, searchQuery.text, findItems, onFetchSuccess]);
useEffect(() => {
fetchItems();
}, [fetchItems, refreshListBouncer]);
const updateQuery = useCallback(
(query: Query) => {
@ -903,7 +912,7 @@ function TableListViewComp<T extends UserContentCommonSchema>({
if (!showFetchError && hasNoItems) {
return (
<PageTemplate panelled isEmptyState={true} data-test-subj={pageDataTestSubject}>
<PageTemplate panelled isEmptyState={true}>
<KibanaPageTemplate.Section
aria-labelledby={hasInitialFetchReturned ? headingId : undefined}
>
@ -920,80 +929,64 @@ function TableListViewComp<T extends UserContentCommonSchema>({
: 'table-is-loading';
return (
<PageTemplate panelled data-test-subj={pageDataTestSubject}>
<KibanaPageTemplate.Header
pageTitle={<span id={headingId}>{tableListTitle}</span>}
description={tableListDescription}
rightSideItems={[
renderCreateButton() ?? <span />,
...additionalRightSideActions?.slice(0, 2),
]}
data-test-subj="top-nav"
/>
<KibanaPageTemplate.Section aria-labelledby={hasInitialFetchReturned ? headingId : undefined}>
{/* Any children passed to the component */}
{children}
<>
{/* Too many items error */}
{showLimitError && (
<ListingLimitWarning
canEditAdvancedSettings={canEditAdvancedSettings}
advancedSettingsLink={getListingLimitSettingsUrl()}
entityNamePlural={entityNamePlural}
totalItems={totalItems}
listingLimit={listingLimit}
/>
)}
{/* Too many items error */}
{showLimitError && (
<ListingLimitWarning
canEditAdvancedSettings={canEditAdvancedSettings}
advancedSettingsLink={getListingLimitSettingsUrl()}
entityNamePlural={entityNamePlural}
totalItems={totalItems}
listingLimit={listingLimit}
/>
)}
{/* Error while fetching items */}
{showFetchError && renderFetchError()}
{/* Error while fetching items */}
{showFetchError && renderFetchError()}
{/* Table of items */}
<div data-test-subj={testSubjectState}>
<Table<T>
dispatch={dispatch}
items={items}
renderCreateButton={renderCreateButton}
isFetchingItems={isFetchingItems}
searchQuery={searchQuery}
tableColumns={tableColumns}
hasUpdatedAtMetadata={hasUpdatedAtMetadata}
tableSort={tableSort}
tableItemsRowActions={tableItemsRowActions}
pagination={pagination}
selectedIds={selectedIds}
entityName={entityName}
entityNamePlural={entityNamePlural}
tagsToTableItemMap={tagsToTableItemMap}
deleteItems={deleteItems}
tableCaption={tableCaption}
onTableChange={onTableChange}
onTableSearchChange={onTableSearchChange}
onSortChange={onSortChange}
addOrRemoveIncludeTagFilter={addOrRemoveIncludeTagFilter}
addOrRemoveExcludeTagFilter={addOrRemoveExcludeTagFilter}
clearTagSelection={clearTagSelection}
/>
{/* Table of items */}
<div data-test-subj={testSubjectState}>
<Table<T>
dispatch={dispatch}
items={items}
isFetchingItems={isFetchingItems}
searchQuery={searchQuery}
tableColumns={tableColumns}
hasUpdatedAtMetadata={hasUpdatedAtMetadata}
tableSort={tableSort}
pagination={pagination}
selectedIds={selectedIds}
{/* Delete modal */}
{showDeleteModal && (
<ConfirmDeleteModal<T>
isDeletingItems={isDeletingItems}
entityName={entityName}
entityNamePlural={entityNamePlural}
tagsToTableItemMap={tagsToTableItemMap}
deleteItems={deleteItems}
tableCaption={tableListTitle}
tableItemsRowActions={tableItemsRowActions}
onTableChange={onTableChange}
onTableSearchChange={onTableSearchChange}
onSortChange={onSortChange}
addOrRemoveIncludeTagFilter={addOrRemoveIncludeTagFilter}
addOrRemoveExcludeTagFilter={addOrRemoveExcludeTagFilter}
clearTagSelection={clearTagSelection}
items={selectedItems}
onConfirm={deleteSelectedItems}
onCancel={() => dispatch({ type: 'onCancelDeleteItems' })}
/>
{/* Delete modal */}
{showDeleteModal && (
<ConfirmDeleteModal<T>
isDeletingItems={isDeletingItems}
entityName={entityName}
entityNamePlural={entityNamePlural}
items={selectedItems}
onConfirm={deleteSelectedItems}
onCancel={() => dispatch({ type: 'onCancelDeleteItems' })}
/>
)}
</div>
</KibanaPageTemplate.Section>
</PageTemplate>
)}
</div>
</>
);
}
const TableListView = React.memo(TableListViewComp) as typeof TableListViewComp;
export { TableListView };
// eslint-disable-next-line import/no-default-export
export default TableListView;
export const TableListViewTable = React.memo(
TableListViewTableComp
) as typeof TableListViewTableComp;

View file

@ -9,7 +9,7 @@ import { useCallback, useMemo } from 'react';
import { Query } from '@elastic/eui';
import type { Tag } from './types';
import type { UserContentCommonSchema } from './table_list_view';
import type { UserContentCommonSchema } from './table_list_view_table';
type QueryUpdater = (query: Query, tag: Tag) => Query;

View file

@ -23,9 +23,7 @@ const context = useContext(DragContext);
In your child application, place a `ChildDragDropProvider` at the root of that, and spread the context into it:
```js
<ChildDragDropProvider {...context}>
... your child app here ...
</ChildDragDropProvider>
<ChildDragDropProvider {...context}>... your child app here ...</ChildDragDropProvider>
```
This enables your child application to share the same drag / drop context as the root application.
@ -71,9 +69,7 @@ return (
To create a reordering group, surround the elements from the same group with a `ReorderProvider`:
```js
<ReorderProvider id="groupId">
... elements from one group here ...
</ReorderProvider>
<ReorderProvider id="groupId">... elements from one group here ...</ReorderProvider>
```
The children `DragDrop` components must have props defined as in the example:
@ -85,8 +81,8 @@ The children `DragDrop` components must have props defined as in the example:
<DragDrop
key={f.id}
draggable
dragTypes={["move"]}
dropType="reorder"
dragType="move"
dropTypes={["reorder"]} // generally shouldn't be set until a drag operation has started
reorderableGroup={fields} // consists all reorderable elements in the group, eg. [{id:'3'}, {id:'5'}, {id:'1'}]
value={{
id: f.id,
@ -102,4 +98,3 @@ The children `DragDrop` components must have props defined as in the example:
</div>
</ReorderProvider>
```

View file

@ -41,7 +41,7 @@ pageLoadAssetSize:
enterpriseSearch: 35741
essSecurity: 16573
esUiShared: 326654
eventAnnotation: 22000
eventAnnotation: 48565
exploratoryView: 74673
expressionError: 22127
expressionGauge: 25000

View file

@ -19,16 +19,23 @@ import { DashboardListing, DashboardListingProps } from './dashboard_listing';
* need to ensure we're passing down the correct props, but the table list view itself doesn't need to be rendered
* in our tests because it is covered in its package.
*/
import { TableListView } from '@kbn/content-management-table-list';
// import { TableListViewKibanaProvider } from '@kbn/content-management-table-list';
jest.mock('@kbn/content-management-table-list', () => {
const originalModule = jest.requireActual('@kbn/content-management-table-list');
import { TableListView } from '@kbn/content-management-table-list-view';
// import { TableListViewKibanaProvider } from '@kbn/content-management-table-list-view';
jest.mock('@kbn/content-management-table-list-view-table', () => {
const originalModule = jest.requireActual('@kbn/content-management-table-list-view-table');
return {
__esModule: true,
...originalModule,
TableListViewKibanaProvider: jest.fn().mockImplementation(({ children }) => {
return <>{children}</>;
}),
};
});
jest.mock('@kbn/content-management-table-list-view', () => {
const originalModule = jest.requireActual('@kbn/content-management-table-list-view-table');
return {
__esModule: true,
...originalModule,
TableListView: jest.fn().mockReturnValue(null),
};
});

View file

@ -10,11 +10,11 @@ import { FormattedRelative, I18nProvider } from '@kbn/i18n-react';
import React, { PropsWithChildren, useCallback, useState } from 'react';
import {
TableListView,
TableListViewKibanaDependencies,
type TableListViewKibanaDependencies,
TableListViewKibanaProvider,
type UserContentCommonSchema,
} from '@kbn/content-management-table-list';
} from '@kbn/content-management-table-list-view-table';
import { TableListView } from '@kbn/content-management-table-list-view';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import type { SavedObjectsFindOptionsReference } from '@kbn/core/public';
@ -233,7 +233,7 @@ export const DashboardListing = ({
createItem={!showWriteControls ? undefined : createItem}
editItem={!showWriteControls ? undefined : editItem}
entityNamePlural={getEntityNamePlural()}
tableListTitle={getTableListTitle()}
title={getTableListTitle()}
headingId="dashboardListingHeading"
initialPageSize={initialPageSize}
initialFilter={initialFilter}

View file

@ -32,7 +32,6 @@
"@kbn/data-view-editor-plugin",
"@kbn/unified-search-plugin",
"@kbn/shared-ux-page-analytics-no-data",
"@kbn/content-management-table-list",
"@kbn/content-management-plugin",
"@kbn/content-management-utils",
"@kbn/i18n-react",
@ -60,7 +59,9 @@
"@kbn/core-saved-objects-server",
"@kbn/core-saved-objects-utils-server",
"@kbn/object-versioning",
"@kbn/core-saved-objects-api-server"
"@kbn/core-saved-objects-api-server",
"@kbn/content-management-table-list-view",
"@kbn/content-management-table-list-view-table"
],
"exclude": ["target/**/*"]
}

View file

@ -0,0 +1,6 @@
{
"prefix": "eventAnnotations",
"paths": {
"eventAnnotations": "."
}
}

View file

@ -25,3 +25,7 @@ export const AvailableAnnotationIcons = {
} as const;
export const EVENT_ANNOTATION_GROUP_TYPE = 'event-annotation-group';
export const ANNOTATIONS_LISTING_VIEW_ID = 'annotations';
export const EVENT_ANNOTATION_APP_NAME = 'event-annotations';

View file

@ -0,0 +1,24 @@
/*
* 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 { getDefaultManualAnnotation } from './manual_event_annotation';
import { EventAnnotationConfig } from './types';
export const createCopiedAnnotation = (
newId: string,
timestamp: string,
source?: EventAnnotationConfig
): EventAnnotationConfig => {
if (!source) {
return getDefaultManualAnnotation(newId, timestamp);
}
return {
...source,
id: newId,
};
};

View file

@ -12,6 +12,7 @@ import { omit, pick } from 'lodash';
import dateMath from '@kbn/datemath';
import moment from 'moment';
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { LineStyle } from '@kbn/visualization-ui-components/common/types';
import {
ManualEventAnnotationOutput,
ManualPointEventAnnotationOutput,
@ -22,7 +23,6 @@ import {
annotationColumns,
AvailableAnnotationIcon,
EventAnnotationOutput,
LineStyle,
PointStyleProps,
} from '../types';

View file

@ -18,8 +18,16 @@ export type {
QueryPointEventAnnotationArgs,
QueryPointEventAnnotationOutput,
} from './query_point_event_annotation/types';
export { manualPointEventAnnotation, manualRangeEventAnnotation } from './manual_event_annotation';
export { queryPointEventAnnotation } from './query_point_event_annotation';
export {
manualPointEventAnnotation,
manualRangeEventAnnotation,
getDefaultManualAnnotation,
} from './manual_event_annotation';
export {
queryPointEventAnnotation,
getDefaultQueryAnnotation,
} from './query_point_event_annotation';
export { createCopiedAnnotation } from './create_copied_annotation';
export { eventAnnotationGroup } from './event_annotation_group';
export type { EventAnnotationGroupArgs } from './event_annotation_group';
@ -36,4 +44,4 @@ export type {
EventAnnotationGroupAttributes,
} from './types';
export { EVENT_ANNOTATION_GROUP_TYPE } from './constants';
export { EVENT_ANNOTATION_GROUP_TYPE, ANNOTATIONS_LISTING_VIEW_ID } from './constants';

View file

@ -9,6 +9,7 @@
import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { i18n } from '@kbn/i18n';
import { AvailableAnnotationIcons } from '../constants';
import { EventAnnotationConfig } from '../types';
import type {
ManualRangeEventAnnotationArgs,
@ -163,3 +164,24 @@ export const manualRangeEventAnnotation: ExpressionFunctionDefinition<
};
},
};
export const defaultAnnotationLabel = i18n.translate(
'eventAnnotation.manualAnnotation.defaultAnnotationLabel',
{
defaultMessage: 'Event',
}
);
export const getDefaultManualAnnotation = (
id: string,
timestamp: string
): EventAnnotationConfig => ({
label: defaultAnnotationLabel,
type: 'manual',
key: {
type: 'point_in_time',
timestamp,
},
icon: 'triangle',
id,
});

View file

@ -9,6 +9,7 @@
import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { i18n } from '@kbn/i18n';
import { AvailableAnnotationIcons } from '../constants';
import { EventAnnotationConfig } from '../types';
import type { QueryPointEventAnnotationArgs, QueryPointEventAnnotationOutput } from './types';
@ -111,3 +112,22 @@ export const queryPointEventAnnotation: ExpressionFunctionDefinition<
};
},
};
export const getDefaultQueryAnnotation = (
id: string,
fieldName: string,
timeField: string
): EventAnnotationConfig => ({
filter: {
type: 'kibana_query',
query: `${fieldName}: *`,
language: 'kuery',
},
timeField,
type: 'query',
key: {
type: 'point_in_time',
},
id,
label: `${fieldName}: *`,
});

View file

@ -6,9 +6,11 @@
* Side Public License, v 1.
*/
import type { UserContentCommonSchema } from '@kbn/content-management-table-list-view';
import { DataViewSpec, KibanaQueryOutput } from '@kbn/data-plugin/common';
import { DatatableColumn } from '@kbn/expressions-plugin/common';
import { $Values } from '@kbn/utility-types';
import { LineStyle } from '@kbn/visualization-ui-components/common/types';
import { AvailableAnnotationIcons } from './constants';
import {
ManualEventAnnotationOutput,
@ -20,7 +22,6 @@ import {
QueryPointEventAnnotationOutput,
} from './query_point_event_annotation/types';
export type LineStyle = 'solid' | 'dashed' | 'dotted';
export type Fill = 'inside' | 'outside' | 'none';
export type ManualAnnotationType = 'manual';
export type QueryAnnotationType = 'query';
@ -85,10 +86,9 @@ export type EventAnnotationConfig =
export interface EventAnnotationGroupAttributes {
title: string;
description: string;
tags: string[];
ignoreGlobalFilters: boolean;
annotations: EventAnnotationConfig[];
dataViewSpec?: DataViewSpec;
dataViewSpec?: DataViewSpec | null;
}
export interface EventAnnotationGroupConfig {
@ -101,6 +101,10 @@ export interface EventAnnotationGroupConfig {
dataViewSpec?: DataViewSpec;
}
export type EventAnnotationGroupContent = UserContentCommonSchema & {
attributes: { indexPatternId: string; dataViewSpec?: DataViewSpec };
};
export type EventAnnotationArgs =
| ManualPointEventAnnotationArgs
| ManualRangeEventAnnotationArgs

View file

@ -10,7 +10,10 @@ module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/src/plugins/event_annotation'],
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/src/plugins/event_ann',
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/src/plugins/event_annotation',
coverageReporters: ['text', 'html'],
collectCoverageFrom: ['<rootDir>/src/plugins/event_ann/{common,public,server}/**/*.{ts,tsx}'],
collectCoverageFrom: [
'<rootDir>/src/plugins/event_annotation/{common,public,server}/**/*.{ts,tsx}',
],
setupFiles: ['jest-canvas-mock'],
};

View file

@ -11,10 +11,23 @@
"expressions",
"savedObjectsManagement",
"data",
"presentationUtil",
"visualizations",
"dataViews",
"unifiedSearch",
"kibanaUtils",
"visualizationUiComponents"
],
"optionalPlugins": [
"savedObjectsTagging",
],
"requiredBundles": [
"data",
"savedObjectsFinder",
"dataViews"
"dataViews",
"kibanaReact",
"visualizationUiComponents",
"unifiedFieldList"
],
"extraPublicDirs": [
"common"

View file

@ -0,0 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`group editor flyout renders controls 1`] = `
Object {
"TagSelector": [MockFunction],
"createDataView": [MockFunction],
"dataViews": Array [
Object {
"id": "some-id",
"title": "My Data View",
},
],
"group": Object {
"annotations": Array [
Object {
"icon": "triangle",
"id": "my-id",
"key": Object {
"timestamp": "some-timestamp",
"type": "point_in_time",
},
"label": "Event",
"type": "manual",
},
],
"description": "",
"ignoreGlobalFilters": false,
"indexPatternId": "some-id",
"tags": Array [],
"title": "My group",
},
"queryInputServices": Object {},
"selectedAnnotation": undefined,
"setSelectedAnnotation": [Function],
"showValidation": false,
"update": [MockFunction],
}
`;

View file

@ -0,0 +1,390 @@
/*
* 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 './index.scss';
import { isFieldLensCompatible } from '@kbn/visualization-ui-components/public';
import React, { useCallback, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiSwitch, EuiSwitchEvent, EuiButtonGroup, EuiSpacer } from '@elastic/eui';
import {
IconSelectSetting,
DimensionEditorSection,
NameInput,
ColorPicker,
LineStyleSettings,
TextDecorationSetting,
FieldPicker,
FieldOption,
type QueryInputServices,
} from '@kbn/visualization-ui-components/public';
import type { FieldOptionValue } from '@kbn/visualization-ui-components/public';
import { DataView } from '@kbn/data-views-plugin/common';
import { useExistingFieldsReader } from '@kbn/unified-field-list-plugin/public';
import moment from 'moment';
import { htmlIdGenerator } from '@elastic/eui';
import { isQueryAnnotationConfig, isRangeAnnotationConfig } from '../..';
import {
AvailableAnnotationIcon,
EventAnnotationConfig,
PointInTimeEventAnnotationConfig,
QueryPointEventAnnotationConfig,
} from '../../../common';
import {
defaultAnnotationColor,
defaultAnnotationLabel,
defaultAnnotationRangeColor,
defaultRangeAnnotationLabel,
toLineAnnotationColor,
} from './helpers';
import { annotationsIconSet } from './icon_set';
import { sanitizeProperties } from './helpers';
import { TooltipSection } from './tooltip_annotation_panel';
import { ConfigPanelManualAnnotation } from './manual_annotation_panel';
import { ConfigPanelQueryAnnotation } from './query_annotation_panel';
export interface Props {
annotation: EventAnnotationConfig;
onAnnotationChange: (annotation: EventAnnotationConfig) => void;
dataView: DataView;
getDefaultRangeEnd: (rangeStart: string) => string;
calendarClassName?: string;
queryInputServices: QueryInputServices;
appName: string;
}
export const idPrefix = htmlIdGenerator()();
const AnnotationEditorControls = ({
annotation: currentAnnotation,
onAnnotationChange,
dataView,
getDefaultRangeEnd,
calendarClassName,
queryInputServices,
appName,
}: Props) => {
const { hasFieldData } = useExistingFieldsReader();
const isQueryBased = isQueryAnnotationConfig(currentAnnotation);
const isRange = isRangeAnnotationConfig(currentAnnotation);
const [queryInputShouldOpen, setQueryInputShouldOpen] = React.useState(false);
useEffect(() => {
setQueryInputShouldOpen(!isQueryBased);
}, [isQueryBased]);
const update = useCallback(
<T extends EventAnnotationConfig>(newAnnotation: Partial<T> | undefined) =>
newAnnotation &&
onAnnotationChange(sanitizeProperties({ ...currentAnnotation, ...newAnnotation })),
[currentAnnotation, onAnnotationChange]
);
return (
<>
<DimensionEditorSection
title={i18n.translate('eventAnnotation.xyChart.placement', {
defaultMessage: 'Placement',
})}
>
<EuiFormRow
label={i18n.translate('eventAnnotation.xyChart.annotationDate.placementType', {
defaultMessage: 'Placement type',
})}
display="rowCompressed"
fullWidth
>
<EuiButtonGroup
legend={i18n.translate('eventAnnotation.xyChart.annotationDate.placementType', {
defaultMessage: 'Placement type',
})}
data-test-subj="lns-xyAnnotation-placementType"
name="placementType"
buttonSize="compressed"
options={[
{
id: `lens_xyChart_annotation_manual`,
label: i18n.translate('eventAnnotation.xyChart.annotation.manual', {
defaultMessage: 'Static date',
}),
'data-test-subj': 'lnsXY_annotation_manual',
},
{
id: `lens_xyChart_annotation_query`,
label: i18n.translate('eventAnnotation.xyChart.annotation.query', {
defaultMessage: 'Custom query',
}),
'data-test-subj': 'lnsXY_annotation_query',
},
]}
idSelected={`lens_xyChart_annotation_${currentAnnotation?.type}`}
onChange={(id) => {
const typeFromId = id.replace(
'lens_xyChart_annotation_',
''
) as EventAnnotationConfig['type'];
if (currentAnnotation?.type === typeFromId) {
return;
}
if (typeFromId === 'query') {
// If coming from a range type, it requires some additional resets
const additionalRangeResets = isRangeAnnotationConfig(currentAnnotation)
? {
label:
currentAnnotation.label === defaultRangeAnnotationLabel
? defaultAnnotationLabel
: currentAnnotation.label,
color: toLineAnnotationColor(currentAnnotation.color),
}
: {};
return update({
type: typeFromId,
timeField:
(dataView.timeFieldName ||
// fallback to the first avaiable date field in the dataView
dataView.fields
.filter(isFieldLensCompatible)
.find(({ type: fieldType }) => fieldType === 'date')?.displayName) ??
'',
key: { type: 'point_in_time' },
...additionalRangeResets,
});
}
// From query to manual annotation
return update<PointInTimeEventAnnotationConfig>({
type: typeFromId,
key: { type: 'point_in_time', timestamp: moment().toISOString() },
});
}}
isFullWidth
/>
</EuiFormRow>
{isQueryBased ? (
<ConfigPanelQueryAnnotation
annotation={currentAnnotation}
onChange={update}
dataView={dataView}
queryInputShouldOpen={queryInputShouldOpen}
queryInputServices={queryInputServices}
appName={appName}
/>
) : (
<ConfigPanelManualAnnotation
annotation={currentAnnotation}
onChange={update}
getDefaultRangeEnd={getDefaultRangeEnd}
calendarClassName={calendarClassName}
/>
)}
</DimensionEditorSection>
<DimensionEditorSection
title={i18n.translate('eventAnnotation.xyChart.appearance', {
defaultMessage: 'Appearance',
})}
>
<NameInput
value={currentAnnotation?.label || defaultAnnotationLabel}
defaultValue={defaultAnnotationLabel}
onChange={(value) => {
update({ label: value });
}}
/>
{!isRange && (
<>
<IconSelectSetting<AvailableAnnotationIcon>
currentIcon={currentAnnotation.icon}
setIcon={(icon) => update({ icon })}
defaultIcon="triangle"
customIconSet={annotationsIconSet}
/>
<TextDecorationSetting
idPrefix={idPrefix}
setConfig={update}
currentConfig={currentAnnotation}
isQueryBased={isQueryBased}
>
{(textDecorationSelected) => {
if (textDecorationSelected !== 'field') {
return null;
}
const options = dataView.fields
.filter(isFieldLensCompatible)
.filter(({ displayName, type }) => displayName && type !== 'document')
.map(
(field) =>
({
label: field.displayName,
value: {
type: 'field',
field: field.name,
dataType: field.type,
},
exists: hasFieldData(dataView.id!, field.name),
compatible: true,
'data-test-subj': `lnsXY-annotation-fieldOption-${field.name}`,
} as FieldOption<FieldOptionValue>)
);
const selectedField = (currentAnnotation as QueryPointEventAnnotationConfig)
.textField;
const fieldIsValid = selectedField
? Boolean(dataView.getFieldByName(selectedField))
: true;
return (
<>
<EuiSpacer size="xs" />
<FieldPicker
selectedOptions={
selectedField
? [
{
label: selectedField,
value: { type: 'field', field: selectedField },
},
]
: []
}
options={options}
onChoose={function (choice: FieldOptionValue | undefined): void {
if (choice) {
update({ textField: choice.field, textVisibility: true });
}
}}
fieldIsInvalid={!fieldIsValid}
data-test-subj="lnsXY-annotation-query-based-text-decoration-field-picker"
autoFocus={!selectedField}
/>
</>
);
}}
</TextDecorationSetting>
<LineStyleSettings
idPrefix={idPrefix}
setConfig={update}
currentConfig={{
lineStyle: currentAnnotation.lineStyle,
lineWidth: currentAnnotation.lineWidth,
}}
/>
</>
)}
{isRange && (
<EuiFormRow
label={i18n.translate('eventAnnotation.xyChart.fillStyle', {
defaultMessage: 'Fill',
})}
display="columnCompressed"
fullWidth
>
<EuiButtonGroup
legend={i18n.translate('eventAnnotation.xyChart.fillStyle', {
defaultMessage: 'Fill',
})}
data-test-subj="lns-xyAnnotation-fillStyle"
name="fillStyle"
buttonSize="compressed"
options={[
{
id: `lens_xyChart_fillStyle_inside`,
label: i18n.translate('eventAnnotation.xyChart.fillStyle.inside', {
defaultMessage: 'Inside',
}),
'data-test-subj': 'lnsXY_fillStyle_inside',
},
{
id: `lens_xyChart_fillStyle_outside`,
label: i18n.translate('eventAnnotation.xyChart.fillStyle.outside', {
defaultMessage: 'Outside',
}),
'data-test-subj': 'lnsXY_fillStyle_inside',
},
]}
idSelected={`lens_xyChart_fillStyle_${
Boolean(currentAnnotation?.outside) ? 'outside' : 'inside'
}`}
onChange={(id) => {
update({
outside: id === `lens_xyChart_fillStyle_outside`,
});
}}
isFullWidth
/>
</EuiFormRow>
)}
<ColorPicker
overwriteColor={currentAnnotation.color}
defaultColor={isRange ? defaultAnnotationRangeColor : defaultAnnotationColor}
showAlpha={isRange}
setConfig={update}
disableHelpTooltip
label={i18n.translate('eventAnnotation.xyChart.lineColor.label', {
defaultMessage: 'Color',
})}
/>
<ConfigPanelGenericSwitch
label={i18n.translate('eventAnnotation.xyChart.annotation.hide', {
defaultMessage: 'Hide annotation',
})}
data-test-subj="lns-annotations-hide-annotation"
value={Boolean(currentAnnotation.isHidden)}
onChange={(ev) => update({ isHidden: ev.target.checked })}
/>
</DimensionEditorSection>
{isQueryBased && currentAnnotation && (
<DimensionEditorSection
title={i18n.translate('eventAnnotation.xyChart.tooltip', {
defaultMessage: 'Tooltip',
})}
>
<EuiFormRow
display="rowCompressed"
className="lnsRowCompressedMargin"
fullWidth
label={i18n.translate('eventAnnotation.xyChart.annotation.tooltip', {
defaultMessage: 'Show additional fields',
})}
>
<TooltipSection
currentConfig={currentAnnotation}
setConfig={update}
dataView={dataView}
/>
</EuiFormRow>
</DimensionEditorSection>
)}
</>
);
};
const ConfigPanelGenericSwitch = ({
label,
['data-test-subj']: dataTestSubj,
value,
onChange,
}: {
label: string;
'data-test-subj': string;
value: boolean;
onChange: (event: EuiSwitchEvent) => void;
}) => (
<EuiFormRow label={label} display="columnCompressedSwitch" fullWidth>
<EuiSwitch
compressed
label={label}
showLabel={false}
data-test-subj={dataTestSubj}
checked={value}
onChange={onChange}
/>
</EuiFormRow>
);
// eslint-disable-next-line import/no-default-export
export default AnnotationEditorControls;

View file

@ -0,0 +1,93 @@
/*
* 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 { transparentize } from '@elastic/eui';
import { pick } from 'lodash';
import { euiLightVars } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n';
import chroma from 'chroma-js';
import { isQueryAnnotationConfig, isRangeAnnotationConfig } from '../..';
import type {
EventAnnotationConfig,
RangeEventAnnotationConfig,
PointInTimeEventAnnotationConfig,
QueryPointEventAnnotationConfig,
} from '../../../common';
export const defaultAnnotationColor = euiLightVars.euiColorAccent;
// Do not compute it live as dependencies will add tens of Kbs to the plugin
export const defaultAnnotationRangeColor = `#F04E981A`; // defaultAnnotationColor with opacity 0.1
export const defaultAnnotationLabel = i18n.translate(
'eventAnnotation.xyChart.defaultAnnotationLabel',
{
defaultMessage: 'Event',
}
);
export const defaultRangeAnnotationLabel = i18n.translate(
'eventAnnotation.xyChart.defaultRangeAnnotationLabel',
{
defaultMessage: 'Event range',
}
);
export const toRangeAnnotationColor = (color = defaultAnnotationColor) => {
return chroma(transparentize(color, 0.1)).hex().toUpperCase();
};
export const toLineAnnotationColor = (color = defaultAnnotationRangeColor) => {
return chroma(transparentize(color, 1)).hex().toUpperCase();
};
export const sanitizeProperties = (annotation: EventAnnotationConfig) => {
if (isRangeAnnotationConfig(annotation)) {
const rangeAnnotation: RangeEventAnnotationConfig = pick(annotation, [
'type',
'label',
'key',
'id',
'isHidden',
'color',
'outside',
]);
return rangeAnnotation;
}
if (isQueryAnnotationConfig(annotation)) {
const lineAnnotation: QueryPointEventAnnotationConfig = pick(annotation, [
'type',
'id',
'label',
'key',
'timeField',
'isHidden',
'lineStyle',
'lineWidth',
'color',
'icon',
'textVisibility',
'textField',
'filter',
'extraFields',
]);
return lineAnnotation;
}
const lineAnnotation: PointInTimeEventAnnotationConfig = pick(annotation, [
'type',
'id',
'label',
'key',
'isHidden',
'lineStyle',
'lineWidth',
'color',
'icon',
'textVisibility',
]);
return lineAnnotation;
};

View file

@ -0,0 +1,111 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { IconTriangle, IconCircle } from '@kbn/chart-icons';
import type { IconSet } from '@kbn/visualization-ui-components/public';
import { AvailableAnnotationIcon } from '../../../common';
export const annotationsIconSet: IconSet<AvailableAnnotationIcon> = [
{
value: 'asterisk',
label: i18n.translate('eventAnnotation.xyChart.iconSelect.asteriskIconLabel', {
defaultMessage: 'Asterisk',
}),
},
{
value: 'alert',
label: i18n.translate('eventAnnotation.xyChart.iconSelect.alertIconLabel', {
defaultMessage: 'Alert',
}),
},
{
value: 'bell',
label: i18n.translate('eventAnnotation.xyChart.iconSelect.bellIconLabel', {
defaultMessage: 'Bell',
}),
},
{
value: 'bolt',
label: i18n.translate('eventAnnotation.xyChart.iconSelect.boltIconLabel', {
defaultMessage: 'Bolt',
}),
},
{
value: 'bug',
label: i18n.translate('eventAnnotation.xyChart.iconSelect.bugIconLabel', {
defaultMessage: 'Bug',
}),
},
{
value: 'circle',
label: i18n.translate('eventAnnotation.xyChart.iconSelect.circleIconLabel', {
defaultMessage: 'Circle',
}),
icon: IconCircle,
canFill: true,
},
{
value: 'editorComment',
label: i18n.translate('eventAnnotation.xyChart.iconSelect.commentIconLabel', {
defaultMessage: 'Comment',
}),
},
{
value: 'flag',
label: i18n.translate('eventAnnotation.xyChart.iconSelect.flagIconLabel', {
defaultMessage: 'Flag',
}),
},
{
value: 'heart',
label: i18n.translate('eventAnnotation.xyChart.iconSelect.heartLabel', {
defaultMessage: 'Heart',
}),
},
{
value: 'mapMarker',
label: i18n.translate('eventAnnotation.xyChart.iconSelect.mapMarkerLabel', {
defaultMessage: 'Map Marker',
}),
},
{
value: 'pinFilled',
label: i18n.translate('eventAnnotation.xyChart.iconSelect.mapPinLabel', {
defaultMessage: 'Map Pin',
}),
},
{
value: 'starEmpty',
label: i18n.translate('eventAnnotation.xyChart.iconSelect.starLabel', {
defaultMessage: 'Star',
}),
},
{
value: 'starFilled',
label: i18n.translate('eventAnnotation.xyChart.iconSelect.starFilledLabel', {
defaultMessage: 'Star filled',
}),
},
{
value: 'tag',
label: i18n.translate('eventAnnotation.xyChart.iconSelect.tagIconLabel', {
defaultMessage: 'Tag',
}),
},
{
value: 'triangle',
label: i18n.translate('eventAnnotation.xyChart.iconSelect.triangleIconLabel', {
defaultMessage: 'Triangle',
}),
icon: IconTriangle,
shouldRotate: true,
canFill: true,
},
];

View file

@ -0,0 +1,435 @@
/*
* 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 { DataView, DataViewField, IIndexPatternFieldList } from '@kbn/data-views-plugin/common';
import AnnotationEditorControls from './annotation_editor_controls';
import React from 'react';
import { mount } from 'enzyme';
import { EventAnnotationConfig, RangeEventAnnotationConfig } from '../../../common';
import { QueryInputServices } from '@kbn/visualization-ui-components/public';
import moment from 'moment';
import { act } from 'react-dom/test-utils';
import { EuiButtonGroup } from '@elastic/eui';
jest.mock('@kbn/unified-search-plugin/public', () => ({
QueryStringInput: () => {
return 'QueryStringInput';
},
}));
const customLineStaticAnnotation: EventAnnotationConfig = {
id: 'ann1',
type: 'manual',
key: { type: 'point_in_time' as const, timestamp: '2022-03-18T08:25:00.000Z' },
label: 'Event',
icon: 'triangle' as const,
color: 'red',
lineStyle: 'dashed' as const,
lineWidth: 3,
};
describe('AnnotationsPanel', () => {
const mockDataView: DataView = {
fields: [
new DataViewField({
type: 'date',
name: 'field1',
searchable: true,
aggregatable: true,
}),
new DataViewField({
type: 'date',
name: '@timestamp',
searchable: true,
aggregatable: true,
}),
] as unknown as IIndexPatternFieldList,
getFieldByName: (name) =>
new DataViewField({ type: 'some-type', name, searchable: true, aggregatable: true }),
timeFieldName: '@timestamp',
} as Partial<DataView> as DataView;
const mockQueryInputServices = {
http: {},
uiSettings: {},
storage: {},
dataViews: {},
unifiedSearch: {},
docLinks: {},
notifications: {},
data: {},
} as QueryInputServices;
describe('Dimension Editor', () => {
test('shows correct options for line annotations', () => {
const component = mount(
<AnnotationEditorControls
annotation={customLineStaticAnnotation}
onAnnotationChange={() => {}}
dataView={{} as DataView}
getDefaultRangeEnd={() => ''}
queryInputServices={mockQueryInputServices}
appName="myApp"
/>
);
expect(
component.find('EuiDatePicker[data-test-subj="lns-xyAnnotation-time"]').prop('selected')
).toEqual(moment('2022-03-18T08:25:00.000Z'));
expect(
component.find('EuiDatePicker[data-test-subj="lns-xyAnnotation-fromTime"]').exists()
).toBeFalsy();
expect(
component.find('EuiDatePicker[data-test-subj="lns-xyAnnotation-toTime"]').exists()
).toBeFalsy();
expect(
component.find('EuiSwitch[data-test-subj="lns-xyAnnotation-rangeSwitch"]').prop('checked')
).toEqual(false);
expect(component.find('EuiFieldText[data-test-subj="name-input"]').prop('value')).toEqual(
'Event'
);
expect(
component.find('EuiComboBox[data-test-subj="lns-icon-select"]').prop('selectedOptions')
).toEqual([{ label: 'Triangle', value: 'triangle' }]);
expect(component.find('TextDecorationSetting').exists()).toBeTruthy();
expect(component.find('LineStyleSettings').exists()).toBeTruthy();
expect(
component.find('EuiButtonGroup[data-test-subj="lns-xyAnnotation-fillStyle"]').exists()
).toBeFalsy();
});
test('shows correct options for range annotations', () => {
const rangeAnnotation: EventAnnotationConfig = {
color: 'red',
icon: 'triangle',
id: 'ann1',
type: 'manual',
isHidden: undefined,
key: {
endTimestamp: '2022-03-21T10:49:00.000Z',
timestamp: '2022-03-18T08:25:00.000Z',
type: 'range',
},
label: 'Event range',
lineStyle: 'dashed',
lineWidth: 3,
};
const component = mount(
<AnnotationEditorControls
annotation={rangeAnnotation}
onAnnotationChange={() => {}}
dataView={{} as DataView}
getDefaultRangeEnd={() => ''}
queryInputServices={mockQueryInputServices}
appName="myApp"
/>
);
expect(
component.find('EuiDatePicker[data-test-subj="lns-xyAnnotation-fromTime"]').prop('selected')
).toEqual(moment('2022-03-18T08:25:00.000Z'));
expect(
component.find('EuiDatePicker[data-test-subj="lns-xyAnnotation-toTime"]').prop('selected')
).toEqual(moment('2022-03-21T10:49:00.000Z'));
expect(
component.find('EuiDatePicker[data-test-subj="lns-xyAnnotation-time"]').exists()
).toBeFalsy();
expect(
component.find('EuiSwitch[data-test-subj="lns-xyAnnotation-rangeSwitch"]').prop('checked')
).toEqual(true);
expect(component.find('EuiFieldText[data-test-subj="name-input"]').prop('value')).toEqual(
'Event range'
);
expect(component.find('EuiComboBox[data-test-subj="lns-icon-select"]').exists()).toBeFalsy();
expect(component.find('TextDecorationSetting').exists()).toBeFalsy();
expect(component.find('LineStyleSettings').exists()).toBeFalsy();
expect(component.find('[data-test-subj="lns-xyAnnotation-fillStyle"]').exists()).toBeTruthy();
});
test('calculates correct endTimstamp and transparent color when switching for range annotation and back', async () => {
const onAnnotationChange = jest.fn();
const rangeEndTimestamp = new Date().toISOString();
const component = mount(
<AnnotationEditorControls
annotation={customLineStaticAnnotation}
onAnnotationChange={onAnnotationChange}
dataView={{} as DataView}
getDefaultRangeEnd={() => rangeEndTimestamp}
queryInputServices={mockQueryInputServices}
appName="myApp"
/>
);
component.find('button[data-test-subj="lns-xyAnnotation-rangeSwitch"]').simulate('click');
const expectedRangeAnnotation: RangeEventAnnotationConfig = {
color: '#FF00001A',
id: 'ann1',
isHidden: undefined,
label: 'Event range',
type: 'manual',
key: {
endTimestamp: rangeEndTimestamp,
timestamp: '2022-03-18T08:25:00.000Z',
type: 'range',
},
};
expect(onAnnotationChange).toBeCalledWith<EventAnnotationConfig[]>(expectedRangeAnnotation);
act(() => {
component.setProps({ annotation: expectedRangeAnnotation });
});
expect(
component.find('EuiSwitch[data-test-subj="lns-xyAnnotation-rangeSwitch"]').prop('checked')
).toEqual(true);
component.find('button[data-test-subj="lns-xyAnnotation-rangeSwitch"]').simulate('click');
expect(onAnnotationChange).toBeCalledWith<EventAnnotationConfig[]>({
color: '#FF0000',
id: 'ann1',
isHidden: undefined,
key: {
timestamp: '2022-03-18T08:25:00.000Z',
type: 'point_in_time',
},
label: 'Event',
type: 'manual',
});
});
test('shows correct options for query based', () => {
const annotation: EventAnnotationConfig = {
color: 'red',
icon: 'triangle',
id: 'ann1',
type: 'query',
isHidden: undefined,
timeField: 'timestamp',
key: {
type: 'point_in_time',
},
label: 'Query based event',
lineStyle: 'dashed',
lineWidth: 3,
filter: { type: 'kibana_query', query: '', language: 'kuery' },
};
const component = mount(
<AnnotationEditorControls
annotation={annotation}
onAnnotationChange={() => {}}
dataView={mockDataView}
getDefaultRangeEnd={() => ''}
queryInputServices={mockQueryInputServices}
appName="myApp"
/>
);
expect(
component.find('[data-test-subj="lnsXY-annotation-query-based-field-picker"]').exists()
).toBeTruthy();
expect(
component.find('[data-test-subj="annotation-query-based-query-input"]').exists()
).toBeTruthy();
// The provided indexPattern has 2 date fields
expect(
component
.find('[data-test-subj="lnsXY-annotation-query-based-field-picker"]')
.at(0)
.prop('options')
).toHaveLength(2);
// When in query mode a new "field" option is added to the previous 2 ones
expect(
component.find('[data-test-subj="lns-lineMarker-text-visibility"]').at(0).prop('options')
).toHaveLength(3);
expect(
component.find('[data-test-subj="lnsXY-annotation-tooltip-add_field"]').exists()
).toBeTruthy();
});
test('should prefill timeField with the default time field when switching to query based annotations', () => {
const onAnnotationChange = jest.fn();
const component = mount(
<AnnotationEditorControls
annotation={customLineStaticAnnotation}
onAnnotationChange={onAnnotationChange}
dataView={mockDataView}
getDefaultRangeEnd={() => ''}
queryInputServices={mockQueryInputServices}
appName="myApp"
/>
);
act(() => {
component
.find(`[data-test-subj="lns-xyAnnotation-placementType"]`)
.find(EuiButtonGroup)
.prop('onChange')!('lens_xyChart_annotation_query');
});
component.update();
expect(onAnnotationChange).toHaveBeenCalledWith(
expect.objectContaining({ timeField: '@timestamp' })
);
});
test('should avoid to retain specific manual configurations when switching to query based annotations', () => {
const onAnnotationChange = jest.fn();
const component = mount(
<AnnotationEditorControls
annotation={customLineStaticAnnotation}
onAnnotationChange={onAnnotationChange}
dataView={mockDataView}
getDefaultRangeEnd={() => ''}
queryInputServices={mockQueryInputServices}
appName="myApp"
/>
);
act(() => {
component
.find(`[data-test-subj="lns-xyAnnotation-placementType"]`)
.find(EuiButtonGroup)
.prop('onChange')!('lens_xyChart_annotation_query');
});
component.update();
expect(onAnnotationChange).toHaveBeenCalledWith(
expect.objectContaining({
key: expect.not.objectContaining({ timestamp: expect.any('string') }),
})
);
});
test('should avoid to retain range manual configurations when switching to query based annotations', () => {
const annotation: EventAnnotationConfig = {
color: 'red',
icon: 'triangle',
id: 'ann1',
type: 'manual',
isHidden: undefined,
key: {
endTimestamp: '2022-03-21T10:49:00.000Z',
timestamp: '2022-03-18T08:25:00.000Z',
type: 'range',
},
label: 'Event range',
lineStyle: 'dashed',
lineWidth: 3,
};
const onAnnotationChange = jest.fn();
const component = mount(
<AnnotationEditorControls
annotation={annotation}
onAnnotationChange={onAnnotationChange}
dataView={mockDataView}
getDefaultRangeEnd={() => ''}
queryInputServices={mockQueryInputServices}
appName="myApp"
/>
);
act(() => {
component
.find(`[data-test-subj="lns-xyAnnotation-placementType"]`)
.find(EuiButtonGroup)
.prop('onChange')!('lens_xyChart_annotation_query');
});
component.update();
expect(onAnnotationChange).toHaveBeenCalledWith(
expect.objectContaining({ label: expect.not.stringContaining('Event range') })
);
});
test('should set a default tiemstamp when switching from query based to manual annotations', () => {
const annotation: EventAnnotationConfig = {
color: 'red',
icon: 'triangle',
id: 'ann1',
type: 'query',
isHidden: undefined,
timeField: 'timestamp',
key: {
type: 'point_in_time',
},
label: 'Query based event',
lineStyle: 'dashed',
lineWidth: 3,
filter: { type: 'kibana_query', query: '', language: 'kuery' },
};
const onAnnotationChange = jest.fn();
const component = mount(
<AnnotationEditorControls
annotation={annotation}
onAnnotationChange={onAnnotationChange}
dataView={mockDataView}
getDefaultRangeEnd={() => ''}
queryInputServices={mockQueryInputServices}
appName="myApp"
/>
);
act(() => {
component
.find(`[data-test-subj="lns-xyAnnotation-placementType"]`)
.find(EuiButtonGroup)
.prop('onChange')!('lens_xyChart_annotation_manual');
});
component.update();
expect(onAnnotationChange).toHaveBeenCalledWith(
expect.objectContaining({
key: { type: 'point_in_time', timestamp: expect.any(String) },
})
);
// also check query specific props are not carried over
expect(onAnnotationChange).toHaveBeenCalledWith(
expect.not.objectContaining({ timeField: 'timestamp' })
);
});
test('should fallback to the first date field available in the dataView if not time-based', () => {
const onAnnotationChange = jest.fn();
const component = mount(
<AnnotationEditorControls
annotation={customLineStaticAnnotation}
onAnnotationChange={onAnnotationChange}
dataView={{ ...mockDataView, timeFieldName: '' } as DataView}
getDefaultRangeEnd={() => ''}
queryInputServices={mockQueryInputServices}
appName="myApp"
/>
);
act(() => {
component
.find(`[data-test-subj="lns-xyAnnotation-placementType"]`)
.find(EuiButtonGroup)
.prop('onChange')!('lens_xyChart_annotation_query');
});
component.update();
expect(onAnnotationChange).toHaveBeenCalledWith(
expect.objectContaining({ timeField: 'field1' })
);
});
});
});

View file

@ -0,0 +1,20 @@
/*
* 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, { Suspense, lazy } from 'react';
import type { Props } from './annotation_editor_controls';
const AnnotationEditorControlsLazy = lazy(() => import('./annotation_editor_controls'));
export const AnnotationEditorControls = (props: Props) => (
<Suspense fallback={null}>
<AnnotationEditorControlsLazy {...props} />
</Suspense>
);
export { annotationsIconSet } from './icon_set';

View file

@ -1,35 +1,31 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
* 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 type { DatatableUtilitiesService } from '@kbn/data-plugin/common';
import { isRangeAnnotationConfig } from '@kbn/event-annotation-plugin/public';
import { i18n } from '@kbn/i18n';
import moment from 'moment';
import React from 'react';
import type { FramePublicAPI } from '../../../../types';
import type { XYState } from '../../types';
import { isRangeAnnotationConfig } from '../..';
import {
ConfigPanelRangeDatePicker,
ConfigPanelApplyAsRangeSwitch,
ConfigPanelRangeDatePicker,
} from './range_annotation_panel';
import type { ManualEventAnnotationType } from './types';
export const ConfigPanelManualAnnotation = ({
annotation,
frame,
state,
onChange,
datatableUtilities,
getDefaultRangeEnd,
calendarClassName,
}: {
annotation?: ManualEventAnnotationType | undefined;
onChange: <T extends ManualEventAnnotationType>(annotation: Partial<T> | undefined) => void;
datatableUtilities: DatatableUtilitiesService;
frame: FramePublicAPI;
state: XYState;
getDefaultRangeEnd: (rangeStart: string) => string;
calendarClassName: string | undefined;
}) => {
const isRange = isRangeAnnotationConfig(annotation);
return (
@ -38,7 +34,8 @@ export const ConfigPanelManualAnnotation = ({
<>
<ConfigPanelRangeDatePicker
dataTestSubj="lns-xyAnnotation-fromTime"
prependLabel={i18n.translate('xpack.lens.xyChart.annotationDate.from', {
calendarClassName={calendarClassName}
prependLabel={i18n.translate('eventAnnotation.xyChart.annotationDate.from', {
defaultMessage: 'From',
})}
value={moment(annotation?.key.timestamp)}
@ -65,13 +62,14 @@ export const ConfigPanelManualAnnotation = ({
}
}
}}
label={i18n.translate('xpack.lens.xyChart.annotationDate', {
label={i18n.translate('eventAnnotation.xyChart.annotationDate', {
defaultMessage: 'Annotation date',
})}
/>
<ConfigPanelRangeDatePicker
dataTestSubj="lns-xyAnnotation-toTime"
prependLabel={i18n.translate('xpack.lens.xyChart.annotationDate.to', {
calendarClassName={calendarClassName}
prependLabel={i18n.translate('eventAnnotation.xyChart.annotationDate.to', {
defaultMessage: 'To',
})}
value={moment(annotation?.key.endTimestamp)}
@ -103,7 +101,8 @@ export const ConfigPanelManualAnnotation = ({
) : (
<ConfigPanelRangeDatePicker
dataTestSubj="lns-xyAnnotation-time"
label={i18n.translate('xpack.lens.xyChart.annotationDate', {
calendarClassName={calendarClassName}
label={i18n.translate('eventAnnotation.xyChart.annotationDate', {
defaultMessage: 'Annotation date',
})}
value={moment(annotation?.key.timestamp)}
@ -122,9 +121,7 @@ export const ConfigPanelManualAnnotation = ({
<ConfigPanelApplyAsRangeSwitch
annotation={annotation}
onChange={onChange}
datatableUtilities={datatableUtilities}
frame={frame}
state={state}
getDefaultRangeEnd={getDefaultRangeEnd}
/>
</>
);

View file

@ -1,27 +1,26 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
* 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 { EuiFormRow } from '@elastic/eui';
import type { Query } from '@kbn/data-plugin/common';
import type { QueryPointEventAnnotationConfig } from '@kbn/event-annotation-plugin/common';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useExistingFieldsReader } from '@kbn/unified-field-list-plugin/public';
import {
FieldOption,
FilterQueryInput,
FieldOptionValue,
FieldPicker,
FilterQueryInput,
type QueryInputServices,
} from '@kbn/visualization-ui-components/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { LENS_APP_NAME } from '../../../../../common/constants';
import type { FramePublicAPI } from '../../../../types';
import type { XYState, XYAnnotationLayerConfig } from '../../types';
import { LensAppServices } from '../../../../app_plugin/types';
import type { DataView } from '@kbn/data-views-plugin/common';
import { isFieldLensCompatible } from '@kbn/visualization-ui-components/public';
import type { QueryPointEventAnnotationConfig } from '../../../common';
export const defaultQuery: Query = {
query: '',
@ -30,23 +29,23 @@ export const defaultQuery: Query = {
export const ConfigPanelQueryAnnotation = ({
annotation,
frame,
state,
dataView,
onChange,
layer,
queryInputShouldOpen,
queryInputServices,
appName,
}: {
annotation?: QueryPointEventAnnotationConfig;
onChange: (annotations: Partial<QueryPointEventAnnotationConfig> | undefined) => void;
frame: FramePublicAPI;
state: XYState;
layer: XYAnnotationLayerConfig;
dataView: DataView;
queryInputShouldOpen?: boolean;
queryInputServices: QueryInputServices;
appName: string;
}) => {
const currentIndexPattern = frame.dataViews.indexPatterns[layer.indexPatternId];
const { hasFieldData } = useExistingFieldsReader();
// list only date fields
const options = currentIndexPattern.fields
const options = dataView.fields
.filter(isFieldLensCompatible)
.filter((field) => field.type === 'date' && field.displayName)
.map((field) => {
return {
@ -56,17 +55,14 @@ export const ConfigPanelQueryAnnotation = ({
field: field.name,
dataType: field.type,
},
exists: hasFieldData(currentIndexPattern.id, field.name),
exists: dataView.id ? hasFieldData(dataView.id, field.name) : false,
compatible: true,
'data-test-subj': `lns-fieldOption-${field.name}`,
} as FieldOption<FieldOptionValue>;
});
const selectedField =
annotation?.timeField || currentIndexPattern.timeFieldName || options[0]?.value.field;
const fieldIsValid = selectedField
? Boolean(currentIndexPattern.getFieldByName(selectedField))
: true;
const selectedField = annotation?.timeField || dataView.timeFieldName || options[0]?.value.field;
const fieldIsValid = selectedField ? Boolean(dataView.getFieldByName(selectedField)) : true;
return (
<>
@ -75,29 +71,28 @@ export const ConfigPanelQueryAnnotation = ({
display="rowCompressed"
className="lnsRowCompressedMargin"
fullWidth
label={i18n.translate('xpack.lens.xyChart.annotation.queryInput', {
label={i18n.translate('eventAnnotation.xyChart.annotation.queryInput', {
defaultMessage: 'Annotation query',
})}
data-test-subj="annotation-query-based-query-input"
>
<FilterQueryInput
data-test-subj="annotation-query-based-query-input"
initiallyOpen={queryInputShouldOpen}
label=""
inputFilter={annotation?.filter ?? defaultQuery}
onChange={(query: Query) => {
onChange({ filter: { type: 'kibana_query', ...query } });
}}
data-test-subj="lnsXY-annotation-query-based-query-input"
dataView={currentIndexPattern}
appName={LENS_APP_NAME}
queryInputServices={useKibana<LensAppServices>().services}
dataView={dataView}
appName={appName}
queryInputServices={queryInputServices}
/>
</EuiFormRow>
<EuiFormRow
display="rowCompressed"
fullWidth
label={i18n.translate('xpack.lens.xyChart.annotation.queryField', {
label={i18n.translate('eventAnnotation.xyChart.annotation.queryField', {
defaultMessage: 'Target date field',
})}
>

View file

@ -1,16 +1,11 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
* 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 type { DatatableUtilitiesService } from '@kbn/data-plugin/common';
import type {
PointInTimeEventAnnotationConfig,
RangeEventAnnotationConfig,
} from '@kbn/event-annotation-plugin/common';
import { isRangeAnnotationConfig } from '@kbn/event-annotation-plugin/public';
import { i18n } from '@kbn/i18n';
import React from 'react';
import {
@ -22,26 +17,20 @@ import {
EuiDatePicker,
} from '@elastic/eui';
import moment from 'moment';
import { DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS } from '../../../../utils';
import type { FramePublicAPI } from '../../../../types';
import { defaultRangeAnnotationLabel, defaultAnnotationLabel } from '../../annotations/helpers';
import type { XYState } from '../../types';
import { getDataLayers } from '../../visualization_helpers';
import { toLineAnnotationColor, getEndTimestamp, toRangeAnnotationColor } from './helpers';
import { isRangeAnnotationConfig } from '../..';
import type { PointInTimeEventAnnotationConfig, RangeEventAnnotationConfig } from '../../../common';
import { defaultRangeAnnotationLabel, defaultAnnotationLabel } from './helpers';
import { toLineAnnotationColor, toRangeAnnotationColor } from './helpers';
import type { ManualEventAnnotationType } from './types';
export const ConfigPanelApplyAsRangeSwitch = ({
annotation,
datatableUtilities,
onChange,
frame,
state,
getDefaultRangeEnd,
}: {
annotation?: ManualEventAnnotationType;
datatableUtilities: DatatableUtilitiesService;
onChange: <T extends ManualEventAnnotationType>(annotations: Partial<T> | undefined) => void;
frame: FramePublicAPI;
state: XYState;
getDefaultRangeEnd: (rangeStart: string) => string;
}) => {
const isRange = isRangeAnnotationConfig(annotation);
return (
@ -50,7 +39,7 @@ export const ConfigPanelApplyAsRangeSwitch = ({
data-test-subj="lns-xyAnnotation-rangeSwitch"
label={
<EuiText size="xs">
{i18n.translate('xpack.lens.xyChart.applyAsRange', {
{i18n.translate('eventAnnotation.xyChart.applyAsRange', {
defaultMessage: 'Apply as range',
})}
</EuiText>
@ -74,19 +63,12 @@ export const ConfigPanelApplyAsRangeSwitch = ({
};
onChange(newPointAnnotation);
} else if (annotation) {
const fromTimestamp = moment(annotation?.key.timestamp);
const dataLayers = getDataLayers(state.layers);
const newRangeAnnotation: RangeEventAnnotationConfig = {
type: 'manual',
key: {
type: 'range',
timestamp: annotation.key.timestamp,
endTimestamp: getEndTimestamp(
datatableUtilities,
fromTimestamp.toISOString(),
frame,
dataLayers
),
endTimestamp: getDefaultRangeEnd(annotation.key.timestamp),
},
id: annotation.id,
label:
@ -110,12 +92,14 @@ export const ConfigPanelRangeDatePicker = ({
label,
prependLabel,
onChange,
calendarClassName,
dataTestSubj = 'lnsXY_annotation_date_picker',
}: {
value: moment.Moment;
prependLabel?: string;
label?: string;
onChange: (val: moment.Moment | null) => void;
calendarClassName: string | undefined;
dataTestSubj?: string;
}) => {
return (
@ -129,7 +113,7 @@ export const ConfigPanelRangeDatePicker = ({
}
>
<EuiDatePicker
calendarClassName={DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS}
calendarClassName={calendarClassName}
fullWidth
showTimeSelect
selected={value}
@ -140,7 +124,7 @@ export const ConfigPanelRangeDatePicker = ({
</EuiFormControlLayout>
) : (
<EuiDatePicker
calendarClassName={DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS}
calendarClassName={calendarClassName}
fullWidth
showTimeSelect
selected={value}

View file

@ -1,28 +1,28 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
* 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 { htmlIdGenerator, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useMemo } from 'react';
import { QueryPointEventAnnotationConfig } from '@kbn/event-annotation-plugin/common';
import { useExistingFieldsReader } from '@kbn/unified-field-list-plugin/public';
import {
FieldOption,
FieldOptionValue,
FieldPicker,
} from '@kbn/visualization-ui-components/public';
import {
useDebouncedValue,
NewBucketButton,
DragDropBuckets,
DraggableBucketContainer,
FieldsBucketContainer,
} from '@kbn/visualization-ui-components/public';
import type { IndexPattern } from '../../../../types';
import { DataView } from '@kbn/data-views-plugin/common';
import { isFieldLensCompatible } from '@kbn/visualization-ui-components/public';
import { QueryPointEventAnnotationConfig } from '../../../common';
export const MAX_TOOLTIP_FIELDS_SIZE = 2;
@ -32,7 +32,7 @@ const supportedTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']);
export interface FieldInputsProps {
currentConfig: QueryPointEventAnnotationConfig;
setConfig: (config: QueryPointEventAnnotationConfig) => void;
indexPattern: IndexPattern;
dataView: DataView;
invalidFields?: string[];
}
@ -51,7 +51,7 @@ function removeNewEmptyField(v: WrappedValue): v is SafeWrappedValue {
export function TooltipSection({
currentConfig,
setConfig,
indexPattern,
dataView,
invalidFields,
}: FieldInputsProps) {
const { hasFieldData } = useExistingFieldsReader();
@ -81,14 +81,14 @@ export function TooltipSection({
(choice, index = 0) => {
const fields = [...localValues];
if (indexPattern.getFieldByName(choice.field)) {
if (dataView.getFieldByName(choice.field)) {
fields[index] = { id: generateId(), value: choice.field };
// update the layer state
handleInputChange(fields);
}
},
[localValues, indexPattern, handleInputChange]
[localValues, dataView, handleInputChange]
);
const newBucketButton = (
@ -98,7 +98,7 @@ export function TooltipSection({
onClick={() => {
handleInputChange([...localValues, { id: generateId(), value: undefined, isNew: true }]);
}}
label={i18n.translate('xpack.lens.xyChart.annotation.tooltip.addField', {
label={i18n.translate('eventAnnotation.xyChart.annotation.tooltip.addField', {
defaultMessage: 'Add field',
})}
isDisabled={localValues.length > MAX_TOOLTIP_FIELDS_SIZE}
@ -115,7 +115,7 @@ export function TooltipSection({
className="lnsConfigPanelAnnotations__noFieldsPrompt"
>
<EuiText color="subdued" size="s" textAlign="center">
{i18n.translate('xpack.lens.xyChart.annotation.tooltip.noFields', {
{i18n.translate('eventAnnotation.xyChart.annotation.tooltip.noFields', {
defaultMessage: 'None selected',
})}
</EuiText>
@ -126,7 +126,8 @@ export function TooltipSection({
);
}
const options = indexPattern.fields
const options = dataView.fields
.filter(isFieldLensCompatible)
.filter(
({ displayName, type }) =>
displayName && !rawValuesLookup.has(displayName) && supportedTypes.has(type)
@ -140,7 +141,7 @@ export function TooltipSection({
field: field.name,
dataType: field.type,
},
exists: hasFieldData(indexPattern.id, field.name),
exists: dataView.id ? hasFieldData(dataView.id, field.name) : false,
compatible: true,
'data-test-subj': `lnsXY-annotation-tooltip-fieldOption-${field.name}`,
} as FieldOption<FieldOptionValue>)
@ -158,7 +159,7 @@ export function TooltipSection({
bgColor="subdued"
>
{localValues.map(({ id, value, isNew }, index, arrayRef) => {
const fieldIsValid = value ? Boolean(indexPattern.getFieldByName(value)) : true;
const fieldIsValid = value ? Boolean(dataView.getFieldByName(value)) : true;
return (
<DraggableBucketContainer
@ -169,7 +170,7 @@ export function TooltipSection({
handleInputChange(arrayRef.filter((_, i) => i !== index));
}}
removeTitle={i18n.translate(
'xpack.lens.xyChart.annotation.tooltip.deleteButtonLabel',
'eventAnnotation.xyChart.annotation.tooltip.deleteButtonLabel',
{
defaultMessage: 'Delete',
}

View file

@ -0,0 +1,13 @@
/*
* 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 type { PointInTimeEventAnnotationConfig, RangeEventAnnotationConfig } from '../../../common';
export type ManualEventAnnotationType =
| PointInTimeEventAnnotationConfig
| RangeEventAnnotationConfig;

View file

@ -0,0 +1,32 @@
/*
* 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 type { AccessorConfig } from '@kbn/visualization-ui-components/public';
import { EventAnnotationConfig } from '../../common';
import {
defaultAnnotationColor,
defaultAnnotationRangeColor,
isRangeAnnotationConfig,
} from '../event_annotation_service/helpers';
import { annotationsIconSet } from './annotation_editor_controls/icon_set';
export const getAnnotationAccessor = (annotation: EventAnnotationConfig): AccessorConfig => {
const annotationIcon = !isRangeAnnotationConfig(annotation)
? annotationsIconSet.find((option) => option.value === annotation?.icon) ||
annotationsIconSet.find((option) => option.value === 'triangle')
: undefined;
const icon = annotationIcon?.icon ?? annotationIcon?.value;
return {
columnId: annotation.id,
triggerIconType: annotation.isHidden ? 'invisible' : icon ? 'custom' : 'color',
customIcon: icon,
color:
annotation?.color ||
(isRangeAnnotationConfig(annotation) ? defaultAnnotationRangeColor : defaultAnnotationColor),
};
};

View file

@ -0,0 +1,132 @@
/*
* 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 { css } from '@emotion/react';
import { DragContext, DragDrop, DropTargetSwapDuplicateCombine } from '@kbn/dom-drag-drop';
import {
DimensionButton,
DimensionTrigger,
EmptyDimensionButton,
} from '@kbn/visualization-ui-components/public';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { euiThemeVars } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n';
import { createCopiedAnnotation, EventAnnotationConfig } from '../../../common';
import { getAnnotationAccessor } from '..';
export const AnnotationList = ({
annotations,
selectAnnotation,
update: updateAnnotations,
}: {
annotations: EventAnnotationConfig[];
selectAnnotation: (annotation: EventAnnotationConfig) => void;
update: (annotations: EventAnnotationConfig[]) => void;
}) => {
const [newAnnotationId, setNewAnnotationId] = useState<string>(uuidv4());
useEffect(() => {
setNewAnnotationId(uuidv4());
}, [annotations.length]);
const { dragging } = useContext(DragContext);
const addAnnotationText = i18n.translate('eventAnnotation.annotationList.add', {
defaultMessage: 'Add annotation',
});
const addNewAnnotation = useCallback(
(sourceAnnotationId?: string) => {
const source = sourceAnnotationId
? annotations.find(({ id }) => id === sourceAnnotationId)
: undefined;
const newAnnotation = createCopiedAnnotation(
newAnnotationId,
new Date().toISOString(),
source
);
if (!source) {
selectAnnotation(newAnnotation);
}
updateAnnotations([...annotations, newAnnotation]);
},
[annotations, newAnnotationId, selectAnnotation, updateAnnotations]
);
return (
<div>
{annotations.map((annotation, index) => (
<div
key={index}
css={css`
margin-top: ${euiThemeVars.euiSizeS};
position: relative; // this is to properly contain the absolutely-positioned drop target in DragDrop
`}
>
<DragDrop
order={[index]}
key={annotation.id}
value={{
id: annotation.id,
humanData: {
label: annotation.label,
},
}}
dragType="copy"
dropTypes={[]}
draggable
>
<DimensionButton
groupLabel={i18n.translate('eventAnnotation.groupEditor.addAnnotation', {
defaultMessage: 'Annotations',
})}
onClick={() => selectAnnotation(annotation)}
onRemoveClick={() =>
updateAnnotations(annotations.filter(({ id }) => id !== annotation.id))
}
accessorConfig={getAnnotationAccessor(annotation)}
label={annotation.label}
>
<DimensionTrigger label={annotation.label} />
</DimensionButton>
</DragDrop>
</div>
))}
<div
css={css`
margin-top: ${euiThemeVars.euiSizeS};
`}
>
<DragDrop
order={[annotations.length]}
getCustomDropTarget={DropTargetSwapDuplicateCombine.getCustomDropTarget}
getAdditionalClassesOnDroppable={
DropTargetSwapDuplicateCombine.getAdditionalClassesOnDroppable
}
dropTypes={dragging ? ['field_add'] : []}
value={{
id: 'addAnnotation',
humanData: {
label: addAnnotationText,
},
}}
onDrop={({ id: sourceId }) => addNewAnnotation(sourceId)}
>
<EmptyDimensionButton
dataTestSubj="addAnnotation"
label={addAnnotationText}
ariaLabel={addAnnotationText}
onClick={() => addNewAnnotation()}
/>
</DragDrop>
</div>
</div>
);
};

View file

@ -0,0 +1,32 @@
/*
* 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 type { AccessorConfig } from '@kbn/visualization-ui-components/public';
import type { EventAnnotationConfig } from '../../../common';
import {
defaultAnnotationColor,
defaultAnnotationRangeColor,
isRangeAnnotationConfig,
} from '../../event_annotation_service/helpers';
import { annotationsIconSet } from '../annotation_editor_controls';
export const getAnnotationAccessor = (annotation: EventAnnotationConfig): AccessorConfig => {
const annotationIcon = !isRangeAnnotationConfig(annotation)
? annotationsIconSet.find((option) => option.value === annotation?.icon) ||
annotationsIconSet.find((option) => option.value === 'triangle')
: undefined;
const icon = annotationIcon?.icon ?? annotationIcon?.value;
return {
columnId: annotation.id,
triggerIconType: annotation.isHidden ? 'invisible' : icon ? 'custom' : 'color',
customIcon: icon,
color:
annotation?.color ||
(isRangeAnnotationConfig(annotation) ? defaultAnnotationRangeColor : defaultAnnotationColor),
};
};

View file

@ -0,0 +1,235 @@
/*
* 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, { ChangeEvent, FormEvent } from 'react';
import { EventAnnotationGroupConfig, getDefaultManualAnnotation } from '../../../common';
import { ReactWrapper } from 'enzyme';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { GroupEditorControls } from './group_editor_controls';
import { EuiTextAreaProps, EuiTextProps } from '@elastic/eui';
import type { DataView } from '@kbn/data-views-plugin/common';
import { act } from 'react-dom/test-utils';
import type { QueryInputServices } from '@kbn/visualization-ui-components/public';
import { AnnotationEditorControls, ENABLE_INDIVIDUAL_ANNOTATION_EDITING } from '..';
jest.mock('@elastic/eui', () => {
return {
...jest.requireActual('@elastic/eui'),
EuiDatePicker: () => <></>, // for some reason this component caused an infinite loop when the props updated
};
});
describe('event annotation group editor', () => {
const dataViewId = 'my-index-pattern';
const adHocDataViewId = 'ad-hoc';
const adHocDataViewSpec = {
id: adHocDataViewId,
title: 'Ad Hoc Data View',
};
const group: EventAnnotationGroupConfig = {
annotations: [],
description: '',
tags: [],
indexPatternId: dataViewId,
title: 'My group',
ignoreGlobalFilters: false,
dataViewSpec: adHocDataViewSpec,
};
let wrapper: ReactWrapper;
let updateMock: jest.Mock;
let setSelectedAnnotationMock: jest.Mock;
const TagSelector = (_props: { onTagsSelected: (tags: string[]) => void }) => <div />;
beforeEach(async () => {
updateMock = jest.fn();
setSelectedAnnotationMock = jest.fn();
wrapper = mountWithIntl(
<GroupEditorControls
group={group}
update={updateMock}
TagSelector={TagSelector}
dataViews={
[
{
id: dataViewId,
title: 'My Data View',
},
] as DataView[]
}
selectedAnnotation={undefined}
setSelectedAnnotation={setSelectedAnnotationMock}
createDataView={(spec) =>
Promise.resolve({
id: spec.id,
title: spec.title,
toSpec: () => spec,
} as unknown as DataView)
}
queryInputServices={{} as QueryInputServices}
showValidation={false}
/>
);
await act(async () => {
await new Promise((resolve) => setImmediate(resolve));
wrapper.update();
});
});
it('reports group updates', () => {
(
wrapper.find(
"EuiFieldText[data-test-subj='annotationGroupTitle']"
) as ReactWrapper<EuiTextProps>
).prop('onChange')!({
target: {
value: 'im a new title!',
} as Partial<EventTarget> as EventTarget,
} as FormEvent<HTMLDivElement>);
(
wrapper.find(
"EuiTextArea[data-test-subj='annotationGroupDescription']"
) as ReactWrapper<EuiTextAreaProps>
).prop('onChange')!({
target: {
value: 'im a new description!',
},
} as ChangeEvent<HTMLTextAreaElement>);
act(() => {
wrapper.find(TagSelector).prop('onTagsSelected')(['im a new tag!']);
});
// TODO - reenable data view selection tests when ENABLE_INDIVIDUAL_ANNOTATION_EDITING is set to true!
// this will happen in https://github.com/elastic/kibana/issues/158774
// const setDataViewId = (id: string) =>
// (
// wrapper.find(
// "EuiSelect[data-test-subj='annotationDataViewSelection']"
// ) as ReactWrapper<EuiSelectProps>
// ).prop('onChange')!({ target: { value: id } } as React.ChangeEvent<HTMLSelectElement>);
// setDataViewId(dataViewId);
// setDataViewId(adHocDataViewId);
expect(updateMock.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"annotations": Array [],
"dataViewSpec": Object {
"id": "ad-hoc",
"title": "Ad Hoc Data View",
},
"description": "",
"ignoreGlobalFilters": false,
"indexPatternId": "my-index-pattern",
"tags": Array [],
"title": "im a new title!",
},
],
Array [
Object {
"annotations": Array [],
"dataViewSpec": Object {
"id": "ad-hoc",
"title": "Ad Hoc Data View",
},
"description": "im a new description!",
"ignoreGlobalFilters": false,
"indexPatternId": "my-index-pattern",
"tags": Array [],
"title": "My group",
},
],
Array [
Object {
"annotations": Array [],
"dataViewSpec": Object {
"id": "ad-hoc",
"title": "Ad Hoc Data View",
},
"description": "",
"ignoreGlobalFilters": false,
"indexPatternId": "my-index-pattern",
"tags": Array [
"im a new tag!",
],
"title": "My group",
},
],
]
`);
});
if (ENABLE_INDIVIDUAL_ANNOTATION_EDITING) {
it('adds a new annotation group', () => {
act(() => {
wrapper.find('button[data-test-subj="addAnnotation"]').simulate('click');
});
expect(updateMock).toHaveBeenCalledTimes(2);
const newAnnotations = (updateMock.mock.calls[0][0] as EventAnnotationGroupConfig)
.annotations;
expect(newAnnotations.length).toBe(group.annotations.length + 1);
expect(wrapper.exists(AnnotationEditorControls)); // annotation controls opened
});
it('incorporates annotation updates into group', () => {
const annotations = [
getDefaultManualAnnotation('1', ''),
getDefaultManualAnnotation('2', ''),
];
act(() => {
wrapper.setProps({
selectedAnnotation: annotations[0],
group: { ...group, annotations },
});
});
wrapper.find(AnnotationEditorControls).prop('onAnnotationChange')({
...annotations[0],
color: 'newColor',
});
expect(updateMock).toHaveBeenCalledTimes(1);
expect(updateMock.mock.calls[0][0].annotations[0].color).toBe('newColor');
expect(setSelectedAnnotationMock).toHaveBeenCalledTimes(1);
});
it('removes an annotation from a group', () => {
const annotations = [
getDefaultManualAnnotation('1', ''),
getDefaultManualAnnotation('2', ''),
];
act(() => {
wrapper.setProps({
group: { ...group, annotations },
});
});
act(() => {
wrapper
.find('button[data-test-subj="indexPattern-dimension-remove"]')
.last()
.simulate('click');
});
expect(updateMock).toHaveBeenCalledTimes(1);
expect(updateMock.mock.calls[0][0].annotations).toEqual(annotations.slice(0, 1));
});
}
});

View file

@ -0,0 +1,212 @@
/*
* 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 {
EuiFieldText,
EuiForm,
EuiFormRow,
EuiSelect,
EuiText,
EuiTextArea,
EuiTitle,
} from '@elastic/eui';
import { css } from '@emotion/react';
import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { SavedObjectsTaggingApiUiComponent } from '@kbn/saved-objects-tagging-oss-plugin/public';
import { euiThemeVars } from '@kbn/ui-theme';
import { QueryInputServices } from '@kbn/visualization-ui-components/public';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { EVENT_ANNOTATION_APP_NAME } from '../../../common/constants';
import { EventAnnotationConfig, EventAnnotationGroupConfig } from '../../../common';
import { AnnotationEditorControls } from '../annotation_editor_controls';
import { AnnotationList } from './annotation_list';
export const ENABLE_INDIVIDUAL_ANNOTATION_EDITING = false;
const isTitleValid = (title: string) => Boolean(title.length);
export const isGroupValid = (group: EventAnnotationGroupConfig) => isTitleValid(group.title);
export const GroupEditorControls = ({
group,
update,
setSelectedAnnotation: _setSelectedAnnotation,
selectedAnnotation,
TagSelector,
dataViews: globalDataViews,
createDataView,
queryInputServices,
showValidation,
}: {
group: EventAnnotationGroupConfig;
update: (group: EventAnnotationGroupConfig) => void;
selectedAnnotation: EventAnnotationConfig | undefined;
setSelectedAnnotation: (annotation: EventAnnotationConfig) => void;
TagSelector: SavedObjectsTaggingApiUiComponent['SavedObjectSaveModalTagSelector'];
dataViews: DataView[];
createDataView: (spec: DataViewSpec) => Promise<DataView>;
queryInputServices: QueryInputServices;
showValidation: boolean;
}) => {
// save the spec for the life of the component since the user might change their mind after selecting another data view
const [adHocDataView, setAdHocDataView] = useState<DataView>();
useEffect(() => {
if (group.dataViewSpec) {
createDataView(group.dataViewSpec).then(setAdHocDataView);
}
}, [createDataView, group.dataViewSpec]);
const setSelectedAnnotation = useCallback(
(newSelection: EventAnnotationConfig) => {
update({
...group,
annotations: group.annotations.map((annotation) =>
annotation.id === newSelection.id ? newSelection : annotation
),
});
_setSelectedAnnotation(newSelection);
},
[_setSelectedAnnotation, group, update]
);
const dataViews = useMemo(() => {
const items = [...globalDataViews];
if (adHocDataView) {
items.push(adHocDataView);
}
return items;
}, [adHocDataView, globalDataViews]);
const currentDataView = useMemo(
() => dataViews.find((dataView) => dataView.id === group.indexPatternId) || dataViews[0],
[dataViews, group.indexPatternId]
);
return !selectedAnnotation ? (
<>
<EuiTitle
size="xs"
css={css`
margin-bottom: ${euiThemeVars.euiSize};
`}
>
<h4>
<FormattedMessage id="eventAnnotation.groupEditor.details" defaultMessage="Details" />
</h4>
</EuiTitle>
<EuiForm>
<EuiFormRow
label={i18n.translate('eventAnnotation.groupEditor.title', {
defaultMessage: 'Title',
})}
isInvalid={showValidation && !isTitleValid(group.title)}
error={i18n.translate('eventAnnotation.groupEditor.titleRequired', {
defaultMessage: 'A title is required.',
})}
>
<EuiFieldText
data-test-subj="annotationGroupTitle"
value={group.title}
isInvalid={showValidation && !isTitleValid(group.title)}
onChange={({ target: { value } }) =>
update({
...group,
title: value,
})
}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('eventAnnotation.groupEditor.description', {
defaultMessage: 'Description',
})}
labelAppend={
<EuiText color="subdued" size="xs">
<FormattedMessage
id="eventAnnotation.groupEditor.optional"
defaultMessage="Optional"
/>
</EuiText>
}
>
<EuiTextArea
data-test-subj="annotationGroupDescription"
value={group.description}
onChange={({ target: { value } }) =>
update({
...group,
description: value,
})
}
/>
</EuiFormRow>
<EuiFormRow>
<TagSelector
initialSelection={group.tags}
markOptional
onTagsSelected={(tags: string[]) =>
update({
...group,
tags,
})
}
/>
</EuiFormRow>
{ENABLE_INDIVIDUAL_ANNOTATION_EDITING && (
<>
<EuiFormRow
label={i18n.translate('eventAnnotation.groupEditor.dataView', {
defaultMessage: 'Data view',
})}
>
<EuiSelect
data-test-subj="annotationDataViewSelection"
options={dataViews.map(({ id: value, title, name }) => ({
value,
text: name ?? title,
}))}
value={group.indexPatternId}
onChange={({ target: { value } }) =>
update({
...group,
indexPatternId: value,
dataViewSpec:
value === adHocDataView?.id ? adHocDataView.toSpec(false) : undefined,
})
}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('eventAnnotation.groupEditor.addAnnotation', {
defaultMessage: 'Annotations',
})}
>
<AnnotationList
annotations={group.annotations}
selectAnnotation={setSelectedAnnotation}
update={(newAnnotations) => update({ ...group, annotations: newAnnotations })}
/>
</EuiFormRow>
</>
)}
</EuiForm>
</>
) : (
<AnnotationEditorControls
annotation={selectedAnnotation}
onAnnotationChange={(changes) => setSelectedAnnotation({ ...selectedAnnotation, ...changes })}
dataView={currentDataView}
getDefaultRangeEnd={(rangeStart) => rangeStart}
queryInputServices={queryInputServices}
appName={EVENT_ANNOTATION_APP_NAME}
/>
);
};

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export * from './group_editor_controls';

View file

@ -0,0 +1,134 @@
/*
* 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 { EuiButton, EuiFlyout } from '@elastic/eui';
import { EventAnnotationGroupConfig, getDefaultManualAnnotation } from '../../common';
import { taggingApiMock } from '@kbn/saved-objects-tagging-oss-plugin/public/api.mock';
import { shallow, ShallowWrapper } from 'enzyme';
import React from 'react';
import { GroupEditorControls } from './group_editor_controls';
import { GroupEditorFlyout } from './group_editor_flyout';
import { DataView } from '@kbn/data-views-plugin/common';
import type { QueryInputServices } from '@kbn/visualization-ui-components/public';
const simulateButtonClick = (component: ShallowWrapper, selector: string) => {
(component.find(selector) as ShallowWrapper<Parameters<typeof EuiButton>[0]>).prop('onClick')!(
{} as any
);
};
const SELECTORS = {
SAVE_BUTTON: '[data-test-subj="saveAnnotationGroup"]',
CANCEL_BUTTON: '[data-test-subj="cancelGroupEdit"]',
BACK_BUTTON: '[data-test-subj="backToGroupSettings"]',
};
const assertGroupEditingState = (component: ShallowWrapper) => {
expect(component.exists(SELECTORS.SAVE_BUTTON)).toBeTruthy();
expect(component.exists(SELECTORS.CANCEL_BUTTON)).toBeTruthy();
expect(component.exists(SELECTORS.BACK_BUTTON)).toBeFalsy();
};
const assertAnnotationEditingState = (component: ShallowWrapper) => {
expect(component.exists(SELECTORS.BACK_BUTTON)).toBeTruthy();
expect(component.exists(SELECTORS.SAVE_BUTTON)).toBeFalsy();
expect(component.exists(SELECTORS.CANCEL_BUTTON)).toBeFalsy();
};
describe('group editor flyout', () => {
const annotation = getDefaultManualAnnotation('my-id', 'some-timestamp');
const group: EventAnnotationGroupConfig = {
annotations: [annotation],
description: '',
tags: [],
indexPatternId: 'some-id',
title: 'My group',
ignoreGlobalFilters: false,
};
const mockTaggingApi = taggingApiMock.create();
let component: ShallowWrapper;
let onSave: jest.Mock;
let onClose: jest.Mock;
let updateGroup: jest.Mock;
beforeEach(() => {
onSave = jest.fn();
onClose = jest.fn();
updateGroup = jest.fn();
component = shallow(
<GroupEditorFlyout
group={group}
onSave={onSave}
onClose={onClose}
updateGroup={updateGroup}
dataViews={[
{
id: 'some-id',
title: 'My Data View',
} as DataView,
]}
savedObjectsTagging={mockTaggingApi}
createDataView={jest.fn()}
queryInputServices={{} as QueryInputServices}
/>
);
});
it('renders controls', () => {
expect(component.find(GroupEditorControls).props()).toMatchSnapshot();
});
it('signals close', () => {
component.find(EuiFlyout).prop('onClose')({} as MouseEvent);
simulateButtonClick(component, SELECTORS.CANCEL_BUTTON);
expect(onClose).toHaveBeenCalledTimes(2);
});
it('signals save', () => {
simulateButtonClick(component, SELECTORS.SAVE_BUTTON);
expect(onSave).toHaveBeenCalledTimes(1);
});
it("doesn't save invalid group config", () => {
component.setProps({
group: { ...group, title: '' },
});
simulateButtonClick(component, SELECTORS.SAVE_BUTTON);
expect(onSave).not.toHaveBeenCalled();
});
it('reports group updates', () => {
const newGroup = { ...group, description: 'new description' };
component.find(GroupEditorControls).prop('update')(newGroup);
expect(updateGroup).toHaveBeenCalledWith(newGroup);
});
test('specific annotation editing', () => {
assertGroupEditingState(component);
component.find(GroupEditorControls).prop('setSelectedAnnotation')(annotation);
assertAnnotationEditingState(component);
component.find(SELECTORS.BACK_BUTTON).simulate('click');
assertGroupEditingState(component);
});
it('removes active annotation instead of signaling close', () => {
component.find(GroupEditorControls).prop('setSelectedAnnotation')(annotation);
assertAnnotationEditingState(component);
component.find(EuiFlyout).prop('onClose')({} as MouseEvent);
assertGroupEditingState(component);
});
});

View file

@ -0,0 +1,146 @@
/*
* 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 {
EuiFlyout,
EuiFlyoutHeader,
EuiTitle,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiButton,
htmlIdGenerator,
} from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public';
import { DataView, DataViewSpec } from '@kbn/data-views-plugin/common';
import type { QueryInputServices } from '@kbn/visualization-ui-components/public';
import { EventAnnotationConfig, EventAnnotationGroupConfig } from '../../common';
import { GroupEditorControls, isGroupValid } from './group_editor_controls';
export const GroupEditorFlyout = ({
group,
updateGroup,
onClose: parentOnClose,
onSave,
savedObjectsTagging,
dataViews,
createDataView,
queryInputServices,
}: {
group: EventAnnotationGroupConfig;
updateGroup: (newGroup: EventAnnotationGroupConfig) => void;
onClose: () => void;
onSave: () => void;
savedObjectsTagging: SavedObjectsTaggingApi;
dataViews: DataView[];
createDataView: (spec: DataViewSpec) => Promise<DataView>;
queryInputServices: QueryInputServices;
}) => {
const flyoutHeadingId = useMemo(() => htmlIdGenerator()(), []);
const flyoutBodyOverflowRef = useRef<Element | null>(null);
useEffect(() => {
if (!flyoutBodyOverflowRef.current) {
flyoutBodyOverflowRef.current = document.querySelector('.euiFlyoutBody__overflow');
}
}, []);
const [hasAttemptedSave, setHasAttemptedSave] = useState(false);
const resetContentScroll = useCallback(
() => flyoutBodyOverflowRef.current && flyoutBodyOverflowRef.current.scroll(0, 0),
[]
);
const [selectedAnnotation, _setSelectedAnnotation] = useState<EventAnnotationConfig>();
const setSelectedAnnotation = useCallback(
(newValue: EventAnnotationConfig | undefined) => {
if ((!newValue && selectedAnnotation) || (newValue && !selectedAnnotation))
resetContentScroll();
_setSelectedAnnotation(newValue);
},
[resetContentScroll, selectedAnnotation]
);
const onClose = () => (selectedAnnotation ? setSelectedAnnotation(undefined) : parentOnClose());
return (
<EuiFlyout onClose={onClose} size={'s'}>
<EuiFlyoutHeader hasBorder aria-labelledby={flyoutHeadingId}>
<EuiTitle size="s">
<h2 id={flyoutHeadingId}>
<FormattedMessage
id="eventAnnotation.groupEditorFlyout.title"
defaultMessage="Edit annotation group"
/>
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<GroupEditorControls
group={group}
update={updateGroup}
selectedAnnotation={selectedAnnotation}
setSelectedAnnotation={setSelectedAnnotation}
TagSelector={savedObjectsTagging.ui.components.SavedObjectSaveModalTagSelector}
dataViews={dataViews}
createDataView={createDataView}
queryInputServices={queryInputServices}
showValidation={hasAttemptedSave}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
{selectedAnnotation ? (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="arrowLeft"
data-test-subj="backToGroupSettings"
onClick={() => setSelectedAnnotation(undefined)}
>
<FormattedMessage id="eventAnnotation.edit.back" defaultMessage="Back" />
</EuiButtonEmpty>
</EuiFlexItem>
) : (
<>
<EuiFlexItem grow={false}>
<EuiButtonEmpty data-test-subj="cancelGroupEdit" onClick={onClose}>
<FormattedMessage id="eventAnnotation.edit.cancel" defaultMessage="Cancel" />
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
iconType="save"
data-test-subj="saveAnnotationGroup"
fill
onClick={() => {
setHasAttemptedSave(true);
if (isGroupValid(group)) {
onSave();
}
}}
>
<FormattedMessage
id="eventAnnotation.edit.save"
defaultMessage="Save annotation group"
/>
</EuiButton>
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
export { AnnotationEditorControls, annotationsIconSet } from './annotation_editor_controls';
export * from './group_editor_controls';
export * from './get_annotation_accessor';

View file

@ -0,0 +1,222 @@
/*
* 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 {
EventAnnotationGroupTableList,
SAVED_OBJECTS_LIMIT_SETTING,
SAVED_OBJECTS_PER_PAGE_SETTING,
} from './table_list';
import {
TableListViewTable,
type UserContentCommonSchema,
} from '@kbn/content-management-table-list-view-table';
import { EventAnnotationServiceType } from '../event_annotation_service/types';
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { shallow, ShallowWrapper } from 'enzyme';
import { EventAnnotationGroupConfig, EVENT_ANNOTATION_GROUP_TYPE } from '../../common';
import { taggingApiMock } from '@kbn/saved-objects-tagging-oss-plugin/public/mocks';
import { act } from 'react-dom/test-utils';
import { GroupEditorFlyout } from './group_editor_flyout';
import { DataView } from '@kbn/data-views-plugin/common';
import { QueryInputServices } from '@kbn/visualization-ui-components/public';
import { toastsServiceMock } from '@kbn/core-notifications-browser-mocks/src/toasts_service.mock';
import { IToasts } from '@kbn/core-notifications-browser';
describe('annotation list view', () => {
const adHocDVId = 'ad-hoc';
const group: EventAnnotationGroupConfig = {
annotations: [],
description: '',
tags: [],
indexPatternId: adHocDVId,
title: 'My group',
ignoreGlobalFilters: false,
dataViewSpec: {
id: adHocDVId,
title: 'Ad hoc data view',
},
};
let wrapper: ShallowWrapper<typeof EventAnnotationGroupTableList>;
let mockEventAnnotationService: EventAnnotationServiceType;
let mockToasts: IToasts;
beforeEach(() => {
mockEventAnnotationService = {
findAnnotationGroupContent: jest.fn(),
deleteAnnotationGroups: jest.fn(),
loadAnnotationGroup: jest.fn().mockResolvedValue(group),
updateAnnotationGroup: jest.fn(() => Promise.resolve()),
} as Partial<EventAnnotationServiceType> as EventAnnotationServiceType;
const mockUiSettings = {
get: jest.fn(
(key) =>
({
[SAVED_OBJECTS_LIMIT_SETTING]: 30,
[SAVED_OBJECTS_PER_PAGE_SETTING]: 10,
}[key])
),
} as Partial<IUiSettingsClient> as IUiSettingsClient;
mockToasts = toastsServiceMock.createStartContract();
wrapper = shallow<typeof EventAnnotationGroupTableList>(
<EventAnnotationGroupTableList
eventAnnotationService={mockEventAnnotationService}
savedObjectsTagging={taggingApiMock.create()}
uiSettings={mockUiSettings}
visualizeCapabilities={{
delete: true,
save: true,
}}
parentProps={{
onFetchSuccess: () => {},
setPageDataTestSubject: () => {},
}}
dataViews={[
{
id: 'some-id',
title: 'Some data view',
} as DataView,
]}
createDataView={() => Promise.resolve({} as DataView)}
queryInputServices={{} as QueryInputServices}
toasts={mockToasts}
navigateToLens={() => {}}
/>
);
});
it('searches for groups', () => {
const searchQuery = 'My Search Query';
const references = [{ id: 'first_id', type: 'sometype' }];
const referencesToExclude = [{ id: 'second_id', type: 'sometype' }];
wrapper.find(TableListViewTable).prop('findItems')(searchQuery, {
references,
referencesToExclude,
});
expect(mockEventAnnotationService.findAnnotationGroupContent).toHaveBeenCalledWith(
'My Search Query',
30,
[{ id: 'first_id', type: 'sometype' }],
[{ id: 'second_id', type: 'sometype' }]
);
});
describe('deleting groups', () => {
it('prevent deleting when user is missing perms', () => {
wrapper.setProps({ visualizeCapabilities: { delete: false } });
expect(wrapper.find(TableListViewTable).prop('deleteItems')).toBeUndefined();
});
it('deletes groups using the service', () => {
expect(wrapper.find(TableListViewTable).prop('deleteItems')).toBeDefined();
wrapper.find(TableListViewTable).prop('deleteItems')!([
{
id: 'some-id-1',
references: [
{
type: 'index-pattern',
name: 'metrics-*',
id: 'metrics-*',
},
],
type: EVENT_ANNOTATION_GROUP_TYPE,
updatedAt: '',
attributes: {
title: 'group1',
},
},
{
id: 'some-id-2',
references: [],
type: EVENT_ANNOTATION_GROUP_TYPE,
updatedAt: '',
attributes: {
title: 'group2',
},
},
]);
expect((mockEventAnnotationService.deleteAnnotationGroups as jest.Mock).mock.calls)
.toMatchInlineSnapshot(`
Array [
Array [
Array [
"some-id-1",
"some-id-2",
],
],
]
`);
});
});
describe('editing groups', () => {
it('prevents editing when user is missing perms', () => {
wrapper.setProps({ visualizeCapabilities: { save: false } });
expect(wrapper.find(TableListViewTable).prop('deleteItems')).toBeUndefined();
});
it('edits existing group', async () => {
expect(wrapper.find(GroupEditorFlyout).exists()).toBeFalsy();
const initialBouncerValue = wrapper.find(TableListViewTable).prop('refreshListBouncer');
act(() => {
wrapper.find(TableListViewTable).prop('editItem')!({
id: '1234',
} as UserContentCommonSchema);
});
// wait one tick to give promise time to settle
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mockEventAnnotationService.loadAnnotationGroup).toHaveBeenCalledWith('1234');
expect(wrapper.find(GroupEditorFlyout).exists()).toBeTruthy();
const updatedGroup = { ...group, tags: ['my-new-tag'] };
wrapper.find(GroupEditorFlyout).prop('updateGroup')(updatedGroup);
wrapper.find(GroupEditorFlyout).prop('onSave')();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mockEventAnnotationService.updateAnnotationGroup).toHaveBeenCalledWith(
updatedGroup,
'1234'
);
expect(wrapper.find(GroupEditorFlyout).exists()).toBeFalsy();
expect(wrapper.find(TableListViewTable).prop('refreshListBouncer')).not.toBe(
initialBouncerValue
); // (should refresh list)
});
it('opens editor when title is clicked', async () => {
act(() => {
wrapper.find(TableListViewTable).prop('onClickTitle')!({
id: '1234',
} as UserContentCommonSchema);
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(wrapper.find(GroupEditorFlyout).exists()).toBeTruthy();
});
});
});

View file

@ -0,0 +1,205 @@
/*
* 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, { useCallback, useState } from 'react';
import { TableListViewTable } from '@kbn/content-management-table-list-view-table';
import type { TableListTabParentProps } from '@kbn/content-management-tabbed-table-list-view';
import { i18n } from '@kbn/i18n';
import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { SavedObjectsFindOptionsReference } from '@kbn/core-saved-objects-api-browser';
import { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public';
import { DataView, DataViewSpec } from '@kbn/data-views-plugin/common';
import type { QueryInputServices } from '@kbn/visualization-ui-components/public';
import { IToasts } from '@kbn/core-notifications-browser';
import { EuiButton, EuiEmptyPrompt, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { EventAnnotationGroupConfig } from '../../common';
import type { EventAnnotationServiceType } from '../event_annotation_service/types';
import { EventAnnotationGroupContent } from '../../common/types';
import { GroupEditorFlyout } from './group_editor_flyout';
export const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit';
export const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage';
const getCustomColumn = (dataViews: DataView[]) => {
const dataViewNameMap = Object.fromEntries(
dataViews.map((dataView) => [dataView.id, dataView.name ?? dataView.title])
);
return {
field: 'dataView',
name: i18n.translate('eventAnnotation.tableList.dataView', {
defaultMessage: 'Data view',
}),
sortable: false,
width: '150px',
render: (_field: string, record: EventAnnotationGroupContent) => (
<div>
{record.attributes.dataViewSpec
? record.attributes.dataViewSpec.name
: dataViewNameMap[record.attributes.indexPatternId]}
</div>
),
};
};
export const EventAnnotationGroupTableList = ({
uiSettings,
eventAnnotationService,
visualizeCapabilities,
savedObjectsTagging,
parentProps,
dataViews,
createDataView,
queryInputServices,
toasts,
navigateToLens,
}: {
uiSettings: IUiSettingsClient;
eventAnnotationService: EventAnnotationServiceType;
visualizeCapabilities: Record<string, boolean | Record<string, boolean>>;
savedObjectsTagging: SavedObjectsTaggingApi;
parentProps: TableListTabParentProps;
dataViews: DataView[];
createDataView: (spec: DataViewSpec) => Promise<DataView>;
queryInputServices: QueryInputServices;
toasts: IToasts;
navigateToLens: () => void;
}) => {
const listingLimit = uiSettings.get(SAVED_OBJECTS_LIMIT_SETTING);
const initialPageSize = uiSettings.get(SAVED_OBJECTS_PER_PAGE_SETTING);
const [refreshListBouncer, setRefreshListBouncer] = useState(false);
const refreshList = useCallback(() => {
setRefreshListBouncer(!refreshListBouncer);
}, [refreshListBouncer]);
const fetchItems = useCallback(
(
searchTerm: string,
{
references,
referencesToExclude,
}: {
references?: SavedObjectsFindOptionsReference[];
referencesToExclude?: SavedObjectsFindOptionsReference[];
} = {}
) => {
// todo - allow page size changes
return eventAnnotationService.findAnnotationGroupContent(
searchTerm,
listingLimit, // TODO is this right?
references,
referencesToExclude
);
},
[eventAnnotationService, listingLimit]
);
const editItem = useCallback(
({ id }: EventAnnotationGroupContent) => {
if (visualizeCapabilities.save) {
eventAnnotationService
.loadAnnotationGroup(id)
.then((group) => setGroupToEditInfo({ group, id }));
}
},
[eventAnnotationService, visualizeCapabilities.save]
);
const [groupToEditInfo, setGroupToEditInfo] = useState<{
group: EventAnnotationGroupConfig;
id: string;
}>();
const flyout = groupToEditInfo ? (
<GroupEditorFlyout
group={groupToEditInfo.group}
updateGroup={(newGroup) => setGroupToEditInfo({ group: newGroup, id: groupToEditInfo.id })}
onClose={() => setGroupToEditInfo(undefined)}
onSave={() =>
(groupToEditInfo.id
? eventAnnotationService.updateAnnotationGroup(groupToEditInfo.group, groupToEditInfo.id)
: eventAnnotationService.createAnnotationGroup(groupToEditInfo.group)
).then(() => {
setGroupToEditInfo(undefined);
toasts.addSuccess(`Saved "${groupToEditInfo.group.title}"`);
refreshList();
})
}
savedObjectsTagging={savedObjectsTagging}
dataViews={dataViews}
createDataView={createDataView}
queryInputServices={queryInputServices}
/>
) : undefined;
return (
<>
<TableListViewTable<EventAnnotationGroupContent>
refreshListBouncer={refreshListBouncer}
tableCaption={i18n.translate('eventAnnotation.tableList.listTitle', {
defaultMessage: 'Annotation Library',
})}
findItems={fetchItems}
deleteItems={
visualizeCapabilities.delete
? (items) => eventAnnotationService.deleteAnnotationGroups(items.map(({ id }) => id))
: undefined
}
editItem={editItem}
listingLimit={listingLimit}
initialPageSize={initialPageSize}
initialFilter={''}
customTableColumn={getCustomColumn(dataViews)}
emptyPrompt={
<EuiEmptyPrompt
title={
<EuiTitle>
<h2>
<FormattedMessage
id="eventAnnotation.tableList.emptyPrompt.title"
defaultMessage="Create your first annotation in Lens"
/>
</h2>
</EuiTitle>
}
body={
<p>
<FormattedMessage
id="eventAnnotation.tableList.emptyPrompt.body"
defaultMessage="You can create and save annotations for use across multiple visualization in the
Lens visualization editor."
/>
</p>
}
actions={
<EuiButton onClick={navigateToLens}>
<FormattedMessage
id="eventAnnotation.tableList.emptyPrompt.cta"
defaultMessage="Create new annotation in Lens"
/>
</EuiButton>
}
iconType="flag"
/>
}
entityName={i18n.translate('eventAnnotation.tableList.entityName', {
defaultMessage: 'annotation group',
})}
entityNamePlural={i18n.translate('eventAnnotation.tableList.entityNamePlural', {
defaultMessage: 'annotation groups',
})}
onClickTitle={editItem}
{...parentProps}
/>
{flyout}
</>
);
};

View file

@ -1,5 +1,80 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Event Annotation Service findAnnotationGroupContent should retrieve saved objects and format them 1`] = `
Object {
"hits": Array [
Object {
"attributes": Object {
"dataViewSpec": undefined,
"description": undefined,
"indexPatternId": undefined,
"title": undefined,
},
"id": "nonExistingGroup",
"references": Array [],
"type": undefined,
"updatedAt": "",
},
Object {
"attributes": Object {
"dataViewSpec": undefined,
"description": "",
"indexPatternId": "ipid",
"title": "groupTitle",
},
"id": undefined,
"references": Array [
Object {
"id": "ipid",
"name": "ipid",
"type": "index-pattern",
},
Object {
"id": "some-tag",
"name": "some-tag",
"type": "tag",
},
],
"type": "event-annotation-group",
"updatedAt": "",
},
Object {
"attributes": Object {
"dataViewSpec": undefined,
"description": undefined,
"indexPatternId": "ipid",
"title": "groupTitle",
},
"id": "multiAnnotations",
"references": Array [
Object {
"id": "ipid",
"name": "ipid",
"type": "index-pattern",
},
],
"type": "event-annotation-group",
"updatedAt": "",
},
Object {
"attributes": Object {
"dataViewSpec": Object {
"id": "my-id",
},
"description": undefined,
"indexPatternId": "my-id",
"title": "groupTitle",
},
"id": "multiAnnotations",
"references": Array [],
"type": "event-annotation-group",
"updatedAt": "",
},
],
"total": 10,
}
`;
exports[`Event Annotation Service loadAnnotationGroup should properly load an annotation group with a multiple annotation 1`] = `
Object {
"annotations": undefined,
@ -7,7 +82,7 @@ Object {
"description": undefined,
"ignoreGlobalFilters": undefined,
"indexPatternId": "ipid",
"tags": undefined,
"tags": Array [],
"title": "groupTitle",
}
`;

View file

@ -5,7 +5,6 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { euiLightVars } from '@kbn/ui-theme';
import {
EventAnnotationConfig,
@ -17,13 +16,6 @@ export const defaultAnnotationColor = euiLightVars.euiColorAccent;
// Do not compute it live as dependencies will add tens of Kbs to the plugin
export const defaultAnnotationRangeColor = `#F04E981A`; // defaultAnnotationColor with opacity 0.1
export const defaultAnnotationLabel = i18n.translate(
'eventAnnotation.manualAnnotation.defaultAnnotationLabel',
{
defaultMessage: 'Event',
}
);
export const isRangeAnnotationConfig = (
annotation?: EventAnnotationConfig
): annotation is RangeEventAnnotationConfig => {

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import { SavedObjectsFindResponse } from '@kbn/core-saved-objects-api-browser';
import { CoreStart, SimpleSavedObject } from '@kbn/core/public';
import { coreMock } from '@kbn/core/public/mocks';
import { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public';
@ -41,6 +42,11 @@ const annotationGroupResolveMocks: Record<string, AnnotationGroupSavedObject> =
name: 'ipid',
type: 'index-pattern',
},
{
id: 'some-tag',
name: 'some-tag',
type: 'tag',
},
],
} as Partial<AnnotationGroupSavedObject> as AnnotationGroupSavedObject,
multiAnnotations: {
@ -138,6 +144,10 @@ describe('Event Annotation Service', () => {
const typedId = id as keyof typeof annotationGroupResolveMocks;
return annotationGroupResolveMocks[typedId];
});
(core.savedObjects.client.find as jest.Mock).mockResolvedValue({
total: 10,
savedObjects: Object.values(annotationGroupResolveMocks),
} as Pick<SavedObjectsFindResponse<EventAnnotationGroupAttributes>, 'total' | 'savedObjects'>);
(core.savedObjects.client.bulkCreate as jest.Mock).mockImplementation(() => {
return annotationResolveMocks.multiAnnotations;
});
@ -474,7 +484,9 @@ describe('Event Annotation Service', () => {
"description": "",
"ignoreGlobalFilters": false,
"indexPatternId": "ipid",
"tags": Array [],
"tags": Array [
"some-tag",
],
"title": "groupTitle",
}
`);
@ -490,16 +502,53 @@ describe('Event Annotation Service', () => {
expect(group.indexPatternId).toBe(group.dataViewSpec?.id);
});
});
// describe.skip('deleteAnnotationGroup', () => {
// it('deletes annotation group along with annotations that reference them', async () => {
// await eventAnnotationService.deleteAnnotationGroup('multiAnnotations');
// expect(core.savedObjects.client.bulkDelete).toHaveBeenCalledWith([
// { id: 'multiAnnotations', type: 'event-annotation-group' },
// { id: 'annotation1', type: 'event-annotation' },
// { id: 'annotation2', type: 'event-annotation' },
// ]);
// });
// });
describe('findAnnotationGroupContent', () => {
it('should retrieve saved objects and format them', async () => {
const searchTerm = 'my search';
const content = await eventAnnotationService.findAnnotationGroupContent(searchTerm, 20, [
{ type: 'mytype', id: '1234' },
]);
expect(content).toMatchSnapshot();
expect((core.savedObjects.client.find as jest.Mock).mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"defaultSearchOperator": "AND",
"hasNoReference": undefined,
"hasReference": Array [
Object {
"id": "1234",
"type": "mytype",
},
],
"page": 1,
"perPage": 20,
"search": "my search*",
"searchFields": Array [
"title^3",
"description",
],
"type": Array [
"event-annotation-group",
],
},
],
]
`);
});
});
describe('deleteAnnotationGroups', () => {
it('deletes annotation group along with annotations that reference them', async () => {
await eventAnnotationService.deleteAnnotationGroups(['id1', 'id2']);
expect(core.savedObjects.client.bulkDelete).toHaveBeenCalledWith([
{ id: 'id1', type: 'event-annotation-group' },
{ id: 'id2', type: 'event-annotation-group' },
]);
});
});
describe('createAnnotationGroup', () => {
it('creates annotation group along with annotations', async () => {
const annotations = [
@ -509,7 +558,7 @@ describe('Event Annotation Service', () => {
await eventAnnotationService.createAnnotationGroup({
title: 'newGroupTitle',
description: 'my description',
tags: ['my', 'many', 'tags'],
tags: ['tag1', 'tag2', 'tag3'],
indexPatternId: 'ipid',
ignoreGlobalFilters: false,
annotations,
@ -519,7 +568,6 @@ describe('Event Annotation Service', () => {
{
title: 'newGroupTitle',
description: 'my description',
tags: ['my', 'many', 'tags'],
ignoreGlobalFilters: false,
dataViewSpec: null,
annotations,
@ -531,6 +579,21 @@ describe('Event Annotation Service', () => {
name: 'event-annotation-group_dataView-ref-ipid',
type: 'index-pattern',
},
{
id: 'tag1',
name: 'tag1',
type: 'tag',
},
{
id: 'tag2',
name: 'tag2',
type: 'tag',
},
{
id: 'tag3',
name: 'tag3',
type: 'tag',
},
],
}
);
@ -555,11 +618,10 @@ describe('Event Annotation Service', () => {
{
title: 'newTitle',
description: '',
tags: [],
annotations: [],
dataViewSpec: null,
ignoreGlobalFilters: false,
},
} as EventAnnotationGroupAttributes,
{
references: [
{
@ -572,72 +634,4 @@ describe('Event Annotation Service', () => {
);
});
});
// describe.skip('updateAnnotations', () => {
// const upsert = [
// {
// id: 'annotation2',
// label: 'Query based event',
// icon: 'triangle',
// color: 'red',
// type: 'query',
// timeField: 'timestamp',
// key: {
// type: 'point_in_time',
// },
// lineStyle: 'dashed',
// lineWidth: 3,
// filter: { type: 'kibana_query', query: '', language: 'kuery' },
// },
// {
// id: 'annotation4',
// label: 'Query based event',
// type: 'query',
// timeField: 'timestamp',
// key: {
// type: 'point_in_time',
// },
// filter: { type: 'kibana_query', query: '', language: 'kuery' },
// },
// ] as EventAnnotationConfig[];
// it('updates annotations - deletes annotations', async () => {
// await eventAnnotationService.updateAnnotations('multiAnnotations', {
// delete: ['annotation1', 'annotation2'],
// });
// expect(core.savedObjects.client.bulkDelete).toHaveBeenCalledWith([
// { id: 'annotation1', type: 'event-annotation' },
// { id: 'annotation2', type: 'event-annotation' },
// ]);
// });
// it('updates annotations - inserts new annotations', async () => {
// await eventAnnotationService.updateAnnotations('multiAnnotations', { upsert });
// expect(core.savedObjects.client.bulkCreate).toHaveBeenCalledWith([
// {
// id: 'annotation2',
// type: 'event-annotation',
// attributes: upsert[0],
// overwrite: true,
// references: [
// {
// id: 'multiAnnotations',
// name: 'event-annotation-group-ref-annotation2',
// type: 'event-annotation-group',
// },
// ],
// },
// {
// id: 'annotation4',
// type: 'event-annotation',
// attributes: upsert[1],
// overwrite: true,
// references: [
// {
// id: 'multiAnnotations',
// name: 'event-annotation-group-ref-annotation4',
// type: 'event-annotation-group',
// },
// ],
// },
// ]);
// });
// });
});

View file

@ -10,9 +10,18 @@ import React from 'react';
import { partition } from 'lodash';
import { queryToAst } from '@kbn/data-plugin/common';
import { ExpressionAstExpression } from '@kbn/expressions-plugin/common';
import { CoreStart, SavedObjectReference, SavedObjectsClientContract } from '@kbn/core/public';
import {
CoreStart,
SavedObjectReference,
SavedObjectsClientContract,
SavedObjectsFindOptions,
SavedObjectsFindOptionsReference,
SimpleSavedObject,
} from '@kbn/core/public';
import { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public';
import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common';
import { defaultAnnotationLabel } from '../../common/manual_event_annotation';
import { EventAnnotationGroupContent } from '../../common/types';
import {
EventAnnotationConfig,
EventAnnotationGroupAttributes,
@ -23,7 +32,6 @@ import { EventAnnotationServiceType } from './types';
import {
defaultAnnotationColor,
defaultAnnotationRangeColor,
defaultAnnotationLabel,
isRangeAnnotationConfig,
isQueryAnnotationConfig,
} from './helpers';
@ -39,6 +47,48 @@ export function getEventAnnotationService(
): EventAnnotationServiceType {
const client: SavedObjectsClientContract = core.savedObjects.client;
const mapSavedObjectToGroupConfig = (
savedObject: SimpleSavedObject<EventAnnotationGroupAttributes>
): EventAnnotationGroupConfig => {
const adHocDataViewSpec = savedObject.attributes.dataViewSpec
? DataViewPersistableStateService.inject(
savedObject.attributes.dataViewSpec,
savedObject.references
)
: undefined;
return {
title: savedObject.attributes.title,
description: savedObject.attributes.description,
tags: savedObject.references.filter((ref) => ref.type === 'tag').map(({ id }) => id),
ignoreGlobalFilters: savedObject.attributes.ignoreGlobalFilters,
indexPatternId: adHocDataViewSpec
? adHocDataViewSpec.id!
: savedObject.references.find((ref) => ref.type === 'index-pattern')?.id!,
annotations: savedObject.attributes.annotations,
dataViewSpec: adHocDataViewSpec,
};
};
const mapSavedObjectToGroupContent = (
savedObject: SimpleSavedObject<EventAnnotationGroupAttributes>
): EventAnnotationGroupContent => {
const groupConfig = mapSavedObjectToGroupConfig(savedObject);
return {
id: savedObject.id,
references: savedObject.references,
type: savedObject.type,
updatedAt: savedObject.updatedAt ? savedObject.updatedAt : '',
attributes: {
title: groupConfig.title,
description: groupConfig.description,
indexPatternId: groupConfig.indexPatternId,
dataViewSpec: groupConfig.dataViewSpec,
},
};
};
const loadAnnotationGroup = async (
savedObjectId: string
): Promise<EventAnnotationGroupConfig> => {
@ -51,26 +101,40 @@ export function getEventAnnotationService(
throw savedObject.error;
}
const adHocDataViewSpec = savedObject.attributes.dataViewSpec
? DataViewPersistableStateService.inject(
savedObject.attributes.dataViewSpec,
savedObject.references
)
: undefined;
return mapSavedObjectToGroupConfig(savedObject);
};
const findAnnotationGroupContent = async (
searchTerm: string,
pageSize: number,
references?: SavedObjectsFindOptionsReference[],
referencesToExclude?: SavedObjectsFindOptionsReference[]
): Promise<{ total: number; hits: EventAnnotationGroupContent[] }> => {
const searchOptions: SavedObjectsFindOptions = {
type: [EVENT_ANNOTATION_GROUP_TYPE],
searchFields: ['title^3', 'description'],
search: searchTerm ? `${searchTerm}*` : undefined,
perPage: pageSize,
page: 1,
defaultSearchOperator: 'AND' as const,
hasReference: references,
hasNoReference: referencesToExclude,
};
const { total, savedObjects } = await client.find<EventAnnotationGroupAttributes>(
searchOptions
);
return {
title: savedObject.attributes.title,
description: savedObject.attributes.description,
tags: savedObject.attributes.tags,
ignoreGlobalFilters: savedObject.attributes.ignoreGlobalFilters,
indexPatternId: adHocDataViewSpec
? adHocDataViewSpec.id!
: savedObject.references.find((ref) => ref.type === 'index-pattern')?.id!,
annotations: savedObject.attributes.annotations,
dataViewSpec: adHocDataViewSpec,
total,
hits: savedObjects.map(mapSavedObjectToGroupContent),
};
};
const deleteAnnotationGroups = async (ids: string[]): Promise<void> => {
await client.bulkDelete([...ids.map((id) => ({ type: EVENT_ANNOTATION_GROUP_TYPE, id }))]);
};
const extractDataViewInformation = (group: EventAnnotationGroupConfig) => {
let { dataViewSpec = null } = group;
@ -99,20 +163,35 @@ export function getEventAnnotationService(
return { references, dataViewSpec };
};
const createAnnotationGroup = async (
const getAnnotationGroupAttributesAndReferences = (
group: EventAnnotationGroupConfig
): Promise<{ id: string }> => {
): { attributes: EventAnnotationGroupAttributes; references: SavedObjectReference[] } => {
const { references, dataViewSpec } = extractDataViewInformation(group);
const { title, description, tags, ignoreGlobalFilters, annotations } = group;
references.push(
...tags.map((tag) => ({
id: tag,
name: tag,
type: 'tag',
}))
);
return {
attributes: { title, description, ignoreGlobalFilters, annotations, dataViewSpec },
references,
};
};
const createAnnotationGroup = async (
group: EventAnnotationGroupConfig
): Promise<{ id: string }> => {
const { attributes, references } = getAnnotationGroupAttributesAndReferences(group);
const groupSavedObjectId = (
await client.create(
EVENT_ANNOTATION_GROUP_TYPE,
{ title, description, tags, ignoreGlobalFilters, annotations, dataViewSpec },
{
references,
}
)
await client.create(EVENT_ANNOTATION_GROUP_TYPE, attributes, {
references,
})
).id;
return { id: groupSavedObjectId };
@ -122,17 +201,11 @@ export function getEventAnnotationService(
group: EventAnnotationGroupConfig,
annotationGroupId: string
): Promise<void> => {
const { references, dataViewSpec } = extractDataViewInformation(group);
const { title, description, tags, ignoreGlobalFilters, annotations } = group;
const { attributes, references } = getAnnotationGroupAttributesAndReferences(group);
await client.update(
EVENT_ANNOTATION_GROUP_TYPE,
annotationGroupId,
{ title, description, tags, ignoreGlobalFilters, annotations, dataViewSpec },
{
references,
}
);
await client.update(EVENT_ANNOTATION_GROUP_TYPE, annotationGroupId, attributes, {
references,
});
};
const checkHasAnnotationGroups = async (): Promise<boolean> => {
@ -148,6 +221,8 @@ export function getEventAnnotationService(
loadAnnotationGroup,
updateAnnotationGroup,
createAnnotationGroup,
deleteAnnotationGroups,
findAnnotationGroupContent,
renderEventAnnotationGroupSavedObjectFinder: (props) => {
return (
<EventAnnotationGroupSavedObjectFinder

View file

@ -7,11 +7,20 @@
*/
import { ExpressionAstExpression } from '@kbn/expressions-plugin/common/ast';
import { SavedObjectsFindOptionsReference } from '@kbn/core-saved-objects-api-browser';
import type { SavedObjectCommon } from '@kbn/saved-objects-finder-plugin/common';
import { EventAnnotationGroupContent } from '../../common/types';
import { EventAnnotationConfig, EventAnnotationGroupConfig } from '../../common';
export interface EventAnnotationServiceType {
loadAnnotationGroup: (savedObjectId: string) => Promise<EventAnnotationGroupConfig>;
findAnnotationGroupContent: (
searchTerm: string,
pageSize: number,
references?: SavedObjectsFindOptionsReference[],
referencesToExclude?: SavedObjectsFindOptionsReference[]
) => Promise<{ total: number; hits: EventAnnotationGroupContent[] }>;
deleteAnnotationGroups: (ids: string[]) => Promise<void>;
createAnnotationGroup: (group: EventAnnotationGroupConfig) => Promise<{ id: string }>;
updateAnnotationGroup: (
group: EventAnnotationGroupConfig,

View file

@ -0,0 +1,61 @@
/*
* 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, { FC } from 'react';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { FormattedRelative } from '@kbn/i18n-react';
import { TableListViewKibanaProvider } from '@kbn/content-management-table-list-view-table';
import { type TableListTabParentProps } from '@kbn/content-management-tabbed-table-list-view';
import type { CoreStart } from '@kbn/core-lifecycle-browser';
import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public';
import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common';
import type { QueryInputServices } from '@kbn/visualization-ui-components/public';
import { RootDragDropProvider } from '@kbn/dom-drag-drop';
import type { EventAnnotationServiceType } from './event_annotation_service/types';
import { EventAnnotationGroupTableList } from './components/table_list';
export interface EventAnnotationListingPageServices {
core: CoreStart;
savedObjectsTagging: SavedObjectsTaggingApi;
eventAnnotationService: EventAnnotationServiceType;
PresentationUtilContextProvider: FC;
dataViews: DataView[];
createDataView: (spec: DataViewSpec) => Promise<DataView>;
queryInputServices: QueryInputServices;
}
export const getTableList = (
parentProps: TableListTabParentProps,
services: EventAnnotationListingPageServices
) => {
return (
<RootDragDropProvider>
<TableListViewKibanaProvider
{...{
core: services.core,
toMountPoint,
savedObjectsTagging: services.savedObjectsTagging,
FormattedRelative,
}}
>
<EventAnnotationGroupTableList
toasts={services.core.notifications.toasts}
savedObjectsTagging={services.savedObjectsTagging}
uiSettings={services.core.uiSettings}
eventAnnotationService={services.eventAnnotationService}
visualizeCapabilities={services.core.application.capabilities.visualize}
parentProps={parentProps}
dataViews={services.dataViews}
createDataView={services.createDataView}
queryInputServices={services.queryInputServices}
navigateToLens={() => services.core.application.navigateToApp('lens')}
/>
</TableListViewKibanaProvider>
</RootDragDropProvider>
);
};

View file

@ -21,3 +21,8 @@ export {
isManualPointAnnotationConfig,
isQueryAnnotationConfig,
} from './event_annotation_service/helpers';
export {
AnnotationEditorControls,
annotationsIconSet,
} from './components/annotation_editor_controls';
export { getAnnotationAccessor } from './components/get_annotation_accessor';

View file

@ -6,10 +6,17 @@
* Side Public License, v 1.
*/
import { Plugin, CoreSetup, CoreStart } from '@kbn/core/public';
import { ExpressionsSetup } from '@kbn/expressions-plugin/public';
import { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { Plugin, CoreSetup, CoreStart } from '@kbn/core/public';
import type { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public';
import type { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public';
import type { ExpressionsSetup } from '@kbn/expressions-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import type { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public/types';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { VisualizationsSetup } from '@kbn/visualizations-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { i18n } from '@kbn/i18n';
import { EventAnnotationService } from './event_annotation_service';
import {
manualPointEventAnnotation,
@ -18,14 +25,21 @@ import {
eventAnnotationGroup,
} from '../common';
import { getFetchEventAnnotations } from './fetch_event_annotations';
import type { EventAnnotationListingPageServices } from './get_table_list';
import { ANNOTATIONS_LISTING_VIEW_ID } from '../common/constants';
export interface EventAnnotationStartDependencies {
savedObjectsManagement: SavedObjectsManagementPluginStart;
data: DataPublicPluginStart;
savedObjectsTagging: SavedObjectTaggingPluginStart;
presentationUtil: PresentationUtilPluginStart;
dataViews: DataViewsPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
}
interface SetupDependencies {
expressions: ExpressionsSetup;
visualizations: VisualizationsSetup;
}
/** @public */
@ -47,6 +61,46 @@ export class EventAnnotationPlugin
dependencies.expressions.registerFunction(
getFetchEventAnnotations({ getStartServices: core.getStartServices })
);
dependencies.visualizations.listingViewRegistry.add({
title: i18n.translate('eventAnnotation.listingViewTitle', {
defaultMessage: 'Annotation groups',
}),
id: ANNOTATIONS_LISTING_VIEW_ID,
getTableList: async (props) => {
const [coreStart, pluginsStart] = await core.getStartServices();
const eventAnnotationService = await new EventAnnotationService(
coreStart,
pluginsStart.savedObjectsManagement
).getService();
const ids = await pluginsStart.dataViews.getIds();
const dataViews = await Promise.all(ids.map((id) => pluginsStart.dataViews.get(id)));
const services: EventAnnotationListingPageServices = {
core: coreStart,
savedObjectsTagging: pluginsStart.savedObjectsTagging,
eventAnnotationService,
PresentationUtilContextProvider: pluginsStart.presentationUtil.ContextProvider,
dataViews,
createDataView: pluginsStart.dataViews.create.bind(pluginsStart.dataViews),
queryInputServices: {
http: coreStart.http,
docLinks: coreStart.docLinks,
notifications: coreStart.notifications,
uiSettings: coreStart.uiSettings,
dataViews: pluginsStart.dataViews,
unifiedSearch: pluginsStart.unifiedSearch,
data: pluginsStart.data,
storage: new Storage(localStorage),
},
};
const { getTableList } = await import('./get_table_list');
return getTableList(props, services);
},
});
}
public start(

View file

@ -16,7 +16,6 @@ import {
queryPointEventAnnotation,
} from '../common';
import { setupSavedObjects } from './saved_objects';
// import { getFetchEventAnnotations } from './fetch_event_annotations';
interface SetupDependencies {
expressions: ExpressionsServerSetup;

View file

@ -14,7 +14,8 @@ import {
} from '@kbn/core/server';
import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common';
import { EVENT_ANNOTATION_GROUP_TYPE } from '../common/constants';
import { VISUALIZE_APP_NAME } from '@kbn/visualizations-plugin/common/constants';
import { ANNOTATIONS_LISTING_VIEW_ID, EVENT_ANNOTATION_GROUP_TYPE } from '../common/constants';
import { EventAnnotationGroupAttributes } from '../common/types';
export function setupSavedObjects(coreSetup: CoreSetup) {
@ -28,6 +29,11 @@ export function setupSavedObjects(coreSetup: CoreSetup) {
defaultSearchField: 'title',
importableAndExportable: true,
getTitle: (obj: { attributes: EventAnnotationGroupAttributes }) => obj.attributes.title,
getInAppUrl: (obj: { id: string }) => ({
// TODO link to specific object
path: `/app/${VISUALIZE_APP_NAME}#/${ANNOTATIONS_LISTING_VIEW_ID}`,
uiCapabilitiesPath: 'visualize.show',
}),
},
migrations: () => {
const dataViewMigrations = DataViewPersistableStateService.getAllMigrations();

View file

@ -21,8 +21,29 @@
"@kbn/ui-theme",
"@kbn/saved-objects-finder-plugin",
"@kbn/saved-objects-management-plugin",
"@kbn/saved-objects-tagging-plugin",
"@kbn/presentation-util-plugin",
"@kbn/content-management-table-list-view",
"@kbn/visualizations-plugin",
"@kbn/data-views-plugin",
"@kbn/visualization-ui-components",
"@kbn/chart-icons",
"@kbn/unified-field-list-plugin",
"@kbn/dom-drag-drop",
"@kbn/i18n-react",
"@kbn/core-saved-objects-server"
"@kbn/core-saved-objects-server",
"@kbn/test-jest-helpers",
"@kbn/saved-objects-tagging-oss-plugin",
"@kbn/core-saved-objects-api-browser",
"@kbn/kibana-react-plugin",
"@kbn/core-lifecycle-browser",
"@kbn/kibana-utils-plugin",
"@kbn/unified-search-plugin",
"@kbn/content-management-table-list-view",
"@kbn/content-management-table-list-view-table",
"@kbn/content-management-tabbed-table-list-view",
"@kbn/core-notifications-browser",
"@kbn/core-notifications-browser-mocks",
],
"exclude": [
"target/**/*",

Some files were not shown because too many files have changed in this diff Show more