[Event annotations] Lens design improvements (#159057)

This commit is contained in:
Drew Tate 2023-07-03 09:16:47 -05:00 committed by GitHub
parent b697c1d651
commit 1d82d2cb61
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 501 additions and 286 deletions

View file

@ -120,6 +120,9 @@ export interface EventAnnotationGroupSearchQuery {
searchFields?: string[];
}
export type EventAnnotationGroupSearchIn = SearchIn<EventAnnotationGroupContentType, {}>;
export type EventAnnotationGroupSearchIn = SearchIn<
EventAnnotationGroupContentType,
EventAnnotationGroupSearchQuery
>;
export type EventAnnotationGroupSearchOut = SearchResult<EventAnnotationGroupSavedObject>;

View file

@ -67,35 +67,37 @@ export const EventAnnotationGroupSavedObjectFinder = ({
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>
<EuiFlexItem>
<EuiEmptyPrompt
titleSize="xs"
title={
<h2>
<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."
id="eventAnnotation.eventAnnotationGroup.savedObjectFinder.emptyPromptTitle"
defaultMessage="Start by adding an annotation layer"
/>
</p>
</EuiText>
}
actions={
<EuiButton onClick={() => onCreateNew()} size="s">
<FormattedMessage
id="eventAnnotation.eventAnnotationGroup.savedObjectFinder.emptyCTA"
defaultMessage="Create annotation layer"
/>
</EuiButton>
}
/>
</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>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
) : (
<SavedObjectFinder

View file

@ -34,7 +34,8 @@ import {
isQueryAnnotationConfig,
} from './helpers';
import { EventAnnotationGroupSavedObjectFinder } from '../components/event_annotation_group_saved_object_finder';
import {
import { CONTENT_ID } from '../../common/content_management';
import type {
EventAnnotationGroupCreateIn,
EventAnnotationGroupCreateOut,
EventAnnotationGroupDeleteIn,
@ -106,7 +107,7 @@ export function getEventAnnotationService(
savedObjectId: string
): Promise<EventAnnotationGroupConfig> => {
const savedObject = await client.get<EventAnnotationGroupGetIn, EventAnnotationGroupGetOut>({
contentTypeId: EVENT_ANNOTATION_GROUP_TYPE,
contentTypeId: CONTENT_ID,
id: savedObjectId,
});
@ -117,6 +118,29 @@ export function getEventAnnotationService(
return mapSavedObjectToGroupConfig(savedObject.item);
};
const groupExistsWithTitle = async (title: string): Promise<boolean> => {
const { hits } = await client.search<
EventAnnotationGroupSearchIn,
EventAnnotationGroupSearchOut
>({
contentTypeId: CONTENT_ID,
query: {
text: title,
},
options: {
searchFields: ['title'],
},
});
for (const hit of hits) {
if (hit.attributes.title.toLowerCase() === title.toLowerCase()) {
return true;
}
}
return false;
};
const findAnnotationGroupContent = async (
searchTerm: string,
pageSize: number,
@ -138,7 +162,7 @@ export function getEventAnnotationService(
EventAnnotationGroupSearchIn,
EventAnnotationGroupSearchOut
>({
contentTypeId: EVENT_ANNOTATION_GROUP_TYPE,
contentTypeId: CONTENT_ID,
query: {
text: searchOptions.search,
},
@ -153,7 +177,7 @@ export function getEventAnnotationService(
const deleteAnnotationGroups = async (ids: string[]): Promise<void> => {
for (const id of ids) {
await client.delete<EventAnnotationGroupDeleteIn, EventAnnotationGroupDeleteOut>({
contentTypeId: EVENT_ANNOTATION_GROUP_TYPE,
contentTypeId: CONTENT_ID,
id,
});
}
@ -223,7 +247,7 @@ export function getEventAnnotationService(
const groupSavedObjectId = (
await client.create<EventAnnotationGroupCreateIn, EventAnnotationGroupCreateOut>({
contentTypeId: EVENT_ANNOTATION_GROUP_TYPE,
contentTypeId: CONTENT_ID,
data: {
...attributes,
},
@ -243,7 +267,7 @@ export function getEventAnnotationService(
const { attributes, references } = getAnnotationGroupAttributesAndReferences(group);
await client.update<EventAnnotationGroupUpdateIn, EventAnnotationGroupUpdateOut>({
contentTypeId: EVENT_ANNOTATION_GROUP_TYPE,
contentTypeId: CONTENT_ID,
id: annotationGroupId,
data: {
...attributes,
@ -259,7 +283,7 @@ export function getEventAnnotationService(
EventAnnotationGroupSearchIn,
EventAnnotationGroupSearchOut
>({
contentTypeId: EVENT_ANNOTATION_GROUP_TYPE,
contentTypeId: CONTENT_ID,
query: {
text: '*',
},
@ -270,6 +294,7 @@ export function getEventAnnotationService(
return {
loadAnnotationGroup,
groupExistsWithTitle,
updateAnnotationGroup,
createAnnotationGroup,
deleteAnnotationGroups,

View file

@ -14,6 +14,7 @@ import { EventAnnotationConfig, EventAnnotationGroupConfig } from '../../common'
export interface EventAnnotationServiceType {
loadAnnotationGroup: (savedObjectId: string) => Promise<EventAnnotationGroupConfig>;
groupExistsWithTitle: (title: string) => Promise<boolean>;
findAnnotationGroupContent: (
searchTerm: string,
pageSize: number,

View file

@ -268,9 +268,11 @@ export class EventAnnotationGroupStorage
EventAnnotationGroupSearchQuery,
EventAnnotationGroupSearchQuery
>(options);
if (optionsError) {
throw Boom.badRequest(`Invalid payload. ${optionsError.message}`);
}
const { searchFields = ['title^3', 'description'], types = [SO_TYPE] } = optionsToLatest;
const { included, excluded } = query.tags ?? {};

View file

@ -28,9 +28,11 @@ exports[`SavedObjectSaveModal should render matching snapshot 1`] = `
<EuiFormRow
describedByIds={Array []}
display="row"
error="A title is required"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label={
<FormattedMessage
defaultMessage="Title"
@ -85,43 +87,41 @@ exports[`SavedObjectSaveModal should render matching snapshot 1`] = `
</EuiFormRow>
</EuiForm>
</EuiModalBody>
<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]}
<EuiModalFooter>
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
>
<FormattedMessage
defaultMessage="Cancel"
id="savedObjects.saveModal.cancelButtonLabel"
values={Object {}}
/>
</EuiButtonEmpty>
<EuiButton
color="primary"
data-test-subj="confirmSaveSavedObjectButton"
fill={true}
form="generated-id_form"
isLoading={false}
size="m"
type="submit"
>
Save
</EuiButton>
<EuiFlexItem
grow={false}
>
<EuiButtonEmpty
data-test-subj="saveCancelButton"
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Cancel"
id="savedObjects.saveModal.cancelButtonLabel"
values={Object {}}
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
color="primary"
data-test-subj="confirmSaveSavedObjectButton"
fill={true}
form="generated-id_form"
isLoading={false}
size="m"
type="submit"
>
Save
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
`;
@ -154,9 +154,11 @@ exports[`SavedObjectSaveModal should render matching snapshot when custom isVali
<EuiFormRow
describedByIds={Array []}
display="row"
error="A title is required"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label={
<FormattedMessage
defaultMessage="Title"
@ -211,43 +213,41 @@ exports[`SavedObjectSaveModal should render matching snapshot when custom isVali
</EuiFormRow>
</EuiForm>
</EuiModalBody>
<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]}
<EuiModalFooter>
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
>
<FormattedMessage
defaultMessage="Cancel"
id="savedObjects.saveModal.cancelButtonLabel"
values={Object {}}
/>
</EuiButtonEmpty>
<EuiButton
color="primary"
data-test-subj="confirmSaveSavedObjectButton"
fill={true}
form="generated-id_form"
isLoading={false}
size="m"
type="submit"
>
Save
</EuiButton>
<EuiFlexItem
grow={false}
>
<EuiButtonEmpty
data-test-subj="saveCancelButton"
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Cancel"
id="savedObjects.saveModal.cancelButtonLabel"
values={Object {}}
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
color="primary"
data-test-subj="confirmSaveSavedObjectButton"
fill={true}
form="generated-id_form"
isLoading={false}
size="m"
type="submit"
>
Save
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
`;
@ -280,9 +280,11 @@ exports[`SavedObjectSaveModal should render matching snapshot when custom isVali
<EuiFormRow
describedByIds={Array []}
display="row"
error="A title is required"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label={
<FormattedMessage
defaultMessage="Title"
@ -337,43 +339,41 @@ exports[`SavedObjectSaveModal should render matching snapshot when custom isVali
</EuiFormRow>
</EuiForm>
</EuiModalBody>
<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]}
<EuiModalFooter>
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
>
<FormattedMessage
defaultMessage="Cancel"
id="savedObjects.saveModal.cancelButtonLabel"
values={Object {}}
/>
</EuiButtonEmpty>
<EuiButton
color="primary"
data-test-subj="confirmSaveSavedObjectButton"
fill={true}
form="generated-id_form"
isLoading={false}
size="m"
type="submit"
>
Save
</EuiButton>
<EuiFlexItem
grow={false}
>
<EuiButtonEmpty
data-test-subj="saveCancelButton"
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Cancel"
id="savedObjects.saveModal.cancelButtonLabel"
values={Object {}}
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
color="primary"
data-test-subj="confirmSaveSavedObjectButton"
fill={true}
form="generated-id_form"
isLoading={false}
size="m"
type="submit"
>
Save
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
`;
@ -410,9 +410,11 @@ exports[`SavedObjectSaveModal should render matching snapshot when given options
<EuiFormRow
describedByIds={Array []}
display="row"
error="A title is required"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label={
<FormattedMessage
defaultMessage="Title"
@ -477,43 +479,41 @@ exports[`SavedObjectSaveModal should render matching snapshot when given options
</EuiFlexGroup>
</EuiForm>
</EuiModalBody>
<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]}
<EuiModalFooter>
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
>
<FormattedMessage
defaultMessage="Cancel"
id="savedObjects.saveModal.cancelButtonLabel"
values={Object {}}
/>
</EuiButtonEmpty>
<EuiButton
color="primary"
data-test-subj="confirmSaveSavedObjectButton"
fill={true}
form="generated-id_form"
isLoading={false}
size="m"
type="submit"
>
Save
</EuiButton>
<EuiFlexItem
grow={false}
>
<EuiButtonEmpty
data-test-subj="saveCancelButton"
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Cancel"
id="savedObjects.saveModal.cancelButtonLabel"
values={Object {}}
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
color="primary"
data-test-subj="confirmSaveSavedObjectButton"
fill={true}
form="generated-id_form"
isLoading={false}
size="m"
type="submit"
>
Save
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
`;

View file

@ -30,7 +30,6 @@ 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;
@ -93,11 +92,19 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
const hasColumns = !!this.props.rightOptions;
const titleInputValid =
hasAttemptedSubmit &&
((!isTitleDuplicateConfirmed && hasTitleDuplicate) || title.length === 0);
const formBodyContent = (
<>
<EuiFormRow
fullWidth
label={<FormattedMessage id="savedObjects.saveModal.titleLabel" defaultMessage="Title" />}
isInvalid={titleInputValid}
error={i18n.translate('savedObjects.saveModal.titleRequired', {
defaultMessage: 'A title is required',
})}
>
<EuiFieldText
fullWidth
@ -105,10 +112,7 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
data-test-subj="savedObjectTitle"
value={title}
onChange={this.onTitleChange}
isInvalid={
hasAttemptedSubmit &&
((!isTitleDuplicateConfirmed && hasTitleDuplicate) || title.length === 0)
}
isInvalid={titleInputValid}
aria-describedby={this.state.hasTitleDuplicate ? duplicateWarningId : undefined}
/>
</EuiFormRow>
@ -167,20 +171,19 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
</EuiForm>
</EuiModalBody>
<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"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
{this.renderConfirmButton()}
<EuiModalFooter>
<EuiFlexGroup justifyContent="flexEnd" alignItems="center">
{this.props.showCopyOnSave && <EuiFlexItem grow>{this.renderCopyOnSave()}</EuiFlexItem>}
<EuiFlexItem grow={false}>
<EuiButtonEmpty data-test-subj="saveCancelButton" onClick={this.props.onClose}>
<FormattedMessage
id="savedObjects.saveModal.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>{this.renderConfirmButton()}</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
);
@ -351,10 +354,6 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
};
private renderCopyOnSave = () => {
if (!this.props.showCopyOnSave) {
return;
}
return (
<EuiSwitch
data-test-subj="saveAsNewCheckbox"

View file

@ -47,6 +47,7 @@ export const getSharedActions = ({
openLayerSettings,
onCloneLayer,
onRemoveLayer,
customRemoveModalText,
}: {
onRemoveLayer: () => void;
onCloneLayer: () => void;
@ -54,12 +55,12 @@ export const getSharedActions = ({
layerId: string;
isOnlyLayer: boolean;
activeVisualization: Visualization;
visualizationState: unknown;
layerType?: LayerType;
isTextBasedLanguage?: boolean;
hasLayerSettings: boolean;
openLayerSettings: () => void;
core: Pick<CoreStart, 'overlays' | 'theme'>;
customRemoveModalText?: { title?: string; description?: string };
}) => [
getOpenLayerSettingsAction({
hasLayerSettings,
@ -77,6 +78,7 @@ export const getSharedActions = ({
layerType,
isOnlyLayer,
core,
customModalText: customRemoveModalText,
}),
];
@ -189,7 +191,7 @@ export const LayerActions = (props: LayerActionsProps) => {
<EuiFlexItem grow={false}>
<EuiToolTip content={outsideListAction.displayName}>
<EuiButtonIcon
size="xs"
size="s"
iconType={outsideListAction.icon}
color={outsideListAction.color ?? 'text'}
data-test-subj={outsideListAction['data-test-subj']}

View file

@ -34,13 +34,15 @@ interface RemoveLayerAction {
layerType?: LayerType;
isOnlyLayer: boolean;
core: Pick<CoreStart, 'overlays' | 'theme'>;
customModalText?: { title?: string; description?: string };
}
const SKIP_DELETE_MODAL_KEY = 'skipDeleteModal';
const getCopy = (
layerType: LayerType,
isOnlyLayer?: boolean
isOnlyLayer?: boolean,
customModalText: { title?: string; description?: string } | undefined = undefined
): { buttonLabel: string; modalTitle: string; modalBody: string } => {
if (isOnlyLayer && layerType === 'data') {
return {
@ -64,34 +66,46 @@ const getCopy = (
case 'data':
return {
buttonLabel,
modalTitle: i18n.translate('xpack.lens.modalTitle.title.deleteVis', {
defaultMessage: 'Delete visualization layer?',
}),
modalBody: i18n.translate('xpack.lens.layer.confirmModal.deleteVis', {
defaultMessage: `Deleting this layer removes the visualization and its configurations. `,
}),
modalTitle:
customModalText?.title ??
i18n.translate('xpack.lens.modalTitle.title.deleteVis', {
defaultMessage: 'Delete visualization layer?',
}),
modalBody:
customModalText?.description ??
i18n.translate('xpack.lens.layer.confirmModal.deleteVis', {
defaultMessage: `Deleting this layer removes the visualization and its configurations. `,
}),
};
case 'annotations':
return {
buttonLabel,
modalTitle: i18n.translate('xpack.lens.modalTitle.title.deleteAnnotations', {
defaultMessage: 'Delete annotations layer?',
}),
modalBody: i18n.translate('xpack.lens.layer.confirmModal.deleteAnnotation', {
defaultMessage: `Deleting this layer removes the annotations and their configurations. `,
}),
modalTitle:
customModalText?.title ??
i18n.translate('xpack.lens.modalTitle.title.deleteAnnotations', {
defaultMessage: 'Delete annotation group?',
}),
modalBody:
customModalText?.description ??
i18n.translate('xpack.lens.layer.confirmModal.deleteAnnotation', {
defaultMessage: `Deleting this layer removes the annotations and their configurations. `,
}),
};
case 'referenceLine':
return {
buttonLabel,
modalTitle: i18n.translate('xpack.lens.modalTitle.title.deleteReferenceLines', {
defaultMessage: 'Delete reference lines layer?',
}),
modalBody: i18n.translate('xpack.lens.layer.confirmModal.deleteRefLine', {
defaultMessage: `Deleting this layer removes the reference lines and their configurations. `,
}),
modalTitle:
customModalText?.title ??
i18n.translate('xpack.lens.modalTitle.title.deleteReferenceLines', {
defaultMessage: 'Delete reference lines layer?',
}),
modalBody:
customModalText?.description ??
i18n.translate('xpack.lens.layer.confirmModal.deleteRefLine', {
defaultMessage: `Deleting this layer removes the reference lines and their configurations. `,
}),
};
default:
@ -193,7 +207,8 @@ const RemoveConfirmModal = ({
export const getRemoveLayerAction = (props: RemoveLayerAction): LayerAction => {
const { buttonLabel, modalTitle, modalBody } = getCopy(
props.layerType || LayerTypes.DATA,
props.isOnlyLayer
props.isOnlyLayer,
props.customModalText
);
return {

View file

@ -364,7 +364,6 @@ export function LayerPanel(
...getSharedActions({
layerId,
activeVisualization,
visualizationState,
core,
layerIndex,
layerType: activeVisualization.getLayerType(layerId, visualizationState),
@ -378,6 +377,10 @@ export function LayerPanel(
openLayerSettings: () => setPanelSettingsOpen(true),
onCloneLayer,
onRemoveLayer: () => onRemoveLayer(layerId),
customRemoveModalText: activeVisualization.getCustomRemoveLayerText?.(
layerId,
visualizationState
),
}),
].filter((i) => i.isCompatible),
[

View file

@ -40,15 +40,7 @@ export const StaticHeader = ({
<EuiTitle size="xxs">
<h5>{label}</h5>
</EuiTitle>
{indicator && (
<div
css={css`
padding-bottom: 3px;
`}
>
{indicator}
</div>
)}
{indicator}
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -1118,6 +1118,15 @@ export interface Visualization<T = unknown, P = T, ExtraAppendLayerArg = unknown
isSaveable?: boolean
) => LayerAction[];
/**
* This method is a clunky solution to the problem, but I'm banking on the confirm modal being removed
* with undo/redo anyways
*/
getCustomRemoveLayerText?: (
layerId: string,
state: T
) => { title?: string; description?: string } | undefined;
/** returns the type string of the given layer */
getLayerType: (layerId: string, state?: T) => LayerType | undefined;

View file

@ -113,7 +113,7 @@ describe('annotation group save action', () => {
describe('save routine', () => {
const layerId = 'mylayerid';
const layer: XYByValueAnnotationLayerConfig = {
const byValueLayer: XYByValueAnnotationLayerConfig = {
layerId,
layerType: 'annotations',
indexPatternId: 'some-index-pattern',
@ -144,10 +144,11 @@ describe('annotation group save action', () => {
legend: { isVisible: true, position: 'bottom' },
layers: [{ layerId } as XYAnnotationLayerConfig],
} as XYState,
layer,
layer: byValueLayer,
setState: jest.fn(),
eventAnnotationService: {
createAnnotationGroup: jest.fn(() => Promise.resolve({ id: savedId })),
groupExistsWithTitle: jest.fn(() => Promise.resolve(false)),
updateAnnotationGroup: jest.fn(),
loadAnnotationGroup: jest.fn(),
toExpression: jest.fn(),
@ -162,7 +163,7 @@ describe('annotation group save action', () => {
newTags: ['my-tag'],
newCopyOnSave: false,
isTitleDuplicateConfirmed: false,
onTitleDuplicate: () => {},
onTitleDuplicate: jest.fn(),
},
dataViews,
goToAnnotationLibrary: () => Promise.resolve(),
@ -321,5 +322,78 @@ describe('annotation group save action', () => {
expect(props.toasts.addSuccess).toHaveBeenCalledTimes(1);
});
it.each`
existingGroup | newCopyOnSave | titleChanged | isTitleDuplicateConfirmed | expectPreventSave
${false} | ${false} | ${false} | ${false} | ${true}
${false} | ${false} | ${false} | ${true} | ${false}
${true} | ${false} | ${false} | ${false} | ${false}
${true} | ${true} | ${false} | ${false} | ${true}
${true} | ${true} | ${false} | ${true} | ${false}
`(
'checks duplicate title when saving group',
async ({
existingGroup,
newCopyOnSave,
titleChanged,
isTitleDuplicateConfirmed,
expectPreventSave,
}) => {
(props.eventAnnotationService.groupExistsWithTitle as jest.Mock).mockResolvedValueOnce(
true
);
const oldTitle = 'old title';
let layer: XYAnnotationLayerConfig = byValueLayer;
if (existingGroup) {
const byReferenceLayer: XYByReferenceAnnotationLayerConfig = {
...props.layer,
annotationGroupId: 'my-group-id',
__lastSaved: {
...props.layer,
title: oldTitle,
description: 'description',
tags: [],
},
};
layer = byReferenceLayer;
}
const newTitle = titleChanged ? 'my changed title' : oldTitle;
await onSave({
...props,
layer,
modalOnSaveProps: {
...props.modalOnSaveProps,
newTitle,
isTitleDuplicateConfirmed,
newCopyOnSave,
},
});
if (expectPreventSave) {
expect(props.eventAnnotationService.updateAnnotationGroup).not.toHaveBeenCalled();
expect(props.eventAnnotationService.createAnnotationGroup).not.toHaveBeenCalled();
expect(props.modalOnSaveProps.closeModal).not.toHaveBeenCalled();
expect(props.setState).not.toHaveBeenCalled();
expect(props.toasts.addSuccess).not.toHaveBeenCalled();
expect(props.modalOnSaveProps.onTitleDuplicate).toHaveBeenCalled();
} else {
expect(props.modalOnSaveProps.onTitleDuplicate).not.toHaveBeenCalled();
expect(props.modalOnSaveProps.closeModal).toHaveBeenCalled();
expect(props.setState).toHaveBeenCalled();
expect(props.toasts.addSuccess).toHaveBeenCalledTimes(1);
}
}
);
});
});

View file

@ -133,6 +133,28 @@ const saveAnnotationGroupToLibrary = async (
return { id: savedId, config: groupConfig };
};
const shouldStopBecauseDuplicateTitle = async (
newTitle: string,
existingTitle: string,
newCopyOnSave: ModalOnSaveProps['newCopyOnSave'],
onTitleDuplicate: ModalOnSaveProps['onTitleDuplicate'],
isTitleDuplicateConfirmed: ModalOnSaveProps['isTitleDuplicateConfirmed'],
eventAnnotationService: EventAnnotationServiceType
) => {
if (isTitleDuplicateConfirmed || (newTitle === existingTitle && !newCopyOnSave)) {
return false;
}
const duplicateExists = await eventAnnotationService.groupExistsWithTitle(newTitle);
if (duplicateExists) {
onTitleDuplicate();
return true;
} else {
return false;
}
};
/** @internal exported for testing only */
export const onSave = async ({
state,
@ -140,7 +162,15 @@ export const onSave = async ({
setState,
eventAnnotationService,
toasts,
modalOnSaveProps: { newTitle, newDescription, newTags, closeModal, newCopyOnSave },
modalOnSaveProps: {
newTitle,
newDescription,
newTags,
closeModal,
newCopyOnSave,
onTitleDuplicate,
isTitleDuplicateConfirmed,
},
dataViews,
goToAnnotationLibrary,
}: {
@ -153,6 +183,17 @@ export const onSave = async ({
dataViews: DataViewsContract;
goToAnnotationLibrary: () => Promise<void>;
}) => {
const shouldStop = await shouldStopBecauseDuplicateTitle(
newTitle,
isByReferenceAnnotationsLayer(layer) ? layer.__lastSaved.title : '',
newCopyOnSave,
onTitleDuplicate,
isTitleDuplicateConfirmed,
eventAnnotationService
);
if (shouldStop) return;
let savedInfo: Awaited<ReturnType<typeof saveAnnotationGroupToLibrary>>;
try {
savedInfo = await saveAnnotationGroupToLibrary(
@ -205,27 +246,25 @@ export const onSave = async ({
text: ((element) =>
render(
<I18nProvider>
<p>
<FormattedMessage
id="xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.successToastBody"
defaultMessage="View or manage in the {link}."
values={{
link: (
<EuiLink
data-test-subj="lnsAnnotationLibraryLink"
onClick={() => goToAnnotationLibrary()}
>
{i18n.translate(
'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.annotationLibrary',
{
defaultMessage: 'annotation library',
}
)}
</EuiLink>
),
}}
/>
</p>
<FormattedMessage
id="xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.successToastBody"
defaultMessage="View or manage in the {link}."
values={{
link: (
<EuiLink
data-test-subj="lnsAnnotationLibraryLink"
onClick={() => goToAnnotationLibrary()}
>
{i18n.translate(
'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.annotationLibrary',
{
defaultMessage: 'annotation library',
}
)}
</EuiLink>
),
}}
/>
</I18nProvider>,
element
)) as MountPoint,
@ -258,7 +297,7 @@ export const getSaveLayerAction = ({
const displayName = i18n.translate(
'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary',
{
defaultMessage: 'Save annotation group',
defaultMessage: 'Save to library',
}
);

View file

@ -416,16 +416,6 @@ export const getAnnotationsConfiguration = ({
}) => {
const groupLabel = getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) });
const emptyButtonLabels = {
buttonAriaLabel: i18n.translate('xpack.lens.indexPattern.addColumnAriaLabelClick', {
defaultMessage: 'Add an annotation to {groupLabel}',
values: { groupLabel },
}),
buttonLabel: i18n.translate('xpack.lens.configure.emptyConfigClick', {
defaultMessage: 'Add an annotation',
}),
};
return {
groups: [
{
@ -445,7 +435,6 @@ export const getAnnotationsConfiguration = ({
supportFieldFormat: false,
enableDimensionEditor: true,
filterOperations: () => false,
labels: emptyButtonLabels,
},
],
};

View file

@ -70,6 +70,7 @@ export function LoadAnnotationLibraryFlyout({
<div
css={css`
padding: ${euiThemeVars.euiSize};
height: 100%;
`}
>
<EventAnnotationGroupSavedObjectFinder

View file

@ -3638,7 +3638,7 @@ describe('xy_visualization', () => {
Object {
"data-test-subj": "lnsXY_annotationLayer_saveToLibrary",
"description": "Saves annotation group as separate saved object",
"displayName": "Save annotation group",
"displayName": "Save to library",
"execute": [Function],
"icon": "save",
"isCompatible": true,
@ -3899,4 +3899,52 @@ describe('xy_visualization', () => {
).not.toThrowError();
});
});
describe('#getCustomRemoveLayerText', () => {
it('should NOT return custom text for the remove layer button if not by-reference', () => {
expect(xyVisualization.getCustomRemoveLayerText!('first', exampleState())).toBeUndefined();
});
it('should return custom text for the remove layer button if by-reference', () => {
const layerId = 'layer-id';
const commonProps = {
layerId,
layerType: 'annotations' as const,
indexPatternId: 'some-index-pattern',
ignoreGlobalFilters: false,
annotations: [
{
id: 'some-annotation-id',
type: 'manual',
key: {
type: 'point_in_time',
timestamp: 'timestamp',
},
} as PointInTimeEventAnnotationConfig,
],
};
const layer: XYByReferenceAnnotationLayerConfig = {
...commonProps,
annotationGroupId: 'some-group-id',
__lastSaved: {
...commonProps,
title: 'My saved object title',
description: 'some description',
tags: [],
},
};
expect(
xyVisualization.getCustomRemoveLayerText!(layerId, {
...exampleState(),
layers: [layer],
})
).toMatchInlineSnapshot(`
Object {
"title": "Delete \\"My saved object title\\"",
}
`);
});
});
});

View file

@ -97,6 +97,7 @@ import {
getVisualizationType,
isAnnotationsLayer,
isBucketed,
isByReferenceAnnotationsLayer,
isDataLayer,
isNumericDynamicMetric,
isReferenceLayer,
@ -304,6 +305,14 @@ export const getXyVisualization = ({
return actions;
},
getCustomRemoveLayerText(layerId, state) {
const layerIndex = state.layers.findIndex((l) => l.layerId === layerId);
const layer = state.layers[layerIndex];
if (layer && isByReferenceAnnotationsLayer(layer)) {
return { title: `Delete "${layer.__lastSaved.title}"` };
}
},
hasLayerSettings({ state, layerId: currentLayerId }) {
const layer = state.layers?.find(({ layerId }) => layerId === currentLayerId);
return { data: Boolean(layer && isAnnotationsLayer(layer)), appearance: false };

View file

@ -161,7 +161,7 @@ export const isPersistedByValueAnnotationsLayer = (
(layer.persistanceType === 'byValue' || !layer.persistanceType);
export const isByReferenceAnnotationsLayer = (
layer: XYAnnotationLayerConfig
layer: XYLayerConfig
): layer is XYByReferenceAnnotationLayerConfig =>
'annotationGroupId' in layer && '__lastSaved' in layer;

View file

@ -19,6 +19,7 @@ import {
import { ToolbarButton } from '@kbn/kibana-react-plugin/public';
import { IconChartBarReferenceLine, IconChartBarAnnotations } from '@kbn/chart-icons';
import { euiThemeVars } from '@kbn/ui-theme';
import { css } from '@emotion/react';
import { getIgnoreGlobalFilterIcon } from '../../../shared_components/ignore_global_filter/data_view_picker_icon';
import type {
VisualizationLayerHeaderContentProps,
@ -96,13 +97,20 @@ function AnnotationsLayerHeader({
}
indicator={
hasUnsavedChanges && (
<EuiIconTip
content={i18n.translate('xpack.lens.xyChart.unsavedChanges', {
defaultMessage: 'Unsaved changes',
})}
type="dot"
color={euiThemeVars.euiColorSuccess}
/>
<div
css={css`
padding-bottom: 3px;
padding-left: 4px;
`}
>
<EuiIconTip
content={i18n.translate('xpack.lens.xyChart.unsavedChanges', {
defaultMessage: 'Unsaved changes',
})}
type="dot"
color={euiThemeVars.euiColorSuccess}
/>
</div>
)
}
/>

View file

@ -20070,7 +20070,6 @@
"xpack.lens.functions.timeScale.dateColumnMissingMessage": "L'ID de colonne de date spécifié {columnId} n'existe pas.",
"xpack.lens.heatmapVisualization.arrayValuesWarningMessage": "{label} contient des valeurs de tableau. Le rendu de votre visualisation peut ne pas se présenter comme attendu.",
"xpack.lens.indexPattern.addColumnAriaLabel": "Ajouter ou faire glisser un champ vers {groupLabel}",
"xpack.lens.indexPattern.addColumnAriaLabelClick": "Ajouter une annotation à {groupLabel}",
"xpack.lens.indexPattern.annotationsDimensionEditorLabel": "Annotation {groupLabel}",
"xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning": "{name} pour cette visualisation peut être approximatif en raison de la manière dont les données sont indexées. Essayez de trier par rareté plutôt que par nombre ascendant denregistrements. Pour en savoir plus sur cette limitation, {link}.",
"xpack.lens.indexPattern.autoIntervalLabel": "Auto ({interval})",
@ -20305,7 +20304,6 @@
"xpack.lens.configPanel.selectVisualization": "Sélectionner une visualisation",
"xpack.lens.configPanel.visualizationType": "Type de visualisation",
"xpack.lens.configure.emptyConfig": "Ajouter ou glisser-déposer un champ",
"xpack.lens.configure.emptyConfigClick": "Ajouter une annotation",
"xpack.lens.configure.invalidBottomReferenceLineDimension": "La ligne de référence est affectée à un axe qui nexiste plus ou qui nest plus valide. Vous pouvez déplacer cette ligne de référence vers un autre axe disponible ou la supprimer.",
"xpack.lens.configure.invalidConfigTooltip": "Configuration non valide.",
"xpack.lens.configure.invalidConfigTooltipClick": "Cliquez pour en savoir plus.",

View file

@ -20069,7 +20069,6 @@
"xpack.lens.functions.timeScale.dateColumnMissingMessage": "指定したdateColumnId {columnId}は存在しません。",
"xpack.lens.heatmapVisualization.arrayValuesWarningMessage": "{label}には配列値が含まれます。可視化が想定通りに表示されない場合があります。",
"xpack.lens.indexPattern.addColumnAriaLabel": "フィールドを追加するか、{groupLabel}にドラッグアンドドロップします",
"xpack.lens.indexPattern.addColumnAriaLabelClick": "注釈を{groupLabel}に追加",
"xpack.lens.indexPattern.annotationsDimensionEditorLabel": "{groupLabel}注釈",
"xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning": "データのインデックス方法のため、このビジュアライゼーションの{name}は近似される場合があります。レコード数の昇順ではなく希少性で並べ替えてください。この制限の詳細については、{link}。",
"xpack.lens.indexPattern.autoIntervalLabel": "自動({interval}",
@ -20305,7 +20304,6 @@
"xpack.lens.configPanel.selectVisualization": "ビジュアライゼーションを選択してください",
"xpack.lens.configPanel.visualizationType": "ビジュアライゼーションタイプ",
"xpack.lens.configure.emptyConfig": "フィールドを追加するか、ドラッグアンドドロップします",
"xpack.lens.configure.emptyConfigClick": "注釈の追加",
"xpack.lens.configure.invalidBottomReferenceLineDimension": "この基準線は存在しないか有効ではない軸に割り当てられています。この基準線を別の使用可能な軸に移動するか、削除することができます。",
"xpack.lens.configure.invalidConfigTooltip": "無効な構成です。",
"xpack.lens.configure.invalidConfigTooltipClick": "詳細はクリックしてください。",

View file

@ -20069,7 +20069,6 @@
"xpack.lens.functions.timeScale.dateColumnMissingMessage": "指定的 dateColumnId {columnId} 不存在。",
"xpack.lens.heatmapVisualization.arrayValuesWarningMessage": "{label} 包含数组值。您的可视化可能无法正常渲染。",
"xpack.lens.indexPattern.addColumnAriaLabel": "将字段添加或拖放到 {groupLabel}",
"xpack.lens.indexPattern.addColumnAriaLabelClick": "添加标注到 {groupLabel}",
"xpack.lens.indexPattern.annotationsDimensionEditorLabel": "{groupLabel} 标注",
"xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning": "由于数据的索引方式,此可视化的 {name} 可能为近似值。尝试按稀有度排序,而不是采用升序记录计数。有关此限制的详情,{link}。",
"xpack.lens.indexPattern.autoIntervalLabel": "自动 ({interval})",
@ -20305,7 +20304,6 @@
"xpack.lens.configPanel.selectVisualization": "选择可视化",
"xpack.lens.configPanel.visualizationType": "可视化类型",
"xpack.lens.configure.emptyConfig": "添加或拖放字段",
"xpack.lens.configure.emptyConfigClick": "添加标注",
"xpack.lens.configure.invalidBottomReferenceLineDimension": "此参考线分配给了不再存在或不再有效的轴。您可以将此参考线移到其他可用的轴,或将其移除。",
"xpack.lens.configure.invalidConfigTooltip": "配置无效。",
"xpack.lens.configure.invalidConfigTooltipClick": "单击了解更多详情。",