diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md index b944c9dcc02a2..07ae46f8bbf12 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md @@ -18,6 +18,7 @@ export interface EmbeddableEditorState | --- | --- | --- | | [embeddableId](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.embeddableid.md) | string | | | [originatingApp](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.originatingapp.md) | string | | +| [originatingPath](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.originatingpath.md) | string | | | [searchSessionId](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.searchsessionid.md) | string | Pass current search session id when navigating to an editor, Editors could use it continue previous search session | | [valueInput](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.valueinput.md) | EmbeddableInput | | diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.originatingpath.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.originatingpath.md new file mode 100644 index 0000000000000..e255f11f8a059 --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.originatingpath.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [EmbeddableEditorState](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md) > [originatingPath](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.originatingpath.md) + +## EmbeddableEditorState.originatingPath property + +Signature: + +```typescript +originatingPath?: string; +``` diff --git a/package.json b/package.json index 00fa0807e0f93..63cbe52b55030 100644 --- a/package.json +++ b/package.json @@ -379,6 +379,7 @@ "redux-saga": "^1.1.3", "redux-thunk": "^2.3.0", "redux-thunks": "^1.0.0", + "remark-stringify": "^9.0.0", "regenerator-runtime": "^0.13.3", "request": "^2.88.0", "require-in-the-middle": "^5.0.2", diff --git a/src/plugins/embeddable/public/lib/state_transfer/types.ts b/src/plugins/embeddable/public/lib/state_transfer/types.ts index 98cf6e70284cd..74ee31ba71104 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/types.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/types.ts @@ -17,6 +17,7 @@ export const EMBEDDABLE_EDITOR_STATE_KEY = 'embeddable_editor_state'; */ export interface EmbeddableEditorState { originatingApp: string; + originatingPath?: string; embeddableId?: string; valueInput?: EmbeddableInput; diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index 2e46cb82dc592..3dfe10445fb85 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -369,6 +369,8 @@ export interface EmbeddableEditorState { embeddableId?: string; // (undocumented) originatingApp: string; + // (undocumented) + originatingPath?: string; searchSessionId?: string; // (undocumented) valueInput?: EmbeddableInput; diff --git a/src/plugins/saved_objects/public/finder/index.ts b/src/plugins/saved_objects/public/finder/index.ts index edec012d90d6f..de6a54795fce5 100644 --- a/src/plugins/saved_objects/public/finder/index.ts +++ b/src/plugins/saved_objects/public/finder/index.ts @@ -9,5 +9,6 @@ export { SavedObjectMetaData, SavedObjectFinderUi, + SavedObjectFinderUiProps, getSavedObjectFinder, } from './saved_object_finder'; diff --git a/src/plugins/saved_objects/public/index.ts b/src/plugins/saved_objects/public/index.ts index 84c39168d82c2..bc84298a63717 100644 --- a/src/plugins/saved_objects/public/index.ts +++ b/src/plugins/saved_objects/public/index.ts @@ -17,7 +17,12 @@ export { SaveResult, showSaveModal, } from './save_modal'; -export { getSavedObjectFinder, SavedObjectFinderUi, SavedObjectMetaData } from './finder'; +export { + getSavedObjectFinder, + SavedObjectFinderUi, + SavedObjectFinderUiProps, + SavedObjectMetaData, +} from './finder'; export { SavedObjectLoader, SavedObjectLoaderFindOptions, diff --git a/x-pack/examples/embedded_lens_example/public/app.tsx b/x-pack/examples/embedded_lens_example/public/app.tsx index 58c932c3ca164..55f2b4ccd71e9 100644 --- a/x-pack/examples/embedded_lens_example/public/app.tsx +++ b/x-pack/examples/embedded_lens_example/public/app.tsx @@ -173,7 +173,9 @@ export const App = (props: { timeRange: time, attributes: getLensAttributes(props.defaultIndexPattern!, color), }, - true + { + openInNewTab: true, + } ); // eslint-disable-next-line no-bitwise const newColor = '#' + ((Math.random() * 0xffffff) << 0).toString(16); @@ -195,7 +197,9 @@ export const App = (props: { timeRange: time, attributes: getLensAttributes(props.defaultIndexPattern!, color), }, - false + { + openInNewTab: false, + } ); }} > diff --git a/x-pack/plugins/cases/README.md b/x-pack/plugins/cases/README.md index 25113ccbb30df..f894ca23dfbf0 100644 --- a/x-pack/plugins/cases/README.md +++ b/x-pack/plugins/cases/README.md @@ -43,6 +43,16 @@ cases: CasesUiStart; cases.getCreateCase({ onCancel: handleSetIsCancel, onSuccess, + lensIntegration?: { + plugins: { + parsingPlugin, + processingPluginRenderer, + uiPlugin, + }, + hooks: { + useInsertTimeline, + }, + } timelineIntegration?: { plugins: { parsingPlugin, diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 3edbd3443ffc1..bf4ec0da6ee56 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -18,6 +18,12 @@ import { UserActionField, } from '../api'; +export interface CasesUiConfigType { + markdownPlugins: { + lens: boolean; + }; +} + export const StatusAll = 'all' as const; export type StatusAllType = typeof StatusAll; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/lens/constants.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/constants.ts new file mode 100644 index 0000000000000..bc67e1b3228bb --- /dev/null +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/constants.ts @@ -0,0 +1,8 @@ +/* + * 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 LENS_ID = 'lens'; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/lens/index.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/index.ts new file mode 100644 index 0000000000000..4f48da5838380 --- /dev/null +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/index.ts @@ -0,0 +1,10 @@ +/* + * 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 * from './constants'; +export * from './parser'; +export * from './serializer'; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/lens/parser.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/parser.ts new file mode 100644 index 0000000000000..58ebfd76d5ac5 --- /dev/null +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/parser.ts @@ -0,0 +1,77 @@ +/* + * 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 { Plugin } from 'unified'; +import { RemarkTokenizer } from '@elastic/eui'; +import { LENS_ID } from './constants'; + +export const LensParser: Plugin = function () { + const Parser = this.Parser; + const tokenizers = Parser.prototype.blockTokenizers; + const methods = Parser.prototype.blockMethods; + + const tokenizeLens: RemarkTokenizer = function (eat, value, silent) { + if (value.startsWith(`!{${LENS_ID}`) === false) return true; + + const nextChar = value[6]; + + if (nextChar !== '{' && nextChar !== '}') return false; // this isn't actually a lens + + if (silent) { + return true; + } + + // is there a configuration? + const hasConfiguration = nextChar === '{'; + + let match = `!{${LENS_ID}`; + let configuration = {}; + + if (hasConfiguration) { + let configurationString = ''; + + let openObjects = 0; + + for (let i = 6; i < value.length; i++) { + const char = value[i]; + if (char === '{') { + openObjects++; + configurationString += char; + } else if (char === '}') { + openObjects--; + if (openObjects === -1) { + break; + } + configurationString += char; + } else { + configurationString += char; + } + } + + match += configurationString; + try { + configuration = JSON.parse(configurationString); + } catch (e) { + const now = eat.now(); + this.file.fail(`Unable to parse lens JSON configuration: ${e}`, { + line: now.line, + column: now.column + 6, + }); + } + } + + match += '}'; + + return eat(match)({ + type: LENS_ID, + ...configuration, + }); + }; + + tokenizers.lens = tokenizeLens; + methods.splice(methods.indexOf('text'), 0, LENS_ID); +}; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/lens/serializer.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/serializer.ts new file mode 100644 index 0000000000000..e561b2f8cfb8a --- /dev/null +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/lens/serializer.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TimeRange } from 'src/plugins/data/common'; +import { LENS_ID } from './constants'; + +export interface LensSerializerProps { + attributes: Record; + timeRange: TimeRange; +} + +export const LensSerializer = ({ timeRange, attributes }: LensSerializerProps) => + `!{${LENS_ID}${JSON.stringify({ + timeRange, + attributes, + })}}`; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/index.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/index.ts new file mode 100644 index 0000000000000..c6a22791db5f6 --- /dev/null +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/index.ts @@ -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 * from './parser'; +export * from './serializer'; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/parser.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/parser.ts new file mode 100644 index 0000000000000..0decdae8c7348 --- /dev/null +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/parser.ts @@ -0,0 +1,83 @@ +/* + * 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 { Plugin } from 'unified'; +import { RemarkTokenizer } from '@elastic/eui'; +import * as i18n from './translations'; + +export const ID = 'timeline'; +const PREFIX = '['; + +export const TimelineParser: Plugin = function () { + const Parser = this.Parser; + const tokenizers = Parser.prototype.blockTokenizers; + const methods = Parser.prototype.blockMethods; + + const tokenizeTimeline: RemarkTokenizer = function (eat, value, silent) { + if ( + value.startsWith(PREFIX) === false || + (value.startsWith(PREFIX) === true && !value.includes('timelines?timeline=(id')) + ) { + return false; + } + + let index = 0; + const nextChar = value[index]; + + if (nextChar !== PREFIX) { + return false; + } + + if (silent) { + return true; + } + + function readArg(open: string, close: string) { + if (value[index] !== open) { + throw new Error(i18n.NO_PARENTHESES); + } + + index++; + + let body = ''; + let openBrackets = 0; + + for (; index < value.length; index++) { + const char = value[index]; + + if (char === close && openBrackets === 0) { + index++; + return body; + } else if (char === close) { + openBrackets--; + } else if (char === open) { + openBrackets++; + } + + body += char; + } + + return ''; + } + + const timelineTitle = readArg(PREFIX, ']'); + const timelineUrl = readArg('(', ')'); + const match = `[${timelineTitle}](${timelineUrl})`; + + return eat(match)({ + type: ID, + match, + }); + }; + + tokenizeTimeline.locator = (value: string, fromIndex: number) => { + return value.indexOf(PREFIX, fromIndex); + }; + + tokenizers.timeline = tokenizeTimeline; + methods.splice(methods.indexOf('url'), 0, ID); +}; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/serializer.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/serializer.ts new file mode 100644 index 0000000000000..0a95c9466b1ff --- /dev/null +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/serializer.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface TimelineSerializerProps { + match: string; +} + +export const TimelineSerializer = ({ match }: TimelineSerializerProps) => match; diff --git a/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/translations.ts b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/translations.ts new file mode 100644 index 0000000000000..a1244f0ae67aa --- /dev/null +++ b/x-pack/plugins/cases/common/utils/markdown_plugins/timeline/translations.ts @@ -0,0 +1,15 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const NO_PARENTHESES = i18n.translate( + 'xpack.cases.markdownEditor.plugins.timeline.noParenthesesErrorMsg', + { + defaultMessage: 'Expected left parentheses', + } +); diff --git a/x-pack/plugins/cases/kibana.json b/x-pack/plugins/cases/kibana.json index f72f0e012bd80..ebac6295166df 100644 --- a/x-pack/plugins/cases/kibana.json +++ b/x-pack/plugins/cases/kibana.json @@ -20,11 +20,15 @@ "requiredPlugins":[ "actions", "esUiShared", + "lens", "features", "kibanaReact", "kibanaUtils", "triggersActionsUi" ], + "requiredBundles": [ + "savedObjects" + ], "server":true, "ui":true, "version":"8.0.0" diff --git a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts index 392b71befe2b4..fb5e3f89d74b1 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts @@ -12,7 +12,11 @@ import { createWithKibanaMock, } from '../kibana_react.mock'; -export const KibanaServices = { get: jest.fn(), getKibanaVersion: jest.fn(() => '8.0.0') }; +export const KibanaServices = { + get: jest.fn(), + getKibanaVersion: jest.fn(() => '8.0.0'), + getConfig: jest.fn(() => null), +}; export const useKibana = jest.fn().mockReturnValue({ services: createStartServicesMock(), }); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts index ff03782447846..e1990efefeffc 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts @@ -15,6 +15,13 @@ import { EuiTheme } from '../../../../../../../src/plugins/kibana_react/common'; import { securityMock } from '../../../../../security/public/mocks'; import { triggersActionsUiMock } from '../../../../../triggers_actions_ui/public/mocks'; +export const mockCreateStartServicesMock = (): StartServices => + (({ + ...coreMock.createStart(), + security: securityMock.createStart(), + triggersActionsUi: triggersActionsUiMock.createStart(), + } as unknown) as StartServices); + export const createStartServicesMock = (): StartServices => (({ ...coreMock.createStart(), diff --git a/x-pack/plugins/cases/public/common/lib/kibana/services.ts b/x-pack/plugins/cases/public/common/lib/kibana/services.ts index 94487bd3ca5e9..3a1f220d9794f 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/services.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/services.ts @@ -6,16 +6,23 @@ */ import { CoreStart } from 'kibana/public'; +import { CasesUiConfigType } from '../../../../common/ui/types'; type GlobalServices = Pick; export class KibanaServices { private static kibanaVersion?: string; private static services?: GlobalServices; + private static config?: CasesUiConfigType; - public static init({ http, kibanaVersion }: GlobalServices & { kibanaVersion: string }) { + public static init({ + http, + kibanaVersion, + config, + }: GlobalServices & { kibanaVersion: string; config: CasesUiConfigType }) { this.services = { http }; this.kibanaVersion = kibanaVersion; + this.config = config; } public static get(): GlobalServices { @@ -34,6 +41,10 @@ export class KibanaServices { return this.kibanaVersion; } + public static getConfig() { + return this.config; + } + private static throwUninitializedError(): never { throw new Error( 'Kibana services not initialized - are you trying to import this module from outside of the Cases app?' diff --git a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx index db3f22a074d3b..06a3897687921 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx @@ -26,6 +26,7 @@ const onCommentPosted = jest.fn(); const postComment = jest.fn(); const addCommentProps: AddCommentProps = { + id: 'newComment', caseId: '1234', userCanCrud: true, onCommentSaving, diff --git a/x-pack/plugins/cases/public/components/add_comment/index.tsx b/x-pack/plugins/cases/public/components/add_comment/index.tsx index 4ec06d6b55197..f788456a30dff 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.tsx @@ -6,7 +6,7 @@ */ import { EuiButton, EuiFlexItem, EuiFlexGroup, EuiLoadingSpinner } from '@elastic/eui'; -import React, { useCallback, forwardRef, useImperativeHandle } from 'react'; +import React, { useCallback, useRef, forwardRef, useImperativeHandle } from 'react'; import styled from 'styled-components'; import { CommentType } from '../../../common'; @@ -19,6 +19,7 @@ import * as i18n from './translations'; import { schema, AddCommentFormSchema } from './schema'; import { InsertTimeline } from '../insert_timeline'; import { useOwnerContext } from '../owner_context/use_owner_context'; + const MySpinner = styled(EuiLoadingSpinner)` position: absolute; top: 50%; @@ -31,9 +32,11 @@ const initialCommentValue: AddCommentFormSchema = { export interface AddCommentRefObject { addQuote: (quote: string) => void; + setComment: (newComment: string) => void; } export interface AddCommentProps { + id: string; caseId: string; userCanCrud?: boolean; onCommentSaving?: () => void; @@ -47,6 +50,7 @@ export const AddComment = React.memo( forwardRef( ( { + id, caseId, userCanCrud, onCommentPosted, @@ -57,6 +61,7 @@ export const AddComment = React.memo( }, ref ) => { + const editorRef = useRef(); const owner = useOwnerContext(); const { isLoading, postComment } = usePostComment(); @@ -77,8 +82,17 @@ export const AddComment = React.memo( [comment, setFieldValue] ); + const setComment = useCallback( + (newComment) => { + setFieldValue(fieldName, newComment); + }, + [setFieldValue] + ); + useImperativeHandle(ref, () => ({ addQuote, + setComment, + editor: editorRef.current, })); const onSubmit = useCallback(async () => { @@ -106,6 +120,8 @@ export const AddComment = React.memo( path={fieldName} component={MarkdownEditorForm} componentProps={{ + ref: editorRef, + id, idAria: 'caseComment', isDisabled: isLoading, dataTestSubj: 'add-comment', diff --git a/x-pack/plugins/cases/public/components/create/description.test.tsx b/x-pack/plugins/cases/public/components/create/description.test.tsx index fcd1f82d64a53..923c73193f992 100644 --- a/x-pack/plugins/cases/public/components/create/description.test.tsx +++ b/x-pack/plugins/cases/public/components/create/description.test.tsx @@ -12,6 +12,7 @@ import { act } from '@testing-library/react'; import { useForm, Form, FormHook } from '../../common/shared_imports'; import { Description } from './description'; import { schema, FormProps } from './schema'; +jest.mock('../markdown_editor/plugins/lens/use_lens_draft_comment'); describe('Description', () => { let globalForm: FormHook; diff --git a/x-pack/plugins/cases/public/components/create/description.tsx b/x-pack/plugins/cases/public/components/create/description.tsx index 0a7102cff1ad5..d11c64789c3f0 100644 --- a/x-pack/plugins/cases/public/components/create/description.tsx +++ b/x-pack/plugins/cases/public/components/create/description.tsx @@ -5,26 +5,43 @@ * 2.0. */ -import React, { memo } from 'react'; +import React, { memo, useEffect, useRef } from 'react'; import { MarkdownEditorForm } from '../markdown_editor'; -import { UseField } from '../../common/shared_imports'; +import { UseField, useFormContext } from '../../common/shared_imports'; +import { useLensDraftComment } from '../markdown_editor/plugins/lens/use_lens_draft_comment'; + interface Props { isLoading: boolean; } export const fieldName = 'description'; -const DescriptionComponent: React.FC = ({ isLoading }) => ( - -); +const DescriptionComponent: React.FC = ({ isLoading }) => { + const { draftComment, openLensModal } = useLensDraftComment(); + const { setFieldValue } = useFormContext(); + const editorRef = useRef>(); + + useEffect(() => { + if (draftComment?.commentId === fieldName && editorRef.current) { + setFieldValue(fieldName, draftComment.comment); + openLensModal({ editorRef: editorRef.current }); + } + }, [draftComment, openLensModal, setFieldValue]); + + return ( + + ); +}; DescriptionComponent.displayName = 'DescriptionComponent'; diff --git a/x-pack/plugins/cases/public/components/create/form.test.tsx b/x-pack/plugins/cases/public/components/create/form.test.tsx index 783ead9b271fd..9c3071fe27ee5 100644 --- a/x-pack/plugins/cases/public/components/create/form.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form.test.tsx @@ -23,6 +23,7 @@ import { useCaseConfigureResponse } from '../configure_cases/__mock__'; jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/configure/use_configure'); +jest.mock('../markdown_editor/plugins/lens/use_lens_draft_comment'); const useGetTagsMock = useGetTags as jest.Mock; const useConnectorsMock = useConnectors as jest.Mock; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/context.tsx b/x-pack/plugins/cases/public/components/markdown_editor/context.tsx new file mode 100644 index 0000000000000..d7f5b0612cb73 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/context.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +export const CommentEditorContext = React.createContext<{ + editorId: string; + value: string; +} | null>(null); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx index 4bd26678e41a2..64aac233f1bb9 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx @@ -5,15 +5,26 @@ * 2.0. */ -import React, { memo, useState, useCallback } from 'react'; +import React, { + memo, + forwardRef, + useCallback, + useMemo, + useRef, + useState, + useImperativeHandle, + ElementRef, +} from 'react'; import { PluggableList } from 'unified'; import { EuiMarkdownEditor, EuiMarkdownEditorUiPlugin } from '@elastic/eui'; +import { ContextShape } from '@elastic/eui/src/components/markdown_editor/markdown_context'; import { usePlugins } from './use_plugins'; +import { CommentEditorContext } from './context'; interface MarkdownEditorProps { ariaLabel: string; dataTestSubj?: string; - editorId?: string; + editorId: string; height?: number; onChange: (content: string) => void; parsingPlugins?: PluggableList; @@ -22,35 +33,64 @@ interface MarkdownEditorProps { value: string; } -const MarkdownEditorComponent: React.FC = ({ - ariaLabel, - dataTestSubj, - editorId, - height, - onChange, - value, -}) => { - const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); - const onParse = useCallback((err, { messages }) => { - setMarkdownErrorMessages(err ? [err] : messages); - }, []); - const { parsingPlugins, processingPlugins, uiPlugins } = usePlugins(); - - return ( - - ); -}; +type EuiMarkdownEditorRef = ElementRef; + +export interface MarkdownEditorRef { + textarea: HTMLTextAreaElement | null; + replaceNode: ContextShape['replaceNode']; + toolbar: HTMLDivElement | null; +} + +const MarkdownEditorComponent = forwardRef( + ({ ariaLabel, dataTestSubj, editorId, height, onChange, value }, ref) => { + const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); + const onParse = useCallback((err, { messages }) => { + setMarkdownErrorMessages(err ? [err] : messages); + }, []); + const { parsingPlugins, processingPlugins, uiPlugins } = usePlugins(); + const editorRef = useRef(null); + + const commentEditorContextValue = useMemo( + () => ({ + editorId, + value, + }), + [editorId, value] + ); + + // @ts-expect-error + useImperativeHandle(ref, () => { + if (!editorRef.current) { + return null; + } + + const editorNode = editorRef.current?.textarea?.closest('.euiMarkdownEditor'); + + return { + ...editorRef.current, + toolbar: editorNode?.querySelector('.euiMarkdownEditorToolbar'), + }; + }); + + return ( + + + + ); + } +); export const MarkdownEditor = memo(MarkdownEditorComponent); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx index c2b2e8c77cb38..2719f38f98fc2 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import React from 'react'; +import React, { forwardRef } from 'react'; import styled from 'styled-components'; import { EuiMarkdownEditorProps, EuiFormRow, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../common/shared_imports'; -import { MarkdownEditor } from './editor'; +import { MarkdownEditor, MarkdownEditorRef } from './editor'; type MarkdownEditorFormProps = EuiMarkdownEditorProps & { id: string; @@ -26,40 +26,39 @@ const BottomContentWrapper = styled(EuiFlexGroup)` `} `; -export const MarkdownEditorForm: React.FC = ({ - id, - field, - dataTestSubj, - idAria, - bottomRightContent, -}) => { - const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); +export const MarkdownEditorForm = React.memo( + forwardRef( + ({ id, field, dataTestSubj, idAria, bottomRightContent }, ref) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - return ( - <> - - - - {bottomRightContent && ( - - {bottomRightContent} - - )} - - ); -}; + return ( + <> + + + + {bottomRightContent && ( + + {bottomRightContent} + + )} + + ); + } + ) +); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/__mocks__/use_lens_draft_comment.ts b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/__mocks__/use_lens_draft_comment.ts new file mode 100644 index 0000000000000..a0f0d49b211fb --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/__mocks__/use_lens_draft_comment.ts @@ -0,0 +1,8 @@ +/* + * 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 useLensDraftComment = () => ({}); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/constants.ts b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/constants.ts new file mode 100644 index 0000000000000..05826f73fe007 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/constants.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const ID = 'lens'; +export const PREFIX = `[`; +export const LENS_VISUALIZATION_HEIGHT = 200; +export const DRAFT_COMMENT_STORAGE_ID = 'xpack.cases.commentDraft'; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/index.ts b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/index.ts new file mode 100644 index 0000000000000..1d0bb2bf6c86e --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { plugin } from './plugin'; +import { LensParser } from './parser'; +import { LensMarkDownRenderer } from './processor'; +import { INSERT_LENS } from './translations'; + +export { plugin, LensParser as parser, LensMarkDownRenderer as renderer, INSERT_LENS }; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/modal_container.tsx b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/modal_container.tsx new file mode 100644 index 0000000000000..0f70e80deed41 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/modal_container.tsx @@ -0,0 +1,16 @@ +/* + * 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 styled from 'styled-components'; + +export const ModalContainer = styled.div` + width: ${({ theme }) => theme.eui.euiBreakpoints.m}; + + .euiModalBody { + min-height: 300px; + } +`; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/parser.ts b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/parser.ts new file mode 100644 index 0000000000000..8d598fad260dc --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/parser.ts @@ -0,0 +1,77 @@ +/* + * 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 { Plugin } from 'unified'; +import { RemarkTokenizer } from '@elastic/eui'; +import { ID } from './constants'; + +export const LensParser: Plugin = function () { + const Parser = this.Parser; + const tokenizers = Parser.prototype.blockTokenizers; + const methods = Parser.prototype.blockMethods; + + const tokenizeLens: RemarkTokenizer = function (eat, value, silent) { + if (value.startsWith(`!{${ID}`) === false) return false; + + const nextChar = value[6]; + + if (nextChar !== '{' && nextChar !== '}') return false; // this isn't actually a lens + + if (silent) { + return true; + } + + // is there a configuration? + const hasConfiguration = nextChar === '{'; + + let match = `!{${ID}`; + let configuration = {}; + + if (hasConfiguration) { + let configurationString = ''; + + let openObjects = 0; + + for (let i = 6; i < value.length; i++) { + const char = value[i]; + if (char === '{') { + openObjects++; + configurationString += char; + } else if (char === '}') { + openObjects--; + if (openObjects === -1) { + break; + } + configurationString += char; + } else { + configurationString += char; + } + } + + match += configurationString; + try { + configuration = JSON.parse(configurationString); + } catch (e) { + const now = eat.now(); + this.file.fail(`Unable to parse lens JSON configuration: ${e}`, { + line: now.line, + column: now.column + 6, + }); + } + } + + match += '}'; + + return eat(match)({ + type: ID, + ...configuration, + }); + }; + + tokenizers.lens = tokenizeLens; + methods.splice(methods.indexOf('text'), 0, ID); +}; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/plugin.tsx b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/plugin.tsx new file mode 100644 index 0000000000000..24dde054d2d19 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/plugin.tsx @@ -0,0 +1,464 @@ +/* + * 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 { first } from 'rxjs/operators'; +import { + EuiFieldText, + EuiModalBody, + EuiModalHeader, + EuiModalHeaderTitle, + EuiMarkdownEditorUiPlugin, + EuiMarkdownContext, + EuiCodeBlock, + EuiSpacer, + EuiModalFooter, + EuiButtonEmpty, + EuiButton, + EuiFlexItem, + EuiFlexGroup, + EuiFormRow, + EuiMarkdownAstNodePosition, + EuiBetaBadge, +} from '@elastic/eui'; +import React, { ReactNode, useCallback, useContext, useMemo, useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useLocation } from 'react-router-dom'; +import styled from 'styled-components'; + +import type { TypedLensByValueInput } from '../../../../../../lens/public'; +import { useKibana } from '../../../../common/lib/kibana'; +import { LensMarkDownRenderer } from './processor'; +import { DRAFT_COMMENT_STORAGE_ID, ID } from './constants'; +import { CommentEditorContext } from '../../context'; +import { ModalContainer } from './modal_container'; +import type { EmbeddablePackageState } from '../../../../../../../../src/plugins/embeddable/public'; +import { + SavedObjectFinderUi, + SavedObjectFinderUiProps, +} from '../../../../../../../../src/plugins/saved_objects/public'; +import { useLensDraftComment } from './use_lens_draft_comment'; + +const BetaBadgeWrapper = styled.span` + display: inline-flex; + + .euiToolTipAnchor { + display: inline-flex; + } +`; + +type LensIncomingEmbeddablePackage = Omit & { + input: TypedLensByValueInput; +}; + +type LensEuiMarkdownEditorUiPlugin = EuiMarkdownEditorUiPlugin<{ + title: string; + timeRange: TypedLensByValueInput['timeRange']; + startDate: string; + endDate: string; + position: EuiMarkdownAstNodePosition; + attributes: TypedLensByValueInput['attributes']; +}>; + +interface LensSavedObjectsPickerProps { + children: ReactNode; + onChoose: SavedObjectFinderUiProps['onChoose']; +} + +const LensSavedObjectsPickerComponent: React.FC = ({ + children, + onChoose, +}) => { + const { savedObjects, uiSettings } = useKibana().services; + + const savedObjectMetaData = useMemo( + () => [ + { + type: 'lens', + getIconForSavedObject: () => 'lensApp', + name: i18n.translate( + 'xpack.cases.markdownEditor.plugins.lens.insertLensSavedObjectModal.searchSelection.savedObjectType.lens', + { + defaultMessage: 'Lens', + } + ), + includeFields: ['*'], + }, + ], + [] + ); + + return ( + + } + savedObjectMetaData={savedObjectMetaData} + fixedPageSize={10} + uiSettings={uiSettings} + savedObjects={savedObjects} + children={children} + /> + ); +}; + +export const LensSavedObjectsPicker = React.memo(LensSavedObjectsPickerComponent); + +const LensEditorComponent: LensEuiMarkdownEditorUiPlugin['editor'] = ({ + node, + onCancel, + onSave, +}) => { + const location = useLocation(); + const { + application: { currentAppId$ }, + embeddable, + lens, + storage, + data: { + query: { + timefilter: { timefilter }, + }, + }, + } = useKibana().services; + const [currentAppId, setCurrentAppId] = useState(undefined); + + const { draftComment, clearDraftComment } = useLensDraftComment(); + + const [nodePosition, setNodePosition] = useState( + undefined + ); + // const [editMode, setEditMode] = useState(!!node); + const [lensEmbeddableAttributes, setLensEmbeddableAttributes] = useState< + TypedLensByValueInput['attributes'] | null + >(node?.attributes || null); + const [timeRange, setTimeRange] = useState( + node?.timeRange ?? { + from: 'now-7d', + to: 'now', + mode: 'relative', + } + ); + const commentEditorContext = useContext(CommentEditorContext); + const markdownContext = useContext(EuiMarkdownContext); + + const handleTitleChange = useCallback((e) => { + const title = e.target.value ?? ''; + setLensEmbeddableAttributes((currentValue) => { + if (currentValue) { + return { ...currentValue, title } as TypedLensByValueInput['attributes']; + } + + return currentValue; + }); + }, []); + + const handleClose = useCallback(() => { + if (currentAppId) { + embeddable?.getStateTransfer().getIncomingEmbeddablePackage(currentAppId, true); + clearDraftComment(); + } + onCancel(); + }, [clearDraftComment, currentAppId, embeddable, onCancel]); + + const handleAdd = useCallback(() => { + if (nodePosition) { + markdownContext.replaceNode( + nodePosition, + `!{${ID}${JSON.stringify({ + timeRange, + attributes: lensEmbeddableAttributes, + })}}` + ); + + handleClose(); + return; + } + + if (lensEmbeddableAttributes) { + onSave( + `!{${ID}${JSON.stringify({ + timeRange, + attributes: lensEmbeddableAttributes, + })}}`, + { + block: true, + } + ); + } + + handleClose(); + }, [nodePosition, lensEmbeddableAttributes, handleClose, markdownContext, timeRange, onSave]); + + const handleDelete = useCallback(() => { + if (nodePosition) { + markdownContext.replaceNode(nodePosition, ``); + onCancel(); + } + }, [markdownContext, nodePosition, onCancel]); + + const originatingPath = useMemo(() => `${location.pathname}${location.search}`, [ + location.pathname, + location.search, + ]); + + const handleEditInLensClick = useCallback( + async (lensAttributes?) => { + storage.set(DRAFT_COMMENT_STORAGE_ID, { + commentId: commentEditorContext?.editorId, + comment: commentEditorContext?.value, + position: node?.position, + title: lensEmbeddableAttributes?.title, + }); + + lens?.navigateToPrefilledEditor( + lensAttributes || lensEmbeddableAttributes + ? { + id: '', + timeRange, + attributes: lensAttributes ?? lensEmbeddableAttributes, + } + : undefined, + { + originatingApp: currentAppId!, + originatingPath, + } + ); + }, + [ + storage, + commentEditorContext?.editorId, + commentEditorContext?.value, + node?.position, + lens, + lensEmbeddableAttributes, + timeRange, + currentAppId, + originatingPath, + ] + ); + + const handleChooseLensSO = useCallback( + (savedObjectId, savedObjectType, fullName, savedObject) => { + handleEditInLensClick({ + ...savedObject.attributes, + title: '', + references: savedObject.references, + }); + }, + [handleEditInLensClick] + ); + + useEffect(() => { + if (node?.attributes) { + setLensEmbeddableAttributes(node.attributes); + } + }, [node?.attributes]); + + useEffect(() => { + const position = node?.position || draftComment?.position; + if (position) { + setNodePosition(position); + } + }, [node?.position, draftComment?.position]); + + useEffect(() => { + const getCurrentAppId = async () => { + const appId = await currentAppId$.pipe(first()).toPromise(); + setCurrentAppId(appId); + }; + getCurrentAppId(); + }, [currentAppId$]); + + useEffect(() => { + let incomingEmbeddablePackage; + + if (currentAppId) { + incomingEmbeddablePackage = embeddable + ?.getStateTransfer() + .getIncomingEmbeddablePackage(currentAppId, true) as LensIncomingEmbeddablePackage; + } + + if ( + incomingEmbeddablePackage?.type === 'lens' && + incomingEmbeddablePackage?.input?.attributes + ) { + const attributesTitle = incomingEmbeddablePackage?.input.attributes.title.length + ? incomingEmbeddablePackage?.input.attributes.title + : null; + setLensEmbeddableAttributes({ + ...incomingEmbeddablePackage?.input.attributes, + title: attributesTitle ?? draftComment?.title ?? '', + }); + + const lensTime = timefilter.getTime(); + if (lensTime?.from && lensTime?.to) { + setTimeRange({ + from: lensTime.from, + to: lensTime.to, + mode: [lensTime.from, lensTime.to].join('').includes('now') ? 'relative' : 'absolute', + }); + } + } + }, [embeddable, storage, timefilter, currentAppId, draftComment?.title]); + + return ( + + + + + + {!!nodePosition ? ( + + ) : ( + + )} + + + + + + + + + + + {lensEmbeddableAttributes ? ( + <> + + + + + + + + + + + + + + + + ) : ( + + + + + + + + )} + + + + + + {!!nodePosition ? ( + + + + ) : null} + + {!!nodePosition ? ( + + ) : ( + + )} + + + + ); +}; + +export const LensEditor = React.memo(LensEditorComponent); + +export const plugin: LensEuiMarkdownEditorUiPlugin = { + name: ID, + button: { + label: i18n.translate('xpack.cases.markdownEditor.plugins.lens.insertLensButtonLabel', { + defaultMessage: 'Insert visualization', + }), + iconType: 'lensApp', + }, + helpText: ( + + {'!{lens}'} + + ), + editor: LensEditor, +}; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/processor.tsx b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/processor.tsx new file mode 100644 index 0000000000000..cc8ef07392670 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/processor.tsx @@ -0,0 +1,129 @@ +/* + * 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 { first } from 'rxjs/operators'; +import React, { useCallback, useEffect, useState } from 'react'; +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer } from '@elastic/eui'; +import styled from 'styled-components'; +import { useLocation } from 'react-router-dom'; + +import { createGlobalStyle } from '../../../../../../../../src/plugins/kibana_react/common'; +import { TypedLensByValueInput } from '../../../../../../lens/public'; +import { useKibana } from '../../../../common/lib/kibana'; +import { LENS_VISUALIZATION_HEIGHT } from './constants'; + +const Container = styled.div` + min-height: ${LENS_VISUALIZATION_HEIGHT}px; +`; + +// when displaying chart in modal the tooltip is render under the modal +const LensChartTooltipFix = createGlobalStyle` + div.euiOverlayMask.euiOverlayMask--aboveHeader ~ [id^='echTooltipPortal'] { + z-index: ${({ theme }) => theme.eui.euiZLevel7} !important; + } +`; + +interface LensMarkDownRendererProps { + attributes: TypedLensByValueInput['attributes'] | null; + id?: string | null; + timeRange?: TypedLensByValueInput['timeRange']; + startDate?: string | null; + endDate?: string | null; + viewMode?: boolean | undefined; +} + +const LensMarkDownRendererComponent: React.FC = ({ + attributes, + timeRange, + viewMode = true, +}) => { + const location = useLocation(); + const { + application: { currentAppId$ }, + lens: { EmbeddableComponent, navigateToPrefilledEditor, canUseEditor }, + } = useKibana().services; + const [currentAppId, setCurrentAppId] = useState(undefined); + + const handleClick = useCallback(() => { + const options = viewMode + ? { + openInNewTab: true, + } + : { + originatingApp: currentAppId, + originatingPath: `${location.pathname}${location.search}`, + }; + + if (attributes) { + navigateToPrefilledEditor( + { + id: '', + timeRange, + attributes, + }, + options + ); + } + }, [ + attributes, + currentAppId, + location.pathname, + location.search, + navigateToPrefilledEditor, + timeRange, + viewMode, + ]); + + useEffect(() => { + const getCurrentAppId = async () => { + const appId = await currentAppId$.pipe(first()).toPromise(); + setCurrentAppId(appId); + }; + getCurrentAppId(); + }, [currentAppId$]); + + return ( + + {attributes ? ( + <> + + + +
{attributes.title}
+
+
+ + {viewMode && canUseEditor() ? ( + + {`Open visualization`} + + ) : null} + +
+ + + + + + + ) : null} +
+ ); +}; + +export const LensMarkDownRenderer = React.memo(LensMarkDownRendererComponent); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/translations.ts b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/translations.ts new file mode 100644 index 0000000000000..8b09b88136054 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/translations.ts @@ -0,0 +1,15 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const INSERT_LENS = i18n.translate( + 'xpack.cases.markdownEditor.plugins.lens.insertLensButtonLabel', + { + defaultMessage: 'Insert visualization', + } +); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/use_lens_draft_comment.ts b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/use_lens_draft_comment.ts new file mode 100644 index 0000000000000..e615416b2a137 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/use_lens_draft_comment.ts @@ -0,0 +1,68 @@ +/* + * 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 { EuiMarkdownAstNodePosition } from '@elastic/eui'; +import { useCallback, useEffect, useState } from 'react'; +import { first } from 'rxjs/operators'; +import { useKibana } from '../../../../common/lib/kibana'; +import { DRAFT_COMMENT_STORAGE_ID } from './constants'; +import { INSERT_LENS } from './translations'; + +interface DraftComment { + commentId: string; + comment: string; + position: EuiMarkdownAstNodePosition; + title: string; +} + +export const useLensDraftComment = () => { + const { + application: { currentAppId$ }, + embeddable, + storage, + } = useKibana().services; + const [draftComment, setDraftComment] = useState(null); + + useEffect(() => { + const fetchDraftComment = async () => { + const currentAppId = await currentAppId$.pipe(first()).toPromise(); + + if (!currentAppId) { + return; + } + + const incomingEmbeddablePackage = embeddable + ?.getStateTransfer() + .getIncomingEmbeddablePackage(currentAppId); + + if (incomingEmbeddablePackage) { + if (storage.get(DRAFT_COMMENT_STORAGE_ID)) { + try { + setDraftComment(storage.get(DRAFT_COMMENT_STORAGE_ID)); + // eslint-disable-next-line no-empty + } catch (e) {} + } + } + }; + fetchDraftComment(); + }, [currentAppId$, embeddable, storage]); + + const openLensModal = useCallback(({ editorRef }) => { + if (editorRef && editorRef.textarea && editorRef.toolbar) { + const lensPluginButton = editorRef.toolbar?.querySelector(`[aria-label="${INSERT_LENS}"]`); + if (lensPluginButton) { + lensPluginButton.click(); + } + } + }, []); + + const clearDraftComment = useCallback(() => { + storage.remove(DRAFT_COMMENT_STORAGE_ID); + }, [storage]); + + return { draftComment, openLensModal, clearDraftComment }; +}; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/types.ts b/x-pack/plugins/cases/public/components/markdown_editor/types.ts index ccc3c59c8977e..33249c0025f8e 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/types.ts +++ b/x-pack/plugins/cases/public/components/markdown_editor/types.ts @@ -22,7 +22,7 @@ export type TemporaryProcessingPluginsType = [ [ typeof rehype2react, Parameters[0] & { - components: { a: FunctionComponent; timeline: unknown }; + components: { a: FunctionComponent; lens: unknown; timeline: unknown }; } ], ...PluggableList diff --git a/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts b/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts index e98af8bca8bce..b87b9ae6ad09a 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts +++ b/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts @@ -13,8 +13,11 @@ import { import { useMemo } from 'react'; import { useTimelineContext } from '../timeline_context/use_timeline_context'; import { TemporaryProcessingPluginsType } from './types'; +import { KibanaServices } from '../../common/lib/kibana'; +import * as lensMarkdownPlugin from './plugins/lens'; export const usePlugins = () => { + const kibanaConfig = KibanaServices.getConfig(); const timelinePlugins = useTimelineContext()?.editor_plugins; return useMemo(() => { @@ -31,10 +34,18 @@ export const usePlugins = () => { processingPlugins[1][1].components.timeline = timelinePlugins.processingPluginRenderer; } + if (kibanaConfig?.markdownPlugins?.lens) { + uiPlugins.push(lensMarkdownPlugin.plugin); + } + + parsingPlugins.push(lensMarkdownPlugin.parser); + // This line of code is TS-compatible and it will break if [1][1] change in the future. + processingPlugins[1][1].components.lens = lensMarkdownPlugin.renderer; + return { uiPlugins, parsingPlugins, processingPlugins, }; - }, [timelinePlugins]); + }, [kibanaConfig?.markdownPlugins?.lens, timelinePlugins]); }; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/constants.ts b/x-pack/plugins/cases/public/components/user_action_tree/constants.ts new file mode 100644 index 0000000000000..584194be65f50 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_action_tree/constants.ts @@ -0,0 +1,8 @@ +/* + * 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 DRAFT_COMMENT_STORAGE_ID = 'xpack.cases.commentDraft'; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx index 86247b503dff7..b7834585e7423 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx @@ -23,7 +23,7 @@ import * as i18n from './translations'; import { useUpdateComment } from '../../containers/use_update_comment'; import { useCurrentUser } from '../../common/lib/kibana'; -import { AddComment, AddCommentRefObject } from '../add_comment'; +import { AddComment } from '../add_comment'; import { ActionConnector, ActionsCommentRequestRt, @@ -55,6 +55,7 @@ import { UserActionTimestamp } from './user_action_timestamp'; import { UserActionUsername } from './user_action_username'; import { UserActionContentToolbar } from './user_action_content_toolbar'; import { getManualAlertIdsWithNoRuleId } from '../case_view/helpers'; +import { useLensDraftComment } from '../markdown_editor/plugins/lens/use_lens_draft_comment'; export interface UserActionTreeProps { caseServices: CaseServices; @@ -155,27 +156,25 @@ export const UserActionTree = React.memo( subCaseId?: string; }>(); const handlerTimeoutId = useRef(0); - const addCommentRef = useRef(null); const [initLoading, setInitLoading] = useState(true); const [selectedOutlineCommentId, setSelectedOutlineCommentId] = useState(''); const { isLoadingIds, patchComment } = useUpdateComment(); const currentUser = useCurrentUser(); - const [manageMarkdownEditIds, setManangeMardownEditIds] = useState([]); + const [manageMarkdownEditIds, setManageMarkdownEditIds] = useState([]); + const commentRefs = useRef>({}); + const { draftComment, openLensModal } = useLensDraftComment(); const [loadingAlertData, manualAlertsData] = useFetchAlertData( getManualAlertIdsWithNoRuleId(caseData.comments) ); - const handleManageMarkdownEditId = useCallback( - (id: string) => { - if (!manageMarkdownEditIds.includes(id)) { - setManangeMardownEditIds([...manageMarkdownEditIds, id]); - } else { - setManangeMardownEditIds(manageMarkdownEditIds.filter((myId) => id !== myId)); - } - }, - [manageMarkdownEditIds] - ); + const handleManageMarkdownEditId = useCallback((id: string) => { + setManageMarkdownEditIds((prevManageMarkdownEditIds) => + !prevManageMarkdownEditIds.includes(id) + ? prevManageMarkdownEditIds.concat(id) + : prevManageMarkdownEditIds.filter((myId) => id !== myId) + ); + }, []); const handleSaveComment = useCallback( ({ id, version }: { id: string; version: string }, content: string) => { @@ -220,8 +219,8 @@ export const UserActionTree = React.memo( (quote: string) => { const addCarrots = quote.replace(new RegExp('\r?\n', 'g'), ' \n> '); - if (addCommentRef && addCommentRef.current) { - addCommentRef.current.addQuote(`> ${addCarrots} \n`); + if (commentRefs.current[NEW_ID]) { + commentRefs.current[NEW_ID].addQuote(`> ${addCarrots} \n`); } handleOutlineComment('add-comment'); @@ -240,6 +239,7 @@ export const UserActionTree = React.memo( const MarkdownDescription = useMemo( () => ( (commentRefs.current[DESCRIPTION_ID] = element)} id={DESCRIPTION_ID} content={caseData.description} isEditable={manageMarkdownEditIds.includes(DESCRIPTION_ID)} @@ -255,9 +255,10 @@ export const UserActionTree = React.memo( const MarkdownNewComment = useMemo( () => ( (commentRefs.current[NEW_ID] = element)} onCommentPosted={handleUpdate} onCommentSaving={handleManageMarkdownEditId.bind(null, NEW_ID)} showLoading={false} @@ -357,6 +358,7 @@ export const UserActionTree = React.memo( }), children: ( (commentRefs.current[comment.id] = element)} id={comment.id} content={comment.comment} isEditable={manageMarkdownEditIds.includes(comment.id)} @@ -629,6 +631,30 @@ export const UserActionTree = React.memo( const comments = [...userActions, ...bottomActions]; + useEffect(() => { + if (draftComment?.commentId) { + setManageMarkdownEditIds((prevManageMarkdownEditIds) => { + if ( + ![NEW_ID].includes(draftComment?.commentId) && + !prevManageMarkdownEditIds.includes(draftComment?.commentId) + ) { + return [draftComment?.commentId]; + } + return prevManageMarkdownEditIds; + }); + + if ( + commentRefs.current && + commentRefs.current[draftComment.commentId] && + commentRefs.current[draftComment.commentId].editor?.textarea && + commentRefs.current[draftComment.commentId].editor?.toolbar + ) { + commentRefs.current[draftComment.commentId].setComment(draftComment.comment); + openLensModal({ editorRef: commentRefs.current[draftComment.commentId].editor }); + } + } + }, [draftComment, openLensModal]); + return ( <> diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx index cf0d6e3ea50d1..f7a6932b35856 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx @@ -6,7 +6,7 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui'; -import React, { useCallback } from 'react'; +import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react'; import styled from 'styled-components'; import * as i18n from '../case_view/translations'; @@ -25,84 +25,96 @@ interface UserActionMarkdownProps { onChangeEditable: (id: string) => void; onSaveContent: (content: string) => void; } -export const UserActionMarkdown = ({ - id, - content, - isEditable, - onChangeEditable, - onSaveContent, -}: UserActionMarkdownProps) => { - const initialState = { content }; - const { form } = useForm({ - defaultValue: initialState, - options: { stripEmptyFields: false }, - schema, - }); - const fieldName = 'content'; - const { submit } = form; +interface UserActionMarkdownRefObject { + setComment: (newComment: string) => void; +} + +export const UserActionMarkdown = forwardRef( + ({ id, content, isEditable, onChangeEditable, onSaveContent }, ref) => { + const editorRef = useRef(); + const initialState = { content }; + const { form } = useForm({ + defaultValue: initialState, + options: { stripEmptyFields: false }, + schema, + }); + + const fieldName = 'content'; + const { setFieldValue, submit } = form; + + const handleCancelAction = useCallback(() => { + onChangeEditable(id); + }, [id, onChangeEditable]); + + const handleSaveAction = useCallback(async () => { + const { isValid, data } = await submit(); + if (isValid) { + onSaveContent(data.content); + } + onChangeEditable(id); + }, [id, onChangeEditable, onSaveContent, submit]); - const handleCancelAction = useCallback(() => { - onChangeEditable(id); - }, [id, onChangeEditable]); + const setComment = useCallback( + (newComment) => { + setFieldValue(fieldName, newComment); + }, + [setFieldValue] + ); - const handleSaveAction = useCallback(async () => { - const { isValid, data } = await submit(); - if (isValid) { - onSaveContent(data.content); - } - onChangeEditable(id); - }, [id, onChangeEditable, onSaveContent, submit]); + const EditorButtons = useMemo( + () => ( + + + + {i18n.CANCEL} + + + + + {i18n.SAVE} + + + + ), + [handleCancelAction, handleSaveAction] + ); - const renderButtons = useCallback( - ({ cancelAction, saveAction }) => ( - - - - {i18n.CANCEL} - - - - - {i18n.SAVE} - - - - ), - [] - ); + useImperativeHandle(ref, () => ({ + setComment, + editor: editorRef.current, + })); - return isEditable ? ( -
- - - ) : ( - - {content} - - ); -}; + return isEditable ? ( +
+ + + ) : ( + + {content} + + ); + } +); diff --git a/x-pack/plugins/cases/public/plugin.ts b/x-pack/plugins/cases/public/plugin.ts index 5bfdf9b8b9509..2b4fb40545548 100644 --- a/x-pack/plugins/cases/public/plugin.ts +++ b/x-pack/plugins/cases/public/plugin.ts @@ -17,7 +17,7 @@ import { getRecentCasesLazy, getAllCasesSelectorModalLazy, } from './methods'; -import { ENABLE_CASE_CONNECTOR } from '../common'; +import { CasesUiConfigType, ENABLE_CASE_CONNECTOR } from '../common'; /** * @public @@ -26,7 +26,7 @@ import { ENABLE_CASE_CONNECTOR } from '../common'; export class CasesUiPlugin implements Plugin { private kibanaVersion: string; - constructor(initializerContext: PluginInitializerContext) { + constructor(private readonly initializerContext: PluginInitializerContext) { this.kibanaVersion = initializerContext.env.packageInfo.version; } public setup(core: CoreSetup, plugins: SetupPlugins) { @@ -36,7 +36,8 @@ export class CasesUiPlugin implements Plugin(); + KibanaServices.init({ ...core, ...plugins, kibanaVersion: this.kibanaVersion, config }); return { /** * Get the all cases table diff --git a/x-pack/plugins/cases/public/types.ts b/x-pack/plugins/cases/public/types.ts index 2b31935c3ff97..db2e5d6ab6bff 100644 --- a/x-pack/plugins/cases/public/types.ts +++ b/x-pack/plugins/cases/public/types.ts @@ -7,11 +7,17 @@ import { CoreStart } from 'kibana/public'; import { ReactElement } from 'react'; + +import { LensPublicStart } from '../../lens/public'; import { SecurityPluginSetup } from '../../security/public'; -import { +import type { TriggersAndActionsUIPublicPluginSetup as TriggersActionsSetup, TriggersAndActionsUIPublicPluginStart as TriggersActionsStart, } from '../../triggers_actions_ui/public'; +import type { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; +import type { Storage } from '../../../../src/plugins/kibana_utils/public'; + import { AllCasesProps } from './components/all_cases'; import { CaseViewProps } from './components/case_view'; import { ConfigureCasesProps } from './components/configure_cases'; @@ -25,6 +31,10 @@ export interface SetupPlugins { } export interface StartPlugins { + data: DataPublicPluginStart; + embeddable: EmbeddableStart; + lens: LensPublicStart; + storage: Storage; triggersActionsUi: TriggersActionsStart; } diff --git a/x-pack/plugins/cases/server/client/attachments/add.ts b/x-pack/plugins/cases/server/client/attachments/add.ts index dd1f09da5cb4a..166ae2ae65012 100644 --- a/x-pack/plugins/cases/server/client/attachments/add.ts +++ b/x-pack/plugins/cases/server/client/attachments/add.ts @@ -16,6 +16,7 @@ import { Logger, SavedObjectsUtils, } from '../../../../../../src/core/server'; +import { LensServerPluginSetup } from '../../../../lens/server'; import { nodeBuilder } from '../../../../../../src/plugins/data/common'; import { @@ -124,6 +125,7 @@ const addGeneratedAlerts = async ( caseService, userActionService, logger, + lensEmbeddableFactory, authorization, } = clientArgs; @@ -182,6 +184,7 @@ const addGeneratedAlerts = async ( unsecuredSavedObjectsClient, caseService, attachmentService, + lensEmbeddableFactory, }); const { @@ -241,12 +244,14 @@ async function getCombinedCase({ unsecuredSavedObjectsClient, id, logger, + lensEmbeddableFactory, }: { caseService: CasesService; attachmentService: AttachmentService; unsecuredSavedObjectsClient: SavedObjectsClientContract; id: string; logger: Logger; + lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; }): Promise { const [casePromise, subCasePromise] = await Promise.allSettled([ caseService.getCase({ @@ -276,6 +281,7 @@ async function getCombinedCase({ caseService, attachmentService, unsecuredSavedObjectsClient, + lensEmbeddableFactory, }); } else { throw Boom.badRequest('Sub case found without reference to collection'); @@ -291,6 +297,7 @@ async function getCombinedCase({ caseService, attachmentService, unsecuredSavedObjectsClient, + lensEmbeddableFactory, }); } } @@ -332,6 +339,7 @@ export const addComment = async ( attachmentService, user, logger, + lensEmbeddableFactory, authorization, } = clientArgs; @@ -362,6 +370,7 @@ export const addComment = async ( unsecuredSavedObjectsClient, id: caseId, logger, + lensEmbeddableFactory, }); // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/x-pack/plugins/cases/server/client/attachments/update.ts b/x-pack/plugins/cases/server/client/attachments/update.ts index 157dd0b410898..da505ed55313c 100644 --- a/x-pack/plugins/cases/server/client/attachments/update.ts +++ b/x-pack/plugins/cases/server/client/attachments/update.ts @@ -9,6 +9,7 @@ import { pick } from 'lodash/fp'; import Boom from '@hapi/boom'; import { SavedObjectsClientContract, Logger } from 'kibana/server'; +import { LensServerPluginSetup } from '../../../../lens/server'; import { checkEnabledCaseConnectorOrThrow, CommentableCase, createCaseError } from '../../common'; import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; import { @@ -46,6 +47,7 @@ interface CombinedCaseParams { unsecuredSavedObjectsClient: SavedObjectsClientContract; caseID: string; logger: Logger; + lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; subCaseId?: string; } @@ -56,6 +58,7 @@ async function getCommentableCase({ caseID, subCaseId, logger, + lensEmbeddableFactory, }: CombinedCaseParams) { if (subCaseId) { const [caseInfo, subCase] = await Promise.all([ @@ -75,6 +78,7 @@ async function getCommentableCase({ subCase, unsecuredSavedObjectsClient, logger, + lensEmbeddableFactory, }); } else { const caseInfo = await caseService.getCase({ @@ -87,6 +91,7 @@ async function getCommentableCase({ collection: caseInfo, unsecuredSavedObjectsClient, logger, + lensEmbeddableFactory, }); } } @@ -105,6 +110,7 @@ export async function update( caseService, unsecuredSavedObjectsClient, logger, + lensEmbeddableFactory, user, userActionService, authorization, @@ -128,6 +134,7 @@ export async function update( caseID, subCaseId: subCaseID, logger, + lensEmbeddableFactory, }); const myComment = await attachmentService.get({ diff --git a/x-pack/plugins/cases/server/client/factory.ts b/x-pack/plugins/cases/server/client/factory.ts index 8fcfbe934c3ad..2fae6996f4aa2 100644 --- a/x-pack/plugins/cases/server/client/factory.ts +++ b/x-pack/plugins/cases/server/client/factory.ts @@ -25,6 +25,8 @@ import { } from '../services'; import { PluginStartContract as FeaturesPluginStart } from '../../../features/server'; import { PluginStartContract as ActionsPluginStart } from '../../../actions/server'; +import { LensServerPluginSetup } from '../../../lens/server'; + import { AuthorizationAuditLogger } from '../authorization'; import { CasesClient, createCasesClient } from '.'; @@ -34,6 +36,7 @@ interface CasesClientFactoryArgs { getSpace: GetSpaceFn; featuresPluginStart: FeaturesPluginStart; actionsPluginStart: ActionsPluginStart; + lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; } /** @@ -108,6 +111,7 @@ export class CasesClientFactory { userActionService: new CaseUserActionService(this.logger), attachmentService: new AttachmentService(this.logger), logger: this.logger, + lensEmbeddableFactory: this.options.lensEmbeddableFactory, authorization: auth, actionsClient: await this.options.actionsPluginStart.getActionsClientWithRequest(request), }); diff --git a/x-pack/plugins/cases/server/client/types.ts b/x-pack/plugins/cases/server/client/types.ts index ebf79519da59a..27829d2539c7d 100644 --- a/x-pack/plugins/cases/server/client/types.ts +++ b/x-pack/plugins/cases/server/client/types.ts @@ -18,6 +18,7 @@ import { AttachmentService, } from '../services'; import { ActionsClient } from '../../../actions/server'; +import { LensServerPluginSetup } from '../../../lens/server'; /** * Parameters for initializing a cases client @@ -33,6 +34,7 @@ export interface CasesClientArgs { readonly alertsService: AlertServiceContract; readonly attachmentService: AttachmentService; readonly logger: Logger; + readonly lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; readonly authorization: PublicMethodsOf; readonly actionsClient: PublicMethodsOf; } diff --git a/x-pack/plugins/cases/server/common/models/commentable_case.ts b/x-pack/plugins/cases/server/common/models/commentable_case.ts index 03d6e5b8cea63..856d6378d5900 100644 --- a/x-pack/plugins/cases/server/common/models/commentable_case.ts +++ b/x-pack/plugins/cases/server/common/models/commentable_case.ts @@ -10,9 +10,11 @@ import { SavedObject, SavedObjectReference, SavedObjectsClientContract, + SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, Logger, } from 'src/core/server'; +import { LensServerPluginSetup } from '../../../../lens/server'; import { AssociationType, CASE_SAVED_OBJECT, @@ -29,12 +31,14 @@ import { SUB_CASE_SAVED_OBJECT, SubCaseAttributes, User, + CommentRequestUserType, CaseAttributes, } from '../../../common'; import { flattenCommentSavedObjects, flattenSubCaseSavedObject, transformNewComment } from '..'; import { AttachmentService, CasesService } from '../../services'; import { createCaseError } from '../error'; import { countAlertsForID } from '../index'; +import { getOrUpdateLensReferences } from '../utils'; interface UpdateCommentResp { comment: SavedObjectsUpdateResponse; @@ -53,6 +57,7 @@ interface CommentableCaseParams { caseService: CasesService; attachmentService: AttachmentService; logger: Logger; + lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; } /** @@ -66,6 +71,7 @@ export class CommentableCase { private readonly caseService: CasesService; private readonly attachmentService: AttachmentService; private readonly logger: Logger; + private readonly lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; constructor({ collection, @@ -74,6 +80,7 @@ export class CommentableCase { caseService, attachmentService, logger, + lensEmbeddableFactory, }: CommentableCaseParams) { this.collection = collection; this.subCase = subCase; @@ -81,6 +88,7 @@ export class CommentableCase { this.caseService = caseService; this.attachmentService = attachmentService; this.logger = logger; + this.lensEmbeddableFactory = lensEmbeddableFactory; } public get status(): CaseStatuses { @@ -188,6 +196,7 @@ export class CommentableCase { caseService: this.caseService, attachmentService: this.attachmentService, logger: this.logger, + lensEmbeddableFactory: this.lensEmbeddableFactory, }); } catch (error) { throw createCaseError({ @@ -212,6 +221,23 @@ export class CommentableCase { }): Promise { try { const { id, version, ...queryRestAttributes } = updateRequest; + const options: SavedObjectsUpdateOptions = { + version, + }; + + if (queryRestAttributes.type === CommentType.user && queryRestAttributes?.comment) { + const currentComment = (await this.attachmentService.get({ + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, + attachmentId: id, + })) as SavedObject; + + const updatedReferences = getOrUpdateLensReferences( + this.lensEmbeddableFactory, + queryRestAttributes.comment, + currentComment + ); + options.references = updatedReferences; + } const [comment, commentableCase] = await Promise.all([ this.attachmentService.update({ @@ -222,7 +248,7 @@ export class CommentableCase { updated_at: updatedAt, updated_by: user, }, - version, + options, }), this.update({ date: updatedAt, user }), ]); @@ -268,6 +294,16 @@ export class CommentableCase { throw Boom.badRequest('The owner field of the comment must match the case'); } + let references = this.buildRefsToCase(); + + if (commentReq.type === CommentType.user && commentReq?.comment) { + const commentStringReferences = getOrUpdateLensReferences( + this.lensEmbeddableFactory, + commentReq.comment + ); + references = [...references, ...commentStringReferences]; + } + const [comment, commentableCase] = await Promise.all([ this.attachmentService.create({ unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, @@ -277,7 +313,7 @@ export class CommentableCase { ...commentReq, ...user, }), - references: this.buildRefsToCase(), + references, id, }), this.update({ date: createdDate, user }), diff --git a/x-pack/plugins/cases/server/common/utils.test.ts b/x-pack/plugins/cases/server/common/utils.test.ts index 46ba33a74acd6..e45b91a28ceb3 100644 --- a/x-pack/plugins/cases/server/common/utils.test.ts +++ b/x-pack/plugins/cases/server/common/utils.test.ts @@ -5,13 +5,15 @@ * 2.0. */ -import { SavedObjectsFindResponse } from 'kibana/server'; +import { SavedObject, SavedObjectsFindResponse } from 'kibana/server'; +import { lensEmbeddableFactory } from '../../../lens/server/embeddable/lens_embeddable_factory'; import { SECURITY_SOLUTION_OWNER } from '../../common'; import { AssociationType, CaseResponse, CommentAttributes, CommentRequest, + CommentRequestUserType, CommentType, } from '../../common/api'; import { mockCaseComments, mockCases } from '../routes/api/__fixtures__/mock_saved_objects'; @@ -25,6 +27,8 @@ import { transformComments, flattenCommentSavedObjects, flattenCommentSavedObject, + extractLensReferencesFromCommentString, + getOrUpdateLensReferences, } from './utils'; interface CommentReference { @@ -865,4 +869,130 @@ describe('common utils', () => { ).toEqual(2); }); }); + + describe('extractLensReferencesFromCommentString', () => { + it('extracts successfully', () => { + const commentString = [ + '**Test** ', + 'Amazingg!!!', + '[asdasdasdasd](http://localhost:5601/moq/app/security/timelines?timeline=(id%3A%27e4362a60-f478-11eb-a4b0-ebefce184d8d%27%2CisOpen%3A!t))', + '!{lens{"timeRange":{"from":"now-7d","to":"now","mode":"relative"},"attributes":{"title":"aaaa","type":"lens","visualizationType":"lnsXY","state":{"datasourceStates":{"indexpattern":{"layers":{"layer1":{"columnOrder":["col1","col2"],"columns":{"col2":{"dataType":"number","isBucketed":false,"label":"Count of records","operationType":"count","scale":"ratio","sourceField":"Records"},"col1":{"dataType":"date","isBucketed":true,"label":"@timestamp","operationType":"date_histogram","params":{"interval":"auto"},"scale":"interval","sourceField":"timestamp"}}}}}},"visualization":{"axisTitlesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"fittingFunction":"None","gridlinesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"layers":[{"accessors":["col2"],"layerId":"layer1","seriesType":"bar_stacked","xAccessor":"col1","yConfig":[{"forAccessor":"col2"}]}],"legend":{"isVisible":true,"position":"right"},"preferredSeriesType":"bar_stacked","tickLabelsVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"valueLabels":"hide","yRightExtent":{"mode":"full"}},"query":{"language":"kuery","query":""},"filters":[]},"references":[{"type":"index-pattern","id":"90943e30-9a47-11e8-b64d-95841ca0b246","name":"indexpattern-datasource-current-indexpattern"},{"type":"index-pattern","id":"90943e30-9a47-11e8-b64d-95841ca0b248","name":"indexpattern-datasource-layer-layer1"}]},"editMode":false}}', + '!{lens{"timeRange":{"from":"now-7d","to":"now","mode":"relative"},"attributes":{"title":"aaaa","type":"lens","visualizationType":"lnsXY","state":{"datasourceStates":{"indexpattern":{"layers":{"layer1":{"columnOrder":["col1","col2"],"columns":{"col2":{"dataType":"number","isBucketed":false,"label":"Count of records","operationType":"count","scale":"ratio","sourceField":"Records"},"col1":{"dataType":"date","isBucketed":true,"label":"@timestamp","operationType":"date_histogram","params":{"interval":"auto"},"scale":"interval","sourceField":"timestamp"}}}}}},"visualization":{"axisTitlesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"fittingFunction":"None","gridlinesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"layers":[{"accessors":["col2"],"layerId":"layer1","seriesType":"bar_stacked","xAccessor":"col1","yConfig":[{"forAccessor":"col2"}]}],"legend":{"isVisible":true,"position":"right"},"preferredSeriesType":"bar_stacked","tickLabelsVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"valueLabels":"hide","yRightExtent":{"mode":"full"}},"query":{"language":"kuery","query":""},"filters":[]},"references":[{"type":"index-pattern","id":"90943e30-9a47-11e8-b64d-95841ca0b246","name":"indexpattern-datasource-current-indexpattern"},{"type":"index-pattern","id":"90943e30-9a47-11e8-b64d-95841ca0b247","name":"indexpattern-datasource-layer-layer1"}]},"editMode":false}}', + ].join('\n\n'); + + const extractedReferences = extractLensReferencesFromCommentString( + lensEmbeddableFactory, + commentString + ); + + const expectedReferences = [ + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b246', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b248', + name: 'indexpattern-datasource-layer-layer1', + }, + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + name: 'indexpattern-datasource-layer-layer1', + }, + ]; + + expect(expectedReferences.length).toEqual(extractedReferences.length); + expect(expectedReferences).toEqual(expect.arrayContaining(extractedReferences)); + }); + }); + + describe('getOrUpdateLensReferences', () => { + it('update references', () => { + const currentCommentStringReferences = [ + [ + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b246', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b248', + name: 'indexpattern-datasource-layer-layer1', + }, + ], + [ + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b246', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b248', + name: 'indexpattern-datasource-layer-layer1', + }, + ], + ]; + const currentCommentString = [ + '**Test** ', + '[asdasdasdasd](http://localhost:5601/moq/app/security/timelines?timeline=(id%3A%27e4362a60-f478-11eb-a4b0-ebefce184d8d%27%2CisOpen%3A!t))', + `!{lens{"timeRange":{"from":"now-7d","to":"now","mode":"relative"},"attributes":{"title":"aaaa","type":"lens","visualizationType":"lnsXY","state":{"datasourceStates":{"indexpattern":{"layers":{"layer1":{"columnOrder":["col1","col2"],"columns":{"col2":{"dataType":"number","isBucketed":false,"label":"Count of records","operationType":"count","scale":"ratio","sourceField":"Records"},"col1":{"dataType":"date","isBucketed":true,"label":"@timestamp","operationType":"date_histogram","params":{"interval":"auto"},"scale":"interval","sourceField":"timestamp"}}}}}},"visualization":{"axisTitlesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"fittingFunction":"None","gridlinesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"layers":[{"accessors":["col2"],"layerId":"layer1","seriesType":"bar_stacked","xAccessor":"col1","yConfig":[{"forAccessor":"col2"}]}],"legend":{"isVisible":true,"position":"right"},"preferredSeriesType":"bar_stacked","tickLabelsVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"valueLabels":"hide","yRightExtent":{"mode":"full"}},"query":{"language":"kuery","query":""},"filters":[]},"references":${JSON.stringify( + currentCommentStringReferences[0] + )}},"editMode":false}}`, + `!{lens{"timeRange":{"from":"now-7d","to":"now","mode":"relative"},"attributes":{"title":"aaaa","type":"lens","visualizationType":"lnsXY","state":{"datasourceStates":{"indexpattern":{"layers":{"layer1":{"columnOrder":["col1","col2"],"columns":{"col2":{"dataType":"number","isBucketed":false,"label":"Count of records","operationType":"count","scale":"ratio","sourceField":"Records"},"col1":{"dataType":"date","isBucketed":true,"label":"@timestamp","operationType":"date_histogram","params":{"interval":"auto"},"scale":"interval","sourceField":"timestamp"}}}}}},"visualization":{"axisTitlesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"fittingFunction":"None","gridlinesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"layers":[{"accessors":["col2"],"layerId":"layer1","seriesType":"bar_stacked","xAccessor":"col1","yConfig":[{"forAccessor":"col2"}]}],"legend":{"isVisible":true,"position":"right"},"preferredSeriesType":"bar_stacked","tickLabelsVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"valueLabels":"hide","yRightExtent":{"mode":"full"}},"query":{"language":"kuery","query":""},"filters":[]},"references":${JSON.stringify( + currentCommentStringReferences[1] + )}},"editMode":false}}`, + ].join('\n\n'); + const nonLensCurrentCommentReferences = [ + { type: 'case', id: '7b4be181-9646-41b8-b12d-faabf1bd9512', name: 'Test case' }, + { + type: 'timeline', + id: '0f847d31-9683-4ebd-92b9-454e3e39aec1', + name: 'Test case timeline', + }, + ]; + const currentCommentReferences = [ + ...currentCommentStringReferences.flat(), + ...nonLensCurrentCommentReferences, + ]; + const newCommentStringReferences = [ + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b245', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b248', + name: 'indexpattern-datasource-layer-layer1', + }, + ]; + const newCommentString = [ + '**Test** ', + 'Awmazingg!!!', + '[asdasdasdasd](http://localhost:5601/moq/app/security/timelines?timeline=(id%3A%27e4362a60-f478-11eb-a4b0-ebefce184d8d%27%2CisOpen%3A!t))', + `!{lens{"timeRange":{"from":"now-7d","to":"now","mode":"relative"},"attributes":{"title":"aaaa","type":"lens","visualizationType":"lnsXY","state":{"datasourceStates":{"indexpattern":{"layers":{"layer1":{"columnOrder":["col1","col2"],"columns":{"col2":{"dataType":"number","isBucketed":false,"label":"Count of records","operationType":"count","scale":"ratio","sourceField":"Records"},"col1":{"dataType":"date","isBucketed":true,"label":"@timestamp","operationType":"date_histogram","params":{"interval":"auto"},"scale":"interval","sourceField":"timestamp"}}}}}},"visualization":{"axisTitlesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"fittingFunction":"None","gridlinesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"layers":[{"accessors":["col2"],"layerId":"layer1","seriesType":"bar_stacked","xAccessor":"col1","yConfig":[{"forAccessor":"col2"}]}],"legend":{"isVisible":true,"position":"right"},"preferredSeriesType":"bar_stacked","tickLabelsVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"valueLabels":"hide","yRightExtent":{"mode":"full"}},"query":{"language":"kuery","query":""},"filters":[]},"references":${JSON.stringify( + newCommentStringReferences + )}},"editMode":false}}`, + ].join('\n\n'); + + const updatedReferences = getOrUpdateLensReferences(lensEmbeddableFactory, newCommentString, { + references: currentCommentReferences, + attributes: { + comment: currentCommentString, + }, + } as SavedObject); + + const expectedReferences = [ + ...nonLensCurrentCommentReferences, + ...newCommentStringReferences, + ]; + + expect(expectedReferences.length).toEqual(updatedReferences.length); + expect(expectedReferences).toEqual(expect.arrayContaining(updatedReferences)); + }); + }); }); diff --git a/x-pack/plugins/cases/server/common/utils.ts b/x-pack/plugins/cases/server/common/utils.ts index bce37764467df..ba7d56f51eea9 100644 --- a/x-pack/plugins/cases/server/common/utils.ts +++ b/x-pack/plugins/cases/server/common/utils.ts @@ -4,11 +4,26 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import Boom from '@hapi/boom'; +import unified from 'unified'; +import type { Node, Parent } from 'unist'; +// installed by @elastic/eui +// eslint-disable-next-line import/no-extraneous-dependencies +import markdown from 'remark-parse'; +import remarkStringify from 'remark-stringify'; -import { SavedObjectsFindResult, SavedObjectsFindResponse, SavedObject } from 'kibana/server'; -import { isEmpty } from 'lodash'; +import { + SavedObjectsFindResult, + SavedObjectsFindResponse, + SavedObject, + SavedObjectReference, +} from 'kibana/server'; +import { filter, flatMap, uniqWith, isEmpty, xorWith } from 'lodash'; +import { TimeRange } from 'src/plugins/data/server'; +import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; import { AlertInfo } from '.'; +import { LensServerPluginSetup, LensDocShape715 } from '../../../lens/server'; import { AssociationType, @@ -33,6 +48,8 @@ import { User, } from '../../common'; import { UpdateAlertRequest } from '../client/alerts/types'; +import { LENS_ID, LensParser, LensSerializer } from '../../common/utils/markdown_plugins/lens'; +import { TimelineSerializer, TimelineParser } from '../../common/utils/markdown_plugins/timeline'; /** * Default sort field for querying saved objects. @@ -398,3 +415,89 @@ export const getNoneCaseConnector = () => ({ type: ConnectorTypes.none, fields: null, }); + +interface LensMarkdownNode extends EmbeddableStateWithType { + timeRange: TimeRange; + attributes: LensDocShape715 & { references: SavedObjectReference[] }; +} + +export const parseCommentString = (comment: string) => { + const processor = unified().use([[markdown, {}], LensParser, TimelineParser]); + return processor.parse(comment) as Parent; +}; + +export const stringifyComment = (comment: Parent) => + unified() + .use([ + [ + remarkStringify, + { + allowDangerousHtml: true, + handlers: { + /* + because we're using rison in the timeline url we need + to make sure that markdown parser doesn't modify the url + */ + timeline: TimelineSerializer, + lens: LensSerializer, + }, + }, + ], + ]) + .stringify(comment); + +export const getLensVisualizations = (parsedComment: Array) => + filter(parsedComment, { type: LENS_ID }) as LensMarkdownNode[]; + +export const extractLensReferencesFromCommentString = ( + lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory'], + comment: string +): SavedObjectReference[] => { + const extract = lensEmbeddableFactory()?.extract; + + if (extract) { + const parsedComment = parseCommentString(comment); + const lensVisualizations = getLensVisualizations(parsedComment.children); + const flattenRefs = flatMap( + lensVisualizations, + (lensObject) => extract(lensObject)?.references ?? [] + ); + + const uniqRefs = uniqWith( + flattenRefs, + (refA, refB) => refA.type === refB.type && refA.id === refB.id && refA.name === refB.name + ); + + return uniqRefs; + } + return []; +}; + +export const getOrUpdateLensReferences = ( + lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory'], + newComment: string, + currentComment?: SavedObject +) => { + if (!currentComment) { + return extractLensReferencesFromCommentString(lensEmbeddableFactory, newComment); + } + + const savedObjectReferences = currentComment.references; + const savedObjectLensReferences = extractLensReferencesFromCommentString( + lensEmbeddableFactory, + currentComment.attributes.comment + ); + + const currentNonLensReferences = xorWith( + savedObjectReferences, + savedObjectLensReferences, + (refA, refB) => refA.type === refB.type && refA.id === refB.id + ); + + const newCommentLensReferences = extractLensReferencesFromCommentString( + lensEmbeddableFactory, + newComment + ); + + return currentNonLensReferences.concat(newCommentLensReferences); +}; diff --git a/x-pack/plugins/cases/server/config.ts b/x-pack/plugins/cases/server/config.ts index 7679a5a389051..317f15283e112 100644 --- a/x-pack/plugins/cases/server/config.ts +++ b/x-pack/plugins/cases/server/config.ts @@ -9,6 +9,9 @@ import { schema, TypeOf } from '@kbn/config-schema'; export const ConfigSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), + markdownPlugins: schema.object({ + lens: schema.boolean({ defaultValue: false }), + }), }); export type ConfigType = TypeOf; diff --git a/x-pack/plugins/cases/server/index.ts b/x-pack/plugins/cases/server/index.ts index 4526ecce28460..5e433b46b80e5 100644 --- a/x-pack/plugins/cases/server/index.ts +++ b/x-pack/plugins/cases/server/index.ts @@ -12,6 +12,9 @@ import { CasePlugin } from './plugin'; export const config: PluginConfigDescriptor = { schema: ConfigSchema, + exposeToBrowser: { + markdownPlugins: true, + }, deprecations: ({ renameFromRoot }) => [ renameFromRoot('xpack.case.enabled', 'xpack.cases.enabled'), ], diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index b1e2f61a595ee..bb1be163585a8 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -18,7 +18,7 @@ import { APP_ID, ENABLE_CASE_CONNECTOR } from '../common'; import { ConfigType } from './config'; import { initCaseApi } from './routes/api'; import { - caseCommentSavedObjectType, + createCaseCommentSavedObjectType, caseConfigureSavedObjectType, caseConnectorMappingsSavedObjectType, caseSavedObjectType, @@ -32,6 +32,7 @@ import type { CasesRequestHandlerContext } from './types'; import { CasesClientFactory } from './client/factory'; import { SpacesPluginStart } from '../../spaces/server'; import { PluginStartContract as FeaturesPluginStart } from '../../features/server'; +import { LensServerPluginSetup } from '../../lens/server'; function createConfig(context: PluginInitializerContext) { return context.config.get(); @@ -40,6 +41,7 @@ function createConfig(context: PluginInitializerContext) { export interface PluginsSetup { security?: SecurityPluginSetup; actions: ActionsPluginSetup; + lens: LensServerPluginSetup; } export interface PluginsStart { @@ -66,6 +68,7 @@ export class CasePlugin { private readonly log: Logger; private clientFactory: CasesClientFactory; private securityPluginSetup?: SecurityPluginSetup; + private lensEmbeddableFactory?: LensServerPluginSetup['lensEmbeddableFactory']; constructor(private readonly initializerContext: PluginInitializerContext) { this.log = this.initializerContext.logger.get(); @@ -80,8 +83,15 @@ export class CasePlugin { } this.securityPluginSetup = plugins.security; + this.lensEmbeddableFactory = plugins.lens.lensEmbeddableFactory; - core.savedObjects.registerType(caseCommentSavedObjectType); + core.savedObjects.registerType( + createCaseCommentSavedObjectType({ + migrationDeps: { + lensEmbeddableFactory: this.lensEmbeddableFactory, + }, + }) + ); core.savedObjects.registerType(caseConfigureSavedObjectType); core.savedObjects.registerType(caseConnectorMappingsSavedObjectType); core.savedObjects.registerType(caseSavedObjectType); @@ -127,6 +137,7 @@ export class CasePlugin { }, featuresPluginStart: plugins.features, actionsPluginStart: plugins.actions, + lensEmbeddableFactory: this.lensEmbeddableFactory!, }); const client = core.elasticsearch.client; diff --git a/x-pack/plugins/cases/server/saved_object_types/comments.ts b/x-pack/plugins/cases/server/saved_object_types/comments.ts index 876ceb9bc2045..0384a65dcb389 100644 --- a/x-pack/plugins/cases/server/saved_object_types/comments.ts +++ b/x-pack/plugins/cases/server/saved_object_types/comments.ts @@ -7,11 +7,15 @@ import { SavedObjectsType } from 'src/core/server'; import { CASE_COMMENT_SAVED_OBJECT } from '../../common'; -import { commentsMigrations } from './migrations'; +import { createCommentsMigrations, CreateCommentsMigrationsDeps } from './migrations'; -export const caseCommentSavedObjectType: SavedObjectsType = { +export const createCaseCommentSavedObjectType = ({ + migrationDeps, +}: { + migrationDeps: CreateCommentsMigrationsDeps; +}): SavedObjectsType => ({ name: CASE_COMMENT_SAVED_OBJECT, - hidden: true, + hidden: false, namespaceType: 'single', mappings: { properties: { @@ -105,5 +109,5 @@ export const caseCommentSavedObjectType: SavedObjectsType = { }, }, }, - migrations: commentsMigrations, -}; + migrations: () => createCommentsMigrations(migrationDeps), +}); diff --git a/x-pack/plugins/cases/server/saved_object_types/index.ts b/x-pack/plugins/cases/server/saved_object_types/index.ts index 1c6bcf6ca710a..2c39a10f61da7 100644 --- a/x-pack/plugins/cases/server/saved_object_types/index.ts +++ b/x-pack/plugins/cases/server/saved_object_types/index.ts @@ -8,6 +8,6 @@ export { caseSavedObjectType } from './cases'; export { subCaseSavedObjectType } from './sub_case'; export { caseConfigureSavedObjectType } from './configure'; -export { caseCommentSavedObjectType } from './comments'; +export { createCaseCommentSavedObjectType } from './comments'; export { caseUserActionSavedObjectType } from './user_actions'; export { caseConnectorMappingsSavedObjectType } from './connector_mappings'; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/index.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/index.test.ts new file mode 100644 index 0000000000000..595ecf290c520 --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/index.test.ts @@ -0,0 +1,236 @@ +/* + * 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 { createCommentsMigrations } from './index'; +import { getLensVisualizations, parseCommentString } from '../../common'; + +import { savedObjectsServiceMock } from '../../../../../../src/core/server/mocks'; +import { lensEmbeddableFactory } from '../../../../lens/server/embeddable/lens_embeddable_factory'; + +const migrations = createCommentsMigrations({ + lensEmbeddableFactory, +}); + +const contextMock = savedObjectsServiceMock.createMigrationContext(); + +describe('lens embeddable migrations for by value panels', () => { + describe('7.14.0 remove time zone from Lens visualization date histogram', () => { + const lensVisualizationToMigrate = { + title: 'MyRenamedOps', + description: '', + visualizationType: 'lnsXY', + state: { + datasourceStates: { + indexpattern: { + layers: { + '2': { + columns: { + '3': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto', timeZone: 'Europe/Berlin' }, + }, + '4': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '5': { + label: '@timestamp', + dataType: 'date', + operationType: 'my_unexpected_operation', + isBucketed: true, + scale: 'interval', + params: { timeZone: 'do not delete' }, + }, + }, + columnOrder: ['3', '4', '5'], + incompleteColumns: {}, + }, + }, + }, + }, + visualization: { + title: 'Empty XY chart', + legend: { isVisible: true, position: 'right' }, + valueLabels: 'hide', + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: '5ab74ddc-93ca-44e2-9857-ecf85c86b53e', + accessors: [ + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0', + 'e5efca70-edb5-4d6d-a30a-79384066987e', + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f', + ], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + xAccessor: '2e57a41e-5a52-42d3-877f-bd211d903ef8', + }, + ], + }, + query: { query: '', language: 'kuery' }, + filters: [], + }, + }; + + const expectedLensVisualizationMigrated = { + title: 'MyRenamedOps', + description: '', + visualizationType: 'lnsXY', + state: { + datasourceStates: { + indexpattern: { + layers: { + '2': { + columns: { + '3': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '4': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '5': { + label: '@timestamp', + dataType: 'date', + operationType: 'my_unexpected_operation', + isBucketed: true, + scale: 'interval', + params: { timeZone: 'do not delete' }, + }, + }, + columnOrder: ['3', '4', '5'], + incompleteColumns: {}, + }, + }, + }, + }, + visualization: { + title: 'Empty XY chart', + legend: { isVisible: true, position: 'right' }, + valueLabels: 'hide', + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: '5ab74ddc-93ca-44e2-9857-ecf85c86b53e', + accessors: [ + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0', + 'e5efca70-edb5-4d6d-a30a-79384066987e', + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f', + ], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + xAccessor: '2e57a41e-5a52-42d3-877f-bd211d903ef8', + }, + ], + }, + query: { query: '', language: 'kuery' }, + filters: [], + }, + }; + + const expectedMigrationCommentResult = `"**Amazing**\n\n!{tooltip[Tessss](https://example.com)}\n\nbrbrbr\n\n[asdasdasdasd](http://localhost:5601/moq/app/security/timelines?timeline=(id%3A%27e4362a60-f478-11eb-a4b0-ebefce184d8d%27%2CisOpen%3A!t))\n\n!{lens{\"timeRange\":{\"from\":\"now-7d\",\"to\":\"now\",\"mode\":\"relative\"},\"attributes\":${JSON.stringify( + expectedLensVisualizationMigrated + )}}}\n\n!{lens{\"timeRange\":{\"from\":\"now-7d\",\"to\":\"now\",\"mode\":\"relative\"},\"attributes\":{\"title\":\"TEst22\",\"type\":\"lens\",\"visualizationType\":\"lnsMetric\",\"state\":{\"datasourceStates\":{\"indexpattern\":{\"layers\":{\"layer1\":{\"columnOrder\":[\"col2\"],\"columns\":{\"col2\":{\"dataType\":\"number\",\"isBucketed\":false,\"label\":\"Count of records\",\"operationType\":\"count\",\"scale\":\"ratio\",\"sourceField\":\"Records\"}}}}}},\"visualization\":{\"layerId\":\"layer1\",\"accessor\":\"col2\"},\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filters\":[]},\"references\":[{\"type\":\"index-pattern\",\"id\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"name\":\"indexpattern-datasource-current-indexpattern\"},{\"type\":\"index-pattern\",\"id\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"name\":\"indexpattern-datasource-layer-layer1\"}]}}}\n\nbrbrbr" +`; + + const caseComment = { + type: 'cases-comments', + id: '1cefd0d0-e86d-11eb-bae5-3d065cd16a32', + attributes: { + associationType: 'case', + comment: `"**Amazing**\n\n!{tooltip[Tessss](https://example.com)}\n\nbrbrbr\n\n[asdasdasdasd](http://localhost:5601/moq/app/security/timelines?timeline=(id%3A%27e4362a60-f478-11eb-a4b0-ebefce184d8d%27%2CisOpen%3A!t))\n\n!{lens{\"timeRange\":{\"from\":\"now-7d\",\"to\":\"now\",\"mode\":\"relative\"},\"editMode\":false,\"attributes\":${JSON.stringify( + lensVisualizationToMigrate + )}}}\n\n!{lens{\"timeRange\":{\"from\":\"now-7d\",\"to\":\"now\",\"mode\":\"relative\"},\"editMode\":false,\"attributes\":{\"title\":\"TEst22\",\"type\":\"lens\",\"visualizationType\":\"lnsMetric\",\"state\":{\"datasourceStates\":{\"indexpattern\":{\"layers\":{\"layer1\":{\"columnOrder\":[\"col2\"],\"columns\":{\"col2\":{\"dataType\":\"number\",\"isBucketed\":false,\"label\":\"Count of records\",\"operationType\":\"count\",\"scale\":\"ratio\",\"sourceField\":\"Records\"}}}}}},\"visualization\":{\"layerId\":\"layer1\",\"accessor\":\"col2\"},\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filters\":[]},\"references\":[{\"type\":\"index-pattern\",\"id\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"name\":\"indexpattern-datasource-current-indexpattern\"},{\"type\":\"index-pattern\",\"id\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"name\":\"indexpattern-datasource-layer-layer1\"}]}}}\n\nbrbrbr"`, + type: 'user', + created_at: '2021-07-19T08:41:29.951Z', + created_by: { + email: null, + full_name: null, + username: 'elastic', + }, + pushed_at: null, + pushed_by: null, + updated_at: '2021-07-19T08:41:47.549Z', + updated_by: { + full_name: null, + email: null, + username: 'elastic', + }, + }, + references: [ + { + name: 'associated-cases', + id: '77d1b230-d35e-11eb-8da6-6f746b9cb499', + type: 'cases', + }, + { + name: 'indexpattern-datasource-current-indexpattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + type: 'index-pattern', + }, + { + name: 'indexpattern-datasource-current-indexpattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + type: 'index-pattern', + }, + ], + migrationVersion: { + 'cases-comments': '7.14.0', + }, + coreMigrationVersion: '8.0.0', + updated_at: '2021-07-19T08:41:47.552Z', + version: 'WzgxMTY4MSw5XQ==', + namespaces: ['default'], + score: 0, + }; + + it('should remove time zone param from date histogram', () => { + expect(migrations['7.14.0']).toBeDefined(); + const result = migrations['7.14.0'](caseComment, contextMock); + + const parsedComment = parseCommentString(result.attributes.comment); + const lensVisualizations = getLensVisualizations(parsedComment.children); + + const layers = Object.values( + lensVisualizations[0].attributes.state.datasourceStates.indexpattern.layers + ); + expect(result.attributes.comment).toEqual(expectedMigrationCommentResult); + expect(layers.length).toBe(1); + const columns = Object.values(layers[0].columns); + expect(columns.length).toBe(3); + expect(columns[0].operationType).toEqual('date_histogram'); + expect((columns[0] as { params: {} }).params).toEqual({ interval: 'auto' }); + expect(columns[1].operationType).toEqual('date_histogram'); + expect((columns[1] as { params: {} }).params).toEqual({ interval: 'auto' }); + expect(columns[2].operationType).toEqual('my_unexpected_operation'); + expect((columns[2] as { params: {} }).params).toEqual({ timeZone: 'do not delete' }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts index 7be87c3abc989..b1792d98cfdb2 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts @@ -7,9 +7,19 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import { flow, mapValues } from 'lodash'; +import { LensServerPluginSetup } from '../../../../lens/server'; + +import { + mergeMigrationFunctionMaps, + MigrateFunction, + MigrateFunctionsObject, +} from '../../../../../../src/plugins/kibana_utils/common'; import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc, + SavedObjectMigrationFn, + SavedObjectMigrationMap, } from '../../../../../../src/core/server'; import { ConnectorTypes, @@ -17,6 +27,7 @@ import { AssociationType, SECURITY_SOLUTION_OWNER, } from '../../../common'; +import { parseCommentString, stringifyComment } from '../../common'; export { caseMigrations } from './cases'; export { configureMigrations } from './configuration'; @@ -103,44 +114,86 @@ interface SanitizedCommentForSubCases { rule?: { id: string | null; name: string | null }; } -export const commentsMigrations = { - '7.11.0': ( - doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { - return { - ...doc, - attributes: { - ...doc.attributes, - type: CommentType.user, - }, - references: doc.references || [], - }; - }, - '7.12.0': ( - doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { - let attributes: SanitizedCommentForSubCases & UnsanitizedComment = { - ...doc.attributes, - associationType: AssociationType.case, - }; - - // only add the rule object for alert comments. Prior to 7.12 we only had CommentType.alert, generated alerts are - // introduced in 7.12. - if (doc.attributes.type === CommentType.alert) { - attributes = { ...attributes, rule: { id: null, name: null } }; +const migrateByValueLensVisualizations = ( + migrate: MigrateFunction, + version: string +): SavedObjectMigrationFn => (doc: any) => { + const parsedComment = parseCommentString(doc.attributes.comment); + const migratedComment = parsedComment.children.map((comment) => { + if (comment?.type === 'lens') { + // @ts-expect-error + return migrate(comment); } - return { - ...doc, - attributes, - references: doc.references || [], - }; - }, - '7.14.0': ( - doc: SavedObjectUnsanitizedDoc> - ): SavedObjectSanitizedDoc => { - return addOwnerToSO(doc); - }, + return comment; + }); + + // @ts-expect-error + parsedComment.children = migratedComment; + doc.attributes.comment = stringifyComment(parsedComment); + + return doc; +}; + +export interface CreateCommentsMigrationsDeps { + lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; +} + +export const createCommentsMigrations = ( + migrationDeps: CreateCommentsMigrationsDeps +): SavedObjectMigrationMap => { + const embeddableMigrations = mapValues( + migrationDeps.lensEmbeddableFactory().migrations, + migrateByValueLensVisualizations + ) as MigrateFunctionsObject; + + const commentsMigrations = { + '7.11.0': flow( + ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + type: CommentType.user, + }, + references: doc.references || [], + }; + } + ), + '7.12.0': flow( + ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + let attributes: SanitizedCommentForSubCases & UnsanitizedComment = { + ...doc.attributes, + associationType: AssociationType.case, + }; + + // only add the rule object for alert comments. Prior to 7.12 we only had CommentType.alert, generated alerts are + // introduced in 7.12. + if (doc.attributes.type === CommentType.alert) { + attributes = { ...attributes, rule: { id: null, name: null } }; + } + + return { + ...doc, + attributes, + references: doc.references || [], + }; + } + ), + '7.14.0': flow( + ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return addOwnerToSO(doc); + } + ), + }; + + return mergeMigrationFunctionMaps(commentsMigrations, embeddableMigrations); }; export const connectorMappingsMigrations = { diff --git a/x-pack/plugins/cases/server/services/attachments/index.ts b/x-pack/plugins/cases/server/services/attachments/index.ts index c2d9b4826fc14..105b6a3125523 100644 --- a/x-pack/plugins/cases/server/services/attachments/index.ts +++ b/x-pack/plugins/cases/server/services/attachments/index.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { Logger, SavedObject, SavedObjectReference } from 'kibana/server'; +import { + Logger, + SavedObject, + SavedObjectReference, + SavedObjectsUpdateOptions, +} from 'kibana/server'; import { KueryNode } from '../../../../../../src/plugins/data/common'; import { @@ -38,10 +43,10 @@ interface CreateAttachmentArgs extends ClientArgs { interface UpdateArgs { attachmentId: string; updatedAttributes: AttachmentPatchAttributes; - version?: string; + options?: SavedObjectsUpdateOptions; } -type UpdateAttachmentArgs = UpdateArgs & ClientArgs; +export type UpdateAttachmentArgs = UpdateArgs & ClientArgs; interface BulkUpdateAttachmentArgs extends ClientArgs { comments: UpdateArgs[]; @@ -142,7 +147,7 @@ export class AttachmentService { unsecuredSavedObjectsClient, attachmentId, updatedAttributes, - version, + options, }: UpdateAttachmentArgs) { try { this.log.debug(`Attempting to UPDATE comment ${attachmentId}`); @@ -150,7 +155,7 @@ export class AttachmentService { CASE_COMMENT_SAVED_OBJECT, attachmentId, updatedAttributes, - { version } + options ); } catch (error) { this.log.error(`Error on UPDATE comment ${attachmentId}: ${error}`); @@ -168,7 +173,7 @@ export class AttachmentService { type: CASE_COMMENT_SAVED_OBJECT, id: c.attachmentId, attributes: c.updatedAttributes, - version: c.version, + ...c.options, })) ); } catch (error) { diff --git a/x-pack/plugins/cases/tsconfig.json b/x-pack/plugins/cases/tsconfig.json index 99622df805ced..1c9373e023366 100644 --- a/x-pack/plugins/cases/tsconfig.json +++ b/x-pack/plugins/cases/tsconfig.json @@ -16,6 +16,7 @@ { "path": "../../../src/core/tsconfig.json" }, // optionalPlugins from ./kibana.json + { "path": "../lens/tsconfig.json" }, { "path": "../security/tsconfig.json" }, { "path": "../spaces/tsconfig.json" }, @@ -24,6 +25,7 @@ { "path": "../triggers_actions_ui/tsconfig.json"}, { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, - { "path": "../../../src/plugins/kibana_utils/tsconfig.json" } + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../../src/plugins/saved_objects/tsconfig.json" } ] } diff --git a/x-pack/plugins/lens/common/embeddable_factory/index.ts b/x-pack/plugins/lens/common/embeddable_factory/index.ts new file mode 100644 index 0000000000000..1eaa1dddfdf08 --- /dev/null +++ b/x-pack/plugins/lens/common/embeddable_factory/index.ts @@ -0,0 +1,36 @@ +/* + * 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 { SerializableRecord, Serializable } from '@kbn/utility-types'; +import { SavedObjectReference } from 'src/core/types'; +import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; +import { EmbeddableRegistryDefinition } from 'src/plugins/embeddable/server'; + +export type LensEmbeddablePersistableState = EmbeddableStateWithType & { + attributes: SerializableRecord; +}; + +export const inject: EmbeddableRegistryDefinition['inject'] = (state, references) => { + const typedState = state as LensEmbeddablePersistableState; + + if ('attributes' in typedState && typedState.attributes !== undefined) { + typedState.attributes.references = (references as unknown) as Serializable[]; + } + + return typedState; +}; + +export const extract: EmbeddableRegistryDefinition['extract'] = (state) => { + let references: SavedObjectReference[] = []; + const typedState = state as LensEmbeddablePersistableState; + + if ('attributes' in typedState && typedState.attributes !== undefined) { + references = (typedState.attributes.references as unknown) as SavedObjectReference[]; + } + + return { state, references }; +}; diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index 5a783bc4180d3..6bbc1284a0f1e 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -147,6 +147,7 @@ export async function mountApp( if (stateTransfer && props?.input) { const { input, isCopied } = props; stateTransfer.navigateToWithEmbeddablePackage(embeddableEditorIncomingState?.originatingApp, { + path: embeddableEditorIncomingState?.originatingPath, state: { embeddableId: isCopied ? undefined : embeddableEditorIncomingState.embeddableId, type: LENS_EMBEDDABLE_TYPE, @@ -155,7 +156,9 @@ export async function mountApp( }, }); } else { - coreStart.application.navigateToApp(embeddableEditorIncomingState?.originatingApp); + coreStart.application.navigateToApp(embeddableEditorIncomingState?.originatingApp, { + path: embeddableEditorIncomingState?.originatingPath, + }); } }; const initialContext = diff --git a/x-pack/plugins/lens/public/embeddable/embeddable_factory.ts b/x-pack/plugins/lens/public/embeddable/embeddable_factory.ts index 4cc074b5e830c..dcb72455e0ee9 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable_factory.ts +++ b/x-pack/plugins/lens/public/embeddable/embeddable_factory.ts @@ -5,11 +5,10 @@ * 2.0. */ -import type { Capabilities, HttpSetup, SavedObjectReference } from 'kibana/public'; +import type { Capabilities, HttpSetup } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { RecursiveReadonly } from '@kbn/utility-types'; import { Ast } from '@kbn/interpreter/target/common'; -import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; import { IndexPatternsContract, TimefilterContract } from '../../../../../src/plugins/data/public'; import { ReactExpressionRendererType } from '../../../../../src/plugins/expressions/public'; @@ -23,6 +22,7 @@ import { Document } from '../persistence/saved_object_store'; import { LensAttributeService } from '../lens_attribute_service'; import { DOC_TYPE } from '../../common'; import { ErrorMessage } from '../editor_frame_service/types'; +import { extract, inject } from '../../common/embeddable_factory'; export interface LensEmbeddableStartServices { timefilter: TimefilterContract; @@ -112,14 +112,6 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { ); } - extract(state: EmbeddableStateWithType) { - let references: SavedObjectReference[] = []; - const typedState = (state as unknown) as LensEmbeddableInput; - - if ('attributes' in typedState && typedState.attributes !== undefined) { - references = typedState.attributes.references; - } - - return { state, references }; - } + extract = extract; + inject = inject; } diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index e0a4848974237..6e8b7d35b0cb9 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -130,7 +130,14 @@ export interface LensPublicStart { * * @experimental */ - navigateToPrefilledEditor: (input: LensEmbeddableInput, openInNewTab?: boolean) => void; + navigateToPrefilledEditor: ( + input: LensEmbeddableInput | undefined, + options?: { + openInNewTab?: boolean; + originatingApp?: string; + originatingPath?: string; + } + ) => void; /** * Method which returns true if the user has permission to use Lens as defined by application capabilities. */ @@ -336,20 +343,24 @@ export class LensPlugin { return { EmbeddableComponent: getEmbeddableComponent(core, startDependencies), SaveModalComponent: getSaveModalComponent(core, startDependencies, this.attributeService!), - navigateToPrefilledEditor: (input: LensEmbeddableInput, openInNewTab?: boolean) => { + navigateToPrefilledEditor: ( + input, + { openInNewTab = false, originatingApp = '', originatingPath } = {} + ) => { // for openInNewTab, we set the time range in url via getEditPath below - if (input.timeRange && !openInNewTab) { + if (input?.timeRange && !openInNewTab) { startDependencies.data.query.timefilter.timefilter.setTime(input.timeRange); } const transfer = new EmbeddableStateTransfer( core.application.navigateToApp, core.application.currentAppId$ ); - transfer.navigateToEditor('lens', { + transfer.navigateToEditor(APP_ID, { openInNewTab, - path: getEditPath(undefined, openInNewTab ? input.timeRange : undefined), + path: getEditPath(undefined, (openInNewTab && input?.timeRange) || undefined), state: { - originatingApp: '', + originatingApp, + originatingPath, valueInput: input, }, }); diff --git a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts index 14a9713d8461e..86a3a600b58ab 100644 --- a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts +++ b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts @@ -19,6 +19,7 @@ import { LensDocShapePre712, VisStatePre715, } from '../migrations/types'; +import { extract, inject } from '../../common/embeddable_factory'; export const lensEmbeddableFactory = (): EmbeddableRegistryDefinition => { return { @@ -50,5 +51,7 @@ export const lensEmbeddableFactory = (): EmbeddableRegistryDefinition => { } as unknown) as SerializableRecord; }, }, + extract, + inject, }; }; diff --git a/x-pack/plugins/lens/server/index.ts b/x-pack/plugins/lens/server/index.ts index b61282c9e26e5..f8a9b2452de41 100644 --- a/x-pack/plugins/lens/server/index.ts +++ b/x-pack/plugins/lens/server/index.ts @@ -8,7 +8,9 @@ import { PluginInitializerContext, PluginConfigDescriptor } from 'kibana/server'; import { LensServerPlugin } from './plugin'; +export type { LensServerPluginSetup } from './plugin'; export * from './plugin'; +export * from './migrations/types'; import { configSchema, ConfigSchema } from '../config'; diff --git a/x-pack/plugins/lens/server/plugin.tsx b/x-pack/plugins/lens/server/plugin.tsx index f0ee801ece89b..e242fc8e4c5d6 100644 --- a/x-pack/plugins/lens/server/plugin.tsx +++ b/x-pack/plugins/lens/server/plugin.tsx @@ -36,7 +36,11 @@ export interface PluginStartContract { data: DataPluginStart; } -export class LensServerPlugin implements Plugin<{}, {}, {}, {}> { +export interface LensServerPluginSetup { + lensEmbeddableFactory: typeof lensEmbeddableFactory; +} + +export class LensServerPlugin implements Plugin { private readonly kibanaIndexConfig: Observable<{ kibana: { index: string } }>; private readonly telemetryLogger: Logger; @@ -63,8 +67,11 @@ export class LensServerPlugin implements Plugin<{}, {}, {}, {}> { plugins.taskManager ); } + plugins.embeddable.registerEmbeddableFactory(lensEmbeddableFactory()); - return {}; + return { + lensEmbeddableFactory, + }; } start(core: CoreStart, plugins: PluginStartContract) { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx index 8cd8977fcf741..62d828b337c2d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx @@ -52,7 +52,7 @@ describe('ExploratoryViewHeader', function () { to: 'now', }, }, - true + { openInNewTab: true } ); }); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx index ded56ec9e817f..bfa457ee4025f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx @@ -69,7 +69,9 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) { timeRange, attributes: lensAttributes, }, - true + { + openInNewTab: true, + } ); } }} diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index f51f76a395199..7d11050f14d15 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -24,6 +24,7 @@ import type { DataPublicPluginSetup, DataPublicPluginStart, } from '../../../../src/plugins/data/public'; +import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import type { HomePublicPluginSetup, HomePublicPluginStart, @@ -52,6 +53,7 @@ export interface ObservabilityPublicPluginsSetup { export interface ObservabilityPublicPluginsStart { cases: CasesUiStart; + embeddable: EmbeddableStart; home?: HomePublicPluginStart; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; data: DataPublicPluginStart; diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx index ae3ddb1c0b861..1ab87949e3493 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx @@ -167,7 +167,7 @@ const ViewResultsInLensActionComponent: React.FC { - const openInNewWindow = !(!isModifiedEvent(event) && isLeftClickEvent(event)); + const openInNewTab = !(!isModifiedEvent(event) && isLeftClickEvent(event)); event.preventDefault(); @@ -181,7 +181,9 @@ const ViewResultsInLensActionComponent: React.FC = ({ - onChange, - value, - ariaLabel, - editorId, - dataTestSubj, - height, - autoFocusDisabled = false, -}) => { - const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); - const onParse = useCallback((err, { messages }) => { - setMarkdownErrorMessages(err ? [err] : messages); - }, []); - - useEffect(() => { - if (!autoFocusDisabled) { - document.querySelector('textarea.euiMarkdownEditorTextArea')?.focus(); - } - }, [autoFocusDisabled]); - - return ( - - ); -}; +type EuiMarkdownEditorRef = ElementRef; + +export interface MarkdownEditorRef { + textarea: HTMLTextAreaElement | null; + replaceNode: ContextShape['replaceNode']; + toolbar: HTMLDivElement | null; +} + +const MarkdownEditorComponent = forwardRef( + ({ onChange, value, ariaLabel, editorId, dataTestSubj, height, autoFocusDisabled }, ref) => { + const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); + const onParse = useCallback((err, { messages }) => { + setMarkdownErrorMessages(err ? [err] : messages); + }, []); + const editorRef = useRef(null); + + useEffect(() => { + if (!autoFocusDisabled) { + editorRef.current?.textarea?.focus(); + } + }, [autoFocusDisabled]); + + // @ts-expect-error update types + useImperativeHandle(ref, () => { + if (!editorRef.current) { + return null; + } + + const editorNode = editorRef.current?.textarea?.closest('.euiMarkdownEditor'); + + return { + ...editorRef.current, + toolbar: editorNode?.querySelector('.euiMarkdownEditorToolbar'), + }; + }); + + return ( + + ); + } +); + +MarkdownEditorComponent.displayName = 'MarkdownEditorComponent'; export const MarkdownEditor = memo(MarkdownEditorComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx index 1c407b3b8f8c2..82e4d5d5a2600 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx @@ -5,12 +5,12 @@ * 2.0. */ -import React from 'react'; +import React, { forwardRef } from 'react'; import styled from 'styled-components'; import { EuiMarkdownEditorProps, EuiFormRow, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports'; -import { MarkdownEditor } from './editor'; +import { MarkdownEditor, MarkdownEditorRef } from './editor'; type MarkdownEditorFormProps = EuiMarkdownEditorProps & { id: string; @@ -27,40 +27,41 @@ const BottomContentWrapper = styled(EuiFlexGroup)` `} `; -export const MarkdownEditorForm: React.FC = ({ - id, - field, - dataTestSubj, - idAria, - bottomRightContent, -}) => { - const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); +export const MarkdownEditorForm = React.memo( + forwardRef( + ({ id, field, dataTestSubj, idAria, bottomRightContent }, ref) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - return ( - - <> - - {bottomRightContent && ( - - {bottomRightContent} - - )} - - - ); -}; + return ( + + <> + + {bottomRightContent && ( + + {bottomRightContent} + + )} + + + ); + } + ) +); + +MarkdownEditorForm.displayName = 'MarkdownEditorForm'; diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 326a6973db53b..968211a0c82df 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -9,6 +9,7 @@ import { CoreStart } from '../../../../src/core/public'; import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; +import { LensPublicStart } from '../../../plugins/lens/public'; import { NewsfeedPublicPluginStart } from '../../../../src/plugins/newsfeed/public'; import { Start as InspectorStart } from '../../../../src/plugins/inspector/public'; import { UiActionsStart } from '../../../../src/plugins/ui_actions/public'; @@ -58,6 +59,7 @@ export interface StartPlugins { embeddable: EmbeddableStart; inspector: InspectorStart; fleet?: FleetStart; + lens: LensPublicStart; lists?: ListsPluginStart; licensing: LicensingPluginStart; newsfeed?: NewsfeedPublicPluginStart;