mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Lens][Visualizations] library annotation groups listing page (#157988)
This commit is contained in:
parent
44bfd0c343
commit
6553ebbdd5
176 changed files with 4818 additions and 2423 deletions
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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.
|
11
packages/content-management/tabbed_table_list_view/index.ts
Normal file
11
packages/content-management/tabbed_table_list_view/index.ts
Normal 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';
|
|
@ -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'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/content-management-tabbed-table-list-view",
|
||||
"owner": "@elastic/appex-sharedux"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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/**/*",
|
||||
]
|
||||
}
|
12
packages/content-management/table_list_view/index.ts
Normal file
12
packages/content-management/table_list_view/index.ts
Normal 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';
|
|
@ -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'],
|
||||
};
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/content-management-table-list",
|
||||
"id": "@kbn/content-management-table-list-view",
|
||||
"owner": "@elastic/appex-sharedux"
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
@ -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;
|
25
packages/content-management/table_list_view/tsconfig.json
Normal file
25
packages/content-management/table_list_view/tsconfig.json
Normal 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/**/*",
|
||||
]
|
||||
}
|
20
packages/content-management/table_list_view_table/README.mdx
Normal file
20
packages/content-management/table_list_view_table/README.mdx
Normal 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.
|
|
@ -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';
|
|
@ -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'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/content-management-table-list-view-table",
|
||||
"owner": "@elastic/appex-sharedux"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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 {
|
|
@ -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> {
|
|
@ -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
|
|
@ -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;
|
|
@ -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';
|
||||
|
|
@ -82,7 +82,7 @@ export const getStoryServices = (params: Params, action: ActionFn = () => {}) =>
|
|||
* consuming component stories.
|
||||
*/
|
||||
export const getStoryArgTypes = () => ({
|
||||
tableListTitle: {
|
||||
title: {
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
|
@ -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>() {
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
```
|
||||
|
||||
|
|
|
@ -41,7 +41,7 @@ pageLoadAssetSize:
|
|||
enterpriseSearch: 35741
|
||||
essSecurity: 16573
|
||||
esUiShared: 326654
|
||||
eventAnnotation: 22000
|
||||
eventAnnotation: 48565
|
||||
exploratoryView: 74673
|
||||
expressionError: 22127
|
||||
expressionGauge: 25000
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
6
src/plugins/event_annotation/.i18nrc.json
Executable file
6
src/plugins/event_annotation/.i18nrc.json
Executable file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"prefix": "eventAnnotations",
|
||||
"paths": {
|
||||
"eventAnnotations": "."
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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}: *`,
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'],
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
|
|
38
src/plugins/event_annotation/public/components/__snapshots__/group_editor_flyout.test.tsx.snap
generated
Normal file
38
src/plugins/event_annotation/public/components/__snapshots__/group_editor_flyout.test.tsx.snap
generated
Normal 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],
|
||||
}
|
||||
`;
|
|
@ -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;
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
];
|
|
@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
|
@ -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',
|
||||
})}
|
||||
>
|
|
@ -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}
|
|
@ -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',
|
||||
}
|
|
@ -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;
|
|
@ -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),
|
||||
};
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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),
|
||||
};
|
||||
};
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
13
src/plugins/event_annotation/public/components/index.ts
Normal file
13
src/plugins/event_annotation/public/components/index.ts
Normal 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';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
205
src/plugins/event_annotation/public/components/table_list.tsx
Normal file
205
src/plugins/event_annotation/public/components/table_list.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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",
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// ]);
|
||||
// });
|
||||
// });
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
61
src/plugins/event_annotation/public/get_table_list.tsx
Normal file
61
src/plugins/event_annotation/public/get_table_list.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -16,7 +16,6 @@ import {
|
|||
queryPointEventAnnotation,
|
||||
} from '../common';
|
||||
import { setupSavedObjects } from './saved_objects';
|
||||
// import { getFetchEventAnnotations } from './fetch_event_annotations';
|
||||
|
||||
interface SetupDependencies {
|
||||
expressions: ExpressionsServerSetup;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue