[Lens] library annotation groups (#152623)

This commit is contained in:
Drew Tate 2023-05-31 15:41:21 -05:00 committed by GitHub
parent 15b31c62ba
commit f630d90697
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
111 changed files with 5104 additions and 923 deletions

View file

@ -6,7 +6,6 @@
* Side Public License, v 1.
*/
import { deepFreeze } from '@kbn/std';
import type { PublicMethodsOf } from '@kbn/utility-types';
import type {
CapabilitiesStart,
@ -14,11 +13,11 @@ import type {
} from '@kbn/core-capabilities-browser-internal';
const createStartContractMock = (): jest.Mocked<CapabilitiesStart> => ({
capabilities: deepFreeze({
capabilities: {
catalogue: {},
management: {},
navLinks: {},
}),
},
});
const createMock = (): jest.Mocked<PublicMethodsOf<CapabilitiesService>> => ({

View file

@ -12,7 +12,6 @@
"**/*.tsx",
],
"kbn_references": [
"@kbn/std",
"@kbn/utility-types",
"@kbn/core-capabilities-browser-internal"
],

View file

@ -931,6 +931,17 @@
}
}
},
"event-annotation-group": {
"dynamic": false,
"properties": {
"title": {
"type": "text"
},
"description": {
"type": "text"
}
}
},
"visualization": {
"dynamic": false,
"properties": {

View file

@ -39,7 +39,7 @@ pageLoadAssetSize:
embeddableEnhanced: 22107
enterpriseSearch: 35741
esUiShared: 326654
eventAnnotation: 20500
eventAnnotation: 22000
exploratoryView: 74673
expressionError: 22127
expressionGauge: 25000

View file

@ -86,6 +86,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"enterprise_search_telemetry": "9ac912e1417fc8681e0cd383775382117c9e3d3d",
"epm-packages": "2449bb565f987eff70b1b39578bb17e90c404c6e",
"epm-packages-assets": "7a3e58efd9a14191d0d1a00b8aaed30a145fd0b1",
"event-annotation-group": "715ba867d8c68f3c9438052210ea1c30a9362582",
"event_loop_delays_daily": "01b967e8e043801357503de09199dfa3853bab88",
"exception-list": "4aebc4e61fb5d608cae48eaeb0977e8db21c61a4",
"exception-list-agnostic": "6d3262d58eee28ac381ec9654f93126a58be6f5d",

View file

@ -201,6 +201,7 @@ describe('split .kibana index into multiple system indices', () => {
"enterprise_search_telemetry",
"epm-packages",
"epm-packages-assets",
"event-annotation-group",
"event_loop_delays_daily",
"exception-list",
"exception-list-agnostic",

View file

@ -43,6 +43,7 @@ const previouslyRegisteredTypes = [
'csp-rule-template',
'csp_rule',
'dashboard',
'event-annotation-group',
'endpoint:user-artifact',
'endpoint:user-artifact-manifest',
'enterprise_search_telemetry',

View file

@ -12,7 +12,7 @@ import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { ChartsPluginStart } from '@kbn/charts-plugin/public';
import { CoreSetup, CoreStart, IUiSettingsClient } from '@kbn/core/public';
import { EventAnnotationPluginSetup } from '@kbn/event-annotation-plugin/public';
import type { EventAnnotationPluginStart } from '@kbn/event-annotation-plugin/public';
import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import { ExpressionXyPluginSetup, ExpressionXyPluginStart, SetupDeps } from './types';
import {
@ -37,7 +37,7 @@ export interface XYPluginStartDependencies {
data: DataPublicPluginStart;
fieldFormats: FieldFormatsStart;
charts: ChartsPluginStart;
eventAnnotation: EventAnnotationPluginSetup;
eventAnnotation: EventAnnotationPluginStart;
usageCollection?: UsageCollectionStart;
}

View file

@ -2,32 +2,13 @@
exports[`renders DashboardSaveModal 1`] = `
<SavedObjectSaveModal
description="dash description"
initialCopyOnSave={true}
objectType="dashboard"
onClose={[Function]}
onSave={[Function]}
options={
<React.Fragment>
<EuiFormRow
describedByIds={Array []}
display="row"
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Description"
id="dashboard.topNav.saveModal.descriptionFormRowLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiTextArea
data-test-subj="dashboardDescription"
onChange={[Function]}
value="dash description"
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
@ -58,7 +39,7 @@ exports[`renders DashboardSaveModal 1`] = `
</React.Fragment>
}
showCopyOnSave={true}
showDescription={false}
showDescription={true}
title="dash title"
/>
`;

View file

@ -9,7 +9,7 @@
import { i18n } from '@kbn/i18n';
import React, { Fragment } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiFormRow, EuiTextArea, EuiSwitch } from '@elastic/eui';
import { EuiFormRow, EuiSwitch } from '@elastic/eui';
import { SavedObjectSaveModal } from '@kbn/saved-objects-plugin/public';
import type { DashboardSaveOptions } from '../../../types';
@ -39,14 +39,12 @@ interface Props {
}
interface State {
description: string;
tags: string[];
timeRestore: boolean;
}
export class DashboardSaveModal extends React.Component<Props, State> {
state: State = {
description: this.props.description,
timeRestore: this.props.timeRestore,
tags: this.props.tags ?? [],
};
@ -57,18 +55,20 @@ export class DashboardSaveModal extends React.Component<Props, State> {
saveDashboard = ({
newTitle,
newDescription,
newCopyOnSave,
isTitleDuplicateConfirmed,
onTitleDuplicate,
}: {
newTitle: string;
newDescription: string;
newCopyOnSave: boolean;
isTitleDuplicateConfirmed: boolean;
onTitleDuplicate: () => void;
}) => {
this.props.onSave({
newTitle,
newDescription: this.state.description,
newDescription,
newCopyOnSave,
newTimeRestore: this.state.timeRestore,
isTitleDuplicateConfirmed,
@ -77,12 +77,6 @@ export class DashboardSaveModal extends React.Component<Props, State> {
});
};
onDescriptionChange = (event: any) => {
this.setState({
description: event.target.value,
});
};
onTimeRestoreChange = (event: any) => {
this.setState({
timeRestore: event.target.checked,
@ -102,26 +96,12 @@ export class DashboardSaveModal extends React.Component<Props, State> {
tags,
});
}}
markOptional
/>
) : undefined;
return (
<Fragment>
<EuiFormRow
label={
<FormattedMessage
id="dashboard.topNav.saveModal.descriptionFormRowLabel"
defaultMessage="Description"
/>
}
>
<EuiTextArea
data-test-subj="dashboardDescription"
value={this.state.description}
onChange={this.onDescriptionChange}
/>
</EuiFormRow>
{tagSelector}
<EuiFormRow
@ -154,13 +134,14 @@ export class DashboardSaveModal extends React.Component<Props, State> {
onSave={this.saveDashboard}
onClose={this.props.onClose}
title={this.props.title}
description={this.props.description}
showDescription
showCopyOnSave={this.props.showCopyOnSave}
initialCopyOnSave={this.props.showCopyOnSave}
objectType={i18n.translate('dashboard.topNav.saveModal.objectType', {
defaultMessage: 'dashboard',
})}
options={this.renderDashboardSaveOptions()}
showDescription={false}
/>
);
}

View file

@ -23,3 +23,5 @@ export const AvailableAnnotationIcons = {
TAG: 'tag',
TRIANGLE: 'triangle',
} as const;
export const EVENT_ANNOTATION_GROUP_TYPE = 'event-annotation-group';

View file

@ -33,4 +33,7 @@ export type {
QueryPointEventAnnotationConfig,
AvailableAnnotationIcon,
EventAnnotationOutput,
EventAnnotationGroupAttributes,
} from './types';
export { EVENT_ANNOTATION_GROUP_TYPE } from './constants';

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { KibanaQueryOutput } from '@kbn/data-plugin/common';
import { DataViewSpec, KibanaQueryOutput } from '@kbn/data-plugin/common';
import { DatatableColumn } from '@kbn/expressions-plugin/common';
import { $Values } from '@kbn/utility-types';
import { AvailableAnnotationIcons } from './constants';
@ -82,10 +82,23 @@ export type EventAnnotationConfig =
| RangeEventAnnotationConfig
| QueryPointEventAnnotationConfig;
export interface EventAnnotationGroupAttributes {
title: string;
description: string;
tags: string[];
ignoreGlobalFilters: boolean;
annotations: EventAnnotationConfig[];
dataViewSpec?: DataViewSpec;
}
export interface EventAnnotationGroupConfig {
annotations: EventAnnotationConfig[];
indexPatternId: string;
ignoreGlobalFilters?: boolean;
ignoreGlobalFilters: boolean;
title: string;
description: string;
tags: string[];
dataViewSpec?: DataViewSpec;
}
export type EventAnnotationArgs =

View file

@ -9,7 +9,12 @@
"browser": true,
"requiredPlugins": [
"expressions",
"data"
"savedObjectsManagement",
"data",
],
"requiredBundles": [
"savedObjectsFinder",
"dataViews"
],
"extraPublicDirs": [
"common"

View file

@ -0,0 +1,133 @@
/*
* 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, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { CoreStart } from '@kbn/core/public';
import { FormattedMessage } from '@kbn/i18n-react';
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import type { SavedObjectCommon } from '@kbn/saved-objects-finder-plugin/common';
import { SavedObjectFinder } from '@kbn/saved-objects-finder-plugin/public';
import { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public';
import {
EuiButton,
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiText,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { EVENT_ANNOTATION_GROUP_TYPE } from '../../common';
export const EventAnnotationGroupSavedObjectFinder = ({
uiSettings,
http,
savedObjectsManagement,
fixedPageSize = 10,
checkHasAnnotationGroups,
onChoose,
onCreateNew,
}: {
uiSettings: IUiSettingsClient;
http: CoreStart['http'];
savedObjectsManagement: SavedObjectsManagementPluginStart;
fixedPageSize?: number;
checkHasAnnotationGroups: () => Promise<boolean>;
onChoose: (value: {
id: string;
type: string;
fullName: string;
savedObject: SavedObjectCommon<unknown>;
}) => void;
onCreateNew: () => void;
}) => {
const [hasAnnotationGroups, setHasAnnotationGroups] = useState<boolean | undefined>();
useEffect(() => {
checkHasAnnotationGroups().then(setHasAnnotationGroups);
}, [checkHasAnnotationGroups]);
return hasAnnotationGroups === undefined ? (
<EuiFlexGroup responsive={false} justifyContent="center">
<EuiFlexItem grow={0}>
<EuiLoadingSpinner />
</EuiFlexItem>
</EuiFlexGroup>
) : hasAnnotationGroups === false ? (
<EuiFlexGroup
css={css`
height: 100%;
`}
direction="column"
justifyContent="center"
>
<EuiEmptyPrompt
titleSize="xs"
title={
<h2>
<FormattedMessage
id="eventAnnotation.eventAnnotationGroup.savedObjectFinder.emptyPromptTitle"
defaultMessage="Start by adding an annotation layer"
/>
</h2>
}
body={
<EuiText size="s">
<p>
<FormattedMessage
id="eventAnnotation.eventAnnotationGroup.savedObjectFinder.emptyPromptDescription"
defaultMessage="There are currently no annotations available to select from the library. Create a new layer to add annotations."
/>
</p>
</EuiText>
}
actions={
<EuiButton onClick={() => onCreateNew()} size="s">
<FormattedMessage
id="eventAnnotation.eventAnnotationGroup.savedObjectFinder.emptyCTA"
defaultMessage="Create annotation layer"
/>
</EuiButton>
}
/>
</EuiFlexGroup>
) : (
<SavedObjectFinder
key="searchSavedObjectFinder"
fixedPageSize={fixedPageSize}
onChoose={(id, type, fullName, savedObject) => {
onChoose({ id, type, fullName, savedObject });
}}
showFilter={false}
noItemsMessage={
<FormattedMessage
id="eventAnnotation.eventAnnotationGroup.savedObjectFinder.notFoundLabel"
defaultMessage="No matching annotation groups found."
/>
}
savedObjectMetaData={savedObjectMetaData}
services={{
uiSettings,
http,
savedObjectsManagement,
}}
/>
);
};
const savedObjectMetaData = [
{
type: EVENT_ANNOTATION_GROUP_TYPE,
getIconForSavedObject: () => 'annotation',
name: i18n.translate('eventAnnotation.eventAnnotationGroup.metadata.name', {
defaultMessage: 'Annotations Groups',
}),
includeFields: ['*'],
},
];

View file

@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Event Annotation Service loadAnnotationGroup should properly load an annotation group with a multiple annotation 1`] = `
Object {
"annotations": undefined,
"dataViewSpec": undefined,
"description": undefined,
"ignoreGlobalFilters": undefined,
"indexPatternId": "ipid",
"tags": undefined,
"title": "groupTitle",
}
`;

View file

@ -6,14 +6,28 @@
* Side Public License, v 1.
*/
import { CoreStart } from '@kbn/core/public';
import { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public';
import { EventAnnotationServiceType } from './types';
export class EventAnnotationService {
private eventAnnotationService?: EventAnnotationServiceType;
private core: CoreStart;
private savedObjectsManagement: SavedObjectsManagementPluginStart;
constructor(core: CoreStart, savedObjectsManagement: SavedObjectsManagementPluginStart) {
this.core = core;
this.savedObjectsManagement = savedObjectsManagement;
}
public async getService() {
if (!this.eventAnnotationService) {
const { getEventAnnotationService } = await import('./service');
this.eventAnnotationService = getEventAnnotationService();
this.eventAnnotationService = getEventAnnotationService(
this.core,
this.savedObjectsManagement
);
}
return this.eventAnnotationService;
}

View file

@ -6,14 +6,150 @@
* Side Public License, v 1.
*/
import { CoreStart, SimpleSavedObject } from '@kbn/core/public';
import { coreMock } from '@kbn/core/public/mocks';
import { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public';
import { EventAnnotationConfig, EventAnnotationGroupAttributes } from '../../common';
import { getEventAnnotationService } from './service';
import { EventAnnotationServiceType } from './types';
type AnnotationGroupSavedObject = SimpleSavedObject<EventAnnotationGroupAttributes>;
const annotationGroupResolveMocks: Record<string, AnnotationGroupSavedObject> = {
nonExistingGroup: {
attributes: {} as EventAnnotationGroupAttributes,
references: [],
id: 'nonExistingGroup',
error: {
error: 'Saved object not found',
statusCode: 404,
message: 'Not found',
},
} as Partial<AnnotationGroupSavedObject> as AnnotationGroupSavedObject,
noAnnotations: {
attributes: {
title: 'groupTitle',
description: '',
tags: [],
ignoreGlobalFilters: false,
annotations: [],
},
type: 'event-annotation-group',
references: [
{
id: 'ipid',
name: 'ipid',
type: 'index-pattern',
},
],
} as Partial<AnnotationGroupSavedObject> as AnnotationGroupSavedObject,
multiAnnotations: {
attributes: {
title: 'groupTitle',
},
id: 'multiAnnotations',
type: 'event-annotation-group',
references: [
{
id: 'ipid',
name: 'ipid',
type: 'index-pattern',
},
],
} as Partial<AnnotationGroupSavedObject> as AnnotationGroupSavedObject,
withAdHocDataView: {
attributes: {
title: 'groupTitle',
dataViewSpec: {
id: 'my-id',
},
} as Partial<EventAnnotationGroupAttributes>,
id: 'multiAnnotations',
type: 'event-annotation-group',
references: [],
} as Partial<AnnotationGroupSavedObject> as AnnotationGroupSavedObject,
};
const annotationResolveMocks = {
nonExistingGroup: { savedObjects: [] },
noAnnotations: { savedObjects: [] },
multiAnnotations: {
savedObjects: [
{
id: 'annotation1',
attributes: {
id: 'annotation1',
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,
} as EventAnnotationConfig,
type: 'event-annotation',
references: [
{
id: 'multiAnnotations',
name: 'event_annotation_group_ref',
type: 'event-annotation-group',
},
],
},
{
id: 'annotation2',
attributes: {
id: 'ann2',
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' },
} as EventAnnotationConfig,
type: 'event-annotation',
references: [
{
id: 'multiAnnotations',
name: 'event_annotation_group_ref',
type: 'event-annotation-group',
},
],
},
],
},
};
let core: CoreStart;
describe('Event Annotation Service', () => {
let eventAnnotationService: EventAnnotationServiceType;
beforeAll(() => {
eventAnnotationService = getEventAnnotationService();
beforeEach(() => {
core = coreMock.createStart();
(core.savedObjects.client.create as jest.Mock).mockImplementation(() => {
return annotationGroupResolveMocks.multiAnnotations;
});
(core.savedObjects.client.get as jest.Mock).mockImplementation((_type, id) => {
const typedId = id as keyof typeof annotationGroupResolveMocks;
return annotationGroupResolveMocks[typedId];
});
(core.savedObjects.client.bulkCreate as jest.Mock).mockImplementation(() => {
return annotationResolveMocks.multiAnnotations;
});
eventAnnotationService = getEventAnnotationService(
core,
{} as SavedObjectsManagementPluginStart
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('toExpression', () => {
it('should work for an empty list', () => {
expect(eventAnnotationService.toExpression([])).toEqual([]);
@ -318,4 +454,190 @@ describe('Event Annotation Service', () => {
}
);
});
describe('loadAnnotationGroup', () => {
it('should throw error when loading group doesnt exist', async () => {
expect(() => eventAnnotationService.loadAnnotationGroup('nonExistingGroup')).rejects
.toMatchInlineSnapshot(`
Object {
"error": "Saved object not found",
"message": "Not found",
"statusCode": 404,
}
`);
});
it('should properly load an annotation group with no annotation', async () => {
expect(await eventAnnotationService.loadAnnotationGroup('noAnnotations'))
.toMatchInlineSnapshot(`
Object {
"annotations": Array [],
"dataViewSpec": undefined,
"description": "",
"ignoreGlobalFilters": false,
"indexPatternId": "ipid",
"tags": Array [],
"title": "groupTitle",
}
`);
});
it('should properly load an annotation group with a multiple annotation', async () => {
expect(
await eventAnnotationService.loadAnnotationGroup('multiAnnotations')
).toMatchSnapshot();
});
it('populates id if group has ad-hoc data view', async () => {
const group = await eventAnnotationService.loadAnnotationGroup('withAdHocDataView');
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('createAnnotationGroup', () => {
it('creates annotation group along with annotations', async () => {
const annotations = [
annotationResolveMocks.multiAnnotations.savedObjects[0].attributes,
annotationResolveMocks.multiAnnotations.savedObjects[1].attributes,
];
await eventAnnotationService.createAnnotationGroup({
title: 'newGroupTitle',
description: 'my description',
tags: ['my', 'many', 'tags'],
indexPatternId: 'ipid',
ignoreGlobalFilters: false,
annotations,
});
expect(core.savedObjects.client.create).toHaveBeenCalledWith(
'event-annotation-group',
{
title: 'newGroupTitle',
description: 'my description',
tags: ['my', 'many', 'tags'],
ignoreGlobalFilters: false,
dataViewSpec: null,
annotations,
},
{
references: [
{
id: 'ipid',
name: 'event-annotation-group_dataView-ref-ipid',
type: 'index-pattern',
},
],
}
);
});
});
describe('updateAnnotationGroup', () => {
it('updates annotation group attributes', async () => {
await eventAnnotationService.updateAnnotationGroup(
{
title: 'newTitle',
description: '',
tags: [],
indexPatternId: 'newId',
annotations: [],
ignoreGlobalFilters: false,
},
'multiAnnotations'
);
expect(core.savedObjects.client.update).toHaveBeenCalledWith(
'event-annotation-group',
'multiAnnotations',
{
title: 'newTitle',
description: '',
tags: [],
annotations: [],
dataViewSpec: null,
ignoreGlobalFilters: false,
},
{
references: [
{
id: 'newId',
name: 'event-annotation-group_dataView-ref-newId',
type: 'index-pattern',
},
],
}
);
});
});
// describe.skip('updateAnnotations', () => {
// const upsert = [
// {
// id: 'annotation2',
// label: 'Query based event',
// icon: 'triangle',
// color: 'red',
// type: 'query',
// timeField: 'timestamp',
// key: {
// type: 'point_in_time',
// },
// lineStyle: 'dashed',
// lineWidth: 3,
// filter: { type: 'kibana_query', query: '', language: 'kuery' },
// },
// {
// id: 'annotation4',
// label: 'Query based event',
// type: 'query',
// timeField: 'timestamp',
// key: {
// type: 'point_in_time',
// },
// filter: { type: 'kibana_query', query: '', language: 'kuery' },
// },
// ] as EventAnnotationConfig[];
// it('updates annotations - deletes annotations', async () => {
// await eventAnnotationService.updateAnnotations('multiAnnotations', {
// delete: ['annotation1', 'annotation2'],
// });
// expect(core.savedObjects.client.bulkDelete).toHaveBeenCalledWith([
// { id: 'annotation1', type: 'event-annotation' },
// { id: 'annotation2', type: 'event-annotation' },
// ]);
// });
// it('updates annotations - inserts new annotations', async () => {
// await eventAnnotationService.updateAnnotations('multiAnnotations', { upsert });
// expect(core.savedObjects.client.bulkCreate).toHaveBeenCalledWith([
// {
// id: 'annotation2',
// type: 'event-annotation',
// attributes: upsert[0],
// overwrite: true,
// references: [
// {
// id: 'multiAnnotations',
// name: 'event-annotation-group-ref-annotation2',
// type: 'event-annotation-group',
// },
// ],
// },
// {
// id: 'annotation4',
// type: 'event-annotation',
// attributes: upsert[1],
// overwrite: true,
// references: [
// {
// id: 'multiAnnotations',
// name: 'event-annotation-group-ref-annotation4',
// type: 'event-annotation-group',
// },
// ],
// },
// ]);
// });
// });
});

View file

@ -6,10 +6,19 @@
* Side Public License, v 1.
*/
import React from 'react';
import { partition } from 'lodash';
import { queryToAst } from '@kbn/data-plugin/common';
import { ExpressionAstExpression } from '@kbn/expressions-plugin/common';
import { EventAnnotationConfig } from '../../common';
import { CoreStart, SavedObjectReference, SavedObjectsClientContract } from '@kbn/core/public';
import { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public';
import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common';
import {
EventAnnotationConfig,
EventAnnotationGroupAttributes,
EventAnnotationGroupConfig,
EVENT_ANNOTATION_GROUP_TYPE,
} from '../../common';
import { EventAnnotationServiceType } from './types';
import {
defaultAnnotationColor,
@ -18,108 +27,138 @@ import {
isRangeAnnotationConfig,
isQueryAnnotationConfig,
} from './helpers';
import { EventAnnotationGroupSavedObjectFinder } from '../components/event_annotation_group_saved_object_finder';
export function hasIcon(icon: string | undefined): icon is string {
return icon != null && icon !== 'empty';
}
export function getEventAnnotationService(): EventAnnotationServiceType {
const annotationsToExpression = (annotations: EventAnnotationConfig[]) => {
const [queryBasedAnnotations, manualBasedAnnotations] = partition(
annotations,
isQueryAnnotationConfig
export function getEventAnnotationService(
core: CoreStart,
savedObjectsManagement: SavedObjectsManagementPluginStart
): EventAnnotationServiceType {
const client: SavedObjectsClientContract = core.savedObjects.client;
const loadAnnotationGroup = async (
savedObjectId: string
): Promise<EventAnnotationGroupConfig> => {
const savedObject = await client.get<EventAnnotationGroupAttributes>(
EVENT_ANNOTATION_GROUP_TYPE,
savedObjectId
);
const expressions = [];
for (const annotation of manualBasedAnnotations) {
if (isRangeAnnotationConfig(annotation)) {
const { label, color, key, outside, id } = annotation;
const { timestamp: time, endTimestamp: endTime } = key;
expressions.push({
type: 'expression' as const,
chain: [
{
type: 'function' as const,
function: 'manual_range_event_annotation',
arguments: {
id: [id],
time: [time],
endTime: [endTime],
label: [label || defaultAnnotationLabel],
color: [color || defaultAnnotationRangeColor],
outside: [Boolean(outside)],
isHidden: [Boolean(annotation.isHidden)],
},
},
],
});
} else {
const { label, color, lineStyle, lineWidth, icon, key, textVisibility, id } = annotation;
expressions.push({
type: 'expression' as const,
chain: [
{
type: 'function' as const,
function: 'manual_point_event_annotation',
arguments: {
id: [id],
time: [key.timestamp],
label: [label || defaultAnnotationLabel],
color: [color || defaultAnnotationColor],
lineWidth: [lineWidth || 1],
lineStyle: [lineStyle || 'solid'],
icon: hasIcon(icon) ? [icon] : ['triangle'],
textVisibility: [textVisibility || false],
isHidden: [Boolean(annotation.isHidden)],
},
},
],
});
}
if (savedObject.error) {
throw savedObject.error;
}
for (const annotation of queryBasedAnnotations) {
const {
id,
label,
color,
lineStyle,
lineWidth,
icon,
timeField,
textVisibility,
textField,
filter,
extraFields,
} = annotation;
expressions.push({
type: 'expression' as const,
chain: [
{
type: 'function' as const,
function: 'query_point_event_annotation',
arguments: {
id: [id],
timeField: timeField ? [timeField] : [],
label: [label || defaultAnnotationLabel],
color: [color || defaultAnnotationColor],
lineWidth: [lineWidth || 1],
lineStyle: [lineStyle || 'solid'],
icon: hasIcon(icon) ? [icon] : ['triangle'],
textVisibility: [textVisibility || false],
textField: textVisibility && textField ? [textField] : [],
filter: filter ? [queryToAst(filter)] : [],
extraFields: extraFields || [],
isHidden: [Boolean(annotation.isHidden)],
},
},
],
});
}
return expressions;
const adHocDataViewSpec = savedObject.attributes.dataViewSpec
? DataViewPersistableStateService.inject(
savedObject.attributes.dataViewSpec,
savedObject.references
)
: undefined;
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,
};
};
const extractDataViewInformation = (group: EventAnnotationGroupConfig) => {
let { dataViewSpec = null } = group;
let references: SavedObjectReference[];
if (dataViewSpec) {
if (!dataViewSpec.id)
throw new Error(
'tried to create annotation group with a data view spec that did not include an ID!'
);
const { state, references: refsFromDataView } =
DataViewPersistableStateService.extract(dataViewSpec);
dataViewSpec = state;
references = refsFromDataView;
} else {
references = [
{
type: 'index-pattern',
id: group.indexPatternId,
name: `event-annotation-group_dataView-ref-${group.indexPatternId}`,
},
];
}
return { references, dataViewSpec };
};
const createAnnotationGroup = async (
group: EventAnnotationGroupConfig
): Promise<{ id: string }> => {
const { references, dataViewSpec } = extractDataViewInformation(group);
const { title, description, tags, ignoreGlobalFilters, annotations } = group;
const groupSavedObjectId = (
await client.create(
EVENT_ANNOTATION_GROUP_TYPE,
{ title, description, tags, ignoreGlobalFilters, annotations, dataViewSpec },
{
references,
}
)
).id;
return { id: groupSavedObjectId };
};
const updateAnnotationGroup = async (
group: EventAnnotationGroupConfig,
annotationGroupId: string
): Promise<void> => {
const { references, dataViewSpec } = extractDataViewInformation(group);
const { title, description, tags, ignoreGlobalFilters, annotations } = group;
await client.update(
EVENT_ANNOTATION_GROUP_TYPE,
annotationGroupId,
{ title, description, tags, ignoreGlobalFilters, annotations, dataViewSpec },
{
references,
}
);
};
const checkHasAnnotationGroups = async (): Promise<boolean> => {
const response = await client.find({
type: EVENT_ANNOTATION_GROUP_TYPE,
perPage: 0,
});
return response.total > 0;
};
return {
loadAnnotationGroup,
updateAnnotationGroup,
createAnnotationGroup,
renderEventAnnotationGroupSavedObjectFinder: (props) => {
return (
<EventAnnotationGroupSavedObjectFinder
http={core.http}
uiSettings={core.uiSettings}
savedObjectsManagement={savedObjectsManagement}
checkHasAnnotationGroups={checkHasAnnotationGroups}
{...props}
/>
);
},
toExpression: annotationsToExpression,
toFetchExpression: ({ interval, groups }) => {
if (groups.length === 0) {
@ -177,3 +216,99 @@ export function getEventAnnotationService(): EventAnnotationServiceType {
},
};
}
const annotationsToExpression = (annotations: EventAnnotationConfig[]) => {
const [queryBasedAnnotations, manualBasedAnnotations] = partition(
annotations,
isQueryAnnotationConfig
);
const expressions = [];
for (const annotation of manualBasedAnnotations) {
if (isRangeAnnotationConfig(annotation)) {
const { label, color, key, outside, id } = annotation;
const { timestamp: time, endTimestamp: endTime } = key;
expressions.push({
type: 'expression' as const,
chain: [
{
type: 'function' as const,
function: 'manual_range_event_annotation',
arguments: {
id: [id],
time: [time],
endTime: [endTime],
label: [label || defaultAnnotationLabel],
color: [color || defaultAnnotationRangeColor],
outside: [Boolean(outside)],
isHidden: [Boolean(annotation.isHidden)],
},
},
],
});
} else {
const { label, color, lineStyle, lineWidth, icon, key, textVisibility, id } = annotation;
expressions.push({
type: 'expression' as const,
chain: [
{
type: 'function' as const,
function: 'manual_point_event_annotation',
arguments: {
id: [id],
time: [key.timestamp],
label: [label || defaultAnnotationLabel],
color: [color || defaultAnnotationColor],
lineWidth: [lineWidth || 1],
lineStyle: [lineStyle || 'solid'],
icon: hasIcon(icon) ? [icon] : ['triangle'],
textVisibility: [textVisibility || false],
isHidden: [Boolean(annotation.isHidden)],
},
},
],
});
}
}
for (const annotation of queryBasedAnnotations) {
const {
id,
label,
color,
lineStyle,
lineWidth,
icon,
timeField,
textVisibility,
textField,
filter,
extraFields,
} = annotation;
expressions.push({
type: 'expression' as const,
chain: [
{
type: 'function' as const,
function: 'query_point_event_annotation',
arguments: {
id: [id],
timeField: timeField ? [timeField] : [],
label: [label || defaultAnnotationLabel],
color: [color || defaultAnnotationColor],
lineWidth: [lineWidth || 1],
lineStyle: [lineStyle || 'solid'],
icon: hasIcon(icon) ? [icon] : ['triangle'],
textVisibility: [textVisibility || false],
textField: textVisibility && textField ? [textField] : [],
filter: filter ? [queryToAst(filter)] : [],
extraFields: extraFields || [],
isHidden: [Boolean(annotation.isHidden)],
},
},
],
});
}
return expressions;
};

View file

@ -7,12 +7,31 @@
*/
import { ExpressionAstExpression } from '@kbn/expressions-plugin/common/ast';
import type { SavedObjectCommon } from '@kbn/saved-objects-finder-plugin/common';
import { EventAnnotationConfig, EventAnnotationGroupConfig } from '../../common';
export interface EventAnnotationServiceType {
loadAnnotationGroup: (savedObjectId: string) => Promise<EventAnnotationGroupConfig>;
createAnnotationGroup: (group: EventAnnotationGroupConfig) => Promise<{ id: string }>;
updateAnnotationGroup: (
group: EventAnnotationGroupConfig,
savedObjectId: string
) => Promise<void>;
toExpression: (props: EventAnnotationConfig[]) => ExpressionAstExpression[];
toFetchExpression: (props: {
interval: string;
groups: EventAnnotationGroupConfig[];
groups: Array<
Pick<EventAnnotationGroupConfig, 'annotations' | 'ignoreGlobalFilters' | 'indexPatternId'>
>;
}) => ExpressionAstExpression[];
renderEventAnnotationGroupSavedObjectFinder: (props: {
fixedPageSize?: number;
onChoose: (value: {
id: string;
type: string;
fullName: string;
savedObject: SavedObjectCommon<unknown>;
}) => void;
onCreateNew: () => void;
}) => JSX.Element;
}

View file

@ -6,7 +6,12 @@
* Side Public License, v 1.
*/
import { coreMock } from '@kbn/core/public/mocks';
import { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public';
import { getEventAnnotationService } from './event_annotation_service/service';
// not really mocking but avoiding async loading
export const eventAnnotationServiceMock = getEventAnnotationService();
export const eventAnnotationServiceMock = getEventAnnotationService(
coreMock.createStart(),
{} as SavedObjectsManagementPluginStart
);

View file

@ -6,8 +6,9 @@
* Side Public License, v 1.
*/
import { Plugin, CoreSetup, CoreStart, IUiSettingsClient } from '@kbn/core/public';
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 { EventAnnotationService } from './event_annotation_service';
import {
@ -19,8 +20,8 @@ import {
import { getFetchEventAnnotations } from './fetch_event_annotations';
export interface EventAnnotationStartDependencies {
savedObjectsManagement: SavedObjectsManagementPluginStart;
data: DataPublicPluginStart;
uiSettings: IUiSettingsClient;
}
interface SetupDependencies {
@ -29,14 +30,12 @@ interface SetupDependencies {
/** @public */
export type EventAnnotationPluginStart = EventAnnotationService;
export type EventAnnotationPluginSetup = EventAnnotationService;
export type EventAnnotationPluginSetup = void;
/** @public */
export class EventAnnotationPlugin
implements Plugin<EventAnnotationPluginSetup, EventAnnotationService>
{
private readonly eventAnnotationService = new EventAnnotationService();
public setup(
core: CoreSetup<EventAnnotationStartDependencies, EventAnnotationService>,
dependencies: SetupDependencies
@ -48,13 +47,12 @@ export class EventAnnotationPlugin
dependencies.expressions.registerFunction(
getFetchEventAnnotations({ getStartServices: core.getStartServices })
);
return this.eventAnnotationService;
}
public start(
core: CoreStart,
startDependencies: EventAnnotationStartDependencies
): EventAnnotationService {
return this.eventAnnotationService;
return new EventAnnotationService(core, startDependencies.savedObjectsManagement);
}
}

View file

@ -15,6 +15,7 @@ import {
manualRangeEventAnnotation,
queryPointEventAnnotation,
} from '../common';
import { setupSavedObjects } from './saved_objects';
// import { getFetchEventAnnotations } from './fetch_event_annotations';
interface SetupDependencies {
@ -33,9 +34,8 @@ export class EventAnnotationServerPlugin implements Plugin<object, object> {
dependencies.expressions.registerFunction(manualRangeEventAnnotation);
dependencies.expressions.registerFunction(queryPointEventAnnotation);
dependencies.expressions.registerFunction(eventAnnotationGroup);
// dependencies.expressions.registerFunction(
// getFetchEventAnnotations({ getStartServices: core.getStartServices })
// );
setupSavedObjects(core);
return {};
}

View file

@ -0,0 +1,50 @@
/*
* 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 { ANALYTICS_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server';
import {
CoreSetup,
mergeSavedObjectMigrationMaps,
SavedObjectMigrationMap,
} from '@kbn/core/server';
import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common';
import { EVENT_ANNOTATION_GROUP_TYPE } from '../common/constants';
import { EventAnnotationGroupAttributes } from '../common/types';
export function setupSavedObjects(coreSetup: CoreSetup) {
coreSetup.savedObjects.registerType({
name: EVENT_ANNOTATION_GROUP_TYPE,
indexPattern: ANALYTICS_SAVED_OBJECT_INDEX,
hidden: false,
namespaceType: 'multiple',
management: {
icon: 'flag',
defaultSearchField: 'title',
importableAndExportable: true,
getTitle: (obj: { attributes: EventAnnotationGroupAttributes }) => obj.attributes.title,
},
migrations: () => {
const dataViewMigrations = DataViewPersistableStateService.getAllMigrations();
return mergeSavedObjectMigrationMaps(eventAnnotationGroupMigrations, dataViewMigrations);
},
mappings: {
dynamic: false,
properties: {
title: {
type: 'text',
},
description: {
type: 'text',
},
},
},
});
}
const eventAnnotationGroupMigrations: SavedObjectMigrationMap = {};

View file

@ -19,6 +19,10 @@
"@kbn/core-ui-settings-browser",
"@kbn/datemath",
"@kbn/ui-theme",
"@kbn/saved-objects-finder-plugin",
"@kbn/saved-objects-management-plugin",
"@kbn/i18n-react",
"@kbn/core-saved-objects-server"
],
"exclude": [
"target/**/*",

View file

@ -62,17 +62,45 @@ exports[`SavedObjectSaveModal should render matching snapshot 1`] = `
values={Object {}}
/>
}
labelAppend={
<EuiText
color="subdued"
size="xs"
>
<FormattedMessage
defaultMessage="Optional"
id="savedObjects.saveModal.optional"
values={Object {}}
/>
</EuiText>
}
labelType="label"
>
<EuiTextArea
data-test-subj="viewDescription"
fullWidth={true}
onChange={[Function]}
value=""
/>
</EuiFormRow>
</EuiForm>
</EuiModalBody>
<EuiModalFooter>
<EuiModalFooter
css={
Object {
"map": undefined,
"name": "fy4vru",
"next": undefined,
"styles": "
align-items: center;
",
"toString": [Function],
}
}
>
<EuiFlexItem
grow={true}
/>
<EuiButtonEmpty
data-test-subj="saveCancelButton"
onClick={[Function]}
@ -88,7 +116,6 @@ exports[`SavedObjectSaveModal should render matching snapshot 1`] = `
data-test-subj="confirmSaveSavedObjectButton"
fill={true}
form="generated-id_form"
isDisabled={false}
isLoading={false}
size="m"
type="submit"
@ -161,17 +188,45 @@ exports[`SavedObjectSaveModal should render matching snapshot when custom isVali
values={Object {}}
/>
}
labelAppend={
<EuiText
color="subdued"
size="xs"
>
<FormattedMessage
defaultMessage="Optional"
id="savedObjects.saveModal.optional"
values={Object {}}
/>
</EuiText>
}
labelType="label"
>
<EuiTextArea
data-test-subj="viewDescription"
fullWidth={true}
onChange={[Function]}
value=""
/>
</EuiFormRow>
</EuiForm>
</EuiModalBody>
<EuiModalFooter>
<EuiModalFooter
css={
Object {
"map": undefined,
"name": "fy4vru",
"next": undefined,
"styles": "
align-items: center;
",
"toString": [Function],
}
}
>
<EuiFlexItem
grow={true}
/>
<EuiButtonEmpty
data-test-subj="saveCancelButton"
onClick={[Function]}
@ -187,7 +242,6 @@ exports[`SavedObjectSaveModal should render matching snapshot when custom isVali
data-test-subj="confirmSaveSavedObjectButton"
fill={true}
form="generated-id_form"
isDisabled={true}
isLoading={false}
size="m"
type="submit"
@ -260,17 +314,45 @@ exports[`SavedObjectSaveModal should render matching snapshot when custom isVali
values={Object {}}
/>
}
labelAppend={
<EuiText
color="subdued"
size="xs"
>
<FormattedMessage
defaultMessage="Optional"
id="savedObjects.saveModal.optional"
values={Object {}}
/>
</EuiText>
}
labelType="label"
>
<EuiTextArea
data-test-subj="viewDescription"
fullWidth={true}
onChange={[Function]}
value=""
/>
</EuiFormRow>
</EuiForm>
</EuiModalBody>
<EuiModalFooter>
<EuiModalFooter
css={
Object {
"map": undefined,
"name": "fy4vru",
"next": undefined,
"styles": "
align-items: center;
",
"toString": [Function],
}
}
>
<EuiFlexItem
grow={true}
/>
<EuiButtonEmpty
data-test-subj="saveCancelButton"
onClick={[Function]}
@ -286,7 +368,6 @@ exports[`SavedObjectSaveModal should render matching snapshot when custom isVali
data-test-subj="confirmSaveSavedObjectButton"
fill={true}
form="generated-id_form"
isDisabled={false}
isLoading={false}
size="m"
type="submit"
@ -363,10 +444,23 @@ exports[`SavedObjectSaveModal should render matching snapshot when given options
values={Object {}}
/>
}
labelAppend={
<EuiText
color="subdued"
size="xs"
>
<FormattedMessage
defaultMessage="Optional"
id="savedObjects.saveModal.optional"
values={Object {}}
/>
</EuiText>
}
labelType="label"
>
<EuiTextArea
data-test-subj="viewDescription"
fullWidth={true}
onChange={[Function]}
value=""
/>
@ -383,7 +477,22 @@ exports[`SavedObjectSaveModal should render matching snapshot when given options
</EuiFlexGroup>
</EuiForm>
</EuiModalBody>
<EuiModalFooter>
<EuiModalFooter
css={
Object {
"map": undefined,
"name": "fy4vru",
"next": undefined,
"styles": "
align-items: center;
",
"toString": [Function],
}
}
>
<EuiFlexItem
grow={true}
/>
<EuiButtonEmpty
data-test-subj="saveCancelButton"
onClick={[Function]}
@ -399,7 +508,6 @@ exports[`SavedObjectSaveModal should render matching snapshot when given options
data-test-subj="confirmSaveSavedObjectButton"
fill={true}
form="generated-id_form"
isDisabled={false}
isLoading={false}
size="m"
type="submit"

View file

@ -1,7 +1,7 @@
.kbnSavedObjectSaveModal {
width: $euiSizeXXL * 10;
width: 600px;
}
.kbnSavedObjectsSaveModal--wide {
width: 800px;
}
}

View file

@ -30,6 +30,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
import { EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
export interface OnSaveProps {
newTitle: string;
@ -53,6 +54,7 @@ interface Props {
description?: string;
showDescription: boolean;
isValid?: boolean;
customModalTitle?: string;
}
export interface SaveModalState {
@ -62,6 +64,7 @@ export interface SaveModalState {
hasTitleDuplicate: boolean;
isLoading: boolean;
visualizationDescription: string;
hasAttemptedSubmit: boolean;
}
const generateId = htmlIdGenerator();
@ -81,10 +84,11 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
hasTitleDuplicate: false,
isLoading: false,
visualizationDescription: this.props.description ? this.props.description : '',
hasAttemptedSubmit: false,
};
public render() {
const { isTitleDuplicateConfirmed, hasTitleDuplicate, title } = this.state;
const { isTitleDuplicateConfirmed, hasTitleDuplicate, title, hasAttemptedSubmit } = this.state;
const duplicateWarningId = generateId();
const hasColumns = !!this.props.rightOptions;
@ -101,7 +105,10 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
data-test-subj="savedObjectTitle"
value={title}
onChange={this.onTitleChange}
isInvalid={(!isTitleDuplicateConfirmed && hasTitleDuplicate) || title.length === 0}
isInvalid={
hasAttemptedSubmit &&
((!isTitleDuplicateConfirmed && hasTitleDuplicate) || title.length === 0)
}
aria-describedby={this.state.hasTitleDuplicate ? duplicateWarningId : undefined}
/>
</EuiFormRow>
@ -135,11 +142,15 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
>
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
id="savedObjects.saveModal.saveTitle"
defaultMessage="Save {objectType}"
values={{ objectType: this.props.objectType }}
/>
{this.props.customModalTitle ? (
this.props.customModalTitle
) : (
<FormattedMessage
id="savedObjects.saveModal.saveTitle"
defaultMessage="Save {objectType}"
values={{ objectType: this.props.objectType }}
/>
)}
</EuiModalHeaderTitle>
</EuiModalHeader>
@ -153,11 +164,15 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
</EuiText>
)}
{formBody}
{this.renderCopyOnSave()}
</EuiForm>
</EuiModalBody>
<EuiModalFooter>
<EuiModalFooter
css={css`
align-items: center;
`}
>
<EuiFlexItem grow>{this.renderCopyOnSave()}</EuiFlexItem>
<EuiButtonEmpty data-test-subj="saveCancelButton" onClick={this.props.onClose}>
<FormattedMessage
id="savedObjects.saveModal.cancelButtonLabel"
@ -179,6 +194,11 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
return (
<EuiFormRow
fullWidth
labelAppend={
<EuiText size="xs" color="subdued">
<FormattedMessage id="savedObjects.saveModal.optional" defaultMessage="Optional" />
</EuiText>
}
label={
<FormattedMessage
id="savedObjects.saveModal.descriptionLabel"
@ -187,6 +207,7 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
}
>
<EuiTextArea
fullWidth
data-test-subj="viewDescription"
value={this.state.visualizationDescription}
onChange={this.onDescriptionChange}
@ -252,11 +273,22 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
private onFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
this.saveSavedObject();
const { hasAttemptedSubmit, title } = this.state;
if (!hasAttemptedSubmit) {
this.setState({ hasAttemptedSubmit: true });
}
const isValid = this.props.isValid !== undefined ? this.props.isValid : true;
if (title.length !== 0 && isValid) {
this.saveSavedObject();
}
};
private renderConfirmButton = () => {
const { isLoading, title } = this.state;
const { isLoading } = this.state;
let confirmLabel: string | React.ReactNode = i18n.translate(
'savedObjects.saveModal.saveButtonLabel',
@ -269,14 +301,11 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
confirmLabel = this.props.confirmButtonLabel;
}
const isValid = this.props.isValid !== undefined ? this.props.isValid : true;
return (
<EuiButton
fill
data-test-subj="confirmSaveSavedObjectButton"
isLoading={isLoading}
isDisabled={title.length === 0 || !isValid}
type="submit"
form={this.formId}
>
@ -327,21 +356,18 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
}
return (
<>
<EuiSpacer />
<EuiSwitch
data-test-subj="saveAsNewCheckbox"
checked={this.state.copyOnSave}
onChange={this.onCopyOnSaveChange}
label={
<FormattedMessage
id="savedObjects.saveModal.saveAsNewLabel"
defaultMessage="Save as new {objectType}"
values={{ objectType: this.props.objectType }}
/>
}
/>
</>
<EuiSwitch
data-test-subj="saveAsNewCheckbox"
checked={this.state.copyOnSave}
onChange={this.onCopyOnSaveChange}
label={
<FormattedMessage
id="savedObjects.saveModal.saveAsNewLabel"
defaultMessage="Save as new {objectType}"
values={{ objectType: this.props.objectType }}
/>
}
/>
);
};
}

View file

@ -268,6 +268,11 @@ export type SavedObjectSaveModalTagSelectorComponentProps = EuiComboBoxProps<
* tags selection callback
*/
onTagsSelected: (ids: string[]) => void;
/**
* Add "Optional" to the label
*/
markOptional?: boolean;
};
/**

View file

@ -544,6 +544,7 @@ export const getTopNavConfig = (
onTagsSelected={(newSelection) => {
selectedTags = newSelection;
}}
markOptional
/>
);
}

View file

@ -16,6 +16,7 @@ interface VisualizeSaveModalArgs {
redirectToOrigin?: boolean;
addToDashboard?: boolean;
dashboardId?: string;
description?: string;
}
type DashboardPickerOption =
@ -393,10 +394,20 @@ export class VisualizePageObject extends FtrService {
public async setSaveModalValues(
vizName: string,
{ saveAsNew, redirectToOrigin, addToDashboard, dashboardId }: VisualizeSaveModalArgs = {}
{
saveAsNew,
redirectToOrigin,
addToDashboard,
dashboardId,
description,
}: VisualizeSaveModalArgs = {}
) {
await this.testSubjects.setValue('savedObjectTitle', vizName);
if (description) {
await this.testSubjects.setValue('viewDescription', description);
}
const saveAsNewCheckboxExists = await this.testSubjects.exists('saveAsNewCheckbox');
if (saveAsNewCheckboxExists) {
const state = saveAsNew ? 'check' : 'uncheck';

View file

@ -113,6 +113,7 @@ export function App({
isLoading,
isSaveable,
visualization,
annotationGroups,
} = useLensSelector((state) => state.lens);
const selectorDependencies = useMemo(
@ -180,7 +181,9 @@ export function App({
persistedDoc,
lastKnownDoc,
data.query.filterManager.inject.bind(data.query.filterManager),
datasourceMap
datasourceMap,
visualizationMap,
annotationGroups
) &&
(isSaveable || persistedDoc)
) {
@ -209,6 +212,8 @@ export function App({
application.capabilities.visualize.save,
data.query.filterManager,
datasourceMap,
visualizationMap,
annotationGroups,
]);
const getLegacyUrlConflictCallout = useCallback(() => {
@ -374,7 +379,14 @@ export function App({
initialDocFromContext,
persistedDoc,
].map((refDoc) =>
isLensEqual(refDoc, lastKnownDoc, data.query.filterManager.inject, datasourceMap)
isLensEqual(
refDoc,
lastKnownDoc,
data.query.filterManager.inject,
datasourceMap,
visualizationMap,
annotationGroups
)
);
if (initialDocFromContextUnchanged || currentDocHasBeenSavedInLens) {
onAppLeave((actions) => {
@ -386,6 +398,7 @@ export function App({
}
}
}, [
annotationGroups,
application,
data.query.filterManager.inject,
datasourceMap,
@ -394,6 +407,7 @@ export function App({
lastKnownDoc,
onAppLeave,
persistedDoc,
visualizationMap,
]);
const navigateToVizEditor = useCallback(() => {
@ -422,7 +436,6 @@ export function App({
dataViews,
uiActions,
core: { http, notifications, uiSettings },
data,
contextDataViewSpec: (initialContext as VisualizeFieldContext | undefined)?.dataViewSpec,
updateIndexPatterns: (newIndexPatternsState, options) => {
dispatch(updateIndexPatterns(newIndexPatternsState));
@ -437,7 +450,7 @@ export function App({
}
},
}),
[dataViews, uiActions, http, notifications, uiSettings, data, initialContext, dispatch]
[dataViews, uiActions, http, notifications, uiSettings, initialContext, dispatch]
);
const onTextBasedSavedAndExit = useCallback(async ({ onSave, onCancel }) => {
@ -597,7 +610,9 @@ export function App({
persistedDoc,
lastKnownDoc,
data.query.filterManager.inject.bind(data.query.filterManager),
datasourceMap
datasourceMap,
visualizationMap,
annotationGroups
)
}
goBackToOriginatingApp={goBackToOriginatingApp}

View file

@ -8,11 +8,19 @@
import { Filter, FilterStateStore } from '@kbn/es-query';
import { isLensEqual } from './lens_document_equality';
import { Document } from '../persistence/saved_object_store';
import { Datasource, DatasourceMap } from '../types';
import {
AnnotationGroups,
Datasource,
DatasourceMap,
Visualization,
VisualizationMap,
} from '../types';
const visualizationType = 'lnsSomeVis';
const defaultDoc: Document = {
title: 'some-title',
visualizationType: 'lnsXY',
visualizationType,
state: {
query: {
query: '',
@ -53,23 +61,67 @@ describe('lens document equality', () => {
);
let mockDatasourceMap: DatasourceMap;
let mockVisualizationMap: VisualizationMap;
let mockAnnotationGroups: AnnotationGroups;
beforeEach(() => {
mockDatasourceMap = {
indexpattern: { isEqual: jest.fn(() => true) },
} as unknown as DatasourceMap;
indexpattern: { isEqual: jest.fn(() => true) } as Partial<Datasource> as Datasource,
};
mockVisualizationMap = {
[visualizationType]: {
isEqual: jest.fn(() => true),
} as Partial<Visualization> as Visualization,
};
mockAnnotationGroups = {};
});
it('returns true when documents are equal', () => {
expect(
isLensEqual(defaultDoc, defaultDoc, mockInjectFilterReferences, mockDatasourceMap)
isLensEqual(
defaultDoc,
defaultDoc,
mockInjectFilterReferences,
mockDatasourceMap,
mockVisualizationMap,
mockAnnotationGroups
)
).toBeTruthy();
});
it('handles undefined documents', () => {
expect(isLensEqual(undefined, undefined, mockInjectFilterReferences, {})).toBeTruthy();
expect(isLensEqual(undefined, {} as Document, mockInjectFilterReferences, {})).toBeFalsy();
expect(isLensEqual({} as Document, undefined, mockInjectFilterReferences, {})).toBeFalsy();
expect(
isLensEqual(
undefined,
undefined,
mockInjectFilterReferences,
{},
mockVisualizationMap,
mockAnnotationGroups
)
).toBeTruthy();
expect(
isLensEqual(
undefined,
{} as Document,
mockInjectFilterReferences,
{},
mockVisualizationMap,
mockAnnotationGroups
)
).toBeFalsy();
expect(
isLensEqual(
{} as Document,
undefined,
mockInjectFilterReferences,
{},
mockVisualizationMap,
mockAnnotationGroups
)
).toBeFalsy();
});
it('should compare visualization type', () => {
@ -78,7 +130,9 @@ describe('lens document equality', () => {
defaultDoc,
{ ...defaultDoc, visualizationType: 'other-type' },
mockInjectFilterReferences,
mockDatasourceMap
mockDatasourceMap,
mockVisualizationMap,
mockAnnotationGroups
)
).toBeFalsy();
});
@ -98,26 +152,9 @@ describe('lens document equality', () => {
},
},
mockInjectFilterReferences,
mockDatasourceMap
)
).toBeFalsy();
});
it('should compare the visualization state', () => {
expect(
isLensEqual(
defaultDoc,
{
...defaultDoc,
state: {
...defaultDoc.state,
visualization: {
some: 'other-props',
},
},
},
mockInjectFilterReferences,
mockDatasourceMap
mockDatasourceMap,
mockVisualizationMap,
mockAnnotationGroups
)
).toBeFalsy();
});
@ -139,7 +176,9 @@ describe('lens document equality', () => {
},
},
mockInjectFilterReferences,
{ ...mockDatasourceMap, foodatasource: { isEqual: () => true } as unknown as Datasource }
{ ...mockDatasourceMap, foodatasource: { isEqual: () => true } as unknown as Datasource },
mockVisualizationMap,
mockAnnotationGroups
)
).toBeFalsy();
@ -167,7 +206,9 @@ describe('lens document equality', () => {
},
},
mockInjectFilterReferences,
{ ...mockDatasourceMap, foodatasource: { isEqual: () => true } as unknown as Datasource }
{ ...mockDatasourceMap, foodatasource: { isEqual: () => true } as unknown as Datasource },
mockVisualizationMap,
mockAnnotationGroups
)
).toBeTruthy();
});
@ -176,7 +217,67 @@ describe('lens document equality', () => {
// datasource's isEqual returns false
(mockDatasourceMap.indexpattern.isEqual as jest.Mock).mockReturnValue(false);
expect(
isLensEqual(defaultDoc, defaultDoc, mockInjectFilterReferences, mockDatasourceMap)
isLensEqual(
defaultDoc,
defaultDoc,
mockInjectFilterReferences,
mockDatasourceMap,
mockVisualizationMap,
mockAnnotationGroups
)
).toBeFalsy();
});
});
describe('comparing the visualizations', () => {
it('delegates to visualization class if visualization.isEqual available', () => {
expect(
isLensEqual(
defaultDoc,
defaultDoc,
mockInjectFilterReferences,
mockDatasourceMap,
mockVisualizationMap,
mockAnnotationGroups
)
).toBeTruthy();
expect(mockVisualizationMap[visualizationType].isEqual).toHaveBeenCalled();
(mockVisualizationMap[visualizationType].isEqual as jest.Mock).mockReturnValue(false);
expect(
isLensEqual(
defaultDoc,
defaultDoc,
mockInjectFilterReferences,
mockDatasourceMap,
mockVisualizationMap,
mockAnnotationGroups
)
).toBeFalsy();
});
it('direct comparison if no isEqual implementation', () => {
delete mockVisualizationMap[visualizationType].isEqual;
expect(
isLensEqual(
defaultDoc,
{
...defaultDoc,
state: {
...defaultDoc.state,
visualization: {
some: 'other-props',
},
},
},
mockInjectFilterReferences,
mockDatasourceMap,
mockVisualizationMap,
mockAnnotationGroups
)
).toBeFalsy();
});
});
@ -197,7 +298,9 @@ describe('lens document equality', () => {
defaultDoc,
{ ...defaultDoc, state: { ...defaultDoc.state, filters: filtersWithPinned } },
mockInjectFilterReferences,
mockDatasourceMap
mockDatasourceMap,
mockVisualizationMap,
mockAnnotationGroups
)
).toBeTruthy();
});
@ -221,7 +324,9 @@ describe('lens document equality', () => {
},
},
mockInjectFilterReferences,
mockDatasourceMap
mockDatasourceMap,
mockVisualizationMap,
mockAnnotationGroups
)
).toBeTruthy();
});
@ -241,7 +346,9 @@ describe('lens document equality', () => {
},
},
mockInjectFilterReferences,
mockDatasourceMap
mockDatasourceMap,
mockVisualizationMap,
mockAnnotationGroups
)
).toBeTruthy();
});

View file

@ -8,7 +8,7 @@
import { isEqual, intersection, union } from 'lodash';
import { FilterManager } from '@kbn/data-plugin/public';
import { Document } from '../persistence/saved_object_store';
import { DatasourceMap } from '../types';
import { AnnotationGroups, DatasourceMap, VisualizationMap } from '../types';
import { removePinnedFilters } from './save_modal_container';
const removeNonSerializable = (obj: Parameters<JSON['stringify']>[0]) =>
@ -18,7 +18,9 @@ export const isLensEqual = (
doc1In: Document | undefined,
doc2In: Document | undefined,
injectFilterReferences: FilterManager['inject'],
datasourceMap: DatasourceMap
datasourceMap: DatasourceMap,
visualizationMap: VisualizationMap,
annotationGroups: AnnotationGroups
) => {
if (doc1In === undefined || doc2In === undefined) {
return doc1In === doc2In;
@ -36,7 +38,23 @@ export const isLensEqual = (
return false;
}
if (!isEqual(doc1.state.visualization, doc2.state.visualization)) {
const isEqualFromVis = visualizationMap[doc1.visualizationType]?.isEqual;
const visualizationStateIsEqual = isEqualFromVis
? (() => {
try {
return isEqualFromVis(
doc1.state.visualization,
doc1.references,
doc2.state.visualization,
doc2.references,
annotationGroups
);
} catch (err) {
return false;
}
})()
: isEqual(doc1.state.visualization, doc2.state.visualization);
if (!visualizationStateIsEqual) {
return false;
}

View file

@ -96,6 +96,7 @@ export async function getLensServices(
inspector,
navigation,
embeddable,
eventAnnotation,
savedObjectsTagging,
usageCollection,
fieldFormats,
@ -107,6 +108,7 @@ export async function getLensServices(
const storage = new Storage(localStorage);
const stateTransfer = embeddable?.getStateTransfer();
const embeddableEditorIncomingState = stateTransfer?.getIncomingEditorState(APP_ID);
const eventAnnotationService = await eventAnnotation.getService();
return {
data,
@ -118,6 +120,7 @@ export async function getLensServices(
usageCollection,
savedObjectsTagging,
attributeService,
eventAnnotationService,
executionContext: coreStart.executionContext,
http: coreStart.http,
uiActions: startDependencies.uiActions,

View file

@ -25,7 +25,10 @@ import { showMemoizedErrorNotification } from '../lens_ui_errors';
import { TableInspectorAdapter } from '../editor_frame_service/types';
import { Datasource, DatasourcePublicAPI, IndexPatternMap } from '../types';
import { Visualization } from '..';
import { getLayerType } from '../editor_frame_service/editor_frame/config_panel/add_layer';
function getLayerType(visualization: Visualization, state: unknown, layerId: string) {
return visualization.getLayerType(layerId, state) || LayerTypes.DATA;
}
/**
* Joins a series of queries.

View file

@ -43,6 +43,7 @@ export const TagEnhancedSavedObjectSaveModalDashboard: FC<
<savedObjectsTagging.ui.components.SavedObjectSaveModalTagSelector
initialSelection={initialTags}
onTagsSelected={setSelectedTags}
markOptional
/>
) : undefined,
[savedObjectsTagging, initialTags]

View file

@ -45,6 +45,7 @@ import type { ChartsPluginSetup } from '@kbn/charts-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import type { SharePluginStart } from '@kbn/share-plugin/public';
import type { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import type { SettingsStart } from '@kbn/core-ui-settings-browser';
import type {
DatasourceMap,
@ -148,6 +149,7 @@ export interface LensAppServices {
dataViews: DataViewsPublicPluginStart;
fieldFormats: FieldFormatsStart;
data: DataPublicPluginStart;
eventAnnotationService: EventAnnotationServiceType;
inspector: LensInspector;
uiSettings: IUiSettingsClient;
settings: SettingsStart;

View file

@ -8,20 +8,18 @@
import type { DataViewsContract, DataView, DataViewSpec } from '@kbn/data-views-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { ActionExecutionContext, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import {
UPDATE_FILTER_REFERENCES_ACTION,
UPDATE_FILTER_REFERENCES_TRIGGER,
} from '@kbn/unified-search-plugin/public';
import type { IndexPattern, IndexPatternMap, IndexPatternRef } from '../types';
import { ensureIndexPattern, loadIndexPatternRefs, loadIndexPatterns } from './loader';
import type { IndexPattern, IndexPatternMap } from '../types';
import { ensureIndexPattern, loadIndexPatterns } from './loader';
import type { DataViewsState } from '../state_management';
import { generateId } from '../id_generator';
export interface IndexPatternServiceProps {
core: Pick<CoreStart, 'http' | 'notifications' | 'uiSettings'>;
data: DataPublicPluginStart;
dataViews: DataViewsContract;
uiActions: UiActionsStart;
contextDataViewSpec?: DataViewSpec;
@ -54,10 +52,7 @@ export interface IndexPatternServiceAPI {
cache: IndexPatternMap;
onIndexPatternRefresh?: () => void;
}) => Promise<IndexPatternMap>;
/**
* Load indexPatternRefs with title and ids
*/
loadIndexPatternRefs: (options: { isFullEditor: boolean }) => Promise<IndexPatternRef[]>;
/**
* Ensure an indexPattern is loaded in the cache, usually used in conjuction with a indexPattern change action.
*/
@ -81,16 +76,15 @@ export interface IndexPatternServiceAPI {
) => void;
}
export function createIndexPatternService({
export const createIndexPatternService = ({
core,
dataViews,
data,
updateIndexPatterns,
replaceIndexPattern,
uiActions,
contextDataViewSpec,
}: IndexPatternServiceProps): IndexPatternServiceAPI {
const onChangeError = (err: Error) =>
}: IndexPatternServiceProps): IndexPatternServiceAPI => {
const showLoadingDataViewError = (err: Error) =>
core.notifications.toasts.addError(err, {
title: i18n.translate('xpack.lens.indexPattern.dataViewLoadError', {
defaultMessage: 'Error loading data view',
@ -131,9 +125,7 @@ export function createIndexPatternService({
} as ActionExecutionContext);
},
ensureIndexPattern: (args) =>
ensureIndexPattern({ onError: onChangeError, dataViews, ...args }),
loadIndexPatternRefs: async ({ isFullEditor }) =>
isFullEditor ? loadIndexPatternRefs(dataViews) : [],
ensureIndexPattern({ onError: showLoadingDataViewError, dataViews, ...args }),
getDefaultIndex: () => core.uiSettings.get('defaultIndex'),
};
}
};

View file

@ -1,158 +0,0 @@
/*
* 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.
*/
import React, { useState, useMemo } from 'react';
import {
EuiToolTip,
EuiButton,
EuiPopover,
EuiIcon,
EuiContextMenu,
EuiBadge,
EuiFlexItem,
EuiFlexGroup,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import type { LayerType } from '../../../../common/types';
import type { FramePublicAPI, Visualization } from '../../../types';
interface AddLayerButtonProps {
visualization: Visualization;
visualizationState: unknown;
onAddLayerClick: (layerType: LayerType) => void;
layersMeta: Pick<FramePublicAPI, 'datasourceLayers' | 'activeData'>;
}
export function getLayerType(visualization: Visualization, state: unknown, layerId: string) {
return visualization.getLayerType(layerId, state) || LayerTypes.DATA;
}
export function AddLayerButton({
visualization,
visualizationState,
onAddLayerClick,
layersMeta,
}: AddLayerButtonProps) {
const [showLayersChoice, toggleLayersChoice] = useState(false);
const supportedLayers = useMemo(() => {
if (!visualization.appendLayer || !visualizationState) {
return null;
}
return visualization
.getSupportedLayers?.(visualizationState, layersMeta)
?.filter(({ canAddViaMenu: hideFromMenu }) => !hideFromMenu);
}, [visualization, visualizationState, layersMeta]);
if (supportedLayers == null || !supportedLayers.length) {
return null;
}
if (supportedLayers.length === 1) {
return (
<EuiToolTip
display="block"
title={i18n.translate('xpack.lens.xyChart.addLayer', {
defaultMessage: 'Add a layer',
})}
content={i18n.translate('xpack.lens.xyChart.addLayerTooltip', {
defaultMessage:
'Use multiple layers to combine visualization types or visualize different data views.',
})}
position="bottom"
>
<EuiButton
size="s"
fullWidth
data-test-subj="lnsLayerAddButton"
aria-label={i18n.translate('xpack.lens.configPanel.addLayerButton', {
defaultMessage: 'Add layer',
})}
onClick={() => onAddLayerClick(supportedLayers[0].type)}
iconType="layers"
>
{i18n.translate('xpack.lens.configPanel.addLayerButton', {
defaultMessage: 'Add layer',
})}
</EuiButton>
</EuiToolTip>
);
}
return (
<EuiPopover
display="block"
data-test-subj="lnsConfigPanel__addLayerPopover"
button={
<EuiButton
size="s"
fullWidth
data-test-subj="lnsLayerAddButton"
aria-label={i18n.translate('xpack.lens.configPanel.addLayerButton', {
defaultMessage: 'Add layer',
})}
onClick={() => toggleLayersChoice(!showLayersChoice)}
iconType="layers"
>
{i18n.translate('xpack.lens.configPanel.addLayerButton', {
defaultMessage: 'Add layer',
})}
</EuiButton>
}
isOpen={showLayersChoice}
closePopover={() => toggleLayersChoice(false)}
panelPaddingSize="none"
>
<EuiContextMenu
initialPanelId={0}
panels={[
{
id: 0,
title: i18n.translate('xpack.lens.configPanel.selectLayerType', {
defaultMessage: 'Select layer type',
}),
width: 300,
items: supportedLayers.map(({ type, label, icon, disabled, toolTipContent }) => {
return {
toolTipContent,
disabled,
name:
type === LayerTypes.ANNOTATIONS ? (
<EuiFlexGroup gutterSize="m">
<EuiFlexItem>
<span className="lnsLayerAddButton__label">{label}</span>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge
className="lnsLayerAddButton__techBadge"
color="hollow"
isDisabled={disabled}
>
{i18n.translate('xpack.lens.configPanel.experimentalLabel', {
defaultMessage: 'Technical preview',
})}
</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
) : (
<span className="lnsLayerAddButtonLabel">{label}</span>
),
className: 'lnsLayerAddButton',
icon: icon && <EuiIcon size="m" type={icon} />,
['data-test-subj']: `lnsLayerAddButton-${type}`,
onClick: () => {
onAddLayerClick(type);
toggleLayersChoice(false);
},
};
}),
},
]}
/>
</EuiPopover>
);
}

View file

@ -35,9 +35,9 @@ export function shouldRemoveSource(source: DragDropIdentifier, dropType: DropTyp
);
}
export function onDropForVisualization<T, P = unknown>(
export function onDropForVisualization<T, P = unknown, E = unknown>(
props: OnVisDropProps<T>,
activeVisualization: Visualization<T, P>
activeVisualization: Visualization<T, P, E>
) {
const { prevState, target, frame, source, group } = props;
const { layerId, columnId, groupId } = target;

View file

@ -16,6 +16,7 @@ import {
mockStoreDeps,
MountStoreProps,
} from '../../../mocks';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { Visualization } from '../../../types';
import { LayerPanels } from './config_panel';
import { LayerPanel } from './layer_panel';
@ -25,11 +26,11 @@ import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { generateId } from '../../../id_generator';
import { mountWithProvider } from '../../../mocks';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import type { LayerType } from '../../../../common/types';
import { ReactWrapper } from 'enzyme';
import { addLayer } from '../../../state_management';
import { AddLayerButton } from './add_layer';
import { createIndexPatternServiceMock } from '../../../mocks/data_views_service_mock';
import { AddLayerButton } from '../../../visualizations/xy/add_layer';
import { LayerType } from '@kbn/visualizations-plugin/common';
jest.mock('../../../id_generator');
@ -43,6 +44,11 @@ jest.mock('@kbn/kibana-utils-plugin/public', () => {
};
});
const addNewLayer = (instance: ReactWrapper, type: LayerType = LayerTypes.REFERENCELINE) =>
act(() => {
instance.find(`button[data-test-subj="${type}"]`).first().simulate('click');
});
const waitMs = (time: number) => new Promise((r) => setTimeout(r, time));
let container: HTMLDivElement | undefined;
@ -117,7 +123,21 @@ describe('ConfigPanel', () => {
activeVisualization: {
...visualizationMap.testVis,
getLayerIds: () => Object.keys(frame.datasourceLayers),
} as unknown as Visualization,
getAddLayerButtonComponent: (props) => {
return (
<>
<button
data-test-subj={LayerTypes.REFERENCELINE}
onClick={() => props.addLayer(LayerTypes.REFERENCELINE)}
/>
<button
data-test-subj={LayerTypes.ANNOTATIONS}
onClick={() => props.addLayer(LayerTypes.ANNOTATIONS)}
/>
</>
);
},
} as Visualization,
datasourceStates: {
testDatasource: {
isLoading: false,
@ -135,6 +155,7 @@ describe('ConfigPanel', () => {
isFullscreen: false,
toggleFullscreen: jest.fn(),
uiActions,
dataViews: {} as DataViewsPublicPluginStart,
getUserMessages: () => [],
};
}
@ -257,34 +278,13 @@ describe('ConfigPanel', () => {
}),
});
act(() => {
instance.find('button[data-test-subj="lnsLayerAddButton"]').first().simulate('click');
});
addNewLayer(instance);
const focusedEl = document.activeElement;
expect(focusedEl?.children[0].getAttribute('data-test-subj')).toEqual('lns-layerPanel-1');
});
});
describe('initial default value', () => {
function clickToAddLayer(
instance: ReactWrapper,
layerType: LayerType = LayerTypes.REFERENCELINE
) {
act(() => {
instance.find('button[data-test-subj="lnsLayerAddButton"]').first().simulate('click');
});
instance.update();
act(() => {
instance
.find(`[data-test-subj="lnsLayerAddButton-${layerType}"]`)
.first()
.simulate('click');
});
instance.update();
return waitMs(0);
}
function clickToAddDimension(instance: ReactWrapper) {
act(() => {
instance.find('[data-test-subj="lns-empty-dimension"]').last().simulate('click');
@ -307,7 +307,7 @@ describe('ConfigPanel', () => {
const props = getDefaultProps({ datasourceMap, visualizationMap });
const { instance, lensStore } = await prepareAndMountComponent(props);
await clickToAddLayer(instance);
addNewLayer(instance);
expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
expect(datasourceMap.testDatasource.initializeDimension).not.toHaveBeenCalled();
@ -337,8 +337,8 @@ describe('ConfigPanel', () => {
]);
const props = getDefaultProps({ datasourceMap, visualizationMap });
const { instance, lensStore } = await prepareAndMountComponent(props);
await clickToAddLayer(instance);
addNewLayer(instance);
expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
expect(datasourceMap.testDatasource.initializeDimension).not.toHaveBeenCalled();
});
@ -364,7 +364,7 @@ describe('ConfigPanel', () => {
const props = getDefaultProps({ datasourceMap, visualizationMap });
const { instance, lensStore } = await prepareAndMountComponent(props);
await clickToAddLayer(instance);
addNewLayer(instance);
expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
expect(datasourceMap.testDatasource.initializeDimension).toHaveBeenCalledWith(
@ -473,7 +473,9 @@ describe('ConfigPanel', () => {
datasourceMap.testDatasource.initializeDimension = jest.fn();
const props = getDefaultProps({ visualizationMap, datasourceMap });
const { instance, lensStore } = await prepareAndMountComponent(props);
await clickToAddLayer(instance, LayerTypes.ANNOTATIONS);
addNewLayer(instance, LayerTypes.ANNOTATIONS);
expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
expect(visualizationMap.testVis.setDimension).toHaveBeenCalledWith({

View file

@ -13,9 +13,8 @@ import {
UPDATE_FILTER_REFERENCES_ACTION,
UPDATE_FILTER_REFERENCES_TRIGGER,
} from '@kbn/unified-search-plugin/public';
import { LayerType } from '../../../../common/types';
import { changeIndexPattern, removeDimension } from '../../../state_management/lens_slice';
import { Visualization } from '../../../types';
import { AddLayerFunction, Visualization } from '../../../types';
import { LayerPanel } from './layer_panel';
import { generateId } from '../../../id_generator';
import { ConfigPanelWrapperProps } from './types';
@ -32,8 +31,8 @@ import {
setToggleFullscreen,
useLensSelector,
selectVisualization,
registerLibraryAnnotationGroup,
} from '../../../state_management';
import { AddLayerButton } from './add_layer';
import { getRemoveOperation } from '../../../utils';
export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) {
@ -218,6 +217,7 @@ export function LayerPanels(
id: indexPatternId,
cache: props.framePublicAPI.dataViews.indexPatterns,
});
dispatchLens(
changeIndexPattern({
indexPatternId,
@ -231,9 +231,9 @@ export function LayerPanels(
[dispatchLens, props.framePublicAPI.dataViews.indexPatterns, props.indexPatternService]
);
const addLayer = (layerType: LayerType) => {
const addLayer: AddLayerFunction = (layerType, extraArg, ignoreInitialValues) => {
const layerId = generateId();
dispatchLens(addLayerAction({ layerId, layerType }));
dispatchLens(addLayerAction({ layerId, layerType, extraArg, ignoreInitialValues }));
setNextFocusedLayerId(layerId);
};
@ -314,14 +314,45 @@ export function LayerPanels(
)
);
})}
{!hideAddLayerButton && (
<AddLayerButton
visualization={activeVisualization}
visualizationState={visualization.state}
layersMeta={props.framePublicAPI}
onAddLayerClick={(layerType) => addLayer(layerType)}
/>
)}
{!hideAddLayerButton &&
activeVisualization?.getAddLayerButtonComponent?.({
supportedLayers: activeVisualization.getSupportedLayers(
visualization.state,
props.framePublicAPI
),
addLayer,
ensureIndexPattern: async (specOrId) => {
let indexPatternId;
if (typeof specOrId === 'string') {
indexPatternId = specOrId;
} else {
const dataView = await props.dataViews.create(specOrId);
if (!dataView.id) {
return;
}
indexPatternId = dataView.id;
}
const newIndexPatterns = await indexPatternService.ensureIndexPattern({
id: indexPatternId,
cache: props.framePublicAPI.dataViews.indexPatterns,
});
dispatchLens(
changeIndexPattern({
dataViews: { indexPatterns: newIndexPatterns },
datasourceIds: Object.keys(datasourceStates),
visualizationIds: visualization.activeId ? [visualization.activeId] : [],
indexPatternId,
})
);
},
registerLibraryAnnotationGroup: (groupInfo) =>
dispatchLens(registerLibraryAnnotationGroup(groupInfo)),
})}
</EuiForm>
);
}

View file

@ -8,7 +8,7 @@
import './dimension_container.scss';
import React from 'react';
import { FlyoutContainer } from './flyout_container';
import { FlyoutContainer } from '../../../shared_components/flyout_container';
export function DimensionContainer({
panel,

View file

@ -8,6 +8,7 @@
import { i18n } from '@kbn/i18n';
import type { LayerAction } from '../../../../types';
import type { Visualization } from '../../../..';
import { FIRST_ACTION_ORDER } from './order_bounds';
interface CloneLayerAction {
execute: () => void;
@ -22,11 +23,11 @@ export const getCloneLayerAction = (props: CloneLayerAction): LayerAction => {
});
return {
id: 'cloneLayerAction',
execute: props.execute,
displayName,
isCompatible: Boolean(props.activeVisualization.cloneLayer && !props.isTextBasedLanguage),
icon: 'copy',
'data-test-subj': `lnsLayerClone--${props.layerIndex}`,
order: FIRST_ACTION_ORDER + 1,
};
};

View file

@ -17,6 +17,9 @@ import {
EuiText,
EuiOutsideClickDetector,
useEuiTheme,
EuiFlexGroup,
EuiFlexItem,
EuiToolTip,
} from '@elastic/eui';
import type { CoreStart } from '@kbn/core/public';
import { css } from '@emotion/react';
@ -29,6 +32,7 @@ import { getOpenLayerSettingsAction } from './open_layer_settings';
export interface LayerActionsProps {
layerIndex: number;
actions: LayerAction[];
mountingPoint?: HTMLDivElement | null | undefined;
}
/** @internal **/
@ -129,9 +133,10 @@ const InContextMenuActions = (props: LayerActionsProps) => {
data-test-subj={i['data-test-subj']}
aria-label={i.displayName}
title={i.displayName}
disabled={i.disabled}
onClick={() => {
closePopover();
i.execute();
i.execute(props.mountingPoint);
}}
{...(i.color
? {
@ -158,20 +163,46 @@ export const LayerActions = (props: LayerActionsProps) => {
return null;
}
if (props.actions.length > 1) {
return <InContextMenuActions {...props} />;
}
const [{ displayName, execute, icon, color, 'data-test-subj': dataTestSubj }] = props.actions;
const sortedActions = [...props.actions].sort(
({ order: order1 }, { order: order2 }) => order1 - order2
);
const outsideListAction =
sortedActions.length === 1
? sortedActions[0]
: sortedActions.find((action) => action.showOutsideList);
const listActions = sortedActions.filter((action) => action !== outsideListAction);
return (
<EuiButtonIcon
size="xs"
iconType={icon}
color={color}
data-test-subj={dataTestSubj}
aria-label={displayName}
title={displayName}
onClick={execute}
/>
<EuiFlexGroup
css={css`
gap: 0;
`}
responsive={false}
alignItems="center"
direction="row"
justifyContent="flexEnd"
>
{outsideListAction && (
<EuiFlexItem grow={false}>
<EuiToolTip content={outsideListAction.displayName}>
<EuiButtonIcon
size="xs"
iconType={outsideListAction.icon}
color={outsideListAction.color ?? 'text'}
data-test-subj={outsideListAction['data-test-subj']}
aria-label={outsideListAction.displayName}
title={outsideListAction.displayName}
disabled={outsideListAction.disabled}
onClick={() => outsideListAction.execute?.(props.mountingPoint)}
/>
</EuiToolTip>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<InContextMenuActions {...props} actions={listActions} />
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -7,6 +7,7 @@
import { i18n } from '@kbn/i18n';
import type { LayerAction } from '../../../../types';
import { FIRST_ACTION_ORDER } from './order_bounds';
export const getOpenLayerSettingsAction = (props: {
openLayerSettings: () => void;
@ -17,11 +18,11 @@ export const getOpenLayerSettingsAction = (props: {
});
return {
id: 'openLayerSettings',
displayName,
execute: props.openLayerSettings,
icon: 'gear',
isCompatible: props.hasLayerSettings,
'data-test-subj': 'lnsLayerSettings',
order: FIRST_ACTION_ORDER,
};
};

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const LAST_ACTION_ORDER = Number.MAX_SAFE_INTEGER;
export const FIRST_ACTION_ORDER = -Number.MAX_SAFE_INTEGER;

View file

@ -26,6 +26,7 @@ import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import type { LayerAction } from '../../../../types';
import { LOCAL_STORAGE_LENS_KEY } from '../../../../settings_storage';
import type { LayerType } from '../../../../../common/types';
import { LAST_ACTION_ORDER } from './order_bounds';
interface RemoveLayerAction {
execute: () => void;
@ -196,7 +197,6 @@ export const getRemoveLayerAction = (props: RemoveLayerAction): LayerAction => {
);
return {
id: 'removeLayerAction',
execute: async () => {
const storage = new Storage(localStorage);
const lensLocalStorage = storage.get(LOCAL_STORAGE_LENS_KEY) ?? {};
@ -224,6 +224,7 @@ export const getRemoveLayerAction = (props: RemoveLayerAction): LayerAction => {
),
{
'data-test-subj': 'lnsLayerRemoveModal',
maxWidth: 600,
}
);
await modal.onClose;
@ -236,5 +237,6 @@ export const getRemoveLayerAction = (props: RemoveLayerAction): LayerAction => {
icon: props.isOnlyLayer ? 'eraser' : 'trash',
color: 'danger',
'data-test-subj': `lnsLayerRemove--${props.layerIndex}`,
order: LAST_ACTION_ORDER,
};
};

View file

@ -22,7 +22,6 @@ import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { DragDropIdentifier, ReorderProvider, DropType } from '@kbn/dom-drag-drop';
import { DimensionButton } from '@kbn/visualization-ui-components/public';
import { LayerType } from '../../../../common/types';
import { LayerActions } from './layer_actions';
import { IndexPatternServiceAPI } from '../../../data_views_service/service';
import { NativeRenderer } from '../../../native_renderer';
@ -34,6 +33,7 @@ import {
LayerAction,
VisualizationDimensionGroupConfig,
UserMessagesGetter,
AddLayerFunction,
} from '../../../types';
import { LayerSettings } from './layer_settings';
import { LayerPanelProps, ActiveDimensionState } from './types';
@ -49,7 +49,7 @@ import {
} from '../../../state_management';
import { onDropForVisualization, shouldRemoveSource } from './buttons/drop_targets_utils';
import { getSharedActions } from './layer_actions/layer_actions';
import { FlyoutContainer } from './flyout_container';
import { FlyoutContainer } from '../../../shared_components/flyout_container';
const initialActiveDimensionState = {
isNew: false,
@ -62,7 +62,7 @@ export function LayerPanel(
layerId: string;
layerIndex: number;
isOnlyLayer: boolean;
addLayer: (layerType: LayerType) => void;
addLayer: AddLayerFunction;
updateVisualization: StateSetter<unknown>;
updateDatasource: (
datasourceId: string | undefined,
@ -118,6 +118,8 @@ export function LayerPanel(
core,
} = props;
const isSaveable = useLensSelector((state) => state.lens.isSaveable);
const datasourceStates = useLensSelector(selectDatasourceStates);
const isFullscreen = useLensSelector(selectIsFullscreenDatasource);
const dateRange = useLensSelector(selectResolvedDateRange);
@ -128,6 +130,7 @@ export function LayerPanel(
const panelRef = useRef<HTMLDivElement | null>(null);
const settingsPanelRef = useRef<HTMLDivElement | null>(null);
const registerLayerRef = useCallback(
(el) => registerNewLayerRef(layerId, el),
[layerId, registerNewLayerRef]
@ -332,15 +335,19 @@ export function LayerPanel(
() =>
[
...(activeVisualization
.getSupportedActionsForLayer?.(layerId, visualizationState)
.getSupportedActionsForLayer?.(
layerId,
visualizationState,
updateVisualization,
isSaveable
)
.map((action) => ({
...action,
execute: () => {
updateVisualization(
activeVisualization.onLayerAction?.(layerId, action.id, visualizationState)
);
action.execute(layerActionsFlyoutRef.current);
},
})) || []),
...getSharedActions({
layerId,
activeVisualization,
@ -373,8 +380,10 @@ export function LayerPanel(
updateVisualization,
visualizationLayerSettings,
visualizationState,
isSaveable,
]
);
const layerActionsFlyoutRef = useRef<HTMLDivElement | null>(null);
return (
<>
@ -398,7 +407,12 @@ export function LayerPanel(
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LayerActions actions={compatibleActions} layerIndex={layerIndex} />
<LayerActions
actions={compatibleActions}
layerIndex={layerIndex}
mountingPoint={layerActionsFlyoutRef.current}
/>
<div ref={layerActionsFlyoutRef} />
</EuiFlexItem>
</EuiFlexGroup>
{(layerDatasource || activeVisualization.renderLayerPanel) && <EuiSpacer size="s" />}

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { IndexPatternServiceAPI } from '../../../data_views_service/service';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type { IndexPatternServiceAPI } from '../../../data_views_service/service';
import {
Visualization,
@ -23,6 +24,7 @@ export interface ConfigPanelWrapperProps {
datasourceMap: DatasourceMap;
visualizationMap: VisualizationMap;
core: DatasourceDimensionEditorProps['core'];
dataViews: DataViewsPublicPluginStart;
indexPatternService: IndexPatternServiceAPI;
uiActions: UiActionsStart;
getUserMessages: UserMessagesGetter;

View file

@ -15,6 +15,7 @@ import { disableAutoApply } from '../../state_management/lens_slice';
import { selectTriggerApplyChanges } from '../../state_management';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { createIndexPatternServiceMock } from '../../mocks/data_views_service_mock';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
describe('Data Panel Wrapper', () => {
describe('Datasource data panel properties', () => {
@ -39,7 +40,11 @@ describe('Data Panel Wrapper', () => {
core={{} as DatasourceDataPanelProps['core']}
dropOntoWorkspace={(field: DragDropIdentifier) => {}}
hasSuggestionForField={(field: DragDropIdentifier) => true}
plugins={{ uiActions: {} as UiActionsStart, dataViews: {} as DataViewsPublicPluginStart }}
plugins={{
uiActions: {} as UiActionsStart,
dataViews: {} as DataViewsPublicPluginStart,
eventAnnotationService: {} as EventAnnotationServiceType,
}}
indexPatternService={createIndexPatternServiceMock()}
frame={createMockFramePublicAPI()}
/>,

View file

@ -11,6 +11,7 @@ import React, { useMemo, memo, useContext, useEffect, useCallback } from 'react'
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import { DragContext, DragDropIdentifier } from '@kbn/dom-drag-drop';
import { Easteregg } from './easteregg';
import { NativeRenderer } from '../../native_renderer';
@ -44,7 +45,11 @@ interface DataPanelWrapperProps {
core: DatasourceDataPanelProps['core'];
dropOntoWorkspace: (field: DragDropIdentifier) => void;
hasSuggestionForField: (field: DragDropIdentifier) => boolean;
plugins: { uiActions: UiActionsStart; dataViews: DataViewsPublicPluginStart };
plugins: {
uiActions: UiActionsStart;
dataViews: DataViewsPublicPluginStart;
eventAnnotationService: EventAnnotationServiceType;
};
indexPatternService: IndexPatternServiceAPI;
frame: FramePublicAPI;
}
@ -80,6 +85,7 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => {
initializeSources(
{
datasourceMap: props.datasourceMap,
eventAnnotationService: props.plugins.eventAnnotationService,
visualizationMap: props.visualizationMap,
visualizationState,
datasourceStates,
@ -127,6 +133,7 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => {
dispatchLens,
props.plugins.dataViews,
props.core.uiSettings,
props.plugins.eventAnnotationService,
]);
const onChangeIndexPattern = useCallback(

View file

@ -51,6 +51,7 @@ import { getLensInspectorService } from '../../lens_inspector_service';
import { toExpression } from '@kbn/interpreter';
import { createIndexPatternServiceMock } from '../../mocks/data_views_service_mock';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
function generateSuggestion(state = {}): DatasourceSuggestion {
return {
@ -94,6 +95,7 @@ function getDefaultProps() {
expressions: expressionsPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
dataViews: wrapDataViewsContract(),
eventAnnotationService: {} as EventAnnotationServiceType,
},
palettes: chartPluginMock.createPaletteRegistry(),
lensInspector: getLensInspectorService(inspectorPluginMock.createStartContract()),

View file

@ -142,6 +142,7 @@ export function EditorFrame(props: EditorFrameProps) {
visualizationMap={visualizationMap}
framePublicAPI={framePublicAPI}
uiActions={props.plugins.uiActions}
dataViews={props.plugins.dataViews}
indexPatternService={props.indexPatternService}
getUserMessages={props.getUserMessages}
/>

View file

@ -13,6 +13,11 @@ import type { DataViewsContract, DataViewSpec } from '@kbn/data-views-plugin/pub
import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common';
import type { TimefilterContract } from '@kbn/data-plugin/public';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import {
EventAnnotationGroupConfig,
EVENT_ANNOTATION_GROUP_TYPE,
} from '@kbn/event-annotation-plugin/common';
import type {
Datasource,
DatasourceMap,
@ -32,12 +37,13 @@ import { loadIndexPatternRefs, loadIndexPatterns } from '../../data_views_servic
import { getDatasourceLayers } from '../../state_management/utils';
function getIndexPatterns(
annotationGroupDataviewIds: string[],
references?: SavedObjectReference[],
initialContext?: VisualizeFieldContext | VisualizeEditorContext,
initialId?: string,
adHocDataviews?: string[]
) {
const indexPatternIds = [];
const indexPatternIds = [...annotationGroupDataviewIds];
// use the initialId only when no context is passed over
if (!initialContext && initialId) {
@ -96,6 +102,7 @@ export async function initializeDataViews(
references,
initialContext,
adHocDataViews: persistedAdHocDataViews,
annotationGroups,
}: {
dataViews: DataViewsContract;
datasourceMap: DatasourceMap;
@ -105,6 +112,7 @@ export async function initializeDataViews(
references?: SavedObjectReference[];
initialContext?: VisualizeFieldContext | VisualizeEditorContext;
adHocDataViews?: Record<string, DataViewSpec>;
annotationGroups: Record<string, EventAnnotationGroupConfig>;
},
options?: InitializationOptions
) {
@ -114,6 +122,14 @@ export async function initializeDataViews(
return [id, spec];
})
);
const annotationGroupValues = Object.values(annotationGroups);
for (const group of annotationGroupValues) {
if (group.dataViewSpec?.id) {
adHocDataViews[group.dataViewSpec.id] = group.dataViewSpec;
}
}
const { isFullEditor } = options ?? {};
// make it explicit or TS will infer never[] and break few lines down
@ -133,6 +149,7 @@ export async function initializeDataViews(
const adHocDataviewsIds: string[] = Object.keys(adHocDataViews || {});
const usedIndexPatternsIds = getIndexPatterns(
annotationGroupValues.map((group) => group.indexPatternId),
references,
initialContext,
initialId,
@ -163,12 +180,32 @@ export async function initializeDataViews(
};
}
const initializeEventAnnotationGroups = async (
eventAnnotationService: EventAnnotationServiceType,
references?: SavedObjectReference[]
) => {
const annotationGroups: Record<string, EventAnnotationGroupConfig> = {};
await Promise.allSettled(
(references || [])
.filter((ref) => ref.type === EVENT_ANNOTATION_GROUP_TYPE)
.map(({ id }) =>
eventAnnotationService.loadAnnotationGroup(id).then((group) => {
annotationGroups[id] = group;
})
)
);
return annotationGroups;
};
/**
* This function composes both initializeDataViews & initializeDatasources into a single call
*/
export async function initializeSources(
{
dataViews,
eventAnnotationService,
datasourceMap,
visualizationMap,
visualizationState,
@ -180,6 +217,7 @@ export async function initializeSources(
adHocDataViews,
}: {
dataViews: DataViewsContract;
eventAnnotationService: EventAnnotationServiceType;
datasourceMap: DatasourceMap;
visualizationMap: VisualizationMap;
visualizationState: VisualizationState;
@ -192,6 +230,11 @@ export async function initializeSources(
},
options?: InitializationOptions
) {
const annotationGroups = await initializeEventAnnotationGroups(
eventAnnotationService,
references
);
const { indexPatternRefs, indexPatterns } = await initializeDataViews(
{
datasourceMap,
@ -202,12 +245,15 @@ export async function initializeSources(
defaultIndexPatternId,
references,
adHocDataViews,
annotationGroups,
},
options
);
return {
indexPatterns,
indexPatternRefs,
annotationGroups,
datasourceStates: initializeDatasources({
datasourceMap,
datasourceStates,
@ -221,6 +267,7 @@ export async function initializeSources(
visualizationState,
references,
initialContext,
annotationGroups,
}),
};
}
@ -229,12 +276,13 @@ export function initializeVisualization({
visualizationMap,
visualizationState,
references,
initialContext,
annotationGroups,
}: {
visualizationState: VisualizationState;
visualizationMap: VisualizationMap;
references?: SavedObjectReference[];
initialContext?: VisualizeFieldContext | VisualizeEditorContext;
annotationGroups: Record<string, EventAnnotationGroupConfig>;
}) {
if (visualizationState?.activeId) {
return (
@ -242,8 +290,8 @@ export function initializeVisualization({
() => '',
visualizationState.state,
undefined,
references,
initialContext
annotationGroups,
references
) ?? visualizationState.state
);
}
@ -282,6 +330,13 @@ export function initializeDatasources({
return states;
}
export interface DocumentToExpressionReturnType {
ast: Ast | null;
indexPatterns: IndexPatternMap;
indexPatternRefs: IndexPatternRef[];
activeVisualizationState: unknown;
}
export async function persistedStateToExpression(
datasourceMap: DatasourceMap,
visualizations: VisualizationMap,
@ -291,12 +346,9 @@ export async function persistedStateToExpression(
storage: IStorageWrapper;
dataViews: DataViewsContract;
timefilter: TimefilterContract;
eventAnnotationService: EventAnnotationServiceType;
}
): Promise<{
ast: Ast | null;
indexPatterns: IndexPatternMap;
indexPatternRefs: IndexPatternRef[];
}> {
): Promise<DocumentToExpressionReturnType> {
const {
state: {
visualization: persistedVisualizationState,
@ -310,15 +362,22 @@ export async function persistedStateToExpression(
description,
} = doc;
if (!visualizationType) {
return { ast: null, indexPatterns: {}, indexPatternRefs: [] };
return { ast: null, indexPatterns: {}, indexPatternRefs: [], activeVisualizationState: null };
}
const annotationGroups = await initializeEventAnnotationGroups(
services.eventAnnotationService,
references
);
const visualization = visualizations[visualizationType!];
const visualizationState = initializeVisualization({
const activeVisualizationState = initializeVisualization({
visualizationMap: visualizations,
visualizationState: {
state: persistedVisualizationState,
activeId: visualizationType,
},
annotationGroups,
references: [...references, ...(internalReferences || [])],
});
const datasourceStatesFromSO = Object.fromEntries(
@ -336,6 +395,7 @@ export async function persistedStateToExpression(
storage: services.storage,
defaultIndexPatternId: services.uiSettings.get('defaultIndex'),
adHocDataViews,
annotationGroups,
},
{ isFullEditor: false }
);
@ -355,6 +415,7 @@ export async function persistedStateToExpression(
ast: null,
indexPatterns,
indexPatternRefs,
activeVisualizationState,
};
}
@ -365,13 +426,14 @@ export async function persistedStateToExpression(
title,
description,
visualization,
visualizationState,
visualizationState: activeVisualizationState,
datasourceMap,
datasourceStates,
datasourceLayers,
indexPatterns,
dateRange: { fromDate: currentTimeRange.from, toDate: currentTimeRange.to },
}),
activeVisualizationState,
indexPatterns,
indexPatternRefs,
};

View file

@ -23,7 +23,6 @@ import type {
DatasourceLayers,
} from '../../types';
import type { LayerType } from '../../../common/types';
import { getLayerType } from './config_panel/add_layer';
import {
LensDispatch,
switchVisualization,
@ -79,7 +78,7 @@ export function getSuggestions({
}
const layers = datasource.getLayers(datasourceState);
for (const layerId of layers) {
const type = getLayerType(activeVisualization, visualizationState, layerId);
const type = activeVisualization.getLayerType(layerId, visualizationState) || LayerTypes.DATA;
memo[layerId] = type;
}
return memo;

View file

@ -47,7 +47,7 @@ describe('chart_switch', () => {
groupLabel: `${id}Group`,
},
],
initialize: jest.fn((_frame, state?: unknown) => {
initialize: jest.fn((_addNewLayer, state) => {
return state || `${id} initial state`;
}),
getSuggestions: jest.fn((options) => {

View file

@ -24,6 +24,7 @@ import {
DataViewsPublicPluginSetup,
DataViewsPublicPluginStart,
} from '@kbn/data-views-plugin/public';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import { Document } from '../persistence/saved_object_store';
import {
Datasource,
@ -50,6 +51,7 @@ export interface EditorFrameStartPlugins {
expressions: ExpressionsStart;
charts: ChartsPluginSetup;
dataViews: DataViewsPublicPluginStart;
eventAnnotationService: EventAnnotationServiceType;
}
export interface EditorFramePlugins {
@ -57,6 +59,7 @@ export interface EditorFramePlugins {
uiSettings: IUiSettingsClient;
storage: IStorageWrapper;
timefilter: TimefilterContract;
eventAnnotationService: EventAnnotationServiceType;
}
async function collectAsyncDefinitions<T extends { id: string }>(

View file

@ -186,6 +186,7 @@ describe('embeddable', () => {
},
indexPatterns: {},
indexPatternRefs: [],
activeVisualizationState: null,
}),
...props,
};
@ -382,6 +383,7 @@ describe('embeddable', () => {
},
indexPatterns: {},
indexPatternRefs: [],
activeVisualizationState: null,
}),
},
{ id: '123' } as LensEmbeddableInput
@ -435,6 +437,7 @@ describe('embeddable', () => {
},
indexPatterns: {},
indexPatternRefs: [],
activeVisualizationState: null,
}),
},
{ id: '123', searchSessionId: 'firstSession' } as LensEmbeddableInput
@ -969,6 +972,7 @@ describe('embeddable', () => {
},
indexPatterns: {},
indexPatternRefs: [],
activeVisualizationState: null,
}),
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
},
@ -1063,6 +1067,7 @@ describe('embeddable', () => {
},
indexPatterns: {},
indexPatternRefs: [],
activeVisualizationState: null,
}),
},
{ id: '123', timeRange, query, filters } as LensEmbeddableInput
@ -1161,6 +1166,7 @@ describe('embeddable', () => {
},
indexPatterns: {},
indexPatternRefs: [],
activeVisualizationState: null,
}),
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
},

View file

@ -31,7 +31,7 @@ import {
import type { Start as InspectorStart } from '@kbn/inspector-plugin/public';
import { merge, Subscription } from 'rxjs';
import { toExpression, Ast } from '@kbn/interpreter';
import { toExpression } from '@kbn/interpreter';
import { DefaultInspectorAdapters, ErrorLike, RenderMode } from '@kbn/expressions-plugin/common';
import { map, distinctUntilChanged, skip, debounceTime } from 'rxjs/operators';
import fastIsEqual from 'fast-deep-equal';
@ -125,6 +125,7 @@ import {
getApplicationUserMessages,
} from '../app_plugin/get_application_user_messages';
import { MessageList } from '../editor_frame_service/editor_frame/workspace_panel/message_list';
import type { DocumentToExpressionReturnType } from '../editor_frame_service/editor_frame';
import { EmbeddableFeatureBadge } from './embeddable_info_badges';
import { getDatasourceLayers } from '../state_management/utils';
@ -191,11 +192,7 @@ export interface LensEmbeddableOutput extends EmbeddableOutput {
export interface LensEmbeddableDeps {
attributeService: LensAttributeService;
data: DataPublicPluginStart;
documentToExpression: (doc: Document) => Promise<{
ast: Ast | null;
indexPatterns: IndexPatternMap;
indexPatternRefs: IndexPatternRef[];
}>;
documentToExpression: (doc: Document) => Promise<DocumentToExpressionReturnType>;
injectFilterReferences: FilterManager['inject'];
visualizationMap: VisualizationMap;
datasourceMap: DatasourceMap;
@ -278,8 +275,14 @@ const getExpressionFromDocument = async (
document: Document,
documentToExpression: LensEmbeddableDeps['documentToExpression']
) => {
const { ast, indexPatterns, indexPatternRefs } = await documentToExpression(document);
return { ast: ast ? toExpression(ast) : null, indexPatterns, indexPatternRefs };
const { ast, indexPatterns, indexPatternRefs, activeVisualizationState } =
await documentToExpression(document);
return {
ast: ast ? toExpression(ast) : null,
indexPatterns,
indexPatternRefs,
activeVisualizationState,
};
};
function getViewUnderlyingDataArgs({
@ -432,6 +435,8 @@ export class Embeddable
private viewUnderlyingDataArgs?: ViewUnderlyingDataArgs;
private activeVisualizationState?: unknown;
constructor(
private deps: LensEmbeddableDeps,
initialInput: LensEmbeddableInput,
@ -560,20 +565,12 @@ export class Embeddable
return this.deps.visualizationMap[this.activeVisualizationId];
}
private get activeVisualizationState() {
if (!this.activeVisualization) return;
return this.activeVisualization.initialize(
() => '',
this.savedVis?.state.visualization,
undefined,
this.savedVis?.references
);
}
private indexPatterns: IndexPatternMap = {};
private indexPatternRefs: IndexPatternRef[] = [];
// TODO - consider getting this from the persistedStateToExpression function
// where it is already computed
private get activeDatasourceState(): undefined | unknown {
if (!this.activeDatasourceId || !this.activeDatasource) return;
@ -733,14 +730,13 @@ export class Embeddable
};
try {
const { ast, indexPatterns, indexPatternRefs } = await getExpressionFromDocument(
this.savedVis,
this.deps.documentToExpression
);
const { ast, indexPatterns, indexPatternRefs, activeVisualizationState } =
await getExpressionFromDocument(this.savedVis, this.deps.documentToExpression);
this.expression = ast;
this.indexPatterns = indexPatterns;
this.indexPatternRefs = indexPatternRefs;
this.activeVisualizationState = activeVisualizationState;
} catch {
// nothing, errors should be reported via getUserMessages
}

View file

@ -14,7 +14,6 @@ import type {
} from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import { RecursiveReadonly } from '@kbn/utility-types';
import { Ast } from '@kbn/interpreter';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import { DataPublicPluginStart, FilterManager, TimefilterContract } from '@kbn/data-plugin/public';
import type { DataViewsContract } from '@kbn/data-views-plugin/public';
@ -32,7 +31,8 @@ import type { Document } from '../persistence/saved_object_store';
import type { LensAttributeService } from '../lens_attribute_service';
import { DOC_TYPE } from '../../common/constants';
import { extract, inject } from '../../common/embeddable_factory';
import type { DatasourceMap, IndexPatternMap, IndexPatternRef, VisualizationMap } from '../types';
import type { DatasourceMap, VisualizationMap } from '../types';
import type { DocumentToExpressionReturnType } from '../editor_frame_service/editor_frame';
export interface LensEmbeddableStartServices {
data: DataPublicPluginStart;
@ -46,11 +46,7 @@ export interface LensEmbeddableStartServices {
dataViews: DataViewsContract;
uiActions?: UiActionsStart;
usageCollection?: UsageCollectionSetup;
documentToExpression: (doc: Document) => Promise<{
ast: Ast | null;
indexPatterns: IndexPatternMap;
indexPatternRefs: IndexPatternRef[];
}>;
documentToExpression: (doc: Document) => Promise<DocumentToExpressionReturnType>;
injectFilterReferences: FilterManager['inject'];
visualizationMap: VisualizationMap;
datasourceMap: DatasourceMap;

View file

@ -6,7 +6,6 @@
*/
import { coreMock } from '@kbn/core/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import {
@ -19,14 +18,12 @@ export function createIndexPatternServiceMock({
core = coreMock.createStart(),
dataViews = dataViewPluginMocks.createStartContract(),
uiActions = uiActionsPluginMock.createStartContract(),
data = dataPluginMock.createStartContract(),
updateIndexPatterns = jest.fn(),
replaceIndexPattern = jest.fn(),
}: Partial<IndexPatternServiceProps> = {}): IndexPatternServiceAPI {
return createIndexPatternService({
core,
dataViews,
data,
updateIndexPatterns,
replaceIndexPattern,
uiActions,

View file

@ -29,6 +29,7 @@ import type { EmbeddableStateTransfer } from '@kbn/embeddable-plugin/public';
import { presentationUtilPluginMock } from '@kbn/presentation-util-plugin/public/mocks';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import type { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import { settingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
import type { LensAttributeService } from '../lens_attribute_service';
import type {
@ -184,5 +185,6 @@ export function makeDefaultServices(
dataViewEditor: indexPatternEditorPluginMock.createStartContract(),
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
docLinks: startMock.docLinks,
eventAnnotationService: {} as EventAnnotationServiceType,
};
}

View file

@ -30,7 +30,7 @@ export function createMockVisualization(id = 'testVis'): jest.Mocked<Visualizati
switchVisualizationType: jest.fn((_, x) => x),
getSuggestions: jest.fn((_options) => []),
getRenderEventCounters: jest.fn((_state) => []),
initialize: jest.fn((_frame, _state?) => ({ newState: 'newState' })),
initialize: jest.fn((_addNewLayer, _state) => ({ newState: 'newState' })),
getConfiguration: jest.fn((props) => ({
groups: [
{

View file

@ -33,7 +33,10 @@ import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public'
import type { UrlForwardingSetup } from '@kbn/url-forwarding-plugin/public';
import type { GlobalSearchPluginSetup } from '@kbn/global-search-plugin/public';
import type { ChartsPluginSetup, ChartsPluginStart } from '@kbn/charts-plugin/public';
import type { EventAnnotationPluginSetup } from '@kbn/event-annotation-plugin/public';
import type {
EventAnnotationPluginStart,
EventAnnotationServiceType,
} from '@kbn/event-annotation-plugin/public';
import type { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public';
import { EmbeddableStateTransfer } from '@kbn/embeddable-plugin/public';
import type { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public';
@ -131,7 +134,6 @@ export interface LensPluginSetupDependencies {
embeddable?: EmbeddableSetup;
visualizations: VisualizationsSetup;
charts: ChartsPluginSetup;
eventAnnotation: EventAnnotationPluginSetup;
globalSearch?: GlobalSearchPluginSetup;
usageCollection?: UsageCollectionSetup;
uiActionsEnhanced: AdvancedUiActionsSetup;
@ -151,7 +153,7 @@ export interface LensPluginStartDependencies {
visualizations: VisualizationsStart;
embeddable: EmbeddableStart;
charts: ChartsPluginStart;
eventAnnotation: EventAnnotationPluginSetup;
eventAnnotation: EventAnnotationPluginStart;
savedObjectsTagging?: SavedObjectTaggingPluginStart;
presentationUtil: PresentationUtilPluginStart;
dataViewFieldEditor: IndexPatternFieldEditorStart;
@ -161,6 +163,7 @@ export interface LensPluginStartDependencies {
usageCollection?: UsageCollectionStart;
docLinks: DocLinksStart;
share?: SharePluginStart;
eventAnnotationService: EventAnnotationServiceType;
contentManagement: ContentManagementPublicStart;
}
@ -283,7 +286,6 @@ export class LensPlugin {
embeddable,
visualizations,
charts,
eventAnnotation,
globalSearch,
usageCollection,
uiActionsEnhanced,
@ -293,7 +295,7 @@ export class LensPlugin {
) {
const startServices = createStartServicesGetter(core.getStartServices);
const getStartServices = async (): Promise<LensEmbeddableStartServices> => {
const getStartServicesForEmbeddable = async (): Promise<LensEmbeddableStartServices> => {
const { getLensAttributeService, setUsageCollectionStart, initMemoizedErrorNotification } =
await import('./async_services');
const { core: coreStart, plugins } = startServices();
@ -304,11 +306,11 @@ export class LensPlugin {
charts,
expressions,
fieldFormats,
plugins.fieldFormats.deserialize,
eventAnnotation
plugins.fieldFormats.deserialize
);
const visualizationMap = await this.editorFrameService!.loadVisualizations();
const datasourceMap = await this.editorFrameService!.loadDatasources();
const eventAnnotationService = await plugins.eventAnnotation.getService();
if (plugins.usageCollection) {
setUsageCollectionStart(plugins.usageCollection);
@ -330,6 +332,7 @@ export class LensPlugin {
storage: new Storage(localStorage),
uiSettings: core.uiSettings,
timefilter: plugins.data.query.timefilter.timefilter,
eventAnnotationService,
}),
injectFilterReferences: data.query.filterManager.inject.bind(data.query.filterManager),
visualizationMap,
@ -345,7 +348,10 @@ export class LensPlugin {
};
if (embeddable) {
embeddable.registerEmbeddableFactory('lens', new EmbeddableFactory(getStartServices));
embeddable.registerEmbeddableFactory(
'lens',
new EmbeddableFactory(getStartServicesForEmbeddable)
);
}
if (share) {
@ -407,8 +413,7 @@ export class LensPlugin {
charts,
expressions,
fieldFormats,
deps.fieldFormats.deserialize,
eventAnnotation
deps.fieldFormats.deserialize
);
const {
@ -458,8 +463,7 @@ export class LensPlugin {
charts,
expressions,
fieldFormats,
plugins.fieldFormats.deserialize,
eventAnnotation
plugins.fieldFormats.deserialize
);
};
@ -484,8 +488,7 @@ export class LensPlugin {
charts: ChartsPluginSetup,
expressions: ExpressionsServiceSetup,
fieldFormats: FieldFormatsSetup,
formatFactory: FormatFactory,
eventAnnotation: EventAnnotationPluginSetup
formatFactory: FormatFactory
) {
const {
DatatableVisualization,
@ -523,7 +526,6 @@ export class LensPlugin {
charts,
editorFrame: editorFrameSetupInterface,
formatFactory,
eventAnnotation,
};
this.FormBasedDatasource.setup(core, dependencies);
this.TextBasedDatasource.setup(core, dependencies);

View file

@ -1,4 +1,4 @@
@import '../../../mixins';
@import '../mixins';
.lnsDimensionContainer {
// Use the EuiFlyout style

View file

@ -19,7 +19,7 @@ import {
EuiFocusTrap,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS } from '../../../utils';
import { DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS } from '../utils';
function fromExcludedClickTarget(event: Event) {
for (
@ -44,14 +44,18 @@ export function FlyoutContainer({
handleClose,
isFullscreen,
panelRef,
panelContainerRef,
children,
customFooter,
}: {
isOpen: boolean;
handleClose: () => boolean;
children: React.ReactElement | null;
groupLabel: string;
isFullscreen: boolean;
panelRef: (el: HTMLDivElement) => void;
isFullscreen?: boolean;
panelRef?: (el: HTMLDivElement) => void;
panelContainerRef?: (el: HTMLDivElement) => void;
customFooter?: React.ReactElement;
}) {
const [focusTrapIsEnabled, setFocusTrapIsEnabled] = useState(false);
@ -91,6 +95,7 @@ export function FlyoutContainer({
onEscapeKey={closeFlyout}
>
<div
ref={panelContainerRef}
role="dialog"
aria-labelledby="lnsDimensionContainerTitle"
className="lnsDimensionContainer euiFlyout"
@ -137,19 +142,21 @@ export function FlyoutContainer({
<div className="lnsDimensionContainer__content">{children}</div>
<EuiFlyoutFooter className="lnsDimensionContainer__footer">
<EuiButtonEmpty
flush="left"
size="s"
iconType="cross"
onClick={closeFlyout}
data-test-subj="lns-indexPattern-dimensionContainerClose"
>
{i18n.translate('xpack.lens.dimensionContainer.close', {
defaultMessage: 'Close',
})}
</EuiButtonEmpty>
</EuiFlyoutFooter>
{customFooter || (
<EuiFlyoutFooter className="lnsDimensionContainer__footer">
<EuiButtonEmpty
flush="left"
size="s"
iconType="cross"
onClick={closeFlyout}
data-test-subj="lns-indexPattern-dimensionContainerClose"
>
{i18n.translate('xpack.lens.dimensionContainer.close', {
defaultMessage: 'Close',
})}
</EuiButtonEmpty>
</EuiFlyoutFooter>
)}
</div>
</EuiFocusTrap>
</div>

View file

@ -7,8 +7,17 @@
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTitle, IconType } from '@elastic/eui';
import { css } from '@emotion/react';
export const StaticHeader = ({ label, icon }: { label: string; icon?: IconType }) => {
export const StaticHeader = ({
label,
icon,
indicator,
}: {
label: string;
icon?: IconType;
indicator?: React.ReactNode;
}) => {
return (
<EuiFlexGroup
gutterSize="s"
@ -21,10 +30,25 @@ export const StaticHeader = ({ label, icon }: { label: string; icon?: IconType }
<EuiIcon type={icon} />{' '}
</EuiFlexItem>
)}
<EuiFlexItem grow>
<EuiFlexItem
grow
css={css`
flex-direction: row;
align-items: center;
`}
>
<EuiTitle size="xxs">
<h5>{label}</h5>
</EuiTitle>
{indicator && (
<div
css={css`
padding-bottom: 3px;
`}
>
{indicator}
</div>
)}
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -4,6 +4,7 @@ exports[`Initializing the store should initialize all datasources with state fro
Object {
"lens": Object {
"activeDatasourceId": "testDatasource",
"annotationGroups": Object {},
"dataViews": Object {
"indexPatternRefs": Array [],
"indexPatterns": Object {},

View file

@ -45,6 +45,8 @@ export const {
addLayer,
setLayerDefaultDimension,
removeDimension,
setIsLoadLibraryVisible,
registerLibraryAnnotationGroup,
} = lensActions;
export const makeConfigureStore = (

View file

@ -114,6 +114,7 @@ export function loadInitial(
const loaderSharedArgs = {
dataViews: lensServices.dataViews,
storage: lensServices.storage,
eventAnnotationService: lensServices.eventAnnotationService,
defaultIndexPatternId: lensServices.uiSettings.get('defaultIndex'),
};
@ -159,39 +160,48 @@ export function loadInitial(
isFullEditor: true,
}
)
.then(({ datasourceStates, visualizationState, indexPatterns, indexPatternRefs }) => {
const currentSessionId =
initialStateFromLocator?.searchSessionId || data.search.session.getSessionId();
store.dispatch(
setState({
isSaveable: true,
filters: initialStateFromLocator.filters || data.query.filterManager.getFilters(),
query: initialStateFromLocator.query || emptyState.query,
searchSessionId: currentSessionId,
activeDatasourceId: emptyState.activeDatasourceId,
visualization: {
activeId: emptyState.visualization.activeId,
state: visualizationState,
},
dataViews: getInitialDataViewsObject(indexPatterns, indexPatternRefs),
datasourceStates: Object.entries(datasourceStates).reduce(
(state, [datasourceId, datasourceState]) => ({
...state,
[datasourceId]: {
...datasourceState,
isLoading: false,
},
}),
{}
),
isLoading: false,
})
);
.then(
({
datasourceStates,
visualizationState,
indexPatterns,
indexPatternRefs,
annotationGroups,
}) => {
const currentSessionId =
initialStateFromLocator?.searchSessionId || data.search.session.getSessionId();
store.dispatch(
setState({
isSaveable: true,
filters: initialStateFromLocator.filters || data.query.filterManager.getFilters(),
query: initialStateFromLocator.query || emptyState.query,
searchSessionId: currentSessionId,
activeDatasourceId: emptyState.activeDatasourceId,
visualization: {
activeId: emptyState.visualization.activeId,
state: visualizationState,
},
dataViews: getInitialDataViewsObject(indexPatterns, indexPatternRefs),
datasourceStates: Object.entries(datasourceStates).reduce(
(state, [datasourceId, datasourceState]) => ({
...state,
[datasourceId]: {
...datasourceState,
isLoading: false,
},
}),
{}
),
isLoading: false,
annotationGroups,
})
);
if (autoApplyDisabled) {
store.dispatch(disableAutoApply());
if (autoApplyDisabled) {
store.dispatch(disableAutoApply());
}
}
})
)
.catch((e: { message: string }) => {
notifications.toasts.addDanger({
title: e.message,
@ -304,52 +314,62 @@ export function loadInitial(
references: [...doc.references, ...(doc.state.internalReferences || [])],
initialContext,
dataViews: lensServices.dataViews,
eventAnnotationService: lensServices.eventAnnotationService,
storage: lensServices.storage,
adHocDataViews: doc.state.adHocDataViews,
defaultIndexPatternId: lensServices.uiSettings.get('defaultIndex'),
},
{ isFullEditor: true }
)
.then(({ datasourceStates, visualizationState, indexPatterns, indexPatternRefs }) => {
const currentSessionId = data.search.session.getSessionId();
store.dispatch(
setState({
isSaveable: true,
sharingSavedObjectProps,
filters: data.query.filterManager.getFilters(),
query: doc.state.query,
searchSessionId:
dashboardFeatureFlag.allowByValueEmbeddables &&
Boolean(embeddableEditorIncomingState?.originatingApp) &&
!(initialInput as LensByReferenceInput)?.savedObjectId &&
currentSessionId
? currentSessionId
: data.search.session.start(),
persistedDoc: doc,
activeDatasourceId: getInitialDatasourceId(datasourceMap, doc),
visualization: {
activeId: doc.visualizationType,
state: visualizationState,
},
dataViews: getInitialDataViewsObject(indexPatterns, indexPatternRefs),
datasourceStates: Object.entries(datasourceStates).reduce(
(state, [datasourceId, datasourceState]) => ({
...state,
[datasourceId]: {
...datasourceState,
isLoading: false,
},
}),
{}
),
isLoading: false,
})
);
.then(
({
datasourceStates,
visualizationState,
indexPatterns,
indexPatternRefs,
annotationGroups,
}) => {
const currentSessionId = data.search.session.getSessionId();
store.dispatch(
setState({
isSaveable: true,
sharingSavedObjectProps,
filters: data.query.filterManager.getFilters(),
query: doc.state.query,
searchSessionId:
dashboardFeatureFlag.allowByValueEmbeddables &&
Boolean(embeddableEditorIncomingState?.originatingApp) &&
!(initialInput as LensByReferenceInput)?.savedObjectId &&
currentSessionId
? currentSessionId
: data.search.session.start(),
persistedDoc: doc,
activeDatasourceId: getInitialDatasourceId(datasourceMap, doc),
visualization: {
activeId: doc.visualizationType,
state: visualizationState,
},
dataViews: getInitialDataViewsObject(indexPatterns, indexPatternRefs),
datasourceStates: Object.entries(datasourceStates).reduce(
(state, [datasourceId, datasourceState]) => ({
...state,
[datasourceId]: {
...datasourceState,
isLoading: false,
},
}),
{}
),
isLoading: false,
annotationGroups,
})
);
if (autoApplyDisabled) {
store.dispatch(disableAutoApply());
if (autoApplyDisabled) {
store.dispatch(disableAutoApply());
}
}
})
)
.catch((e: { message: string }) =>
notifications.toasts.addDanger({
title: e.message,

View file

@ -362,6 +362,7 @@ describe('lensSlice', () => {
addLayer({
layerId: 'foo',
layerType: LayerTypes.DATA,
extraArg: 'some arg',
})
);
const state = customStore.getState().lens;
@ -405,6 +406,7 @@ describe('lensSlice', () => {
addLayer({
layerId: 'foo',
layerType: layerTypes.DATA,
extraArg: undefined,
})
);

View file

@ -10,6 +10,8 @@ import { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public';
import { mapValues, uniq } from 'lodash';
import { Query } from '@kbn/es-query';
import { History } from 'history';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { EventAnnotationGroupConfig } from '@kbn/event-annotation-plugin/common';
import { LensEmbeddableInput } from '..';
import { TableInspectorAdapter } from '../editor_frame_service/types';
import type {
@ -24,7 +26,6 @@ import type { DataViewsState, LensAppState, LensStoreDeps, VisualizationState }
import type { Datasource, Visualization } from '../types';
import { generateId } from '../id_generator';
import type { LayerType } from '../../common/types';
import { getLayerType } from '../editor_frame_service/editor_frame/config_panel/add_layer';
import { getVisualizeFieldSuggestions } from '../editor_frame_service/editor_frame/suggestion_helpers';
import type { FramePublicAPI, LensEditContextMapping, LensEditEvent } from '../types';
import { selectDataViews, selectFramePublicAPI } from './selectors';
@ -50,6 +51,7 @@ export const initialState: LensAppState = {
indexPatternRefs: [],
indexPatterns: {},
},
annotationGroups: {},
};
export const getPreloadedState = ({
@ -161,6 +163,7 @@ export const switchVisualization = createAction<{
}>('lens/switchVisualization');
export const rollbackSuggestion = createAction<void>('lens/rollbackSuggestion');
export const setToggleFullscreen = createAction<void>('lens/setToggleFullscreen');
export const setIsLoadLibraryVisible = createAction<boolean>('lens/setIsLoadLibraryVisible');
export const submitSuggestion = createAction<void>('lens/submitSuggestion');
export const switchDatasource = createAction<{
newDatasourceId: string;
@ -212,6 +215,8 @@ export const cloneLayer = createAction(
export const addLayer = createAction<{
layerId: string;
layerType: LayerType;
extraArg: unknown;
ignoreInitialValues?: boolean;
}>('lens/addLayer');
export const setLayerDefaultDimension = createAction<{
@ -238,6 +243,10 @@ export const removeDimension = createAction<{
columnId: string;
datasourceId?: string;
}>('lens/removeDimension');
export const registerLibraryAnnotationGroup = createAction<{
group: EventAnnotationGroupConfig;
id: string;
}>('lens/registerLibraryAnnotationGroup');
export const lensActions = {
setState,
@ -254,6 +263,7 @@ export const lensActions = {
switchVisualization,
rollbackSuggestion,
setToggleFullscreen,
setIsLoadLibraryVisible,
submitSuggestion,
switchDatasource,
switchAndCleanDatasource,
@ -271,6 +281,7 @@ export const lensActions = {
changeIndexPattern,
removeDimension,
syncLinkedDimensions,
registerLibraryAnnotationGroup,
};
export const makeLensReducer = (storeDeps: LensStoreDeps) => {
@ -1031,11 +1042,13 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
[addLayer.type]: (
state,
{
payload: { layerId, layerType },
payload: { layerId, layerType, extraArg, ignoreInitialValues },
}: {
payload: {
layerId: string;
layerType: LayerType;
extraArg: unknown;
ignoreInitialValues: boolean;
};
}
) => {
@ -1053,7 +1066,8 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
state.visualization.state,
layerId,
layerType,
currentDataViewsId
currentDataViewsId,
extraArg
);
const framePublicAPI = selectFramePublicAPI({ lens: current(state) }, datasourceMap);
@ -1075,15 +1089,17 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
)
: state.datasourceStates[state.activeDatasourceId].state;
const { activeDatasourceState, activeVisualizationState } = addInitialValueIfAvailable({
datasourceState,
visualizationState,
framePublicAPI,
activeVisualization,
activeDatasource,
layerId,
layerType,
});
const { activeDatasourceState, activeVisualizationState } = ignoreInitialValues
? { activeDatasourceState: datasourceState, activeVisualizationState: visualizationState }
: addInitialValueIfAvailable({
datasourceState,
visualizationState,
framePublicAPI,
activeVisualization,
activeDatasource,
layerId,
layerType,
});
state.visualization.state = activeVisualizationState;
state.datasourceStates[state.activeDatasourceId].state = activeDatasourceState;
@ -1115,7 +1131,8 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
const activeDatasource = datasourceMap[state.activeDatasourceId];
const activeVisualization = visualizationMap[state.visualization.activeId];
const layerType = getLayerType(activeVisualization, state.visualization.state, layerId);
const layerType =
activeVisualization.getLayerType(layerId, state.visualization.state) || LayerTypes.DATA;
const { activeDatasourceState, activeVisualizationState } = addInitialValueIfAvailable({
datasourceState: state.datasourceStates[state.activeDatasourceId].state,
visualizationState: state.visualization.state,
@ -1197,6 +1214,16 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
remove({ columnId: linkedDimension.columnId, layerId: linkedDimension.layerId })
);
},
[registerLibraryAnnotationGroup.type]: (
state,
{
payload: { group, id },
}: {
payload: { group: EventAnnotationGroupConfig; id: string };
}
) => {
state.annotationGroups[id] = group;
},
});
};

View file

@ -22,6 +22,7 @@ import type {
VisualizeEditorContext,
IndexPattern,
IndexPatternRef,
AnnotationGroups,
} from '../types';
export interface VisualizationState {
activeId: string | null;
@ -63,6 +64,7 @@ export interface LensAppState extends EditorFrameState {
sharingSavedObjectProps?: Omit<SharingSavedObjectProps, 'sourceId'>;
// Dataview/Indexpattern management has moved in here from datasource
dataViews: DataViewsState;
annotationGroups: AnnotationGroups;
}
export type DispatchSetState = (state: Partial<LensAppState>) => {

View file

@ -39,6 +39,7 @@ import { SearchRequest } from '@kbn/data-plugin/public';
import { estypes } from '@elastic/elasticsearch';
import React from 'react';
import { CellValueContext } from '@kbn/embeddable-plugin/public';
import { EventAnnotationGroupConfig } from '@kbn/event-annotation-plugin/common';
import type {
DraggingIdentifier,
DragDropIdentifier,
@ -133,8 +134,10 @@ export interface EditorFrameSetup {
registerDatasource: <T, P>(
datasource: Datasource<T, P> | (() => Promise<Datasource<T, P>>)
) => void;
registerVisualization: <T, P>(
visualization: Visualization<T, P> | (() => Promise<Visualization<T, P>>)
registerVisualization: <T, P, ExtraAppendLayerArg>(
visualization:
| Visualization<T, P, ExtraAppendLayerArg>
| (() => Promise<Visualization<T, P, ExtraAppendLayerArg>>)
) => void;
}
@ -604,18 +607,18 @@ export interface DatasourceDataPanelProps<T = unknown> {
/** @internal **/
export interface LayerAction {
id: string;
displayName: string;
description?: string;
execute: () => void | Promise<void>;
execute: (mountingPoint: HTMLDivElement | null | undefined) => void | Promise<void>;
icon: IconType;
color?: EuiButtonIconProps['color'];
isCompatible: boolean;
disabled?: boolean;
'data-test-subj'?: string;
order: number;
showOutsideList?: boolean;
}
export type LayerActionFromVisualization = Omit<LayerAction, 'execute'>;
interface SharedDimensionProps {
/** Visualizations can restrict operations based on their own rules.
* For example, limiting to only bucketed or only numeric operations.
@ -1002,7 +1005,29 @@ interface VisualizationStateFromContextChangeProps {
context: VisualizeEditorContext;
}
export interface Visualization<T = unknown, P = T> {
export type AddLayerFunction<T = unknown> = (
layerType: LayerType,
extraArg?: T,
ignoreInitialValues?: boolean
) => void;
export type AnnotationGroups = Record<string, EventAnnotationGroupConfig>;
export interface VisualizationLayerDescription {
type: LayerType;
label: string;
icon?: IconType;
noDatasource?: boolean;
disabled?: boolean;
toolTipContent?: string;
initialDimensions?: Array<{
columnId: string;
groupId: string;
staticValue?: unknown;
autoTimeField?: boolean;
}>;
}
export interface Visualization<T = unknown, P = T, ExtraAppendLayerArg = unknown> {
/** Plugin ID, such as "lnsXY" */
id: string;
@ -1012,13 +1037,16 @@ export interface Visualization<T = unknown, P = T> {
* - Loading from a saved visualization
* - When using suggestions, the suggested state is passed in
*/
initialize: (
addNewLayer: () => string,
state?: T | P,
mainPalette?: PaletteOutput,
references?: SavedObjectReference[],
initialContext?: VisualizeFieldContext | VisualizeEditorContext
) => T;
initialize: {
(addNewLayer: () => string, nonPersistedState?: T, mainPalette?: PaletteOutput): T;
(
addNewLayer: () => string,
persistedState: P,
mainPalette?: PaletteOutput,
annotationGroups?: AnnotationGroups,
references?: SavedObjectReference[]
): T;
};
getUsedDataView?: (state: T, layerId: string) => string | undefined;
/**
@ -1065,37 +1093,29 @@ export interface Visualization<T = unknown, P = T> {
/** Optional, if the visualization supports multiple layers */
removeLayer?: (state: T, layerId: string) => T;
/** Track added layers in internal state */
appendLayer?: (state: T, layerId: string, type: LayerType, indexPatternId: string) => T;
appendLayer?: (
state: T,
layerId: string,
type: LayerType,
indexPatternId: string,
extraArg?: ExtraAppendLayerArg
) => T;
/** Retrieve a list of supported layer types with initialization data */
getSupportedLayers: (
state?: T,
frame?: Pick<FramePublicAPI, 'datasourceLayers' | 'activeData'>
) => Array<{
type: LayerType;
label: string;
icon?: IconType;
noDatasource?: boolean;
disabled?: boolean;
toolTipContent?: string;
initialDimensions?: Array<{
columnId: string;
groupId: string;
staticValue?: unknown;
autoTimeField?: boolean;
}>;
canAddViaMenu?: boolean;
}>;
) => VisualizationLayerDescription[];
/**
* returns a list of custom actions supported by the visualization layer.
* Default actions like delete/clear are not included in this list and are managed by the editor frame
* */
getSupportedActionsForLayer?: (layerId: string, state: T) => LayerActionFromVisualization[];
/**
* Perform state mutations in response to a layer action
*/
onLayerAction?: (layerId: string, actionId: string, state: T) => T;
getSupportedActionsForLayer?: (
layerId: string,
state: T,
setState: StateSetter<T>,
isSaveable?: boolean
) => LayerAction[];
/** returns the type string of the given layer */
getLayerType: (layerId: string, state?: T) => LayerType | undefined;
@ -1217,6 +1237,15 @@ export interface Visualization<T = unknown, P = T> {
label: string;
hideTooltip?: boolean;
}) => JSX.Element | null;
getAddLayerButtonComponent?: (props: {
supportedLayers: VisualizationLayerDescription[];
addLayer: AddLayerFunction;
ensureIndexPattern: (specOrId: DataViewSpec | string) => Promise<void>;
registerLibraryAnnotationGroup: (groupInfo: {
id: string;
group: EventAnnotationGroupConfig;
}) => void;
}) => JSX.Element | null;
/**
* Creates map of columns ids and unique lables. Used only for noDatasource layers
*/
@ -1280,6 +1309,14 @@ export interface Visualization<T = unknown, P = T> {
props: VisualizationStateFromContextChangeProps
) => Suggestion<T> | undefined;
isEqual?: (
state1: P,
references1: SavedObjectReference[],
state2: P,
references2: SavedObjectReference[],
annotationGroups: AnnotationGroups
) => boolean;
getVisualizationInfo?: (state: T, frame?: FramePublicAPI) => VisualizationInfo;
/**
* A visualization can return custom dimensions for the reporting tool

View file

@ -175,6 +175,7 @@ export function getIndexPatternsIds({
}
return currentId;
}, undefined);
const referencesIds = references
.filter(({ type }) => type === 'index-pattern')
.map(({ id }) => id);

View file

@ -923,7 +923,6 @@ describe('metric visualization', () => {
expect(supportedLayers[0].initialDimensions).toBeUndefined();
expect(supportedLayers[0]).toMatchInlineSnapshot(`
Object {
"canAddViaMenu": true,
"disabled": true,
"initialDimensions": undefined,
"label": "Visualization",
@ -933,7 +932,6 @@ describe('metric visualization', () => {
expect({ ...supportedLayers[1], initialDimensions: undefined }).toMatchInlineSnapshot(`
Object {
"canAddViaMenu": true,
"disabled": false,
"initialDimensions": undefined,
"label": "Trendline",

View file

@ -420,7 +420,6 @@ export const getMetricVisualization = ({
]
: undefined,
disabled: true,
canAddViaMenu: true,
},
{
type: layerTypes.METRIC_TRENDLINE,
@ -431,7 +430,6 @@ export const getMetricVisualization = ({
{ groupId: GROUP_ID.TREND_TIME, columnId: generateId(), autoTimeField: true },
],
disabled: Boolean(state?.trendlineLayerId),
canAddViaMenu: true,
},
];
},

View file

@ -0,0 +1,133 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`xy_visualization #appendLayer adding an annotation layer adds a by-reference annotation layer 1`] = `
Object {
"__lastSaved": Object {
"annotations": Array [
Object {
"icon": "circle",
"id": "an1",
"key": Object {
"timestamp": "2022-03-18T08:25:17.140Z",
"type": "point_in_time",
},
"label": "Event 1",
"type": "manual",
},
],
"description": "Some description",
"ignoreGlobalFilters": false,
"indexPatternId": "indexPattern1",
"tags": Array [],
"title": "Title",
},
"annotationGroupId": "some-annotation-group-id",
"annotations": Array [
Object {
"icon": "circle",
"id": "an1",
"key": Object {
"timestamp": "2022-03-18T08:25:17.140Z",
"type": "point_in_time",
},
"label": "Event 1",
"type": "manual",
},
],
"ignoreGlobalFilters": false,
"indexPatternId": "indexPattern1",
"layerId": "",
"layerType": "annotations",
}
`;
exports[`xy_visualization #appendLayer adding an annotation layer adds a by-value annotation layer 1`] = `
Object {
"annotations": Array [],
"ignoreGlobalFilters": true,
"indexPatternId": "indexPattern1",
"layerId": "",
"layerType": "annotations",
}
`;
exports[`xy_visualization #initialize should hydrate by-reference annotation groups 1`] = `
Array [
Object {
"__lastSaved": Object {
"annotations": Array [
Object {
"icon": "circle",
"id": "an1",
"key": Object {
"timestamp": "2022-03-18T08:25:17.140Z",
"type": "point_in_time",
},
"label": "Event 1",
"type": "manual",
},
],
"description": "",
"ignoreGlobalFilters": true,
"indexPatternId": "data-view-123",
"tags": Array [],
"title": "my title!",
},
"annotationGroupId": "my-annotation-group-id1",
"annotations": Array [
Object {
"icon": "circle",
"id": "an1",
"key": Object {
"timestamp": "2022-03-18T08:25:17.140Z",
"type": "point_in_time",
},
"label": "Event 1",
"type": "manual",
},
],
"ignoreGlobalFilters": true,
"indexPatternId": "data-view-123",
"layerId": "annotation",
"layerType": "annotations",
},
Object {
"__lastSaved": Object {
"annotations": Array [
Object {
"icon": "circle",
"id": "an2",
"key": Object {
"timestamp": "2022-04-18T11:01:59.135Z",
"type": "point_in_time",
},
"label": "Annotation2",
"type": "manual",
},
],
"description": "",
"ignoreGlobalFilters": true,
"indexPatternId": "data-view-773203",
"tags": Array [],
"title": "my other title!",
},
"annotationGroupId": "my-annotation-group-id2",
"annotations": Array [
Object {
"icon": "circle",
"id": "an2",
"key": Object {
"timestamp": "2022-04-18T11:01:59.135Z",
"type": "point_in_time",
},
"label": "Annotation2",
"type": "manual",
},
],
"ignoreGlobalFilters": true,
"indexPatternId": "data-view-773203",
"layerId": "annotation",
"layerType": "annotations",
},
]
`;

View file

@ -0,0 +1,172 @@
/*
* 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.
*/
import React, { useState } from 'react';
import {
EuiButton,
EuiPopover,
EuiIcon,
EuiContextMenu,
EuiBadge,
EuiFlexItem,
EuiFlexGroup,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import { AddLayerFunction, VisualizationLayerDescription } from '../../types';
import { LoadAnnotationLibraryFlyout } from './load_annotation_library_flyout';
import type { ExtraAppendLayerArg } from './visualization';
interface AddLayerButtonProps {
supportedLayers: VisualizationLayerDescription[];
addLayer: AddLayerFunction<ExtraAppendLayerArg>;
eventAnnotationService: EventAnnotationServiceType;
}
export function AddLayerButton({
supportedLayers,
addLayer,
eventAnnotationService,
}: AddLayerButtonProps) {
const [showLayersChoice, toggleLayersChoice] = useState(false);
const [isLoadLibraryVisible, setLoadLibraryFlyoutVisible] = useState(false);
const annotationPanel = ({
type,
label,
icon,
disabled,
toolTipContent,
}: typeof supportedLayers[0]) => {
return {
panel: 1,
toolTipContent,
disabled,
name: (
<EuiFlexGroup gutterSize="m" responsive={false}>
<EuiFlexItem>
<span className="lnsLayerAddButton__label">{label}</span>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge className="lnsLayerAddButton__techBadge" color="hollow" isDisabled={disabled}>
{i18n.translate('xpack.lens.configPanel.experimentalLabel', {
defaultMessage: 'Technical preview',
})}
</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
),
className: 'lnsLayerAddButton',
icon: icon && <EuiIcon size="m" type={icon} />,
['data-test-subj']: `lnsLayerAddButton-${type}`,
};
};
return (
<>
<EuiPopover
display="block"
data-test-subj="lnsConfigPanel__addLayerPopover"
button={
<EuiButton
fullWidth
data-test-subj="lnsLayerAddButton"
aria-label={i18n.translate('xpack.lens.configPanel.addLayerButton', {
defaultMessage: 'Add layer',
})}
fill
color="text"
onClick={() => toggleLayersChoice(!showLayersChoice)}
iconType="layers"
>
{i18n.translate('xpack.lens.configPanel.addLayerButton', {
defaultMessage: 'Add layer',
})}
</EuiButton>
}
isOpen={showLayersChoice}
closePopover={() => toggleLayersChoice(false)}
panelPaddingSize="none"
>
<EuiContextMenu
initialPanelId={0}
panels={[
{
id: 0,
title: i18n.translate('xpack.lens.configPanel.selectLayerType', {
defaultMessage: 'Select layer type',
}),
width: 300,
items: supportedLayers.map((props) => {
const { type, label, icon, disabled, toolTipContent } = props;
if (type === LayerTypes.ANNOTATIONS) {
return annotationPanel(props);
}
return {
toolTipContent,
disabled,
name: <span className="lnsLayerAddButtonLabel">{label}</span>,
className: 'lnsLayerAddButton',
icon: icon && <EuiIcon size="m" type={icon} />,
['data-test-subj']: `lnsLayerAddButton-${type}`,
onClick: () => {
addLayer(type);
toggleLayersChoice(false);
},
};
}),
},
{
id: 1,
initialFocusedItemIndex: 0,
title: i18n.translate('xpack.lens.configPanel.selectAnnotationMethod', {
defaultMessage: 'Select annotation method',
}),
items: [
{
name: i18n.translate('xpack.lens.configPanel.newAnnotation', {
defaultMessage: 'New annotation',
}),
icon: 'plusInCircle',
onClick: () => {
addLayer(LayerTypes.ANNOTATIONS);
toggleLayersChoice(false);
},
'data-test-subj': 'lnsAnnotationLayer_new',
},
{
name: i18n.translate('xpack.lens.configPanel.loadFromLibrary', {
defaultMessage: 'Load from library',
}),
icon: 'folderOpen',
onClick: () => {
setLoadLibraryFlyoutVisible(true);
toggleLayersChoice(false);
},
'data-test-subj': 'lnsAnnotationLayer_addFromLibrary',
},
],
},
]}
/>
</EuiPopover>
{isLoadLibraryVisible && (
<LoadAnnotationLibraryFlyout
isLoadLibraryVisible={isLoadLibraryVisible}
setLoadLibraryFlyoutVisible={setLoadLibraryFlyoutVisible}
eventAnnotationService={eventAnnotationService}
addLayer={(extraArg) => {
addLayer(LayerTypes.ANNOTATIONS, extraArg);
}}
/>
)}
</>
);
}

View file

@ -1,22 +0,0 @@
/*
* 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.
*/
import type { LayerActionFromVisualization } from '../../../types';
import type { XYState, XYAnnotationLayerConfig } from '../types';
// Leaving the stub for annotation groups
export const createAnnotationActions = ({
state,
layer,
layerIndex,
}: {
state: XYState;
layer: XYAnnotationLayerConfig;
layerIndex: number;
}): LayerActionFromVisualization[] => {
return [];
};

View file

@ -0,0 +1,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`revert changes routine reverts changes 2`] = `
Object {
"layers": Array [
Object {
"__lastSaved": Object {
"annotations": Array [
Object {
"id": "some-different-annotation-id",
"key": Object {
"timestamp": "timestamp2",
"type": "point_in_time",
},
"type": "manual",
},
],
"description": "",
"ignoreGlobalFilters": true,
"indexPatternId": "other index pattern",
"layerId": "some-id",
"layerType": "annotations",
"tags": Array [],
"title": "My library group",
},
"annotationGroupId": "shouldnt show up",
"annotations": Array [
Object {
"id": "some-different-annotation-id",
"key": Object {
"timestamp": "timestamp2",
"type": "point_in_time",
},
"type": "manual",
},
],
"ignoreGlobalFilters": true,
"indexPatternId": "other index pattern",
"layerId": "some-id",
"layerType": "annotations",
},
],
}
`;

View file

@ -0,0 +1,215 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`annotation group save action save routine saving an existing group as new 1`] = `
Array [
Array [
Object {
"layers": Array [
Object {
"__lastSaved": Object {
"annotations": Array [
Object {
"id": "some-annotation-id",
"key": Object {
"timestamp": "timestamp",
"type": "point_in_time",
},
"type": "manual",
},
],
"dataViewSpec": undefined,
"description": "my description",
"ignoreGlobalFilters": false,
"indexPatternId": "some-index-pattern",
"tags": Array [
"my-tag",
],
"title": "my title",
},
"annotationGroupId": "saved-id-123",
"annotations": Array [
Object {
"id": "some-annotation-id",
"key": Object {
"timestamp": "timestamp",
"type": "point_in_time",
},
"type": "manual",
},
],
"ignoreGlobalFilters": false,
"indexPatternId": "some-index-pattern",
"layerId": "mylayerid",
"layerType": "annotations",
},
],
"legend": Object {
"isVisible": true,
"position": "bottom",
},
"preferredSeriesType": "area",
},
],
]
`;
exports[`annotation group save action save routine successful initial save 1`] = `
Array [
Array [
Object {
"layers": Array [
Object {
"__lastSaved": Object {
"annotations": Array [
Object {
"id": "some-annotation-id",
"key": Object {
"timestamp": "timestamp",
"type": "point_in_time",
},
"type": "manual",
},
],
"dataViewSpec": undefined,
"description": "my description",
"ignoreGlobalFilters": false,
"indexPatternId": "some-index-pattern",
"tags": Array [
"my-tag",
],
"title": "my title",
},
"annotationGroupId": "saved-id-123",
"annotations": Array [
Object {
"id": "some-annotation-id",
"key": Object {
"timestamp": "timestamp",
"type": "point_in_time",
},
"type": "manual",
},
],
"ignoreGlobalFilters": false,
"indexPatternId": "some-index-pattern",
"layerId": "mylayerid",
"layerType": "annotations",
},
],
"legend": Object {
"isVisible": true,
"position": "bottom",
},
"preferredSeriesType": "area",
},
],
]
`;
exports[`annotation group save action save routine successful initial save with ad-hoc data view 1`] = `
Array [
Array [
Object {
"layers": Array [
Object {
"__lastSaved": Object {
"annotations": Array [
Object {
"id": "some-annotation-id",
"key": Object {
"timestamp": "timestamp",
"type": "point_in_time",
},
"type": "manual",
},
],
"dataViewSpec": Object {
"id": "some-adhoc-data-view-id",
},
"description": "my description",
"ignoreGlobalFilters": false,
"indexPatternId": "some-index-pattern",
"tags": Array [
"my-tag",
],
"title": "my title",
},
"annotationGroupId": "saved-id-123",
"annotations": Array [
Object {
"id": "some-annotation-id",
"key": Object {
"timestamp": "timestamp",
"type": "point_in_time",
},
"type": "manual",
},
],
"ignoreGlobalFilters": false,
"indexPatternId": "some-index-pattern",
"layerId": "mylayerid",
"layerType": "annotations",
},
],
"legend": Object {
"isVisible": true,
"position": "bottom",
},
"preferredSeriesType": "area",
},
],
]
`;
exports[`annotation group save action save routine updating an existing group 1`] = `
Array [
Array [
Object {
"layers": Array [
Object {
"__lastSaved": Object {
"annotations": Array [
Object {
"id": "some-annotation-id",
"key": Object {
"timestamp": "timestamp",
"type": "point_in_time",
},
"type": "manual",
},
],
"dataViewSpec": undefined,
"description": "my description",
"ignoreGlobalFilters": false,
"indexPatternId": "some-index-pattern",
"tags": Array [
"my-tag",
],
"title": "my title",
},
"annotationGroupId": "my-group-id",
"annotations": Array [
Object {
"id": "some-annotation-id",
"key": Object {
"timestamp": "timestamp",
"type": "point_in_time",
},
"type": "manual",
},
],
"ignoreGlobalFilters": false,
"indexPatternId": "some-index-pattern",
"layerId": "mylayerid",
"layerType": "annotations",
},
],
"legend": Object {
"isVisible": true,
"position": "bottom",
},
"preferredSeriesType": "area",
},
],
]
`;

View file

@ -0,0 +1,72 @@
/*
* 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.
*/
import type { CoreStart } from '@kbn/core/public';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public';
import { DataViewsContract } from '@kbn/data-views-plugin/public';
import type { LayerAction, StateSetter } from '../../../../types';
import { XYState, XYAnnotationLayerConfig } from '../../types';
import { getUnlinkLayerAction } from './unlink_action';
import { getSaveLayerAction } from './save_action';
import { isByReferenceAnnotationsLayer } from '../../visualization_helpers';
import { getRevertChangesAction } from './revert_changes_action';
export const createAnnotationActions = ({
state,
layer,
setState,
core,
isSaveable,
eventAnnotationService,
savedObjectsTagging,
dataViews,
}: {
state: XYState;
layer: XYAnnotationLayerConfig;
setState: StateSetter<XYState, unknown>;
core: CoreStart;
isSaveable?: boolean;
eventAnnotationService: EventAnnotationServiceType;
savedObjectsTagging?: SavedObjectTaggingPluginStart;
dataViews: DataViewsContract;
}): LayerAction[] => {
const actions = [];
const savingToLibraryPermitted = Boolean(
core.application.capabilities.visualize.save && isSaveable
);
if (savingToLibraryPermitted) {
actions.push(
getSaveLayerAction({
state,
layer,
setState,
eventAnnotationService,
toasts: core.notifications.toasts,
savedObjectsTagging,
dataViews,
})
);
}
if (isByReferenceAnnotationsLayer(layer)) {
actions.push(
getUnlinkLayerAction({
state,
layer,
setState,
toasts: core.notifications.toasts,
})
);
actions.push(getRevertChangesAction({ state, layer, setState, core }));
}
return actions;
};

View file

@ -0,0 +1,95 @@
/*
* 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.
*/
import { OverlayRef } from '@kbn/core-mount-utils-browser';
import { IToasts } from '@kbn/core-notifications-browser';
import { PointInTimeEventAnnotationConfig } from '@kbn/event-annotation-plugin/common';
import { cloneDeep } from 'lodash';
import {
XYByReferenceAnnotationLayerConfig,
XYByValueAnnotationLayerConfig,
XYState,
} from '../../types';
import { revert } from './revert_changes_action';
describe('revert changes routine', () => {
const byValueLayer: XYByValueAnnotationLayerConfig = {
layerId: 'some-id',
layerType: 'annotations',
indexPatternId: 'some-index-pattern',
ignoreGlobalFilters: false,
annotations: [
{
id: 'some-annotation-id',
type: 'manual',
key: {
type: 'point_in_time',
timestamp: 'timestamp',
},
} as PointInTimeEventAnnotationConfig,
],
};
const byRefLayer: XYByReferenceAnnotationLayerConfig = {
...byValueLayer,
annotationGroupId: 'shouldnt show up',
__lastSaved: {
...cloneDeep(byValueLayer),
// some differences
annotations: [
{
id: 'some-different-annotation-id',
type: 'manual',
key: {
type: 'point_in_time',
timestamp: 'timestamp2',
},
} as PointInTimeEventAnnotationConfig,
],
ignoreGlobalFilters: true,
indexPatternId: 'other index pattern',
title: 'My library group',
description: '',
tags: [],
},
};
it('reverts changes', async () => {
const setState = jest.fn();
const modal = {
close: jest.fn(() => Promise.resolve()),
} as Partial<OverlayRef> as OverlayRef;
const toasts = { addSuccess: jest.fn() } as Partial<IToasts> as IToasts;
revert({
setState,
layer: byRefLayer,
state: { layers: [byRefLayer] } as XYState,
modal,
toasts,
});
expect(setState).toHaveBeenCalled();
expect((toasts.addSuccess as jest.Mock).mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"text": "The most recently saved version of this annotation group has been restored.",
"title": "Reverted \\"My library group\\"",
}
`);
expect(modal.close).toHaveBeenCalled();
const newState = setState.mock.calls[0][0] as XYState;
expect(
(newState.layers[0] as XYByReferenceAnnotationLayerConfig).ignoreGlobalFilters
).toBeTruthy();
expect(newState).toMatchSnapshot();
});
});

View file

@ -0,0 +1,174 @@
/*
* 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.
*/
import React from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { CoreStart } from '@kbn/core-lifecycle-browser';
import { cloneDeep } from 'lodash';
import { OverlayRef } from '@kbn/core-mount-utils-browser';
import { IToasts } from '@kbn/core-notifications-browser';
import type { LayerAction, StateSetter } from '../../../../types';
import type { XYState, XYByReferenceAnnotationLayerConfig } from '../../types';
import { annotationLayerHasUnsavedChanges } from '../../state_helpers';
export const getRevertChangesAction = ({
state,
layer,
setState,
core,
}: {
state: XYState;
layer: XYByReferenceAnnotationLayerConfig;
setState: StateSetter<XYState, unknown>;
core: Pick<CoreStart, 'overlays' | 'theme' | 'notifications'>;
}): LayerAction => {
return {
displayName: i18n.translate('xpack.lens.xyChart.annotations.revertChanges', {
defaultMessage: 'Revert changes',
}),
description: i18n.translate('xpack.lens.xyChart.annotations.revertChangesDescription', {
defaultMessage: 'Restores annotation group to the last saved state.',
}),
execute: async () => {
const modal = core.overlays.openModal(
toMountPoint(
<RevertChangesConfirmModal
modalTitle={i18n.translate('xpack.lens.modalTitle.revertAnnotationGroupTitle', {
defaultMessage: 'Revert "{title}" changes?',
values: { title: layer.__lastSaved.title },
})}
onCancel={() => modal.close()}
onConfirm={() => {
revert({ setState, layer, state, modal, toasts: core.notifications.toasts });
}}
/>,
{
theme$: core.theme.theme$,
}
),
{
'data-test-subj': 'lnsAnnotationLayerRevertModal',
maxWidth: 600,
}
);
await modal.onClose;
},
icon: 'editorUndo',
isCompatible: true,
disabled: !annotationLayerHasUnsavedChanges(layer),
'data-test-subj': 'lnsXY_annotationLayer_revertChanges',
order: 200,
};
};
export const revert = ({
setState,
layer,
state,
modal,
toasts,
}: {
setState: StateSetter<XYState>;
layer: XYByReferenceAnnotationLayerConfig;
state: XYState;
modal: OverlayRef;
toasts: IToasts;
}) => {
const newLayer: XYByReferenceAnnotationLayerConfig = {
layerId: layer.layerId,
layerType: layer.layerType,
annotationGroupId: layer.annotationGroupId,
indexPatternId: layer.__lastSaved.indexPatternId,
ignoreGlobalFilters: layer.__lastSaved.ignoreGlobalFilters,
annotations: cloneDeep(layer.__lastSaved.annotations),
__lastSaved: layer.__lastSaved,
};
setState({
...state,
layers: state.layers.map((layerToCheck) =>
layerToCheck.layerId === layer.layerId ? newLayer : layerToCheck
),
});
modal.close();
toasts.addSuccess({
title: i18n.translate('xpack.lens.xyChart.annotations.notificationReverted', {
defaultMessage: `Reverted "{title}"`,
values: { title: layer.__lastSaved.title },
}),
text: i18n.translate('xpack.lens.xyChart.annotations.notificationRevertedExplanation', {
defaultMessage: 'The most recently saved version of this annotation group has been restored.',
}),
});
};
const RevertChangesConfirmModal = ({
modalTitle,
onConfirm,
onCancel,
}: {
modalTitle: string;
onConfirm: () => void;
onCancel: () => void;
}) => {
return (
<>
<EuiModalHeader>
<EuiModalHeaderTitle>{modalTitle}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<p>
{i18n.translate('xpack.lens.layer.revertModal.revertAnnotationGroupDescription', {
defaultMessage: `This action will remove all unsaved changes that you've made and restore the most recent saved version of this annotation group to you visualization. Any lost unsaved changes cannot be restored.`,
})}
</p>
</EuiModalBody>
<EuiModalFooter>
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem>
<EuiFlexGroup alignItems="center" justifyContent="flexEnd" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onCancel}>
{i18n.translate('xpack.lens.layer.cancelDelete', {
defaultMessage: `Cancel`,
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="lnsLayerRevertChangesButton"
onClick={onConfirm}
color="warning"
iconType="returnKey"
fill
>
{i18n.translate('xpack.lens.layer.unlinkConfirm', {
defaultMessage: 'Revert changes',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</>
);
};

View file

@ -0,0 +1,324 @@
/*
* 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.
*/
import React from 'react';
import { toastsServiceMock } from '@kbn/core-notifications-browser-mocks/src/toasts_service.mock';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import {
XYByValueAnnotationLayerConfig,
XYAnnotationLayerConfig,
XYState,
XYByReferenceAnnotationLayerConfig,
} from '../../types';
import { onSave, SaveModal } from './save_action';
import { shallowWithIntl } from '@kbn/test-jest-helpers';
import { PointInTimeEventAnnotationConfig } from '@kbn/event-annotation-plugin/common';
import { SavedObjectSaveModal } from '@kbn/saved-objects-plugin/public';
import { taggingApiMock } from '@kbn/saved-objects-tagging-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/public';
describe('annotation group save action', () => {
describe('save modal', () => {
const modalSaveArgs = {
newCopyOnSave: false,
isTitleDuplicateConfirmed: false,
onTitleDuplicate: () => {},
};
it('reports new saved object attributes', async () => {
const onSaveMock = jest.fn();
const savedObjectsTagging = taggingApiMock.create();
const wrapper = shallowWithIntl(
<SaveModal
domElement={document.createElement('div')}
onSave={onSaveMock}
savedObjectsTagging={savedObjectsTagging}
title=""
description=""
tags={[]}
showCopyOnSave={false}
/>
);
const newTitle = 'title';
const newDescription = 'description';
const myTags = ['my', 'many', 'tags'];
(wrapper
.find(SavedObjectSaveModal)
.prop('options') as React.ReactElement)!.props.onTagsSelected(myTags);
// ignore the linter, you need this await statement
await wrapper.find(SavedObjectSaveModal).prop('onSave')({
newTitle,
newDescription,
...modalSaveArgs,
});
expect(onSaveMock).toHaveBeenCalledTimes(1);
expect(onSaveMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"closeModal": [Function],
"isTitleDuplicateConfirmed": false,
"newCopyOnSave": false,
"newDescription": "description",
"newTags": Array [
"my",
"many",
"tags",
],
"newTitle": "title",
"onTitleDuplicate": [Function],
},
]
`);
});
it('shows existing saved object attributes', async () => {
const savedObjectsTagging = taggingApiMock.create();
const title = 'my title';
const description = 'my description';
const tags = ['my', 'tags'];
const wrapper = shallowWithIntl(
<SaveModal
domElement={document.createElement('div')}
onSave={() => {}}
savedObjectsTagging={savedObjectsTagging}
title={title}
description={description}
tags={tags}
showCopyOnSave={true}
/>
);
const saveModal = wrapper.find(SavedObjectSaveModal);
expect(saveModal.prop('title')).toBe(title);
expect(saveModal.prop('description')).toBe(description);
expect(saveModal.prop('showCopyOnSave')).toBe(true);
expect((saveModal.prop('options') as React.ReactElement).props.initialSelection).toEqual(
tags
);
});
});
describe('save routine', () => {
const layerId = 'mylayerid';
const layer: XYByValueAnnotationLayerConfig = {
layerId,
layerType: 'annotations',
indexPatternId: 'some-index-pattern',
ignoreGlobalFilters: false,
annotations: [
{
id: 'some-annotation-id',
type: 'manual',
key: {
type: 'point_in_time',
timestamp: 'timestamp',
},
} as PointInTimeEventAnnotationConfig,
],
};
const savedId = 'saved-id-123';
const getProps: () => Parameters<typeof onSave>[0] = () => {
const dataViews = dataViewPluginMocks.createStartContract();
dataViews.get.mockResolvedValue({
isPersisted: () => true,
} as Partial<DataView> as DataView);
return {
state: {
preferredSeriesType: 'area',
legend: { isVisible: true, position: 'bottom' },
layers: [{ layerId } as XYAnnotationLayerConfig],
} as XYState,
layer,
setState: jest.fn(),
eventAnnotationService: {
createAnnotationGroup: jest.fn(() => Promise.resolve({ id: savedId })),
updateAnnotationGroup: jest.fn(),
loadAnnotationGroup: jest.fn(),
toExpression: jest.fn(),
toFetchExpression: jest.fn(),
renderEventAnnotationGroupSavedObjectFinder: jest.fn(),
} as EventAnnotationServiceType,
toasts: toastsServiceMock.createStartContract(),
modalOnSaveProps: {
newTitle: 'my title',
newDescription: 'my description',
closeModal: jest.fn(),
newTags: ['my-tag'],
newCopyOnSave: false,
isTitleDuplicateConfirmed: false,
onTitleDuplicate: () => {},
},
dataViews,
};
};
let props: ReturnType<typeof getProps>;
beforeEach(() => {
props = getProps();
});
test('successful initial save', async () => {
await onSave(props);
expect(props.eventAnnotationService.createAnnotationGroup).toHaveBeenCalledWith({
annotations: props.layer.annotations,
indexPatternId: props.layer.indexPatternId,
ignoreGlobalFilters: props.layer.ignoreGlobalFilters,
title: props.modalOnSaveProps.newTitle,
description: props.modalOnSaveProps.newDescription,
tags: props.modalOnSaveProps.newTags,
});
expect(props.modalOnSaveProps.closeModal).toHaveBeenCalled();
expect((props.setState as jest.Mock).mock.calls).toMatchSnapshot();
expect(props.toasts.addSuccess).toHaveBeenCalledTimes(1);
});
test('successful initial save with ad-hoc data view', async () => {
const dataViewSpec = {
id: 'some-adhoc-data-view-id',
} as DataViewSpec;
(props.dataViews.get as jest.Mock).mockResolvedValueOnce({
isPersisted: () => false, // ad-hoc
toSpec: () => dataViewSpec,
} as Partial<DataView>);
await onSave(props);
expect(props.eventAnnotationService.createAnnotationGroup).toHaveBeenCalledWith({
annotations: props.layer.annotations,
indexPatternId: props.layer.indexPatternId,
ignoreGlobalFilters: props.layer.ignoreGlobalFilters,
title: props.modalOnSaveProps.newTitle,
description: props.modalOnSaveProps.newDescription,
tags: props.modalOnSaveProps.newTags,
dataViewSpec,
});
expect(props.modalOnSaveProps.closeModal).toHaveBeenCalled();
expect((props.setState as jest.Mock).mock.calls).toMatchSnapshot();
expect(props.toasts.addSuccess).toHaveBeenCalledTimes(1);
});
test('failed initial save', async () => {
(props.eventAnnotationService.createAnnotationGroup as jest.Mock).mockRejectedValue(
new Error('oh noooooo')
);
await onSave(props);
expect(props.eventAnnotationService.createAnnotationGroup).toHaveBeenCalledWith({
annotations: props.layer.annotations,
indexPatternId: props.layer.indexPatternId,
ignoreGlobalFilters: props.layer.ignoreGlobalFilters,
title: props.modalOnSaveProps.newTitle,
description: props.modalOnSaveProps.newDescription,
tags: props.modalOnSaveProps.newTags,
});
expect(props.toasts.addError).toHaveBeenCalledTimes(1);
expect(props.modalOnSaveProps.closeModal).not.toHaveBeenCalled();
expect(props.setState).not.toHaveBeenCalled();
expect(props.toasts.addSuccess).not.toHaveBeenCalled();
});
test('updating an existing group', async () => {
const annotationGroupId = 'my-group-id';
const byReferenceLayer: XYByReferenceAnnotationLayerConfig = {
...props.layer,
annotationGroupId,
__lastSaved: {
...props.layer,
title: 'old title',
description: 'old description',
tags: [],
},
};
await onSave({ ...props, layer: byReferenceLayer });
expect(props.eventAnnotationService.createAnnotationGroup).not.toHaveBeenCalled();
expect(props.eventAnnotationService.updateAnnotationGroup).toHaveBeenCalledWith(
{
annotations: props.layer.annotations,
indexPatternId: props.layer.indexPatternId,
ignoreGlobalFilters: props.layer.ignoreGlobalFilters,
title: props.modalOnSaveProps.newTitle,
description: props.modalOnSaveProps.newDescription,
tags: props.modalOnSaveProps.newTags,
},
annotationGroupId
);
expect(props.modalOnSaveProps.closeModal).toHaveBeenCalled();
expect((props.setState as jest.Mock).mock.calls).toMatchSnapshot();
expect(props.toasts.addSuccess).toHaveBeenCalledTimes(1);
});
test('saving an existing group as new', async () => {
const annotationGroupId = 'my-group-id';
const byReferenceLayer: XYByReferenceAnnotationLayerConfig = {
...props.layer,
annotationGroupId,
__lastSaved: {
...props.layer,
title: 'old title',
description: 'old description',
tags: [],
},
};
await onSave({
...props,
layer: byReferenceLayer,
modalOnSaveProps: { ...props.modalOnSaveProps, newCopyOnSave: true },
});
expect(props.eventAnnotationService.updateAnnotationGroup).not.toHaveBeenCalled();
expect(props.eventAnnotationService.createAnnotationGroup).toHaveBeenCalledWith({
annotations: props.layer.annotations,
indexPatternId: props.layer.indexPatternId,
ignoreGlobalFilters: props.layer.ignoreGlobalFilters,
title: props.modalOnSaveProps.newTitle,
description: props.modalOnSaveProps.newDescription,
tags: props.modalOnSaveProps.newTags,
});
expect(props.modalOnSaveProps.closeModal).toHaveBeenCalled();
expect((props.setState as jest.Mock).mock.calls).toMatchSnapshot();
expect(props.toasts.addSuccess).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,281 @@
/*
* 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.
*/
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { render, unmountComponentAtNode } from 'react-dom';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import { ToastsStart } from '@kbn/core-notifications-browser';
import { MountPoint } from '@kbn/core-mount-utils-browser';
import { FormattedMessage } from '@kbn/i18n-react';
import {
OnSaveProps as SavedObjectOnSaveProps,
SavedObjectSaveModal,
} from '@kbn/saved-objects-plugin/public';
import { EventAnnotationGroupConfig } from '@kbn/event-annotation-plugin/common';
import { EuiIcon } from '@elastic/eui';
import { type SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public';
import { DataViewsContract } from '@kbn/data-views-plugin/public';
import type { LayerAction, StateSetter } from '../../../../types';
import { XYByReferenceAnnotationLayerConfig, XYAnnotationLayerConfig, XYState } from '../../types';
import { isByReferenceAnnotationsLayer } from '../../visualization_helpers';
type ModalOnSaveProps = SavedObjectOnSaveProps & { newTags: string[]; closeModal: () => void };
/** @internal exported for testing only */
export const SaveModal = ({
domElement,
savedObjectsTagging,
onSave,
title,
description,
tags,
showCopyOnSave,
}: {
domElement: HTMLDivElement;
savedObjectsTagging: SavedObjectTaggingPluginStart | undefined;
onSave: (props: ModalOnSaveProps) => void;
title: string;
description: string;
tags: string[];
showCopyOnSave: boolean;
}) => {
const [selectedTags, setSelectedTags] = useState<string[]>(tags);
const closeModal = () => unmountComponentAtNode(domElement);
return (
<SavedObjectSaveModal
onSave={async (props) => onSave({ ...props, closeModal, newTags: selectedTags })}
onClose={closeModal}
title={title}
description={description}
showCopyOnSave={showCopyOnSave}
objectType={i18n.translate(
'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.objectType',
{ defaultMessage: 'group' }
)}
customModalTitle={i18n.translate(
'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.modalTitle',
{
defaultMessage: 'Save annotation group to library',
}
)}
showDescription={true}
confirmButtonLabel={
<>
<div>
<EuiIcon type="save" />
</div>
<div>
{i18n.translate(
'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.confirmButton',
{ defaultMessage: 'Save group' }
)}
</div>
</>
}
options={
savedObjectsTagging ? (
<savedObjectsTagging.ui.components.SavedObjectSaveModalTagSelector
initialSelection={selectedTags}
onTagsSelected={setSelectedTags}
markOptional
/>
) : undefined
}
/>
);
};
const saveAnnotationGroupToLibrary = async (
layer: XYAnnotationLayerConfig,
{
newTitle,
newDescription,
newTags,
newCopyOnSave,
}: Pick<ModalOnSaveProps, 'newTitle' | 'newDescription' | 'newTags' | 'newCopyOnSave'>,
eventAnnotationService: EventAnnotationServiceType,
dataViews: DataViewsContract
): Promise<{ id: string; config: EventAnnotationGroupConfig }> => {
let savedId: string;
const dataView = await dataViews.get(layer.indexPatternId);
const saveAsNew = !isByReferenceAnnotationsLayer(layer) || newCopyOnSave;
const groupConfig: EventAnnotationGroupConfig = {
annotations: layer.annotations,
indexPatternId: layer.indexPatternId,
ignoreGlobalFilters: layer.ignoreGlobalFilters,
title: newTitle,
description: newDescription,
tags: newTags,
dataViewSpec: dataView.isPersisted() ? undefined : dataView.toSpec(),
};
if (saveAsNew) {
const { id } = await eventAnnotationService.createAnnotationGroup(groupConfig);
savedId = id;
} else {
await eventAnnotationService.updateAnnotationGroup(groupConfig, layer.annotationGroupId);
savedId = layer.annotationGroupId;
}
return { id: savedId, config: groupConfig };
};
/** @internal exported for testing only */
export const onSave = async ({
state,
layer,
setState,
eventAnnotationService,
toasts,
modalOnSaveProps: { newTitle, newDescription, newTags, closeModal, newCopyOnSave },
dataViews,
}: {
state: XYState;
layer: XYAnnotationLayerConfig;
setState: StateSetter<XYState, unknown>;
eventAnnotationService: EventAnnotationServiceType;
toasts: ToastsStart;
modalOnSaveProps: ModalOnSaveProps;
dataViews: DataViewsContract;
}) => {
let savedInfo: Awaited<ReturnType<typeof saveAnnotationGroupToLibrary>>;
try {
savedInfo = await saveAnnotationGroupToLibrary(
layer,
{ newTitle, newDescription, newTags, newCopyOnSave },
eventAnnotationService,
dataViews
);
} catch (err) {
toasts.addError(err, {
title: i18n.translate(
'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.errorToastTitle',
{
defaultMessage: 'Failed to save "{title}"',
values: {
title: newTitle,
},
}
),
});
return;
}
const newLayer: XYByReferenceAnnotationLayerConfig = {
...layer,
annotationGroupId: savedInfo.id,
__lastSaved: savedInfo.config,
};
setState({
...state,
layers: state.layers.map((existingLayer) =>
existingLayer.layerId === newLayer.layerId ? newLayer : existingLayer
),
});
closeModal();
toasts.addSuccess({
title: i18n.translate(
'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.successToastTitle',
{
defaultMessage: 'Saved "{title}"',
values: {
title: newTitle,
},
}
),
text: ((element) =>
render(
<p>
<FormattedMessage
id="xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.successToastBody"
defaultMessage="View or manage in the {link}"
values={{
link: <a href="#">annotation library</a>,
}}
/>
</p>,
element
)) as MountPoint,
});
};
export const getSaveLayerAction = ({
state,
layer,
setState,
eventAnnotationService,
toasts,
savedObjectsTagging,
dataViews,
}: {
state: XYState;
layer: XYAnnotationLayerConfig;
setState: StateSetter<XYState, unknown>;
eventAnnotationService: EventAnnotationServiceType;
toasts: ToastsStart;
savedObjectsTagging?: SavedObjectTaggingPluginStart;
dataViews: DataViewsContract;
}): LayerAction => {
const neverSaved = !isByReferenceAnnotationsLayer(layer);
const displayName = i18n.translate(
'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary',
{
defaultMessage: 'Save annotation group',
}
);
return {
displayName,
description: i18n.translate(
'xpack.lens.xyChart.annotations.addAnnotationGroupToLibraryDescription',
{ defaultMessage: 'Saves annotation group as separate saved object' }
),
execute: async (domElement) => {
if (domElement) {
render(
<SaveModal
domElement={domElement}
savedObjectsTagging={savedObjectsTagging}
onSave={async (props) => {
await onSave({
state,
layer,
setState,
eventAnnotationService,
toasts,
modalOnSaveProps: props,
dataViews,
});
}}
title={neverSaved ? '' : layer.__lastSaved.title}
description={neverSaved ? '' : layer.__lastSaved.description}
tags={neverSaved ? [] : layer.__lastSaved.tags}
showCopyOnSave={!neverSaved}
/>,
domElement
);
}
},
icon: 'save',
isCompatible: true,
'data-test-subj': 'lnsXY_annotationLayer_saveToLibrary',
order: 100,
showOutsideList: true,
};
};

View file

@ -0,0 +1,72 @@
/*
* 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.
*/
import {
XYByValueAnnotationLayerConfig,
XYByReferenceAnnotationLayerConfig,
XYState,
} from '../../types';
import { toastsServiceMock } from '@kbn/core-notifications-browser-mocks/src/toasts_service.mock';
import { PointInTimeEventAnnotationConfig } from '@kbn/event-annotation-plugin/common';
import { cloneDeep } from 'lodash';
import { getUnlinkLayerAction } from './unlink_action';
describe('annotation group unlink actions', () => {
const layerId = 'mylayerid';
const byValueLayer: XYByValueAnnotationLayerConfig = {
layerId,
layerType: 'annotations',
indexPatternId: 'some-index-pattern',
ignoreGlobalFilters: false,
annotations: [
{
id: 'some-annotation-id',
type: 'manual',
key: {
type: 'point_in_time',
timestamp: 'timestamp',
},
} as PointInTimeEventAnnotationConfig,
],
};
const byRefLayer: XYByReferenceAnnotationLayerConfig = {
...byValueLayer,
annotationGroupId: 'shouldnt show up',
__lastSaved: {
...cloneDeep(byValueLayer),
title: 'My library group',
description: '',
tags: [],
},
};
const state: XYState = {
layers: [byRefLayer],
legend: { isVisible: false, position: 'bottom' },
preferredSeriesType: 'area',
};
it('should unlink layer from library annotation group', () => {
const toasts = toastsServiceMock.createStartContract();
const setState = jest.fn();
const action = getUnlinkLayerAction({
state,
layer: byRefLayer,
setState,
toasts,
});
action.execute(undefined);
expect(setState).toHaveBeenCalledWith({ ...state, layers: [byValueLayer] });
expect(toasts.addSuccess).toHaveBeenCalledWith(`Unlinked "${byRefLayer.__lastSaved.title}"`);
});
});

View file

@ -0,0 +1,63 @@
/*
* 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.
*/
import { ToastsStart } from '@kbn/core-notifications-browser';
import { i18n } from '@kbn/i18n';
import type { LayerAction, StateSetter } from '../../../../types';
import {
XYByReferenceAnnotationLayerConfig,
XYByValueAnnotationLayerConfig,
XYState,
} from '../../types';
export const getUnlinkLayerAction = ({
state,
layer,
setState,
toasts,
}: {
state: XYState;
layer: XYByReferenceAnnotationLayerConfig;
setState: StateSetter<XYState, unknown>;
toasts: ToastsStart;
}): LayerAction => {
return {
execute: () => {
const unlinkedLayer: XYByValueAnnotationLayerConfig = {
layerId: layer.layerId,
layerType: layer.layerType,
indexPatternId: layer.indexPatternId,
ignoreGlobalFilters: layer.ignoreGlobalFilters,
annotations: layer.annotations,
};
setState({
...state,
layers: state.layers.map((layerToCheck) =>
layerToCheck.layerId === layer.layerId ? unlinkedLayer : layerToCheck
),
});
toasts.addSuccess(
i18n.translate('xpack.lens.xyChart.annotations.notificationUnlinked', {
defaultMessage: `Unlinked "{title}"`,
values: { title: layer.__lastSaved.title },
})
);
},
description: i18n.translate('xpack.lens.xyChart.annotations.unlinksFromLibrary', {
defaultMessage: 'Saves the annotation group as a part of the Lens Saved Object',
}),
displayName: i18n.translate('xpack.lens.xyChart.annotations.unlinkFromLibrary', {
defaultMessage: 'Unlink from library',
}),
isCompatible: true,
icon: 'unlink',
'data-test-subj': 'lnsXY_annotationLayer_unlinkFromLibrary',
order: 300,
};
};

View file

@ -7,7 +7,6 @@
import type { CoreSetup } from '@kbn/core/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { EventAnnotationPluginSetup } from '@kbn/event-annotation-plugin/public';
import type { ExpressionsSetup } from '@kbn/expressions-plugin/public';
import type { ChartsPluginSetup } from '@kbn/charts-plugin/public';
import { LEGACY_TIME_AXIS } from '@kbn/charts-plugin/common';
@ -20,7 +19,6 @@ export interface XyVisualizationPluginSetupPlugins {
formatFactory: FormatFactory;
editorFrame: EditorFrameSetup;
charts: ChartsPluginSetup;
eventAnnotation: EventAnnotationPluginSetup;
}
export class XyVisualization {
@ -30,8 +28,10 @@ export class XyVisualization {
) {
editorFrame.registerVisualization(async () => {
const { getXyVisualization } = await import('../../async_services');
const [coreStart, { charts, data, fieldFormats, eventAnnotation, unifiedSearch }] =
await core.getStartServices();
const [
coreStart,
{ charts, data, fieldFormats, eventAnnotation, unifiedSearch, savedObjectsTagging },
] = await core.getStartServices();
const [palettes, eventAnnotationService] = await Promise.all([
charts.palettes.getPalettes(),
eventAnnotation.getService(),
@ -47,6 +47,7 @@ export class XyVisualization {
useLegacyTimeAxis,
kibanaTheme: core.theme,
unifiedSearch,
savedObjectsTagging,
});
});
}

View file

@ -0,0 +1,90 @@
/*
* 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.
*/
import React from 'react';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiFlyoutFooter } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import { euiThemeVars } from '@kbn/ui-theme';
import { css } from '@emotion/react';
import { FlyoutContainer } from '../../shared_components/flyout_container';
import type { ExtraAppendLayerArg } from './visualization';
export function LoadAnnotationLibraryFlyout({
eventAnnotationService,
isLoadLibraryVisible,
setLoadLibraryFlyoutVisible,
addLayer,
}: {
isLoadLibraryVisible: boolean;
setLoadLibraryFlyoutVisible: (visible: boolean) => void;
eventAnnotationService: EventAnnotationServiceType;
addLayer: (argument?: ExtraAppendLayerArg) => void;
}) {
const {
renderEventAnnotationGroupSavedObjectFinder: EventAnnotationGroupSavedObjectFinder,
loadAnnotationGroup,
} = eventAnnotationService || {};
return (
<FlyoutContainer
customFooter={
<EuiFlyoutFooter className="lnsDimensionContainer__footer">
<EuiFlexGroup
responsive={false}
gutterSize="s"
alignItems="center"
justifyContent="spaceBetween"
>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
flush="left"
size="s"
iconType="cross"
onClick={() => {
setLoadLibraryFlyoutVisible(false);
}}
data-test-subj="lns-indexPattern-loadLibraryCancel"
>
{i18n.translate('xpack.lens.loadAnnotationsLibrary.cancel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
}
isOpen={Boolean(isLoadLibraryVisible)}
groupLabel={i18n.translate('xpack.lens.editorFrame.loadFromLibrary', {
defaultMessage: 'Select annotations from library',
})}
handleClose={() => {
setLoadLibraryFlyoutVisible(false);
return true;
}}
>
<div
css={css`
padding: ${euiThemeVars.euiSize};
`}
>
<EventAnnotationGroupSavedObjectFinder
onChoose={({ id }) => {
loadAnnotationGroup(id).then((loadedGroup) => {
addLayer({ ...loadedGroup, annotationGroupId: id });
setLoadLibraryFlyoutVisible(false);
});
}}
onCreateNew={() => {
addLayer();
setLoadLibraryFlyoutVisible(false);
}}
/>
</div>
</FlyoutContainer>
);
}

View file

@ -5,15 +5,20 @@
* 2.0.
*/
import { DistributiveOmit } from '@elastic/eui';
import { EuiIconType } from '@elastic/eui/src/components/icon/icon';
import type { SavedObjectReference } from '@kbn/core/public';
import {
EventAnnotationGroupConfig,
EVENT_ANNOTATION_GROUP_TYPE,
} from '@kbn/event-annotation-plugin/common';
import { v4 as uuidv4 } from 'uuid';
import { isQueryAnnotationConfig } from '@kbn/event-annotation-plugin/public';
import { i18n } from '@kbn/i18n';
import { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public';
import fastIsEqual from 'fast-deep-equal';
import { cloneDeep } from 'lodash';
import { validateQuery } from '@kbn/visualization-ui-components/public';
import { DataViewsState } from '../../state_management';
import type { FramePublicAPI, DatasourcePublicAPI, VisualizeEditorContext } from '../../types';
import { FramePublicAPI, DatasourcePublicAPI, AnnotationGroups } from '../../types';
import {
visualizationTypes,
XYLayerConfig,
@ -24,8 +29,21 @@ import {
XYState,
XYPersistedState,
XYAnnotationLayerConfig,
XYPersistedLayerConfig,
XYByReferenceAnnotationLayerConfig,
XYPersistedByReferenceAnnotationLayerConfig,
XYPersistedLinkedByValueAnnotationLayerConfig,
} from './types';
import { getDataLayers, isAnnotationsLayer, isDataLayer } from './visualization_helpers';
import {
getDataLayers,
isAnnotationsLayer,
isDataLayer,
isPersistedByReferenceAnnotationsLayer,
isByReferenceAnnotationsLayer,
isPersistedByValueAnnotationsLayer,
isPersistedAnnotationsLayer,
} from './visualization_helpers';
import { nonNullable } from '../../utils';
export function isHorizontalSeries(seriesType: SeriesType) {
return (
@ -117,64 +135,177 @@ function getLayerReferenceName(layerId: string) {
return `xy-visualization-layer-${layerId}`;
}
export function extractReferences(state: XYState) {
export const annotationLayerHasUnsavedChanges = (layer: XYAnnotationLayerConfig) => {
if (!isByReferenceAnnotationsLayer(layer)) {
return false;
}
type PropsToCompare = Pick<
EventAnnotationGroupConfig,
'annotations' | 'ignoreGlobalFilters' | 'indexPatternId'
>;
const currentConfig: PropsToCompare = {
annotations: layer.annotations,
ignoreGlobalFilters: layer.ignoreGlobalFilters,
indexPatternId: layer.indexPatternId,
};
const savedConfig: PropsToCompare = {
annotations: layer.__lastSaved.annotations,
ignoreGlobalFilters: layer.__lastSaved.ignoreGlobalFilters,
indexPatternId: layer.__lastSaved.indexPatternId,
};
return !fastIsEqual(currentConfig, savedConfig);
};
export function getPersistableState(state: XYState) {
const savedObjectReferences: SavedObjectReference[] = [];
const persistableLayers: Array<DistributiveOmit<XYLayerConfig, 'indexPatternId'>> = [];
const persistableLayers: XYPersistedLayerConfig[] = [];
state.layers.forEach((layer) => {
if (isAnnotationsLayer(layer)) {
const { indexPatternId, ...persistableLayer } = layer;
savedObjectReferences.push({
type: 'index-pattern',
id: indexPatternId,
name: getLayerReferenceName(layer.layerId),
});
persistableLayers.push(persistableLayer);
} else {
if (!isAnnotationsLayer(layer)) {
persistableLayers.push(layer);
} else {
if (isByReferenceAnnotationsLayer(layer)) {
const referenceName = `ref-${uuidv4()}`;
savedObjectReferences.push({
type: EVENT_ANNOTATION_GROUP_TYPE,
id: layer.annotationGroupId,
name: referenceName,
});
if (!annotationLayerHasUnsavedChanges(layer)) {
const persistableLayer: XYPersistedByReferenceAnnotationLayerConfig = {
persistanceType: 'byReference',
layerId: layer.layerId,
layerType: layer.layerType,
annotationGroupRef: referenceName,
};
persistableLayers.push(persistableLayer);
} else {
const persistableLayer: XYPersistedLinkedByValueAnnotationLayerConfig = {
persistanceType: 'linked',
layerId: layer.layerId,
layerType: layer.layerType,
annotationGroupRef: referenceName,
annotations: layer.annotations,
ignoreGlobalFilters: layer.ignoreGlobalFilters,
};
persistableLayers.push(persistableLayer);
savedObjectReferences.push({
type: 'index-pattern',
id: layer.indexPatternId,
name: getLayerReferenceName(layer.layerId),
});
}
} else {
const { indexPatternId, ...persistableLayer } = layer;
savedObjectReferences.push({
type: 'index-pattern',
id: indexPatternId,
name: getLayerReferenceName(layer.layerId),
});
persistableLayers.push({ ...persistableLayer, persistanceType: 'byValue' });
}
}
});
return { savedObjectReferences, state: { ...state, layers: persistableLayers } };
}
export function isPersistedState(state: XYPersistedState | XYState): state is XYPersistedState {
const annotationLayers = state.layers.filter((l) => isAnnotationsLayer(l));
return annotationLayers.some((l) => !('indexPatternId' in l));
return state.layers.some(isPersistedAnnotationsLayer);
}
export function injectReferences(
state: XYPersistedState,
references?: SavedObjectReference[],
initialContext?: VisualizeFieldContext | VisualizeEditorContext
annotationGroups?: AnnotationGroups,
references?: SavedObjectReference[]
): XYState {
if (!references || !references.length) {
return state as XYState;
}
const fallbackIndexPatternId = references.find(({ type }) => type === 'index-pattern')!.id;
if (!annotationGroups) {
throw new Error(
'xy visualization: injecting references relies on annotation groups but they were not provided'
);
}
// called on-demand since indexPattern reference won't be here on the vis if its a by-reference group
const getIndexPatternIdFromReferences = (annotationLayerId: string) => {
const fallbackIndexPatternId = references.find(({ type }) => type === 'index-pattern')!.id;
return (
references.find(({ name }) => name === getLayerReferenceName(annotationLayerId))?.id ||
fallbackIndexPatternId
);
};
return {
...state,
layers: state.layers.map((layer) => {
if (!isAnnotationsLayer(layer)) {
return layer as XYLayerConfig;
}
return {
...layer,
indexPatternId:
getIndexPatternIdFromInitialContext(layer, initialContext) ||
references.find(({ name }) => name === getLayerReferenceName(layer.layerId))?.id ||
fallbackIndexPatternId,
};
}),
};
}
layers: state.layers
.map((persistedLayer) => {
if (!isPersistedAnnotationsLayer(persistedLayer)) {
return persistedLayer as XYLayerConfig;
}
function getIndexPatternIdFromInitialContext(
layer: XYAnnotationLayerConfig,
initialContext?: VisualizeFieldContext | VisualizeEditorContext
) {
if (initialContext && 'isVisualizeAction' in initialContext) {
return layer && 'indexPatternId' in layer ? layer.indexPatternId : undefined;
}
let injectedLayer: XYAnnotationLayerConfig;
if (isPersistedByValueAnnotationsLayer(persistedLayer)) {
injectedLayer = {
...persistedLayer,
indexPatternId: getIndexPatternIdFromReferences(persistedLayer.layerId),
};
} else {
const annotationGroupId = references?.find(
({ name }) => name === persistedLayer.annotationGroupRef
)?.id;
const annotationGroup = annotationGroupId
? annotationGroups[annotationGroupId]
: undefined;
if (!annotationGroupId || !annotationGroup) {
return undefined; // the annotation group this layer was referencing is gone, so remove the layer
}
// declared as a separate variable for type checking
const commonProps: Pick<
XYByReferenceAnnotationLayerConfig,
'layerId' | 'layerType' | 'annotationGroupId' | '__lastSaved'
> = {
layerId: persistedLayer.layerId,
layerType: persistedLayer.layerType,
annotationGroupId,
__lastSaved: annotationGroup,
};
if (isPersistedByReferenceAnnotationsLayer(persistedLayer)) {
// a clean by-reference layer inherits everything from the library annotation group
injectedLayer = {
...commonProps,
ignoreGlobalFilters: annotationGroup.ignoreGlobalFilters,
indexPatternId: annotationGroup.indexPatternId,
annotations: cloneDeep(annotationGroup.annotations),
};
} else {
// a linked by-value layer gets settings from visualization state while
// still maintaining the reference to the library annotation group
injectedLayer = {
...commonProps,
ignoreGlobalFilters: persistedLayer.ignoreGlobalFilters,
indexPatternId: getIndexPatternIdFromReferences(persistedLayer.layerId),
annotations: cloneDeep(persistedLayer.annotations),
};
}
}
return injectedLayer;
})
.filter(nonNullable),
};
}
export function getAnnotationLayerErrors(

View file

@ -39,13 +39,14 @@ import { EventAnnotationConfig } from '@kbn/event-annotation-plugin/common';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { SystemPaletteExpressionFunctionDefinition } from '@kbn/charts-plugin/common';
import type {
State,
State as XYState,
YConfig,
XYDataLayerConfig,
XYReferenceLineLayerConfig,
XYAnnotationLayerConfig,
AxisConfig,
ValidXYDataLayerConfig,
XYLayerConfig,
} from './types';
import type { OperationMetadata, DatasourcePublicAPI, DatasourceLayers } from '../../types';
import { getColumnToLabelMap } from './state_helpers';
@ -65,6 +66,10 @@ import {
import type { CollapseExpressionFunction } from '../../../common/expressions';
import { hasIcon } from './xy_config_panel/shared/marker_decoration_settings';
type XYLayerConfigWithSimpleView = XYLayerConfig & { simpleView?: boolean };
type XYAnnotationLayerConfigWithSimpleView = XYAnnotationLayerConfig & { simpleView?: boolean };
type State = Omit<XYState, 'layers'> & { layers: XYLayerConfigWithSimpleView[] };
export const getSortedAccessors = (
datasource: DatasourcePublicAPI | undefined,
layer: XYDataLayerConfig | XYReferenceLineLayerConfig
@ -83,7 +88,6 @@ export const toExpression = (
state: State,
datasourceLayers: DatasourceLayers,
paletteService: PaletteRegistry,
attributes: Partial<{ title: string; description: string }> = {},
datasourceExpressionsByLayers: Record<string, Ast>,
eventAnnotationService: EventAnnotationServiceType
): Ast | null => {
@ -152,7 +156,6 @@ export function toPreviewExpression(
},
datasourceLayers,
paletteService,
{},
datasourceExpressionsByLayers,
eventAnnotationService
);
@ -429,7 +432,7 @@ const referenceLineLayerToExpression = (
};
const annotationLayerToExpression = (
layer: XYAnnotationLayerConfig,
layer: XYAnnotationLayerConfigWithSimpleView,
eventAnnotationService: EventAnnotationServiceType
): Ast => {
const extendedAnnotationLayerFn = buildExpressionFunction<ExtendedAnnotationLayerFn>(

View file

@ -21,7 +21,10 @@ import type {
FillStyle,
YAxisConfig,
} from '@kbn/expression-xy-plugin/common';
import { EventAnnotationConfig } from '@kbn/event-annotation-plugin/common';
import {
EventAnnotationConfig,
EventAnnotationGroupConfig,
} from '@kbn/event-annotation-plugin/common';
import {
IconChartArea,
IconChartLine,
@ -35,7 +38,6 @@ import {
IconChartBarHorizontal,
} from '@kbn/chart-icons';
import { DistributiveOmit } from '@elastic/eui';
import { CollapseFunction } from '../../../common/expressions';
import type { VisualizationType } from '../../types';
import type { ValueLabelConfig } from '../../../common/types';
@ -113,16 +115,66 @@ export interface XYReferenceLineLayerConfig {
layerType: 'referenceLine';
}
export interface XYAnnotationLayerConfig {
export interface XYByValueAnnotationLayerConfig {
layerId: string;
layerType: 'annotations';
annotations: EventAnnotationConfig[];
hide?: boolean;
indexPatternId: string;
simpleView?: boolean;
ignoreGlobalFilters: boolean;
}
export type XYPersistedByValueAnnotationLayerConfig = Omit<
XYByValueAnnotationLayerConfig,
'indexPatternId' | 'hide' | 'simpleView'
> & { persistanceType?: 'byValue'; hide?: boolean; simpleView?: boolean }; // props made optional for backwards compatibility since this is how the existing saved objects are
export type XYByReferenceAnnotationLayerConfig = XYByValueAnnotationLayerConfig & {
annotationGroupId: string;
__lastSaved: EventAnnotationGroupConfig;
};
export type XYPersistedByReferenceAnnotationLayerConfig = Pick<
XYByValueAnnotationLayerConfig,
'layerId' | 'layerType'
> & {
persistanceType: 'byReference';
annotationGroupRef: string;
};
/**
* This is the type of hybrid layer we get after the user has made a change to
* a by-reference annotation layer and saved the visualization without
* first saving the changes to the library annotation layer.
*
* We maintain the link to the library annotation group, but allow the users
* changes (persisted in the visualization state) to override the attributes in
* the library version until the user
* - saves the changes to the library annotation group
* - reverts the changes
* - unlinks the layer from the library annotation group
*/
export type XYPersistedLinkedByValueAnnotationLayerConfig = Omit<
XYPersistedByValueAnnotationLayerConfig,
'persistanceType'
> &
Omit<XYPersistedByReferenceAnnotationLayerConfig, 'persistanceType'> & {
persistanceType: 'linked';
};
export type XYAnnotationLayerConfig =
| XYByReferenceAnnotationLayerConfig
| XYByValueAnnotationLayerConfig;
export type XYPersistedAnnotationLayerConfig =
| XYPersistedByReferenceAnnotationLayerConfig
| XYPersistedByValueAnnotationLayerConfig
| XYPersistedLinkedByValueAnnotationLayerConfig;
export type XYPersistedLayerConfig =
| XYDataLayerConfig
| XYReferenceLineLayerConfig
| XYPersistedAnnotationLayerConfig;
export type XYLayerConfig =
| XYDataLayerConfig
| XYReferenceLineLayerConfig
@ -166,7 +218,7 @@ export interface XYState {
export type State = XYState;
export type XYPersistedState = Omit<XYState, 'layers'> & {
layers: Array<DistributiveOmit<XYLayerConfig, 'indexPatternId'>>;
layers: XYPersistedLayerConfig[];
};
export type PersistedState = XYPersistedState;

View file

@ -13,7 +13,7 @@ import { i18n } from '@kbn/i18n';
import type { PaletteRegistry } from '@kbn/coloring';
import { IconChartBarReferenceLine, IconChartBarAnnotations } from '@kbn/chart-icons';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { CoreStart, ThemeServiceStart } from '@kbn/core/public';
import { CoreStart, SavedObjectReference, ThemeServiceStart } from '@kbn/core/public';
import type { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public';
@ -21,6 +21,9 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public';
import { EventAnnotationGroupConfig } from '@kbn/event-annotation-plugin/common';
import { isEqual } from 'lodash';
import type { AccessorConfig } from '@kbn/visualization-ui-components/public';
import { generateId } from '../../id_generator';
import {
@ -37,7 +40,13 @@ import {
DimensionEditor,
} from './xy_config_panel/dimension_editor';
import { LayerHeader, LayerHeaderContent } from './xy_config_panel/layer_header';
import type { Visualization, FramePublicAPI, Suggestion, UserMessage } from '../../types';
import type {
Visualization,
FramePublicAPI,
Suggestion,
UserMessage,
AnnotationGroups,
} from '../../types';
import type { FormBasedPersistedState } from '../../datasources/form_based/types';
import {
type State,
@ -48,7 +57,7 @@ import {
visualizationTypes,
} from './types';
import {
extractReferences,
getPersistableState,
getAnnotationLayerErrors,
injectReferences,
isHorizontalChart,
@ -99,10 +108,15 @@ import { AnnotationsPanel } from './xy_config_panel/annotations_config_panel';
import { DimensionTrigger } from '../../shared_components/dimension_trigger';
import { defaultAnnotationLabel } from './annotations/helpers';
import { onDropForVisualization } from '../../editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils';
import { createAnnotationActions } from './annotations/actions';
import { AddLayerButton } from './add_layer';
import { IgnoredGlobalFiltersEntries } from './info_badges';
import { LayerSettings } from './layer_settings';
const XY_ID = 'lnsXY';
export type ExtraAppendLayerArg = EventAnnotationGroupConfig & { annotationGroupId: string };
export const getXyVisualization = ({
core,
storage,
@ -113,6 +127,7 @@ export const getXyVisualization = ({
kibanaTheme,
eventAnnotationService,
unifiedSearch,
savedObjectsTagging,
}: {
core: CoreStart;
storage: IStorageWrapper;
@ -123,7 +138,8 @@ export const getXyVisualization = ({
useLegacyTimeAxis: boolean;
kibanaTheme: ThemeServiceStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
}): Visualization<State, PersistedState> => ({
savedObjectsTagging?: SavedObjectTaggingPluginStart;
}): Visualization<State, PersistedState, ExtraAppendLayerArg> => ({
id: XY_ID,
visualizationTypes,
getVisualizationTypeId(state) {
@ -165,7 +181,7 @@ export const getXyVisualization = ({
return state;
},
appendLayer(state, layerId, layerType, indexPatternId) {
appendLayer(state, layerId, layerType, indexPatternId, extraArg) {
if (layerType === 'metricTrendline') {
return state;
}
@ -180,6 +196,7 @@ export const getXyVisualization = ({
layerId,
layerType,
indexPatternId,
extraArg,
}),
],
};
@ -201,7 +218,7 @@ export const getXyVisualization = ({
},
getPersistableState(state) {
return extractReferences(state);
return getPersistableState(state);
},
getDescription,
@ -218,10 +235,16 @@ export const getXyVisualization = ({
triggers: [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.brush],
initialize(addNewLayer, state, _, references, initialContext) {
initialize(
addNewLayer,
state,
_mainPalette?,
annotationGroups?: AnnotationGroups,
references?: SavedObjectReference[]
) {
const finalState =
state && isPersistedState(state)
? injectReferences(state, references, initialContext)
? injectReferences(state, annotationGroups!, references)
: state;
return (
finalState || {
@ -255,8 +278,25 @@ export const getXyVisualization = ({
];
},
getSupportedActionsForLayer() {
return [];
getSupportedActionsForLayer(layerId, state, setState, isSaveable) {
const layerIndex = state.layers.findIndex((l) => l.layerId === layerId);
const layer = state.layers[layerIndex];
const actions = [];
if (isAnnotationsLayer(layer)) {
actions.push(
...createAnnotationActions({
state,
layer,
setState,
core,
isSaveable,
eventAnnotationService,
savedObjectsTagging,
dataViews: data.dataViews,
})
);
}
return actions;
},
hasLayerSettings({ state, layerId: currentLayerId }) {
@ -274,11 +314,6 @@ export const getXyVisualization = ({
domElement
);
},
onLayerAction(layerId, actionId, state) {
return state;
},
onIndexPatternChange(state, indexPatternId, layerId) {
const layerIndex = state.layers.findIndex((l) => l.layerId === layerId);
const layer = state.layers[layerIndex];
@ -683,13 +718,33 @@ export const getXyVisualization = ({
domElement
);
},
getAddLayerButtonComponent: (props) => {
return (
<AddLayerButton
{...props}
eventAnnotationService={eventAnnotationService}
addLayer={async (type, loadedGroupInfo) => {
if (type === LayerTypes.ANNOTATIONS && loadedGroupInfo) {
await props.ensureIndexPattern(
loadedGroupInfo.dataViewSpec ?? loadedGroupInfo.indexPatternId
);
props.registerLibraryAnnotationGroup({
id: loadedGroupInfo.annotationGroupId,
group: loadedGroupInfo,
});
}
props.addLayer(type, loadedGroupInfo, !!loadedGroupInfo);
}}
/>
);
},
toExpression: (state, layers, attributes, datasourceExpressionsByLayers = {}) =>
toExpression(
state,
layers,
paletteService,
attributes,
datasourceExpressionsByLayers,
eventAnnotationService
),
@ -924,6 +979,12 @@ export const getXyVisualization = ({
return suggestion;
},
isEqual(state1, references1, state2, references2, annotationGroups) {
const injected1 = injectReferences(state1, annotationGroups, references1);
const injected2 = injectReferences(state2, annotationGroups, references2);
return isEqual(injected1, injected2);
},
getVisualizationInfo(state, frame) {
return getVisualizationInfo(state, frame, paletteService, fieldFormats);
},

View file

@ -6,7 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import { uniq } from 'lodash';
import { cloneDeep, uniq } from 'lodash';
import { IconChartBarHorizontal, IconChartBarStacked, IconChartMixedXy } from '@kbn/chart-icons';
import type { LayerType as XYLayerType } from '@kbn/expression-xy-plugin/common';
import { DatasourceLayers, OperationMetadata, VisualizationType } from '../../types';
@ -19,9 +19,17 @@ import {
XYDataLayerConfig,
XYReferenceLineLayerConfig,
SeriesType,
XYByReferenceAnnotationLayerConfig,
XYPersistedAnnotationLayerConfig,
XYPersistedByReferenceAnnotationLayerConfig,
XYPersistedLinkedByValueAnnotationLayerConfig,
XYPersistedLayerConfig,
XYPersistedByValueAnnotationLayerConfig,
XYByValueAnnotationLayerConfig,
} from './types';
import { isHorizontalChart } from './state_helpers';
import { layerTypes } from '../..';
import type { ExtraAppendLayerArg } from './visualization';
export function getAxisName(
axis: 'x' | 'y' | 'yLeft' | 'yRight',
@ -138,7 +146,34 @@ export const getReferenceLayers = (layers: Array<Pick<XYLayerConfig, 'layerType'
export const isAnnotationsLayer = (
layer: Pick<XYLayerConfig, 'layerType'>
): layer is XYAnnotationLayerConfig => layer.layerType === layerTypes.ANNOTATIONS;
): layer is XYAnnotationLayerConfig =>
layer.layerType === layerTypes.ANNOTATIONS && 'indexPatternId' in layer;
export const isPersistedAnnotationsLayer = (
layer: XYPersistedLayerConfig
): layer is XYPersistedAnnotationLayerConfig =>
layer.layerType === layerTypes.ANNOTATIONS && !('indexPatternId' in layer);
export const isPersistedByValueAnnotationsLayer = (
layer: XYPersistedLayerConfig
): layer is XYPersistedByValueAnnotationLayerConfig =>
isPersistedAnnotationsLayer(layer) &&
(layer.persistanceType === 'byValue' || !layer.persistanceType);
export const isByReferenceAnnotationsLayer = (
layer: XYAnnotationLayerConfig
): layer is XYByReferenceAnnotationLayerConfig =>
'annotationGroupId' in layer && '__lastSaved' in layer;
export const isPersistedByReferenceAnnotationsLayer = (
layer: XYPersistedAnnotationLayerConfig
): layer is XYPersistedByReferenceAnnotationLayerConfig =>
isPersistedAnnotationsLayer(layer) && layer.persistanceType === 'byReference';
export const isPersistedLinkedByValueAnnotationsLayer = (
layer: XYPersistedAnnotationLayerConfig
): layer is XYPersistedLinkedByValueAnnotationLayerConfig =>
isPersistedAnnotationsLayer(layer) && layer.persistanceType === 'linked';
export const getAnnotationsLayers = (layers: Array<Pick<XYLayerConfig, 'layerType'>>) =>
(layers || []).filter((layer): layer is XYAnnotationLayerConfig => isAnnotationsLayer(layer));
@ -273,16 +308,39 @@ const newLayerFn = {
[layerTypes.ANNOTATIONS]: ({
layerId,
indexPatternId,
extraArg,
}: {
layerId: string;
indexPatternId: string;
}): XYAnnotationLayerConfig => ({
layerId,
layerType: layerTypes.ANNOTATIONS,
annotations: [],
indexPatternId,
ignoreGlobalFilters: true,
}),
extraArg: ExtraAppendLayerArg | undefined;
}): XYAnnotationLayerConfig => {
if (extraArg) {
const { annotationGroupId, ...libraryGroupConfig } = extraArg;
const newLayer: XYByReferenceAnnotationLayerConfig = {
layerId,
layerType: layerTypes.ANNOTATIONS,
annotationGroupId,
annotations: cloneDeep(libraryGroupConfig.annotations),
indexPatternId: libraryGroupConfig.indexPatternId,
ignoreGlobalFilters: libraryGroupConfig.ignoreGlobalFilters,
__lastSaved: libraryGroupConfig,
};
return newLayer;
}
const newLayer: XYByValueAnnotationLayerConfig = {
layerId,
layerType: layerTypes.ANNOTATIONS,
annotations: [],
indexPatternId,
ignoreGlobalFilters: true,
};
return newLayer;
},
};
export function newLayerState({
@ -290,13 +348,15 @@ export function newLayerState({
layerType = layerTypes.DATA,
seriesType,
indexPatternId,
extraArg,
}: {
layerId: string;
layerType?: XYLayerType;
seriesType: SeriesType;
indexPatternId: string;
extraArg?: ExtraAppendLayerArg;
}) {
return newLayerFn[layerType]({ layerId, seriesType, indexPatternId });
return newLayerFn[layerType]({ layerId, seriesType, indexPatternId, extraArg });
}
export function getLayersByType(state: State, byType?: string) {

View file

@ -13,7 +13,7 @@ import { AnnotationsPanel } from '.';
import { FramePublicAPI } from '../../../../types';
import { DatasourcePublicAPI } from '../../../..';
import { createMockFramePublicAPI } from '../../../../mocks';
import { State } from '../../types';
import { State, XYState } from '../../types';
import { Position } from '@elastic/charts';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import moment from 'moment';
@ -206,7 +206,7 @@ describe('AnnotationsPanel', () => {
component.find('button[data-test-subj="lns-xyAnnotation-rangeSwitch"]').simulate('click');
expect(setState).toBeCalledWith({
expect(setState).toBeCalledWith<XYState[]>({
...state,
layers: [
{
@ -232,7 +232,7 @@ describe('AnnotationsPanel', () => {
],
});
component.find('button[data-test-subj="lns-xyAnnotation-rangeSwitch"]').simulate('click');
expect(setState).toBeCalledWith({
expect(setState).toBeCalledWith<XYState[]>({
...state,
layers: [
{

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