diff --git a/dev_docs/tutorials/versioning_http_apis.mdx b/dev_docs/tutorials/versioning_http_apis.mdx index 81599028819b9..b0a4bf899158c 100644 --- a/dev_docs/tutorials/versioning_http_apis.mdx +++ b/dev_docs/tutorials/versioning_http_apis.mdx @@ -212,17 +212,45 @@ The changes are: ### 4. Adhere to the HTTP versioning specification -#### Choosing the right version -##### Public endpoints +We categorize our endpoints based on their intended audience: `public` or `internal`. Different versioning practices apply to each. + +#### Public endpoints Public endpoints include any endpoint that is intended for users to directly integrate with via HTTP. + + All Kibana's public endpoints must be versioned using the format described below. + + +##### Version lifecycle + +Introducing a new version or moving a current version into deprecation to eventually be deleted must +follow [this process](https://github.com/elastic/dev/issues/new?assignees=&labels=breaking-change-proposal&projects=&template=breaking-change.md). + +##### Version format + Choose a date string in the format `YYYY-MM-DD`. This date should be the date that a (group) of APIs was made available. -##### Internal endpoints -Internal endpoints are all non-public endpoints (see definition above). +-------- + +#### Internal endpoints +Internal endpoints are all non-public endpoints (see definition above). Note: these endpoints do not need to be versioned, +but versioning can be leveraged to maintain BWC with existing clients. -If you need to maintain backwards-compatibility for an internal endpoint use a single, larger-than-zero number. Ex. `1`. +##### Version lifecycle + +Introducing/removing a version is up to the team who owns the HTTP API. Consider how introduction or removal might +affect client code when being rolled out. + + + To keep maintenance light it is **highly** recommended to reduce the number of versions you have for internal endpoints. In your code it is possible to + centrally define and share internal versions through code that is `common` to your browser- and server-side plugin code. + + + +##### Version format + +If you need to version an internal endpoint use a single, larger-than-zero major version. Ex. `1`. #### Use the versioned router @@ -335,4 +363,5 @@ export class MyPlugin implements Plugin { ``` #### Additional reading + For more details on the versioning specification see [this document](https://docs.google.com/document/d/1YpF6hXIHZaHvwNaQAxWFzexUF1nbqACTtH2IfDu0ldA/edit?usp=sharing). diff --git a/packages/core/chrome/core-chrome-browser/src/project_navigation.ts b/packages/core/chrome/core-chrome-browser/src/project_navigation.ts index deea1a9cb3d52..43acc3897307e 100644 --- a/packages/core/chrome/core-chrome-browser/src/project_navigation.ts +++ b/packages/core/chrome/core-chrome-browser/src/project_navigation.ts @@ -7,6 +7,7 @@ */ import type { ComponentType } from 'react'; +import type { ChromeNavLink } from './nav_links'; /** @internal */ type AppId = string; @@ -17,20 +18,44 @@ type DeepLinkId = string; /** @internal */ export type AppDeepLinkId = `${AppId}:${DeepLinkId}`; -/** @public */ +/** + * @public + * + * App id or deeplink id + */ export type ChromeProjectNavigationLink = AppId | AppDeepLinkId; /** @public */ export interface ChromeProjectNavigationNode { - id?: string; - link?: ChromeProjectNavigationLink; - children?: ChromeProjectNavigationNode[]; - title?: string; + /** Optional id, if not passed a "link" must be provided. */ + id: string; + /** Optional title. If not provided and a "link" is provided the title will be the Deep link title */ + title: string; + /** Path in the tree of the node */ + path: string[]; + /** App id or deeplink id */ + deepLink?: ChromeNavLink; + /** Optional icon for the navigation node. Note: not all navigation depth will render the icon */ icon?: string; + /** Optional children of the navigation node */ + children?: ChromeProjectNavigationNode[]; + /** + * Temporarilly we allow href to be passed. + * Once all the deeplinks will be exposed in packages we will not allow href anymore + * and force deeplink id to be passed + */ + href?: string; } /** @public */ export interface ChromeProjectNavigation { + /** + * The URL href for the home link + */ + homeRef: string; + /** + * The navigation tree representation of the side bar navigation. + */ navigationTree: ChromeProjectNavigationNode[]; } diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/document_migrator/validate_migration.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/document_migrator/validate_migration.test.ts index 8fbaf06b56f80..f2a43545319a0 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/document_migrator/validate_migration.test.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/document_migrator/validate_migration.test.ts @@ -187,7 +187,7 @@ describe('validateTypeMigrations', () => { }); expect(() => validate({ type, kibanaVersion: '3.2.3' })).toThrowErrorMatchingInlineSnapshot( - `"Type foo: Uusing modelVersions requires to specify switchToModelVersionAt"` + `"Type foo: Using modelVersions requires to specify switchToModelVersionAt"` ); }); @@ -234,6 +234,15 @@ describe('validateTypeMigrations', () => { `"Type foo: gaps between model versions aren't allowed (missing versions: 2,4,5)"` ); }); + + it('does not throw passing an empty model version map', () => { + const type = createType({ + name: 'foo', + modelVersions: {}, + }); + + expect(() => validate({ type, kibanaVersion: '3.2.3' })).not.toThrow(); + }); }); describe('modelVersions mapping additions', () => { diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/document_migrator/validate_migrations.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/document_migrator/validate_migrations.ts index 42ee0ad6ae8e1..be3ff2e0e325b 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/document_migrator/validate_migrations.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/document_migrator/validate_migrations.ts @@ -73,45 +73,47 @@ export function validateTypeMigrations({ const modelVersionMap = typeof type.modelVersions === 'function' ? type.modelVersions() : type.modelVersions ?? {}; - if (Object.keys(modelVersionMap).length > 0 && !type.switchToModelVersionAt) { - throw new Error( - `Type ${type.name}: Uusing modelVersions requires to specify switchToModelVersionAt` - ); - } + if (Object.keys(modelVersionMap).length > 0) { + if (!type.switchToModelVersionAt) { + throw new Error( + `Type ${type.name}: Using modelVersions requires to specify switchToModelVersionAt` + ); + } - Object.entries(modelVersionMap).forEach(([version, definition]) => { - assertValidModelVersion(version); - }); + Object.entries(modelVersionMap).forEach(([version, definition]) => { + assertValidModelVersion(version); + }); - const { min: minVersion, max: maxVersion } = Object.keys(modelVersionMap).reduce( - (minMax, rawVersion) => { - const version = Number.parseInt(rawVersion, 10); - minMax.min = Math.min(minMax.min, version); - minMax.max = Math.max(minMax.max, version); - return minMax; - }, - { min: Infinity, max: -Infinity } - ); + const { min: minVersion, max: maxVersion } = Object.keys(modelVersionMap).reduce( + (minMax, rawVersion) => { + const version = Number.parseInt(rawVersion, 10); + minMax.min = Math.min(minMax.min, version); + minMax.max = Math.max(minMax.max, version); + return minMax; + }, + { min: Infinity, max: -Infinity } + ); - if (minVersion > 1) { - throw new Error(`Type ${type.name}: model versioning must start with version 1`); - } + if (minVersion > 1) { + throw new Error(`Type ${type.name}: model versioning must start with version 1`); + } - validateAddedMappings(type.name, type.mappings, modelVersionMap); + validateAddedMappings(type.name, type.mappings, modelVersionMap); - const missingVersions = getMissingVersions( - minVersion, - maxVersion, - Object.keys(modelVersionMap).map((v) => Number.parseInt(v, 10)) - ); - if (missingVersions.length) { - throw new Error( - `Type ${ - type.name - }: gaps between model versions aren't allowed (missing versions: ${missingVersions.join( - ',' - )})` + const missingVersions = getMissingVersions( + minVersion, + maxVersion, + Object.keys(modelVersionMap).map((v) => Number.parseInt(v, 10)) ); + if (missingVersions.length) { + throw new Error( + `Type ${ + type.name + }: gaps between model versions aren't allowed (missing versions: ${missingVersions.join( + ',' + )})` + ); + } } } diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 3dc0710a62c5d..f9c11d419fa02 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -7,7 +7,7 @@ pageLoadAssetSize: banners: 17946 bfetch: 22837 canvas: 1066647 - cases: 175000 + cases: 180000 charts: 55000 cloud: 21076 cloudChat: 19894 diff --git a/packages/shared-ux/chrome/navigation/index.ts b/packages/shared-ux/chrome/navigation/index.ts index 9f9e15bacb207..213147a99a093 100644 --- a/packages/shared-ux/chrome/navigation/index.ts +++ b/packages/shared-ux/chrome/navigation/index.ts @@ -7,7 +7,20 @@ */ export { NavigationKibanaProvider, NavigationProvider } from './src/services'; -export { Navigation } from './src/ui/navigation'; + +export { DefaultNavigation, Navigation, getPresets } from './src/ui'; + +export type { + NavigationTreeDefinition, + ProjectNavigationDefinition, + NodeDefinition, + NavigationGroupPreset, + GroupDefinition, + RecentlyAccessedDefinition, + CloudLinkDefinition, + RootNavigationItemDefinition, +} from './src/ui'; + export type { ChromeNavigation, ChromeNavigationViewModel, diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/cloud_link.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/cloud_link.tsx new file mode 100644 index 0000000000000..b71b949e47603 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/components/cloud_link.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { EuiCollapsibleNavGroup, EuiLink } from '@elastic/eui'; +import React, { FC } from 'react'; +import { getI18nStrings } from '../i18n_strings'; + +const i18nTexts = getI18nStrings(); + +const presets = { + projects: { + href: 'https://cloud.elastic.co/projects', + icon: 'spaces', + title: i18nTexts.linkToCloudProjects, + dataTestSubj: 'nav-header-link-to-projects', + }, + deployments: { + href: 'https://cloud.elastic.co/deployments', + icon: 'spaces', + title: i18nTexts.linkToCloudDeployments, + dataTestSubj: 'nav-header-link-to-deployments', + }, +}; + +export interface Props { + /** Use one of the cloud link presets */ + preset?: 'projects' | 'deployments' | null; + /** Optional. If "preset" is not provided it is required */ + href?: string; + /** Optional. If "preset" is not provided it is required */ + icon?: string; + /** Optional. If "preset" is not provided it is required */ + title?: string; +} + +export const CloudLink: FC = ({ preset, href: _href, icon: _icon, title: _title }) => { + if (preset === null) { + return null; + } + + if (!preset && (!_href || !_icon || !_title)) { + throw new Error(`Navigation.CloudLink requires href, icon, and title`); + } + + const { href, icon, title, dataTestSubj } = + preset && presets[preset] + ? presets[preset]! + : { + href: _href, + icon: _icon, + title: _title, + dataTestSubj: 'nav-header-link-to-cloud', + }; + + return ( + + + + ); +}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/index.ts b/packages/shared-ux/chrome/navigation/src/ui/components/index.ts new file mode 100644 index 0000000000000..bb7aeacdfabb7 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/components/index.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { Props as CloudLinkProps } from './cloud_link'; +export { Navigation } from './navigation'; +export type { Props as RecentlyAccessedProps } from './recently_accessed'; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation.test.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation.test.tsx new file mode 100644 index 0000000000000..3eef283038a14 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation.test.tsx @@ -0,0 +1,449 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { type Observable, of } from 'rxjs'; +import type { ChromeNavLink } from '@kbn/core-chrome-browser'; + +import { getServicesMock } from '../../../mocks/src/jest'; +import { NavigationProvider } from '../../services'; +import { Navigation } from './navigation'; +import { + defaultAnalyticsNavGroup, + defaultDevtoolsNavGroup, + defaultManagementNavGroup, + defaultMlNavGroup, +} from '../default_navigation.test.helpers'; + +describe('', () => { + const services = getServicesMock(); + + describe('builds the navigation tree', () => { + test('render reference UI and build the navigation tree', async () => { + const onProjectNavigationChange = jest.fn(); + + const { findByTestId } = render( + + + + + + + + + + + + + + + ); + + expect(await findByTestId('nav-item-group1.item1')).toBeVisible(); + expect(await findByTestId('nav-item-group1.item2')).toBeVisible(); + expect(await findByTestId('nav-item-group1.group1A')).toBeVisible(); + expect(await findByTestId('nav-item-group1.group1A.item1')).toBeVisible(); + expect(await findByTestId('nav-item-group1.group1A.group1A_1')).toBeVisible(); + + // Click the last group to expand and show the last depth + (await findByTestId('nav-item-group1.group1A.group1A_1')).click(); + + expect(await findByTestId('nav-item-group1.group1A.group1A_1.item1')).toBeVisible(); + + expect(onProjectNavigationChange).toHaveBeenCalled(); + const lastCall = + onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; + const [navTree] = lastCall; + + expect(navTree).toEqual({ + homeRef: 'https://elastic.co', + navigationTree: [ + { + id: 'group1', + path: ['group1'], + title: '', + children: [ + { + id: 'item1', + title: 'Item 1', + path: ['group1', 'item1'], + }, + { + id: 'item2', + title: 'Item 2', + path: ['group1', 'item2'], + }, + { + id: 'group1A', + title: 'Group1A', + path: ['group1', 'group1A'], + children: [ + { + id: 'item1', + title: 'Group 1A Item 1', + path: ['group1', 'group1A', 'item1'], + }, + { + id: 'group1A_1', + title: 'Group1A_1', + path: ['group1', 'group1A', 'group1A_1'], + children: [ + { + id: 'item1', + title: 'Group 1A_1 Item 1', + path: ['group1', 'group1A', 'group1A_1', 'item1'], + }, + ], + }, + ], + }, + ], + }, + ], + }); + }); + + test('should read the title from props, children or deeplink', async () => { + const navLinks$: Observable = of([ + { + id: 'item1', + title: 'Title from deeplink', + baseUrl: '', + url: '', + href: '', + }, + ]); + + const onProjectNavigationChange = jest.fn(); + + render( + + + + + {/* Title from deeplink */} + + + + Title in children + + + + + ); + + expect(onProjectNavigationChange).toHaveBeenCalled(); + const lastCall = + onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; + const [navTree] = lastCall; + + expect(navTree).toEqual({ + homeRef: 'https://elastic.co', + navigationTree: [ + { + id: 'root', + path: ['root'], + title: '', + children: [ + { + id: 'group1', + path: ['root', 'group1'], + title: '', + children: [ + { + id: 'item1', + path: ['root', 'group1', 'item1'], + title: 'Title from deeplink', + deepLink: { + id: 'item1', + title: 'Title from deeplink', + baseUrl: '', + url: '', + href: '', + }, + }, + { + id: 'item2', + title: 'Overwrite deeplink title', + path: ['root', 'group1', 'item2'], + deepLink: { + id: 'item1', + title: 'Title from deeplink', + baseUrl: '', + url: '', + href: '', + }, + }, + { + id: 'item3', + title: 'Title in props', + path: ['root', 'group1', 'item3'], + }, + { + id: 'item4', + path: ['root', 'group1', 'item4'], + title: 'Title in children', + }, + ], + }, + ], + }, + ], + }); + }); + + test('should filter out unknown deeplinks', async () => { + const navLinks$: Observable = of([ + { + id: 'item1', + title: 'Title from deeplink', + baseUrl: '', + url: '', + href: '', + }, + ]); + + const onProjectNavigationChange = jest.fn(); + + const { findByTestId } = render( + + + + + {/* Title from deeplink */} + + {/* Should not appear */} + + + + + + ); + + expect(await findByTestId('nav-item-root.group1.item1')).toBeVisible(); + expect(await findByTestId('nav-item-root.group1.item1')).toBeVisible(); + + expect(onProjectNavigationChange).toHaveBeenCalled(); + const lastCall = + onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; + const [navTree] = lastCall; + + expect(navTree).toEqual({ + homeRef: 'https://elastic.co', + navigationTree: [ + { + id: 'root', + path: ['root'], + title: '', + children: [ + { + id: 'group1', + path: ['root', 'group1'], + title: '', + children: [ + { + id: 'item1', + path: ['root', 'group1', 'item1'], + title: 'Title from deeplink', + deepLink: { + id: 'item1', + title: 'Title from deeplink', + baseUrl: '', + url: '', + href: '', + }, + }, + ], + }, + ], + }, + ], + }); + }); + + test('should render custom react element', async () => { + const navLinks$: Observable = of([ + { + id: 'item1', + title: 'Title from deeplink', + baseUrl: '', + url: '', + href: '', + }, + ]); + + const onProjectNavigationChange = jest.fn(); + + const { findByTestId } = render( + + + + + +
Custom element
+
+ + {(navNode) =>
{navNode.title}
} +
+
+
+
+
+ ); + + expect(await findByTestId('my-custom-element')).toBeVisible(); + expect(await findByTestId('my-other-custom-element')).toBeVisible(); + expect(await (await findByTestId('my-other-custom-element')).textContent).toBe( + 'Children prop' + ); + + expect(onProjectNavigationChange).toHaveBeenCalled(); + const lastCall = + onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; + const [navTree] = lastCall; + + expect(navTree).toEqual({ + homeRef: 'https://elastic.co', + navigationTree: [ + { + id: 'root', + path: ['root'], + title: '', + children: [ + { + id: 'group1', + path: ['root', 'group1'], + title: '', + children: [ + { + id: 'item1', + path: ['root', 'group1', 'item1'], + title: 'Title from deeplink', + renderItem: expect.any(Function), + deepLink: { + id: 'item1', + title: 'Title from deeplink', + baseUrl: '', + url: '', + href: '', + }, + }, + { + id: 'item2', + path: ['root', 'group1', 'item2'], + title: 'Children prop', + renderItem: expect.any(Function), + }, + ], + }, + ], + }, + ], + }); + }); + + test('should render group preset (analytics, ml...)', async () => { + const onProjectNavigationChange = jest.fn(); + + render( + + + + + + + + + ); + + expect(onProjectNavigationChange).toHaveBeenCalled(); + const lastCall = + onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; + const [navTreeGenerated] = lastCall; + + expect(navTreeGenerated).toEqual({ + homeRef: 'https://elastic.co', + navigationTree: expect.any(Array), + }); + + // The default navigation tree for analytics + expect(navTreeGenerated.navigationTree[0]).toEqual(defaultAnalyticsNavGroup); + + // The default navigation tree for ml + expect(navTreeGenerated.navigationTree[1]).toEqual(defaultMlNavGroup); + + // The default navigation tree for devtools+ + expect(navTreeGenerated.navigationTree[2]).toEqual(defaultDevtoolsNavGroup); + + // The default navigation tree for management + expect(navTreeGenerated.navigationTree[3]).toEqual(defaultManagementNavGroup); + }); + + test('should render cloud link', async () => { + const onProjectNavigationChange = jest.fn(); + + const { findByTestId } = render( + + + + + + + + + + + + ); + + expect(await findByTestId('nav-header-link-to-projects')).toBeVisible(); + expect(await findByTestId('nav-header-link-to-deployments')).toBeVisible(); + expect(await findByTestId('nav-header-link-to-cloud')).toBeVisible(); + expect(await (await findByTestId('nav-header-link-to-cloud')).textContent).toBe( + 'Custom link' + ); + }); + + test('should render recently accessed items', async () => { + const recentlyAccessed$ = of([ + { label: 'This is an example', link: '/app/example/39859', id: '39850' }, + { label: 'Another example', link: '/app/example/5235', id: '5235' }, + ]); + + const { findByTestId } = render( + + + + + + + + + + ); + + expect(await findByTestId('nav-bucket-recentlyAccessed')).toBeVisible(); + expect(await (await findByTestId('nav-bucket-recentlyAccessed')).textContent).toBe( + 'RecentThis is an exampleAnother example' + ); + }); + }); +}); diff --git a/packages/shared-ux/chrome/navigation/src/ui/v2/components/navigation.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation.tsx similarity index 52% rename from packages/shared-ux/chrome/navigation/src/ui/v2/components/navigation.tsx rename to packages/shared-ux/chrome/navigation/src/ui/components/navigation.tsx index 378648c26012d..6825c8a8084bf 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/v2/components/navigation.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation.tsx @@ -16,14 +16,21 @@ import React, { useContext, useRef, } from 'react'; +import type { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser'; -import { useNavigation as useNavigationServices } from '../../../services'; -import { InternalNavigationNode, RegisterFunction } from '../types'; +import { useNavigation as useNavigationServices } from '../../services'; +import { RegisterFunction, UnRegisterFunction } from '../types'; +import { CloudLink } from './cloud_link'; +import { NavigationFooter } from './navigation_footer'; import { NavigationGroup } from './navigation_group'; import { NavigationItem } from './navigation_item'; +import { NavigationUI } from './navigation_ui'; +import { RecentlyAccessed } from './recently_accessed'; interface Context { register: RegisterFunction; + updateFooterChildren: (children: ReactNode) => void; + unstyled: boolean; } const NavigationContext = createContext({ @@ -31,14 +38,25 @@ const NavigationContext = createContext({ unregister: () => {}, path: [], }), + updateFooterChildren: () => {}, + unstyled: false, }); interface Props { children: ReactNode; - onRootItemRemove?: (id: string) => void; + /** + * Href to the home page + */ + homeRef: string; + /** + * Flag to indicate if the Navigation should not be styled with EUI components. + * If set to true, the children will be rendered as is. + */ + unstyled?: boolean; + dataTestSubj?: string; } -export function Navigation({ children, onRootItemRemove }: Props) { +export function Navigation({ children, homeRef, unstyled = false, dataTestSubj }: Props) { const { onProjectNavigationChange } = useNavigationServices(); // We keep a reference of the order of the children that register themselves when mounting. @@ -47,12 +65,21 @@ export function Navigation({ children, onRootItemRemove }: Props) { const orderChildrenRef = useRef>({}); const idx = useRef(0); - const [navigationItems, setNavigationItems] = useState>( - {} - ); + const [navigationItems, setNavigationItems] = useState< + Record + >({}); + const [footerChildren, setFooterChildren] = useState(null); + + const unregister: UnRegisterFunction = useCallback((id: string) => { + setNavigationItems((prevItems) => { + const updatedItems = { ...prevItems }; + delete updatedItems[id]; + return updatedItems; + }); + }, []); const register = useCallback( - (navNode: InternalNavigationNode) => { + (navNode: ChromeProjectNavigationNode) => { orderChildrenRef.current[navNode.id] = idx.current++; setNavigationItems((prevItems) => { @@ -63,44 +90,44 @@ export function Navigation({ children, onRootItemRemove }: Props) { }); return { - unregister: () => { - if (onRootItemRemove) { - onRootItemRemove(navNode.id); - } - - setNavigationItems((prevItems) => { - const updatedItems = { ...prevItems }; - delete updatedItems[navNode.id]; - return updatedItems; - }); - }, - path: [], + unregister, + path: [navNode.id], }; }, - [onRootItemRemove] + [unregister] ); const contextValue = useMemo( () => ({ register, + updateFooterChildren: setFooterChildren, + unstyled, }), - [register] + [register, unstyled] ); useEffect(() => { - // Send the navigation tree to the Chrome service + // This will update the navigation tree in the Chrome service (calling the serverless.setNavigation()) onProjectNavigationChange({ + homeRef, navigationTree: Object.values(navigationItems).sort((a, b) => { const aOrder = orderChildrenRef.current[a.id]; const bOrder = orderChildrenRef.current[b.id]; return aOrder - bOrder; }), }); - }, [navigationItems, onProjectNavigationChange]); + }, [navigationItems, onProjectNavigationChange, homeRef]); return ( -
    {children}
+ + {children} +
); } @@ -115,3 +142,6 @@ export function useNavigation() { Navigation.Group = NavigationGroup; Navigation.Item = NavigationItem; +Navigation.Footer = NavigationFooter; +Navigation.CloudLink = CloudLink; +Navigation.RecentlyAccessed = RecentlyAccessed; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_bucket.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_bucket.tsx new file mode 100644 index 0000000000000..3d287e918d5c8 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_bucket.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC, useCallback } from 'react'; + +import { getPresets } from '../nav_tree_presets'; +import { Navigation } from './navigation'; +import type { NavigationGroupPreset, NodeDefinition } from '../types'; + +const navTreePresets = getPresets('all'); + +export interface Props { + preset?: NavigationGroupPreset; + nodeDefinition?: NodeDefinition; + defaultIsCollapsed?: boolean; +} + +export const NavigationBucket: FC = ({ + nodeDefinition: _nodeDefinition, + defaultIsCollapsed, + preset, +}) => { + const nodeDefinition = preset ? navTreePresets[preset] : _nodeDefinition; + + if (!nodeDefinition) { + throw new Error('Either preset or nodeDefinition must be defined'); + } + + const renderItems = useCallback( + (items: NodeDefinition[], isRoot = false) => { + return items.map((item) => { + const id = item.id ?? item.link; + + if (!id) { + throw new Error( + `At least one of id or link must be defined for navigation item ${item.title}` + ); + } + + return ( + + {item.children ? ( + + {renderItems(item.children)} + + ) : ( + + )} + + ); + }); + }, + [defaultIsCollapsed] + ); + + return <>{renderItems([nodeDefinition], true)}; +}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_footer.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_footer.tsx new file mode 100644 index 0000000000000..d4b78c1e93053 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_footer.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useEffect } from 'react'; +import { useNavigation } from './navigation'; + +export interface Props { + children?: React.ReactNode; +} + +function NavigationFooterComp({ children }: Props) { + const { updateFooterChildren } = useNavigation(); + + useEffect(() => { + if (children) { + updateFooterChildren(children); + } + }, [children, updateFooterChildren]); + + return null; +} + +export const NavigationFooter = React.memo(NavigationFooterComp); diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_group.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_group.tsx new file mode 100644 index 0000000000000..aa896f6bf45e5 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_group.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { createContext, useCallback, useMemo, useContext } from 'react'; + +import { useInitNavNode } from '../hooks'; +import type { NodeProps, RegisterFunction } from '../types'; +import { NavigationSectionUI } from './navigation_section_ui'; +import { useNavigation } from './navigation'; +import { NavigationBucket, Props as NavigationBucketProps } from './navigation_bucket'; + +interface Context { + register: RegisterFunction; +} + +export const NavigationGroupContext = createContext(undefined); + +export function useNavigationGroup( + throwIfNotFound: T = true as T +): T extends true ? Context : Context | undefined { + const context = useContext(NavigationGroupContext); + if (!context && throwIfNotFound) { + throw new Error('useNavigationGroup must be used within a NavigationGroup provider'); + } + return context as T extends true ? Context : Context | undefined; +} + +export interface Props extends NodeProps { + unstyled?: boolean; + defaultIsCollapsed?: boolean; +} + +function NavigationGroupInternalComp(props: Props) { + const navigationContext = useNavigation(); + const { children, defaultIsCollapsed, ...node } = props; + const { navNode, registerChildNode, path, childrenNodes } = useInitNavNode(node); + + const unstyled = props.unstyled ?? navigationContext.unstyled; + + const renderContent = useCallback(() => { + if (!path || !navNode) { + return null; + } + + if (unstyled) { + // No UI for unstyled groups + return children; + } + + // Each "top level" group is rendered using the EuiCollapsibleNavGroup component + // inside the NavigationSectionUI. That's how we get the "collapsible" behavior. + const isTopLevel = path && path.length === 1; + + return ( + <> + {isTopLevel && ( + + )} + {/* We render the children so they mount and can register themselves but + visually they don't appear here in the DOM. They are rendered inside the + "items" prop (see ) */} + {children} + + ); + }, [navNode, path, childrenNodes, children, defaultIsCollapsed, unstyled]); + + const contextValue = useMemo(() => { + return { + register: registerChildNode, + }; + }, [registerChildNode]); + + if (!navNode) { + return null; + } + + return ( + + {renderContent()} + + ); +} + +function NavigationGroupComp(props: Props & NavigationBucketProps) { + if (props.preset) { + const { id, title, link, icon, children, ...rest } = props; + return ; + } + + const { preset, nodeDefinition, ...rest } = props; + return ; +} + +export const NavigationGroup = React.memo(NavigationGroupComp) as typeof NavigationGroupComp; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_header.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_header.tsx new file mode 100644 index 0000000000000..7895ca62e7a4b --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_header.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiHeaderLogo, EuiLoadingSpinner } from '@elastic/eui'; +import useObservable from 'react-use/lib/useObservable'; +import { useNavigation as useServices } from '../../services'; +import { ElasticMark } from '../elastic_mark'; +import { getI18nStrings } from '../i18n_strings'; + +import '../header_logo.scss'; + +interface Props { + homeHref: string; +} + +export const NavHeader: FC = ({ homeHref }) => { + const strings = getI18nStrings(); + const { basePath, navigateToUrl, loadingCount$ } = useServices(); + const loadingCount = useObservable(loadingCount$, 0); + const homeUrl = basePath.prepend(homeHref); + + const navigateHome = (event: React.MouseEvent) => { + event.preventDefault(); + navigateToUrl(homeUrl); + }; + + const logo = + loadingCount === 0 ? ( + + ) : ( + + + + ); + + return ( + + {logo} + + + + + ); +}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_item.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_item.tsx new file mode 100644 index 0000000000000..44ffe44572d6e --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_item.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { Fragment, ReactElement, ReactNode, useEffect } from 'react'; + +import type { ChromeProjectNavigationNodeEnhanced, NodeProps } from '../types'; +import { useInitNavNode } from '../hooks'; +import { useNavigation } from './navigation'; + +export interface Props extends NodeProps { + element?: string; + unstyled?: boolean; +} + +function isReactElement(element: ReactNode): element is ReactElement { + return React.isValidElement(element); +} + +function NavigationItemComp(props: Props) { + const navigationContext = useNavigation(); + const navNodeRef = React.useRef(null); + + const { element, children, ...node } = props; + const unstyled = props.unstyled ?? navigationContext.unstyled; + + let renderItem: (() => ReactElement) | undefined; + + if (!unstyled && children && (typeof children === 'function' || isReactElement(children))) { + renderItem = + typeof children === 'function' ? () => children(navNodeRef.current) : () => children; + } + + const { navNode } = useInitNavNode({ ...node, children, renderItem }); + + useEffect(() => { + navNodeRef.current = navNode; + }, [navNode]); + + if (!navNode || !unstyled) { + return null; + } + + if (children) { + if (typeof children === 'function') { + return children(navNode); + } + return <>{children}; + } + + const Element = element || Fragment; + + return {navNode.title}; +} + +export const NavigationItem = React.memo(NavigationItemComp); diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_section_ui.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_section_ui.tsx new file mode 100644 index 0000000000000..a592e6cc539b2 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_section_ui.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC } from 'react'; +import { + EuiCollapsibleNavGroup, + EuiIcon, + EuiSideNav, + EuiSideNavItemType, + EuiText, +} from '@elastic/eui'; +import type { BasePathService, NavigateToUrlFn } from '../../../types/internal'; +import { navigationStyles as styles } from '../../styles'; +import { useNavigation as useServices } from '../../services'; +import { ChromeProjectNavigationNodeEnhanced } from '../types'; + +const navigationNodeToEuiItem = ( + item: ChromeProjectNavigationNodeEnhanced, + { navigateToUrl, basePath }: { navigateToUrl: NavigateToUrlFn; basePath: BasePathService } +): EuiSideNavItemType => { + const href = item.deepLink?.href ?? item.href; + const id = item.path ? item.path.join('.') : item.id; + + return { + id, + name: item.title, + onClick: + href !== undefined + ? (event: React.MouseEvent) => { + event.preventDefault(); + navigateToUrl(basePath.prepend(href!)); + } + : undefined, + href, + renderItem: item.renderItem, + items: item.children?.map((_item) => + navigationNodeToEuiItem(_item, { navigateToUrl, basePath }) + ), + ['data-test-subj']: `nav-item-${id}`, + ...(item.icon && { + icon: , + }), + }; +}; + +interface Props { + navNode: ChromeProjectNavigationNodeEnhanced; + items?: ChromeProjectNavigationNodeEnhanced[]; + defaultIsCollapsed?: boolean; +} + +export const NavigationSectionUI: FC = ({ + navNode, + items = [], + defaultIsCollapsed = true, +}) => { + const { id, title, icon } = navNode; + const { navigateToUrl, basePath } = useServices(); + + return ( + + + navigationNodeToEuiItem(item, { navigateToUrl, basePath }))} + css={styles.euiSideNavItems} + /> + + + ); +}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_ui.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_ui.tsx new file mode 100644 index 0000000000000..a0f48aef8a112 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_ui.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiCollapsibleNavGroup, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; +import React, { FC } from 'react'; +import { NavHeader } from './navigation_header'; + +interface Props { + homeRef: string; + unstyled?: boolean; + footerChildren?: React.ReactNode; + dataTestSubj?: string; +} + +export const NavigationUI: FC = ({ + children, + unstyled, + footerChildren, + homeRef, + dataTestSubj, +}) => { + const { euiTheme } = useEuiTheme(); + + return ( + <> + + + + + {unstyled ? ( + <>{children} + ) : ( + + {children} + + {footerChildren && {footerChildren}} + + )} + + ); +}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/recently_accessed.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/recently_accessed.tsx new file mode 100644 index 0000000000000..b6bbff8904ed4 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/components/recently_accessed.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiCollapsibleNavGroup, EuiSideNav, EuiSideNavItemType } from '@elastic/eui'; +import React, { FC } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import type { Observable } from 'rxjs'; + +import { RecentItem } from '../../../types/internal'; +import { useNavigation as useServices } from '../../services'; +import { navigationStyles as styles } from '../../styles'; + +import { getI18nStrings } from '../i18n_strings'; + +export interface Props { + recentlyAccessed$?: Observable; + /** + * If true, the recently accessed list will be collapsed by default. + * @default false + */ + defaultIsCollapsed?: boolean; +} + +export const RecentlyAccessed: FC = ({ + recentlyAccessed$: recentlyAccessedProp$, + defaultIsCollapsed = false, +}) => { + const strings = getI18nStrings(); + const { recentlyAccessed$ } = useServices(); + const recentlyAccessed = useObservable(recentlyAccessedProp$ ?? recentlyAccessed$, []); + + if (recentlyAccessed.length === 0) { + return null; + } + + const navItems: Array> = [ + { + name: '', // no list header title + id: 'recents_root', + items: recentlyAccessed.map(({ id, label, link }) => ({ + id, + name: label, + href: link, + })), + }, + ]; + + return ( + + + + ); +}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/default_navigation.test.helpers.ts b/packages/shared-ux/chrome/navigation/src/ui/default_navigation.test.helpers.ts new file mode 100644 index 0000000000000..35d0cd1728a80 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/default_navigation.test.helpers.ts @@ -0,0 +1,496 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * This is the default navigation tree that is added to a project + * when only a project navigation tree is provided. + * NOTE: This will have to be updated once we add the deep link ids as each of the node + * will contain the deep link information. + */ + +export const defaultAnalyticsNavGroup = { + id: 'sharedux:analytics', + title: 'Data exploration', + icon: 'stats', + path: ['sharedux:analytics'], + children: [ + { + id: 'root', + path: ['sharedux:analytics', 'root'], + title: '', + children: [ + { + title: 'Discover', + id: 'discover', + href: '/app/discover', + path: ['sharedux:analytics', 'root', 'discover'], + }, + { + title: 'Dashboard', + id: 'dashboard', + href: '/app/dashboards', + path: ['sharedux:analytics', 'root', 'dashboard'], + }, + { + id: 'visualize_library', + title: 'Visualize Library', + href: '/app/visualize', + path: ['sharedux:analytics', 'root', 'visualize_library'], + }, + ], + }, + ], +}; + +export const defaultMlNavGroup = { + id: 'sharedux:ml', + title: 'Machine learning', + icon: 'indexMapping', + path: ['sharedux:ml'], + children: [ + { + title: '', + id: 'root', + path: ['sharedux:ml', 'root'], + children: [ + { + id: 'overview', + title: 'Overview', + href: '/app/ml/overview', + path: ['sharedux:ml', 'root', 'overview'], + }, + { + id: 'notifications', + title: 'Notifications', + href: '/app/ml/notifications', + path: ['sharedux:ml', 'root', 'notifications'], + }, + ], + }, + { + title: 'Anomaly detection', + id: 'anomaly_detection', + path: ['sharedux:ml', 'anomaly_detection'], + children: [ + { + id: 'jobs', + title: 'Jobs', + href: '/app/ml/jobs', + path: ['sharedux:ml', 'anomaly_detection', 'jobs'], + }, + { + id: 'explorer', + title: 'Anomaly explorer', + href: '/app/ml/explorer', + path: ['sharedux:ml', 'anomaly_detection', 'explorer'], + }, + { + id: 'single_metric_viewer', + title: 'Single metric viewer', + href: '/app/ml/timeseriesexplorer', + path: ['sharedux:ml', 'anomaly_detection', 'single_metric_viewer'], + }, + { + id: 'settings', + title: 'Settings', + href: '/app/ml/settings', + path: ['sharedux:ml', 'anomaly_detection', 'settings'], + }, + ], + }, + { + id: 'data_frame_analytics', + title: 'Data frame analytics', + path: ['sharedux:ml', 'data_frame_analytics'], + children: [ + { + id: 'jobs', + title: 'Jobs', + href: '/app/ml/data_frame_analytics', + path: ['sharedux:ml', 'data_frame_analytics', 'jobs'], + }, + { + id: 'results_explorer', + title: 'Results explorer', + href: '/app/ml/data_frame_analytics/exploration', + path: ['sharedux:ml', 'data_frame_analytics', 'results_explorer'], + }, + { + id: 'analytics_map', + title: 'Analytics map', + href: '/app/ml/data_frame_analytics/map', + path: ['sharedux:ml', 'data_frame_analytics', 'analytics_map'], + }, + ], + }, + { + id: 'model_management', + title: 'Model management', + path: ['sharedux:ml', 'model_management'], + children: [ + { + id: 'trained_models', + title: 'Trained models', + href: '/app/ml/trained_models', + path: ['sharedux:ml', 'model_management', 'trained_models'], + }, + { + id: 'nodes', + title: 'Nodes', + href: '/app/ml/nodes', + path: ['sharedux:ml', 'model_management', 'nodes'], + }, + ], + }, + { + id: 'data_visualizer', + title: 'Data visualizer', + path: ['sharedux:ml', 'data_visualizer'], + children: [ + { + id: 'file', + title: 'File', + href: '/app/ml/filedatavisualizer', + path: ['sharedux:ml', 'data_visualizer', 'file'], + }, + { + id: 'data_view', + title: 'Data view', + href: '/app/ml/datavisualizer_index_select', + path: ['sharedux:ml', 'data_visualizer', 'data_view'], + }, + ], + }, + { + id: 'aiops_labs', + title: 'AIOps labs', + path: ['sharedux:ml', 'aiops_labs'], + children: [ + { + id: 'explain_log_rate_spikes', + title: 'Explain log rate spikes', + href: '/app/ml/aiops/explain_log_rate_spikes_index_select', + path: ['sharedux:ml', 'aiops_labs', 'explain_log_rate_spikes'], + }, + { + id: 'log_pattern_analysis', + title: 'Log pattern analysis', + href: '/app/ml/aiops/log_categorization_index_select', + path: ['sharedux:ml', 'aiops_labs', 'log_pattern_analysis'], + }, + ], + }, + ], +}; + +export const defaultDevtoolsNavGroup = { + title: 'Developer tools', + id: 'sharedux:devtools', + icon: 'editorCodeBlock', + path: ['sharedux:devtools'], + children: [ + { + id: 'root', + path: ['sharedux:devtools', 'root'], + title: '', + children: [ + { + id: 'console', + title: 'Console', + href: '/app/dev_tools#/console', + path: ['sharedux:devtools', 'root', 'console'], + }, + { + id: 'search_profiler', + title: 'Search profiler', + href: '/app/dev_tools#/searchprofiler', + path: ['sharedux:devtools', 'root', 'search_profiler'], + }, + { + id: 'grok_debugger', + title: 'Grok debugger', + href: '/app/dev_tools#/grokdebugger', + path: ['sharedux:devtools', 'root', 'grok_debugger'], + }, + { + id: 'painless_lab', + title: 'Painless lab', + href: '/app/dev_tools#/painless_lab', + path: ['sharedux:devtools', 'root', 'painless_lab'], + }, + ], + }, + ], +}; + +export const defaultManagementNavGroup = { + id: 'sharedux:management', + title: 'Management', + icon: 'gear', + path: ['sharedux:management'], + children: [ + { + id: 'root', + title: '', + path: ['sharedux:management', 'root'], + children: [ + { + id: 'stack_monitoring', + title: 'Stack monitoring', + href: '/app/monitoring', + path: ['sharedux:management', 'root', 'stack_monitoring'], + }, + ], + }, + { + id: 'integration_management', + title: 'Integration management', + path: ['sharedux:management', 'integration_management'], + children: [ + { + id: 'integrations', + title: 'Integrations', + href: '/app/integrations', + path: ['sharedux:management', 'integration_management', 'integrations'], + }, + { + id: 'fleet', + title: 'Fleet', + href: '/app/fleet', + path: ['sharedux:management', 'integration_management', 'fleet'], + }, + { + id: 'osquery', + title: 'Osquery', + href: '/app/osquery', + path: ['sharedux:management', 'integration_management', 'osquery'], + }, + ], + }, + { + id: 'stack_management', + title: 'Stack management', + path: ['sharedux:management', 'stack_management'], + children: [ + { + id: 'upgrade_assistant', + title: 'Upgrade assistant', + href: '/app/management/stack/upgrade_assistant', + path: ['sharedux:management', 'stack_management', 'upgrade_assistant'], + }, + { + id: 'ingest', + title: 'Ingest', + path: ['sharedux:management', 'stack_management', 'ingest'], + children: [ + { + id: 'ingest_pipelines', + title: 'Ingest pipelines', + href: '/app/management/ingest/ingest_pipelines', + path: ['sharedux:management', 'stack_management', 'ingest', 'ingest_pipelines'], + }, + { + id: 'logstash_pipelines', + title: 'Logstash pipelines', + href: '/app/management/ingest/pipelines', + path: ['sharedux:management', 'stack_management', 'ingest', 'logstash_pipelines'], + }, + ], + }, + { + id: 'data', + title: 'Data', + path: ['sharedux:management', 'stack_management', 'data'], + children: [ + { + id: 'index_management', + title: 'Index management', + href: '/app/management/data/index_management', + path: ['sharedux:management', 'stack_management', 'data', 'index_management'], + }, + { + id: 'index_lifecycle_policies', + title: 'Index lifecycle policies', + href: '/app/management/data/index_lifecycle_management', + path: ['sharedux:management', 'stack_management', 'data', 'index_lifecycle_policies'], + }, + { + id: 'snapshot_and_restore', + title: 'Snapshot and restore', + href: 'app/management/data/snapshot_restore', + path: ['sharedux:management', 'stack_management', 'data', 'snapshot_and_restore'], + }, + { + id: 'rollup_jobs', + title: 'Rollup jobs', + href: '/app/management/data/rollup_jobs', + path: ['sharedux:management', 'stack_management', 'data', 'rollup_jobs'], + }, + { + id: 'transforms', + title: 'Transforms', + href: '/app/management/data/transform', + path: ['sharedux:management', 'stack_management', 'data', 'transforms'], + }, + { + id: 'cross_cluster_replication', + title: 'Cross-cluster replication', + href: '/app/management/data/cross_cluster_replication', + path: [ + 'sharedux:management', + 'stack_management', + 'data', + 'cross_cluster_replication', + ], + }, + { + id: 'remote_clusters', + title: 'Remote clusters', + href: '/app/management/data/remote_clusters', + path: ['sharedux:management', 'stack_management', 'data', 'remote_clusters'], + }, + ], + }, + { + id: 'alerts_and_insights', + title: 'Alerts and insights', + path: ['sharedux:management', 'stack_management', 'alerts_and_insights'], + children: [ + { + id: 'rules', + title: 'Rules', + href: '/app/management/insightsAndAlerting/triggersActions/rules', + path: ['sharedux:management', 'stack_management', 'alerts_and_insights', 'rules'], + }, + { + id: 'cases', + title: 'Cases', + href: '/app/management/insightsAndAlerting/cases', + path: ['sharedux:management', 'stack_management', 'alerts_and_insights', 'cases'], + }, + { + id: 'connectors', + title: 'Connectors', + href: '/app/management/insightsAndAlerting/triggersActionsConnectors/connectors', + path: [ + 'sharedux:management', + 'stack_management', + 'alerts_and_insights', + 'connectors', + ], + }, + { + id: 'reporting', + title: 'Reporting', + href: '/app/management/insightsAndAlerting/reporting', + path: ['sharedux:management', 'stack_management', 'alerts_and_insights', 'reporting'], + }, + { + id: 'machine_learning', + title: 'Machine learning', + href: '/app/management/insightsAndAlerting/jobsListLink', + path: [ + 'sharedux:management', + 'stack_management', + 'alerts_and_insights', + 'machine_learning', + ], + }, + { + id: 'watcher', + title: 'Watcher', + href: '/app/management/insightsAndAlerting/watcher', + path: ['sharedux:management', 'stack_management', 'alerts_and_insights', 'watcher'], + }, + ], + }, + { + id: 'security', + title: 'Security', + path: ['sharedux:management', 'stack_management', 'security'], + children: [ + { + id: 'users', + title: 'Users', + href: '/app/management/security/users', + path: ['sharedux:management', 'stack_management', 'security', 'users'], + }, + { + id: 'roles', + title: 'Roles', + href: '/app/management/security/roles', + path: ['sharedux:management', 'stack_management', 'security', 'roles'], + }, + { + id: 'role_mappings', + title: 'Role mappings', + href: '/app/management/security/role_mappings', + path: ['sharedux:management', 'stack_management', 'security', 'role_mappings'], + }, + { + id: 'api_keys', + title: 'API keys', + href: '/app/management/security/api_keys', + path: ['sharedux:management', 'stack_management', 'security', 'api_keys'], + }, + ], + }, + { + id: 'kibana', + title: 'Kibana', + path: ['sharedux:management', 'stack_management', 'kibana'], + children: [ + { + id: 'data_views', + title: 'Data view', + href: '/app/management/kibana/dataViews', + path: ['sharedux:management', 'stack_management', 'kibana', 'data_views'], + }, + { + id: 'saved_objects', + title: 'Saved objects', + href: '/app/management/kibana/objects', + path: ['sharedux:management', 'stack_management', 'kibana', 'saved_objects'], + }, + { + id: 'tags', + title: 'Tags', + href: '/app/management/kibana/tags', + path: ['sharedux:management', 'stack_management', 'kibana', 'tags'], + }, + { + id: 'search_sessions', + title: 'Search sessions', + href: '/app/management/kibana/search_sessions', + path: ['sharedux:management', 'stack_management', 'kibana', 'search_sessions'], + }, + { + id: 'spaces', + title: 'Spaces', + href: '/app/management/kibana/spaces', + path: ['sharedux:management', 'stack_management', 'kibana', 'spaces'], + }, + { + id: 'advanced_settings', + title: 'Advanced settings', + href: '/app/management/kibana/settings', + path: ['sharedux:management', 'stack_management', 'kibana', 'advanced_settings'], + }, + ], + }, + ], + }, + ], +}; + +export const defaultNavigationTree = [ + defaultAnalyticsNavGroup, + defaultMlNavGroup, + defaultDevtoolsNavGroup, + defaultManagementNavGroup, +]; diff --git a/packages/shared-ux/chrome/navigation/src/ui/default_navigation.test.tsx b/packages/shared-ux/chrome/navigation/src/ui/default_navigation.test.tsx new file mode 100644 index 0000000000000..3820dcd5dd5c9 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/default_navigation.test.tsx @@ -0,0 +1,433 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { type Observable, of } from 'rxjs'; +import type { ChromeNavLink } from '@kbn/core-chrome-browser'; + +import { getServicesMock } from '../../mocks/src/jest'; +import { NavigationProvider } from '../services'; +import { DefaultNavigation } from './default_navigation'; +import type { ProjectNavigationTreeDefinition, RootNavigationItemDefinition } from './types'; +import { + defaultAnalyticsNavGroup, + defaultDevtoolsNavGroup, + defaultManagementNavGroup, + defaultMlNavGroup, +} from './default_navigation.test.helpers'; + +const defaultProps = { + homeRef: 'https://elastic.co', +}; + +describe('', () => { + const services = getServicesMock(); + + describe('builds custom navigation tree', () => { + test('render reference UI and build the navigation tree', async () => { + const onProjectNavigationChange = jest.fn(); + + const navigationBody: RootNavigationItemDefinition[] = [ + { + type: 'navGroup', + id: 'group1', + children: [ + { + id: 'item1', + title: 'Item 1', + }, + { + id: 'item2', + title: 'Item 2', + }, + { + id: 'group1A', + title: 'Group1A', + children: [ + { + id: 'item1', + title: 'Group 1A Item 1', + }, + { + id: 'group1A_1', + title: 'Group1A_1', + children: [ + { + id: 'item1', + title: 'Group 1A_1 Item 1', + }, + ], + }, + ], + }, + ], + }, + ]; + + const { findByTestId } = render( + + + + ); + + expect(await findByTestId('nav-item-group1.item1')).toBeVisible(); + expect(await findByTestId('nav-item-group1.item2')).toBeVisible(); + expect(await findByTestId('nav-item-group1.group1A')).toBeVisible(); + expect(await findByTestId('nav-item-group1.group1A.item1')).toBeVisible(); + expect(await findByTestId('nav-item-group1.group1A.group1A_1')).toBeVisible(); + + // Click the last group to expand and show the last depth + (await findByTestId('nav-item-group1.group1A.group1A_1')).click(); + + expect(await findByTestId('nav-item-group1.group1A.group1A_1.item1')).toBeVisible(); + + expect(onProjectNavigationChange).toHaveBeenCalled(); + const lastCall = + onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; + const [navTreeGenerated] = lastCall; + + expect(navTreeGenerated).toEqual({ + homeRef: 'https://elastic.co', + navigationTree: [ + { + id: 'group1', + path: ['group1'], + title: '', + children: [ + { + id: 'item1', + title: 'Item 1', + path: ['group1', 'item1'], + }, + { + id: 'item2', + title: 'Item 2', + path: ['group1', 'item2'], + }, + { + id: 'group1A', + title: 'Group1A', + path: ['group1', 'group1A'], + children: [ + { + id: 'item1', + title: 'Group 1A Item 1', + path: ['group1', 'group1A', 'item1'], + }, + { + id: 'group1A_1', + title: 'Group1A_1', + path: ['group1', 'group1A', 'group1A_1'], + children: [ + { + id: 'item1', + title: 'Group 1A_1 Item 1', + path: ['group1', 'group1A', 'group1A_1', 'item1'], + }, + ], + }, + ], + }, + ], + }, + ], + }); + }); + + test('should read the title from deeplink', async () => { + const navLinks$: Observable = of([ + { + id: 'item1', + title: 'Title from deeplink', + baseUrl: '', + url: '', + href: '', + }, + ]); + + const onProjectNavigationChange = jest.fn(); + + const navigationBody: RootNavigationItemDefinition[] = [ + { + type: 'navGroup', + id: 'root', + children: [ + { + id: 'group1', + children: [ + { + id: 'item1', + link: 'item1', // Title from deeplink + }, + { + id: 'item2', + link: 'item1', // Overwrite title from deeplink + title: 'Overwrite deeplink title', + }, + { + id: 'item3', + link: 'unknown', // Unknown deeplink + title: 'Should not be rendered', + }, + ], + }, + ], + }, + ]; + + render( + + + + ); + + expect(onProjectNavigationChange).toHaveBeenCalled(); + const lastCall = + onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; + const [navTreeGenerated] = lastCall; + + expect(navTreeGenerated).toEqual({ + homeRef: 'https://elastic.co', + navigationTree: [ + { + id: 'root', + path: ['root'], + title: '', + children: [ + { + id: 'group1', + path: ['root', 'group1'], + title: '', + children: [ + { + id: 'item1', + path: ['root', 'group1', 'item1'], + title: 'Title from deeplink', + deepLink: { + id: 'item1', + title: 'Title from deeplink', + baseUrl: '', + url: '', + href: '', + }, + }, + { + id: 'item2', + title: 'Overwrite deeplink title', + path: ['root', 'group1', 'item2'], + deepLink: { + id: 'item1', + title: 'Title from deeplink', + baseUrl: '', + url: '', + href: '', + }, + }, + ], + }, + ], + }, + ], + }); + }); + + test('should render cloud link', async () => { + const navigationBody: RootNavigationItemDefinition[] = [ + { + type: 'cloudLink', + preset: 'deployments', + }, + { + type: 'cloudLink', + preset: 'projects', + }, + { + type: 'cloudLink', + href: 'https://foo.com', + icon: 'myIcon', + title: 'Custom link', + }, + ]; + + const { findByTestId } = render( + + + + ); + + expect(await findByTestId('nav-header-link-to-projects')).toBeVisible(); + expect(await findByTestId('nav-header-link-to-deployments')).toBeVisible(); + expect(await findByTestId('nav-header-link-to-cloud')).toBeVisible(); + expect(await (await findByTestId('nav-header-link-to-cloud')).textContent).toBe( + 'Custom link' + ); + }); + + test('should render recently accessed items', async () => { + const recentlyAccessed$ = of([ + { label: 'This is an example', link: '/app/example/39859', id: '39850' }, + { label: 'Another example', link: '/app/example/5235', id: '5235' }, + ]); + + const navigationBody: RootNavigationItemDefinition[] = [ + { + type: 'recentlyAccessed', + }, + ]; + + const { findByTestId } = render( + + + + ); + + expect(await findByTestId('nav-bucket-recentlyAccessed')).toBeVisible(); + expect(await (await findByTestId('nav-bucket-recentlyAccessed')).textContent).toBe( + 'RecentThis is an exampleAnother example' + ); + }); + }); + + describe('builds the full navigation tree when only custom project is provided', () => { + test('reading the title from config or deeplink', async () => { + const navLinks$: Observable = of([ + { + id: 'item2', + title: 'Title from deeplink!', + baseUrl: '', + url: '', + href: '', + }, + ]); + + const onProjectNavigationChange = jest.fn(); + + // Custom project navigation tree definition + const projectNavigationTree: ProjectNavigationTreeDefinition = [ + { + id: 'group1', + title: 'Group 1', + children: [ + { + id: 'item1', + title: 'Item 1', + }, + { + id: 'item2', + link: 'item2', // Title from deeplink + }, + { + id: 'item3', + link: 'item2', + title: 'Deeplink title overriden', // Override title from deeplink + }, + { + link: 'disabled', + title: 'Should NOT be there', + }, + ], + }, + ]; + + render( + + + + ); + + expect(onProjectNavigationChange).toHaveBeenCalled(); + const lastCall = + onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; + const [navTreeGenerated] = lastCall; + + expect(navTreeGenerated).toEqual({ + homeRef: 'https://elastic.co', + navigationTree: expect.any(Array), + }); + + // The project navigation tree passed + expect(navTreeGenerated.navigationTree[0]).toEqual({ + id: 'group1', + title: 'Group 1', + path: ['group1'], + children: [ + { + id: 'item1', + title: 'Item 1', + path: ['group1', 'item1'], + }, + { + id: 'item2', + path: ['group1', 'item2'], + title: 'Title from deeplink!', + deepLink: { + id: 'item2', + title: 'Title from deeplink!', + baseUrl: '', + url: '', + href: '', + }, + }, + { + id: 'item3', + title: 'Deeplink title overriden', + path: ['group1', 'item3'], + deepLink: { + id: 'item2', + title: 'Title from deeplink!', + baseUrl: '', + url: '', + href: '', + }, + }, + ], + }); + + // The default navigation tree for analytics + expect(navTreeGenerated.navigationTree[1]).toEqual(defaultAnalyticsNavGroup); + + // The default navigation tree for ml + expect(navTreeGenerated.navigationTree[2]).toEqual(defaultMlNavGroup); + + // The default navigation tree for devtools+ + expect(navTreeGenerated.navigationTree[3]).toEqual(defaultDevtoolsNavGroup); + + // The default navigation tree for management + expect(navTreeGenerated.navigationTree[4]).toEqual(defaultManagementNavGroup); + }); + }); +}); diff --git a/packages/shared-ux/chrome/navigation/src/ui/default_navigation.tsx b/packages/shared-ux/chrome/navigation/src/ui/default_navigation.tsx new file mode 100644 index 0000000000000..ec2b921e01e82 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/default_navigation.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC, useCallback } from 'react'; + +import { Navigation } from './components'; +import type { + GroupDefinition, + NavigationGroupPreset, + NavigationTreeDefinition, + NodeDefinition, + ProjectNavigationDefinition, + ProjectNavigationTreeDefinition, + RootNavigationItemDefinition, +} from './types'; +import { CloudLink } from './components/cloud_link'; +import { RecentlyAccessed } from './components/recently_accessed'; +import { NavigationFooter } from './components/navigation_footer'; +import { getPresets } from './nav_tree_presets'; + +type NodeDefinitionWithPreset = NodeDefinition & { preset?: NavigationGroupPreset }; + +const isRootNavigationItemDefinition = ( + item: RootNavigationItemDefinition | NodeDefinitionWithPreset +): item is RootNavigationItemDefinition => { + // Only RootNavigationItemDefinition has a "type" property + return (item as RootNavigationItemDefinition).type !== undefined; +}; + +const getDefaultNavigationTree = ( + projectDefinition: ProjectNavigationTreeDefinition +): NavigationTreeDefinition => { + return { + body: [ + { + type: 'cloudLink', + preset: 'deployments', + }, + { + type: 'recentlyAccessed', + }, + ...projectDefinition.map((def) => ({ ...def, type: 'navGroup' as const })), + { + type: 'navGroup', + ...getPresets('analytics'), + }, + { + type: 'navGroup', + ...getPresets('ml'), + }, + ], + footer: [ + { + type: 'navGroup', + ...getPresets('devtools'), + }, + { + type: 'navGroup', + ...getPresets('management'), + }, + ], + }; +}; + +let idCounter = 0; + +export const DefaultNavigation: FC = ({ + homeRef, + projectNavigationTree, + navigationTree, + dataTestSubj, +}) => { + if (!navigationTree && !projectNavigationTree) { + throw new Error('One of navigationTree or projectNavigationTree must be defined'); + } + + const navigationDefinition = !navigationTree + ? getDefaultNavigationTree(projectNavigationTree!) + : navigationTree!; + + const renderItems = useCallback( + ( + items: Array = [], + path: string[] = [] + ) => { + return items.map((item) => { + const isRootNavigationItem = isRootNavigationItemDefinition(item); + if (isRootNavigationItem) { + if (item.type === 'cloudLink') { + return ; + } + + if (item.type === 'recentlyAccessed') { + return ; + } + } + + if (item.preset) { + return ; + } + + const id = item.id ?? item.link; + + if (!id) { + throw new Error( + `At least one of id or link must be defined for navigation item ${item.title}` + ); + } + + const { type, ...rest } = item as GroupDefinition; + + return ( + + {rest.children ? ( + + {renderItems(rest.children, [...path, id])} + + ) : ( + + )} + + ); + }); + }, + [] + ); + + return ( + + <> + {renderItems(navigationDefinition.body)} + {navigationDefinition.footer && ( + {renderItems(navigationDefinition.footer)} + )} + + + ); +}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/v2/components/index.ts b/packages/shared-ux/chrome/navigation/src/ui/hooks/index.ts similarity index 87% rename from packages/shared-ux/chrome/navigation/src/ui/v2/components/index.ts rename to packages/shared-ux/chrome/navigation/src/ui/hooks/index.ts index 50ab11e2263b7..631ad5f590ce4 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/v2/components/index.ts +++ b/packages/shared-ux/chrome/navigation/src/ui/hooks/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { Navigation } from './navigation'; +export { useInitNavNode } from './use_init_navnode'; diff --git a/packages/shared-ux/chrome/navigation/src/ui/hooks/use_init_navnode.ts b/packages/shared-ux/chrome/navigation/src/ui/hooks/use_init_navnode.ts new file mode 100644 index 0000000000000..1ee9972f58a12 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/hooks/use_init_navnode.ts @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ChromeNavLink, ChromeProjectNavigationNode } from '@kbn/core-chrome-browser'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import useObservable from 'react-use/lib/useObservable'; + +import { useNavigation as useNavigationServices } from '../../services'; +import { + ChromeProjectNavigationNodeEnhanced, + NodeProps, + NodePropsEnhanced, + RegisterFunction, + UnRegisterFunction, +} from '../types'; +import { useRegisterTreeNode } from './use_register_tree_node'; + +function getIdFromNavigationNode({ id: _id, link, title }: NodeProps): string { + const id = _id ?? link; + + if (!id) { + throw new Error(`Id or link prop missing for navigation item [${title}]`); + } + + return id; +} + +function isNodeVisible({ link, deepLink }: { link?: string; deepLink?: ChromeNavLink }) { + if (link && !deepLink) { + // If a link is provided, but no deepLink is found, don't render anything + return false; + } + return true; +} + +function createInternalNavNode( + id: string, + _navNode: NodePropsEnhanced, + deepLinks: Readonly, + path: string[] | null +): ChromeProjectNavigationNodeEnhanced | null { + const { children, link, ...navNode } = _navNode; + const deepLink = deepLinks.find((dl) => dl.id === link); + const isVisible = isNodeVisible({ link, deepLink }); + + const titleFromDeepLinkOrChildren = typeof children === 'string' ? children : deepLink?.title; + const title = navNode.title ?? titleFromDeepLinkOrChildren; + + if (!isVisible) { + return null; + } + + return { + ...navNode, + id, + path: path ?? [id], + title: title ?? '', + deepLink, + }; +} + +export const useInitNavNode = (node: NodePropsEnhanced) => { + /** + * Map of children nodes + */ + const [childrenNodes, setChildrenNodes] = useState< + Record + >({}); + + const isMounted = useRef(false); + + /** + * Flag to indicate if the current node has been registered + */ + const isRegistered = useRef(false); + + /** + * Reference to the unregister function + */ + const unregisterRef = useRef(); + + /** + * Map to keep track of the order of the children when they mount. + * This allows us to keep in sync the nav tree sent to the Chrome service + * with the order of the DOM elements + */ + const orderChildrenRef = useRef>({}); + + /** + * Index to keep track of the order of the children when they mount. + */ + const idx = useRef(0); + + /** + * The current node path, including all of its parents. We'll use it to match it against + * the list of active routes based on current URL location (passed by the Chrome service) + */ + const [nodePath, setNodePath] = useState(null); + + /** + * Whenever a child node is registered, we need to re-register the current node + * on the parent. This state keeps track when child node register. + */ + const [childrenNodesUpdated, setChildrenNodesUpdated] = useState([]); + + const { navLinks$ } = useNavigationServices(); + const deepLinks = useObservable(navLinks$, []); + const { register: registerNodeOnParent } = useRegisterTreeNode(); + + const id = getIdFromNavigationNode(node); + + const internalNavNode = useMemo( + () => createInternalNavNode(id, node, deepLinks, nodePath), + [node, id, deepLinks, nodePath] + ); + + // Register the node on the parent whenever its properties change or whenever + // a child node is registered. + const register = useCallback(() => { + if (!internalNavNode) { + return; + } + + if (!isRegistered.current || childrenNodesUpdated.length > 0) { + const children = Object.values(childrenNodes).sort((a, b) => { + const aOrder = orderChildrenRef.current[a.id]; + const bOrder = orderChildrenRef.current[b.id]; + return aOrder - bOrder; + }); + + const { unregister, path } = registerNodeOnParent({ + ...internalNavNode, + children: children.length ? children : undefined, + }); + + setNodePath(path); + setChildrenNodesUpdated([]); + + unregisterRef.current = unregister; + isRegistered.current = true; + } + }, [internalNavNode, childrenNodesUpdated.length, childrenNodes, registerNodeOnParent]); + + // Un-register from the parent. This will happen when the node is unmounted or if the deeplink + // is not active anymore. + const unregister = useCallback(() => { + isRegistered.current = false; + if (unregisterRef.current) { + unregisterRef.current(id); + unregisterRef.current = undefined; + } + }, [id]); + + const registerChildNode = useCallback( + (childNode) => { + const childPath = nodePath ? [...nodePath, childNode.id] : []; + + setChildrenNodes((prev) => { + return { + ...prev, + [childNode.id]: { + ...childNode, + path: childPath, + }, + }; + }); + + orderChildrenRef.current[childNode.id] = idx.current++; + setChildrenNodesUpdated((prev) => [...prev, childNode.id]); + + return { + unregister: (childId: string) => { + setChildrenNodes((prev) => { + const updatedItems = { ...prev }; + delete updatedItems[childId]; + return updatedItems; + }); + }, + path: childPath, + }; + }, + [nodePath] + ); + + /** Register when mounting and whenever the internal nav node changes */ + useEffect(() => { + if (!isMounted.current) { + return; + } + + if (internalNavNode) { + register(); + } else { + unregister(); + } + }, [unregister, register, internalNavNode]); + + /** Unregister when unmounting */ + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + unregister(); + }; + }, [unregister]); + + return useMemo( + () => ({ + navNode: internalNavNode, + path: nodePath, + registerChildNode, + childrenNodes, + }), + [internalNavNode, registerChildNode, nodePath, childrenNodes] + ); +}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/v2/use_register_tree_node.ts b/packages/shared-ux/chrome/navigation/src/ui/hooks/use_register_tree_node.ts similarity index 75% rename from packages/shared-ux/chrome/navigation/src/ui/v2/use_register_tree_node.ts rename to packages/shared-ux/chrome/navigation/src/ui/hooks/use_register_tree_node.ts index 4e5cf26392c93..5ad690b2de2e1 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/v2/use_register_tree_node.ts +++ b/packages/shared-ux/chrome/navigation/src/ui/hooks/use_register_tree_node.ts @@ -8,8 +8,8 @@ import { useMemo } from 'react'; -import { useNavigation } from './components/navigation'; -import { useNavigationGroup } from './components/navigation_group'; +import { useNavigation } from '../components/navigation'; +import { useNavigationGroup } from '../components/navigation_group'; /** * Helper hook that will proxy the correct "register" handler. @@ -17,8 +17,8 @@ import { useNavigationGroup } from './components/navigation_group'; */ export const useRegisterTreeNode = () => { const root = useNavigation(); - const parent = useNavigationGroup(false); - const register = parent ? parent.register : root.register; + const group = useNavigationGroup(false); + const register = group ? group.register : root.register; return useMemo( () => ({ diff --git a/packages/shared-ux/chrome/navigation/src/ui/index.ts b/packages/shared-ux/chrome/navigation/src/ui/index.ts new file mode 100644 index 0000000000000..9091a261b1dd8 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { Navigation } from './components'; + +export { DefaultNavigation } from './default_navigation'; + +export { getPresets } from './nav_tree_presets'; + +export type { + NavigationTreeDefinition, + ProjectNavigationDefinition, + NodeDefinition, + NavigationGroupPreset, + GroupDefinition, + RecentlyAccessedDefinition, + CloudLinkDefinition, + RootNavigationItemDefinition, +} from './types'; diff --git a/packages/shared-ux/chrome/navigation/src/ui/nav_tree_presets/analytics.ts b/packages/shared-ux/chrome/navigation/src/ui/nav_tree_presets/analytics.ts new file mode 100644 index 0000000000000..6435fe6b0f55e --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/nav_tree_presets/analytics.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { NodeDefinitionWithChildren } from '.'; + +export type ID = 'sharedux:analytics' | 'root' | 'discover' | 'dashboard' | 'visualize_library'; + +export const analytics: NodeDefinitionWithChildren = { + // Make sure we have a unique id otherwise it might override a custom id from the project + id: 'sharedux:analytics', + title: 'Data exploration', + icon: 'stats', + children: [ + { + id: 'root', + children: [ + { + title: 'Discover', + id: 'discover', + href: '/app/discover', + }, + { + title: 'Dashboard', + id: 'dashboard', + href: '/app/dashboards', + }, + { + id: 'visualize_library', + title: 'Visualize Library', + href: '/app/visualize', + }, + ], + }, + ], +}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/nav_tree_presets/devtools.ts b/packages/shared-ux/chrome/navigation/src/ui/nav_tree_presets/devtools.ts new file mode 100644 index 0000000000000..03b2eff015433 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/nav_tree_presets/devtools.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { NodeDefinitionWithChildren } from '.'; + +export type ID = + | 'sharedux:devtools' + | 'root' + | 'console' + | 'search_profiler' + | 'grok_debugger' + | 'painless_lab'; + +export const devtools: NodeDefinitionWithChildren = { + title: 'Developer tools', + id: 'sharedux:devtools', + icon: 'editorCodeBlock', + children: [ + { + id: 'root', + children: [ + { + id: 'console', + title: 'Console', + href: '/app/dev_tools#/console', + }, + { + id: 'search_profiler', + title: 'Search profiler', + href: '/app/dev_tools#/searchprofiler', + }, + { + id: 'grok_debugger', + title: 'Grok debugger', + href: '/app/dev_tools#/grokdebugger', + }, + { + id: 'painless_lab', + title: 'Painless lab', + href: '/app/dev_tools#/painless_lab', + }, + ], + }, + ], +}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/nav_tree_presets/index.ts b/packages/shared-ux/chrome/navigation/src/ui/nav_tree_presets/index.ts new file mode 100644 index 0000000000000..0aefcf3f92aa7 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/nav_tree_presets/index.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { cloneDeep } from 'lodash'; +import { NavigationGroupPreset, NodeDefinition } from '../types'; +import { analytics, type ID as AnalyticsID } from './analytics'; +import { devtools, type ID as DevtoolsID } from './devtools'; +import { management, type ID as ManagementID } from './management'; +import { ml, type ID as MlID } from './ml'; + +export type NodeDefinitionWithChildren = NodeDefinition & { + children: Required>['children']; +}; + +export function getPresets(preset: 'devtools'): NodeDefinitionWithChildren; +export function getPresets(preset: 'management'): NodeDefinitionWithChildren; +export function getPresets(preset: 'ml'): NodeDefinitionWithChildren; +export function getPresets(preset: 'analytics'): NodeDefinitionWithChildren; +export function getPresets(preset: 'all'): { + analytics: NodeDefinitionWithChildren; + devtools: NodeDefinitionWithChildren; + ml: NodeDefinitionWithChildren; + management: NodeDefinitionWithChildren; +}; +export function getPresets(preset: NavigationGroupPreset | 'all'): + | NodeDefinitionWithChildren + | NodeDefinitionWithChildren + | NodeDefinitionWithChildren + | NodeDefinitionWithChildren + | { + analytics: NodeDefinitionWithChildren; + devtools: NodeDefinitionWithChildren; + ml: NodeDefinitionWithChildren; + management: NodeDefinitionWithChildren; + } { + if (preset === 'all') { + return { + analytics: cloneDeep(analytics), + devtools: cloneDeep(devtools), + ml: cloneDeep(ml), + management: cloneDeep(management), + }; + } + + switch (preset) { + case 'analytics': + return cloneDeep(analytics); + case 'devtools': + return cloneDeep(devtools); + case 'ml': + return cloneDeep(ml); + case 'management': + return cloneDeep(management); + default: + throw new Error(`Unknown preset: ${preset}`); + } +} diff --git a/packages/shared-ux/chrome/navigation/src/ui/nav_tree_presets/management.ts b/packages/shared-ux/chrome/navigation/src/ui/nav_tree_presets/management.ts new file mode 100644 index 0000000000000..db87f84ec37d2 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/nav_tree_presets/management.ts @@ -0,0 +1,256 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { NodeDefinitionWithChildren } from '.'; + +export type ID = + | 'sharedux:management' + | 'root' + | 'stack_monitoring' + | 'integration_management' + | 'integrations' + | 'fleet' + | 'osquery' + | 'stack_management' + | 'ingest' + | 'ingest_pipelines' + | 'logstash_pipelines' + | 'data' + | 'index_management' + | 'index_lifecycle_policies' + | 'snapshot_and_restore' + | 'rollup_jobs' + | 'transforms' + | 'cross_cluster_replication' + | 'remote_clusters' + | 'alerts_and_insights' + | 'rules' + | 'cases' + | 'connectors' + | 'reporting' + | 'machine_learning' + | 'watcher' + | 'security' + | 'users' + | 'roles' + | 'role_mappings' + | 'api_keys' + | 'kibana' + | 'data_views' + | 'saved_objects' + | 'tags' + | 'search_sessions' + | 'spaces' + | 'advanced_settings' + | 'upgrade_assistant'; + +export const management: NodeDefinitionWithChildren = { + id: 'sharedux:management', + title: 'Management', + icon: 'gear', + children: [ + { + id: 'root', + title: '', + children: [ + { + id: 'stack_monitoring', + title: 'Stack monitoring', + href: '/app/monitoring', + }, + ], + }, + { + id: 'integration_management', + title: 'Integration management', + children: [ + { + id: 'integrations', + title: 'Integrations', + href: '/app/integrations', + }, + { + id: 'fleet', + title: 'Fleet', + href: '/app/fleet', + }, + { + id: 'osquery', + title: 'Osquery', + href: '/app/osquery', + }, + ], + }, + { + id: 'stack_management', + title: 'Stack management', + children: [ + { + id: 'ingest', + title: 'Ingest', + children: [ + { + id: 'ingest_pipelines', + title: 'Ingest pipelines', + href: '/app/management/ingest/ingest_pipelines', + }, + { + id: 'logstash_pipelines', + title: 'Logstash pipelines', + href: '/app/management/ingest/pipelines', + }, + ], + }, + { + id: 'data', + title: 'Data', + children: [ + { + id: 'index_management', + title: 'Index management', + href: '/app/management/data/index_management', + }, + { + id: 'index_lifecycle_policies', + title: 'Index lifecycle policies', + href: '/app/management/data/index_lifecycle_management', + }, + { + id: 'snapshot_and_restore', + title: 'Snapshot and restore', + href: 'app/management/data/snapshot_restore', + }, + { + id: 'rollup_jobs', + title: 'Rollup jobs', + href: '/app/management/data/rollup_jobs', + }, + { + id: 'transforms', + title: 'Transforms', + href: '/app/management/data/transform', + }, + { + id: 'cross_cluster_replication', + title: 'Cross-cluster replication', + href: '/app/management/data/cross_cluster_replication', + }, + { + id: 'remote_clusters', + title: 'Remote clusters', + href: '/app/management/data/remote_clusters', + }, + ], + }, + { + id: 'alerts_and_insights', + title: 'Alerts and insights', + children: [ + { + id: 'rules', + title: 'Rules', + href: '/app/management/insightsAndAlerting/triggersActions/rules', + }, + { + id: 'cases', + title: 'Cases', + href: '/app/management/insightsAndAlerting/cases', + }, + { + id: 'connectors', + title: 'Connectors', + href: '/app/management/insightsAndAlerting/triggersActionsConnectors/connectors', + }, + { + id: 'reporting', + title: 'Reporting', + href: '/app/management/insightsAndAlerting/reporting', + }, + { + id: 'machine_learning', + title: 'Machine learning', + href: '/app/management/insightsAndAlerting/jobsListLink', + }, + { + id: 'watcher', + title: 'Watcher', + href: '/app/management/insightsAndAlerting/watcher', + }, + ], + }, + { + id: 'security', + title: 'Security', + children: [ + { + id: 'users', + title: 'Users', + href: '/app/management/security/users', + }, + { + id: 'roles', + title: 'Roles', + href: '/app/management/security/roles', + }, + { + id: 'role_mappings', + title: 'Role mappings', + href: '/app/management/security/role_mappings', + }, + { + id: 'api_keys', + title: 'API keys', + href: '/app/management/security/api_keys', + }, + ], + }, + { + id: 'kibana', + title: 'Kibana', + children: [ + { + id: 'data_views', + title: 'Data view', + href: '/app/management/kibana/dataViews', + }, + { + id: 'saved_objects', + title: 'Saved objects', + href: '/app/management/kibana/objects', + }, + { + id: 'tags', + title: 'Tags', + href: '/app/management/kibana/tags', + }, + { + id: 'search_sessions', + title: 'Search sessions', + href: '/app/management/kibana/search_sessions', + }, + { + id: 'spaces', + title: 'Spaces', + href: '/app/management/kibana/spaces', + }, + { + id: 'advanced_settings', + title: 'Advanced settings', + href: '/app/management/kibana/settings', + }, + ], + }, + { + id: 'upgrade_assistant', + title: 'Upgrade assistant', + href: '/app/management/stack/upgrade_assistant', + }, + ], + }, + ], +}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/nav_tree_presets/ml.ts b/packages/shared-ux/chrome/navigation/src/ui/nav_tree_presets/ml.ts new file mode 100644 index 0000000000000..ba3b7e0645d67 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/nav_tree_presets/ml.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { NodeDefinitionWithChildren } from '.'; + +export type ID = + | 'sharedux:ml' + | 'root' + | 'overview' + | 'notifications' + | 'anomaly_detection' + | 'jobs' + | 'explorer' + | 'single_metric_viewer' + | 'settings' + | 'data_frame_analytics' + | 'results_explorer' + | 'analytics_map' + | 'model_management' + | 'trained_models' + | 'nodes' + | 'data_visualizer' + | 'file' + | 'data_view' + | 'aiops_labs' + | 'explain_log_rate_spikes' + | 'log_pattern_analysis'; + +export const ml: NodeDefinitionWithChildren = { + id: 'sharedux:ml', + title: 'Machine learning', + icon: 'indexMapping', + children: [ + { + title: '', + id: 'root', + children: [ + { + id: 'overview', + title: 'Overview', + href: '/app/ml/overview', + }, + { + id: 'notifications', + title: 'Notifications', + href: '/app/ml/notifications', + }, + ], + }, + { + title: 'Anomaly detection', + id: 'anomaly_detection', + children: [ + { + id: 'jobs', + title: 'Jobs', + href: '/app/ml/jobs', + }, + { + id: 'explorer', + title: 'Anomaly explorer', + href: '/app/ml/explorer', + }, + { + id: 'single_metric_viewer', + title: 'Single metric viewer', + href: '/app/ml/timeseriesexplorer', + }, + { + id: 'settings', + title: 'Settings', + href: '/app/ml/settings', + }, + ], + }, + { + id: 'data_frame_analytics', + title: 'Data frame analytics', + children: [ + { + id: 'jobs', + title: 'Jobs', + href: '/app/ml/data_frame_analytics', + }, + { + id: 'results_explorer', + title: 'Results explorer', + href: '/app/ml/data_frame_analytics/exploration', + }, + { + id: 'analytics_map', + title: 'Analytics map', + href: '/app/ml/data_frame_analytics/map', + }, + ], + }, + { + id: 'model_management', + title: 'Model management', + children: [ + { + id: 'trained_models', + title: 'Trained models', + href: '/app/ml/trained_models', + }, + { + id: 'nodes', + title: 'Nodes', + href: '/app/ml/nodes', + }, + ], + }, + { + id: 'data_visualizer', + title: 'Data visualizer', + children: [ + { + id: 'file', + title: 'File', + href: '/app/ml/filedatavisualizer', + }, + { + id: 'data_view', + title: 'Data view', + href: '/app/ml/datavisualizer_index_select', + }, + ], + }, + { + id: 'aiops_labs', + title: 'AIOps labs', + children: [ + { + id: 'explain_log_rate_spikes', + title: 'Explain log rate spikes', + href: '/app/ml/aiops/explain_log_rate_spikes_index_select', + }, + { + id: 'log_pattern_analysis', + title: 'Log pattern analysis', + href: '/app/ml/aiops/log_categorization_index_select', + }, + ], + }, + ], +}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx b/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx index b666f4d409869..cc6bd2978a9c5 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx @@ -6,39 +6,41 @@ * Side Public License, v 1. */ +import React, { FC, useCallback, useState } from 'react'; +import { of } from 'rxjs'; +import { ComponentMeta } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import type { ChromeNavLink } from '@kbn/core-chrome-browser'; + import { - EuiButtonEmpty, + EuiButton, EuiButtonIcon, EuiCollapsibleNav, - EuiPopover, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiText, EuiThemeProvider, + EuiTitle, } from '@elastic/eui'; import { css } from '@emotion/react'; -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import React, { useCallback, useState } from 'react'; -import { BehaviorSubject } from 'rxjs'; -import { getSolutionPropertiesMock, NavigationStorybookMock } from '../../mocks'; +import { NavigationStorybookMock } from '../../mocks'; import mdx from '../../README.mdx'; -import { ChromeNavigationViewModel, NavigationServices } from '../../types'; -import { Platform } from '../model'; import { NavigationProvider } from '../services'; -import { Navigation as Component } from './navigation'; +import { DefaultNavigation } from './default_navigation'; +import type { ChromeNavigationViewModel, NavigationServices } from '../../types'; +import { Navigation } from './components'; +import { ProjectNavigationDefinition } from './types'; +import { getPresets } from './nav_tree_presets'; const storybookMock = new NavigationStorybookMock(); const SIZE_OPEN = 248; const SIZE_CLOSED = 40; -const Template = (args: ChromeNavigationViewModel & NavigationServices) => { - const services = storybookMock.getServices(args); - const props = storybookMock.getProps(args); - +const NavigationWrapper: FC = ({ children }) => { const [isOpen, setIsOpen] = useState(true); - const toggleOpen = useCallback(() => { - setIsOpen(!isOpen); - }, [isOpen, setIsOpen]); - const collabsibleNavCSS = css` border-inline-end-width: 1, display: flex, @@ -57,11 +59,16 @@ const Template = (args: ChromeNavigationViewModel & NavigationServices) => { iconType={isOpen ? 'menuLeft' : 'menuRight'} color={isOpen ? 'ghost' : 'text'} onClick={toggleOpen} + aria-label={isOpen ? 'Collapse navigation' : 'Expand navigation'} /> ); }; + const toggleOpen = useCallback(() => { + setIsOpen(!isOpen); + }, [isOpen, setIsOpen]); + return ( { hideCloseButton={false} button={} > - {isOpen && ( - - - - )} + {isOpen && children} ); }; -export default { - title: 'Chrome/Navigation', - description: 'Navigation container to render items for cross-app linking', - parameters: { - docs: { - page: mdx, - }, - }, - component: Template, -} as ComponentMeta; +const baseDeeplink: ChromeNavLink = { + id: 'foo', + title: 'Title from deep link', + href: 'https://elastic.co', + url: '', + baseUrl: '', +}; -export const SingleExpanded: ComponentStory = Template.bind({}); -SingleExpanded.args = { - activeNavItemId: 'example_project.root.get_started', - navigationTree: [getSolutionPropertiesMock()], +const createDeepLink = (id: string, title: string = baseDeeplink.title) => { + return { + ...baseDeeplink, + id, + title, + }; }; -SingleExpanded.argTypes = storybookMock.getArgumentTypes(); - -export const ReducedPlatformLinks: ComponentStory = Template.bind({}); -ReducedPlatformLinks.args = { - activeNavItemId: 'example_project.root.get_started', - platformConfig: { - [Platform.Analytics]: { enabled: false }, - [Platform.MachineLearning]: { enabled: false }, - [Platform.DevTools]: { enabled: false }, - [Platform.Management]: { - properties: { - root: { - enabled: false, // disables the un-named section that contains only "Stack Monitoring" - }, - integration_management: { - properties: { - integrations: { enabled: false }, // enable only osquery - fleet: { enabled: false }, // enable only osquery - }, + +const deepLinks: ChromeNavLink[] = [ + createDeepLink('item1'), + createDeepLink('item2', 'Foo'), + createDeepLink('group1:item1'), + createDeepLink('group1:groupA:groupI:item1'), + createDeepLink('group1:groupA', 'Group title from deep link'), + createDeepLink('group2', 'Group title from deep link'), + createDeepLink('group2:item1'), + createDeepLink('group2:item3'), +]; + +const simpleNavigationDefinition: ProjectNavigationDefinition = { + homeRef: 'https://elastic.co', + projectNavigationTree: [ + { + id: 'example_projet', + title: 'Example project', + icon: 'logoObservability', + defaultIsCollapsed: false, + children: [ + { + id: 'root', + children: [ + { + id: 'item1', + title: 'Get started', + }, + { + id: 'item2', + title: 'Alerts', + }, + { + id: 'item3', + title: 'Dashboards', + }, + ], }, - stack_management: { - enabled: false, // disables the stack management section + { + id: 'group:settings', + title: 'Settings', + children: [ + { + id: 'logs', + title: 'Logs', + }, + { + id: 'signals', + title: 'Signals', + }, + { + id: 'tracing', + title: 'Tracing', + }, + ], }, - }, + ], + }, + ], +}; + +export const SimpleObjectDefinition = (args: ChromeNavigationViewModel & NavigationServices) => { + const services = storybookMock.getServices({ + ...args, + navLinks$: of(deepLinks), + onProjectNavigationChange: (updated) => { + action('Update chrome navigation')(JSON.stringify(updated, null, 2)); }, + recentlyAccessed$: of([ + { label: 'This is an example', link: '/app/example/39859', id: '39850' }, + { label: 'Another example', link: '/app/example/5235', id: '5235' }, + ]), + }); + + return ( + + + + + + ); +}; + +const navigationDefinition: ProjectNavigationDefinition = { + homeRef: 'https://elastic.co', + navigationTree: { + body: [ + { + type: 'cloudLink', + preset: 'deployments', + }, + // My custom project + { + type: 'navGroup', + id: 'example_projet', + title: 'Example project', + icon: 'logoObservability', + defaultIsCollapsed: false, + children: [ + { + id: 'root', + children: [ + { + id: 'item1', + title: 'Get started', + }, + { + id: 'item2', + title: 'Alerts', + }, + { + id: 'item3', + title: 'Some other node', + }, + ], + }, + { + id: 'group:settings', + title: 'Settings', + children: [ + { + id: 'logs', + title: 'Logs', + }, + { + id: 'signals', + title: 'Signals', + }, + { + id: 'tracing', + title: 'Tracing', + }, + ], + }, + ], + }, + // Add ml + { + type: 'navGroup', + preset: 'ml', + }, + // And specific links from analytics + { + type: 'navGroup', + ...getPresets('analytics'), + title: 'My analytics', // Change the title + children: getPresets('analytics').children.map((child) => ({ + ...child, + children: child.children?.filter((item) => { + // Hide discover and dashboard + return item.id !== 'discover' && item.id !== 'dashboard'; + }), + })), + }, + ], + footer: [ + { + type: 'recentlyAccessed', + defaultIsCollapsed: true, + // Override the default recently accessed items with our own + recentlyAccessed$: of([ + { + label: 'My own recent item', + id: '1234', + link: '/app/example/39859', + }, + { + label: 'I also own this', + id: '4567', + link: '/app/example/39859', + }, + ]), + }, + { + type: 'navGroup', + ...getPresets('devtools'), + }, + ], }, - navigationTree: [getSolutionPropertiesMock()], }; -ReducedPlatformLinks.argTypes = storybookMock.getArgumentTypes(); -export const WithRequestsLoading: ComponentStory = Template.bind({}); -WithRequestsLoading.args = { - activeNavItemId: 'example_project.root.get_started', - loadingCount$: new BehaviorSubject(1), - navigationTree: [getSolutionPropertiesMock()], +export const ComplexObjectDefinition = (args: ChromeNavigationViewModel & NavigationServices) => { + const services = storybookMock.getServices({ + ...args, + navLinks$: of(deepLinks), + onProjectNavigationChange: (updated) => { + action('Update chrome navigation')(JSON.stringify(updated, null, 2)); + }, + recentlyAccessed$: of([ + { label: 'This is an example', link: '/app/example/39859', id: '39850' }, + { label: 'Another example', link: '/app/example/5235', id: '5235' }, + ]), + }); + + return ( + + + + + + ); }; -WithRequestsLoading.argTypes = storybookMock.getArgumentTypes(); - -export const WithRecentlyAccessed: ComponentStory = Template.bind({}); -WithRecentlyAccessed.args = { - activeNavItemId: 'example_project.root.get_started', - loadingCount$: new BehaviorSubject(0), - recentlyAccessed$: new BehaviorSubject([ - { label: 'This is an example', link: '/app/example/39859', id: '39850' }, - { label: 'This is not an example', link: '/app/non-example/39458', id: '39458' }, // NOTE: this will be filtered out - ]), - recentlyAccessedFilter: (items) => - items.filter((item) => item.link.indexOf('/app/example') === 0), - navigationTree: [getSolutionPropertiesMock()], + +export const WithUIComponents = (args: ChromeNavigationViewModel & NavigationServices) => { + const services = storybookMock.getServices({ + ...args, + navLinks$: of(deepLinks), + onProjectNavigationChange: (updated) => { + action('Update chrome navigation')(JSON.stringify(updated, null, 2)); + }, + recentlyAccessed$: of([ + { label: 'This is an example', link: '/app/example/39859', id: '39850' }, + { label: 'Another example', link: '/app/example/5235', id: '5235' }, + ]), + }); + + return ( + + + + + + + + + + + + {(navNode) => { + return ( + {`Render prop: ${navNode.id} - ${navNode.title}`} + ); + }} + + + + Title in ReactNode + + + + + + + + + + + + + + + + + + + + + + ); }; -WithRecentlyAccessed.argTypes = storybookMock.getArgumentTypes(); -export const CustomElements: ComponentStory = Template.bind({}); -CustomElements.args = { - activeNavItemId: 'example_project.custom', - navigationTree: [ - { - ...getSolutionPropertiesMock(), - items: [ - { - title: ( - - Custom element - - } - isOpen={true} - anchorPosition="rightCenter" - > - Cool popover content - - ), - id: 'custom', - }, - ], +export const MinimalUIAndCustomCloudLink = ( + args: ChromeNavigationViewModel & NavigationServices +) => { + const services = storybookMock.getServices({ + ...args, + navLinks$: of(deepLinks), + onProjectNavigationChange: (updated) => { + action('Update chrome navigation')(JSON.stringify(updated, null, 2)); }, - ], + recentlyAccessed$: of([ + { label: 'This is an example', link: '/app/example/39859', id: '39850' }, + { label: 'Another example', link: '/app/example/5235', id: '5235' }, + ]), + }); + + return ( + + + + + + + + + + + + + + Some children node + + + + + + + + + + + + + + + + + + ); +}; + +export default { + title: 'Chrome/Navigation/v2', + description: 'Navigation container to render items for cross-app linking', + parameters: { + docs: { + page: mdx, + }, + }, + component: WithUIComponents, +} as ComponentMeta; + +export const CreativeUI = (args: ChromeNavigationViewModel & NavigationServices) => { + const services = storybookMock.getServices({ + ...args, + navLinks$: of(deepLinks), + onProjectNavigationChange: (updated) => { + action('Update chrome navigation')(JSON.stringify(updated, null, 2)); + }, + recentlyAccessed$: of([ + { label: 'This is an example', link: '/app/example/39859', id: '39850' }, + { label: 'Another example', link: '/app/example/5235', id: '5235' }, + ]), + }); + + return ( + + + + + + + + +

Hello!

+
+ + + + +

+ As you can see there is really no limit in what UI you can create! +
+

+

+ Have fun! +

+
+ + +
+
+
+ + +
+
+
+ ); }; -CustomElements.argTypes = storybookMock.getArgumentTypes(); diff --git a/packages/shared-ux/chrome/navigation/src/ui/navigation.test.tsx b/packages/shared-ux/chrome/navigation/src/ui/navigation.test.tsx deleted file mode 100644 index 4a39a4e651d7a..0000000000000 --- a/packages/shared-ux/chrome/navigation/src/ui/navigation.test.tsx +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { render } from '@testing-library/react'; -import React from 'react'; -import { BehaviorSubject } from 'rxjs'; -import { getServicesMock } from '../../mocks/src/jest'; -import { ChromeNavigationNodeViewModel, PlatformConfigSet } from '../../types'; -import { Platform } from '../model'; -import { NavigationProvider } from '../services'; -import { Navigation } from './navigation'; - -describe('', () => { - const services = getServicesMock(); - - const homeHref = '#'; - let platformSections: PlatformConfigSet | undefined; - let solutions: ChromeNavigationNodeViewModel[]; - - beforeEach(() => { - platformSections = { analytics: {}, ml: {}, devTools: {}, management: {} }; - solutions = [{ id: 'navigation_testing', title: 'Navigation testing', icon: 'gear' }]; - }); - - test('renders the header logo and top-level navigation buckets', async () => { - const { findByTestId, findByText, queryByTestId } = render( - - - - ); - - expect(await findByText('Navigation testing')).toBeVisible(); - - expect(await findByTestId('nav-header-logo')).toBeVisible(); - expect(await findByTestId('nav-bucket-navigation_testing')).toBeVisible(); - expect(await findByTestId('nav-bucket-analytics')).toBeVisible(); - expect(await findByTestId('nav-bucket-ml')).toBeVisible(); - expect(await findByTestId('nav-bucket-devTools')).toBeVisible(); - expect(await findByTestId('nav-bucket-management')).toBeVisible(); - - expect(queryByTestId('nav-bucket-recentlyAccessed')).not.toBeInTheDocument(); - }); - - test('includes link to deployments', async () => { - const { findByText } = render( - - - - ); - - expect(await findByText('My deployments')).toBeVisible(); - }); - - test('platform links can be disabled', async () => { - platformSections = { - [Platform.Analytics]: { enabled: false }, - [Platform.MachineLearning]: { enabled: false }, - [Platform.DevTools]: { enabled: false }, - [Platform.Management]: { enabled: false }, - }; - - const { findByTestId, queryByTestId } = render( - - - - ); - - expect(await findByTestId('nav-header-logo')).toBeVisible(); - expect(queryByTestId('nav-bucket-analytics')).not.toBeInTheDocument(); - expect(queryByTestId('nav-bucket-ml')).not.toBeInTheDocument(); - expect(queryByTestId('nav-bucket-devTools')).not.toBeInTheDocument(); - expect(queryByTestId('nav-bucket-management')).not.toBeInTheDocument(); - }); - - test('sets the specified nav item to active', async () => { - solutions[0].items = [ - { - id: 'root', - title: '', - items: [ - { - id: 'city', - title: 'City', - }, - { - id: 'town', - title: 'Town', - }, - ], - }, - ]; - - const { findByTestId } = render( - - - - ); - - const label = await findByTestId('nav-item-navigation_testing.root.city-selected'); - expect(label).toHaveTextContent('City'); - expect(label).toBeVisible(); - }); - - test('shows loading state', async () => { - services.loadingCount$ = new BehaviorSubject(5); - - const { findByTestId } = render( - - - - ); - - expect(await findByTestId('nav-header-loading-spinner')).toBeVisible(); - }); - - describe('recent items', () => { - const recentlyAccessed = [ - { id: 'dashboard:234', label: 'Recently Accessed Test Item', link: '/app/dashboard/234' }, - ]; - - test('shows recent items', async () => { - services.recentlyAccessed$ = new BehaviorSubject(recentlyAccessed); - - const { findByTestId } = render( - - - - ); - - expect(await findByTestId('nav-bucket-recentlyAccessed')).toBeVisible(); - }); - - test('shows no recent items container when items are filtered', async () => { - services.recentlyAccessed$ = new BehaviorSubject(recentlyAccessed); - - const { queryByTestId } = render( - - []} - /> - - ); - - expect(queryByTestId('nav-bucket-recentlyAccessed')).not.toBeInTheDocument(); - }); - }); -}); diff --git a/packages/shared-ux/chrome/navigation/src/ui/navigation.tsx b/packages/shared-ux/chrome/navigation/src/ui/navigation.tsx deleted file mode 100644 index 05407fd0b1a7b..0000000000000 --- a/packages/shared-ux/chrome/navigation/src/ui/navigation.tsx +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { - EuiCollapsibleNavGroup, - EuiFlexGroup, - EuiFlexItem, - EuiHeaderLogo, - EuiLink, - EuiLoadingSpinner, - EuiSideNav, - EuiSideNavItemType, - EuiSpacer, - useEuiTheme, -} from '@elastic/eui'; -import React from 'react'; -import useObservable from 'react-use/lib/useObservable'; -import type { ChromeNavigationViewModel } from '../../types'; -import { NavigationModel } from '../model'; -import { useNavigation } from '../services'; -import { navigationStyles as styles } from '../styles'; -import { ElasticMark } from './elastic_mark'; -import './header_logo.scss'; -import { getI18nStrings } from './i18n_strings'; -import { NavigationBucket, type Props as NavigationBucketProps } from './navigation_bucket'; - -interface Props extends ChromeNavigationViewModel { - /** - * ID of sections to highlight - */ - activeNavItemId?: string; - dataTestSubj?: string; // optional test subject for the navigation -} - -export const Navigation = ({ - platformConfig, - navigationTree, - homeHref, - linkToCloud, - activeNavItemId: activeNavItemIdProps, - ...props -}: Props) => { - const { activeNavItemId } = useNavigation(); - const { euiTheme } = useEuiTheme(); - - const activeNav = activeNavItemId ?? activeNavItemIdProps; - - const nav = new NavigationModel(platformConfig, navigationTree); - - const solutions = nav.getSolutions(); - const { analytics, ml, devTools, management } = nav.getPlatform(); - - const strings = getI18nStrings(); - - const NavHeader = () => { - const { basePath, navIsOpen, navigateToUrl, loadingCount$ } = useNavigation(); - const loadingCount = useObservable(loadingCount$, 0); - const homeUrl = basePath.prepend(homeHref); - const navigateHome = (event: React.MouseEvent) => { - event.preventDefault(); - navigateToUrl(homeUrl); - }; - const logo = - loadingCount === 0 ? ( - - ) : ( - - - - ); - - return ( - <> - {logo} - {navIsOpen ? : null} - - ); - }; - - const LinkToCloud = () => { - switch (linkToCloud) { - case 'projects': - return ( - - - - ); - case 'deployments': - return ( - - - - ); - default: - return null; - } - }; - - const RecentlyAccessed = () => { - const { recentlyAccessed$ } = useNavigation(); - const recentlyAccessed = useObservable(recentlyAccessed$, []); - - // consumer may filter objects from recent that are not applicable to the project - let filteredRecent = recentlyAccessed; - if (props.recentlyAccessedFilter) { - filteredRecent = props.recentlyAccessedFilter(recentlyAccessed); - } - - if (filteredRecent.length > 0) { - const navItems: Array> = [ - { - name: '', // no list header title - id: 'recents_root', - items: filteredRecent.map(({ id, label, link }) => ({ - id, - name: label, - href: link, - })), - }, - ]; - - return ( - - - - ); - } - - return null; - }; - - // higher-order-component to keep the common props DRY - const NavigationBucketHoc = (outerProps: Omit) => ( - - ); - - return ( - - - - - - - - - - - {solutions.map((navTree, idx) => { - return ; - })} - - {nav.isEnabled('analytics') ? : null} - {nav.isEnabled('ml') ? : null} - - - - - - - - {nav.isEnabled('devTools') ? : null} - {nav.isEnabled('management') ? : null} - - - ); -}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/navigation_bucket.tsx b/packages/shared-ux/chrome/navigation/src/ui/navigation_bucket.tsx deleted file mode 100644 index 8055daf834a64..0000000000000 --- a/packages/shared-ux/chrome/navigation/src/ui/navigation_bucket.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { - EuiCollapsibleNavGroup, - EuiIcon, - EuiSideNav, - EuiSideNavItemType, - EuiText, -} from '@elastic/eui'; -import React from 'react'; -import { ChromeNavigationNodeViewModel } from '../../types'; -import type { BasePathService, NavigateToUrlFn } from '../../types/internal'; -import { useNavigation } from '../services'; -import { navigationStyles as styles } from '../styles'; - -const navigationNodeToEuiItem = ( - item: ChromeNavigationNodeViewModel, - { - navigateToUrl, - basePath, - activeNavItemId, - }: { activeNavItemId?: string; navigateToUrl: NavigateToUrlFn; basePath: BasePathService } -): EuiSideNavItemType => { - const path = item.path ?? item.id; - - let subjId = path; - let isSelected: boolean = false; - - if (!item.items && path === activeNavItemId) { - // if there are no subnav items and ID is current, mark the item as selected - isSelected = true; - subjId += '-selected'; - } - - return { - id: path, - name: item.title, - isSelected, - onClick: - item.href !== undefined - ? (event: React.MouseEvent) => { - event.preventDefault(); - navigateToUrl(basePath.prepend(item.href!)); - } - : undefined, - href: item.href, - items: item.items?.map((_item) => - navigationNodeToEuiItem(_item, { navigateToUrl, basePath, activeNavItemId }) - ), - ['data-test-subj']: `nav-item-${subjId}`, - ...(item.icon && { - icon: , - }), - }; -}; - -export interface Props { - navigationTree: ChromeNavigationNodeViewModel; - activeNavItemId?: string; -} - -export const NavigationBucket = (props: Props) => { - const { navigationTree, activeNavItemId } = props; - const { navIsOpen, navigateToUrl, basePath } = useNavigation(); - const { id, title, icon, items } = navigationTree; - - if (navIsOpen) { - return ( - - - - navigationNodeToEuiItem(item, { navigateToUrl, basePath, activeNavItemId }) - )} - css={styles.euiSideNavItems} - /> - - - ); - } - - return ( -
- -
-
- ); -}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/types.ts b/packages/shared-ux/chrome/navigation/src/ui/types.ts new file mode 100644 index 0000000000000..96c283d508f87 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/types.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ReactElement, ReactNode } from 'react'; +import type { + ChromeProjectNavigationLink, + ChromeProjectNavigationNode, +} from '@kbn/core-chrome-browser'; + +import type { CloudLinkProps, RecentlyAccessedProps } from './components'; + +/** + * @public + * + * A navigation node definition with its unique id, title, path in the tree and optional + * deep link and children. + */ +export interface NodeDefinition { + /** Optional id, if not passed a "link" must be provided. */ + id?: T; + /** Optional title. If not provided and a "link" is provided the title will be the Deep link title */ + title?: string; + /** App id or deeplink id */ + link?: ChromeProjectNavigationLink; + /** Optional icon for the navigation node. Note: not all navigation depth will render the icon */ + icon?: string; + /** Optional children of the navigation node */ + children?: Array>; + /** + * Temporarilly we allow href to be passed. + * Once all the deeplinks will be exposed in packages we will not allow href anymore + * and force deeplink id to be passed + */ + href?: string; +} + +/** + * @public + * + * A navigation node definition with its unique id, title, path in the tree and optional deep link. + * Those are the props that can be passed to the Navigation.Group and Navigation.Item components. + */ +export interface NodeProps extends Omit { + /** + * Children of the node. For Navigation.Item (only) it allows a function to be set. + * This function will receive the ChromeProjectNavigationNode object + */ + children?: ((navNode: ChromeProjectNavigationNode) => ReactNode) | ReactNode; +} + +/** + * @internal + * + * Internally we enhance the Props passed to the Navigation.Item component. + */ +export interface NodePropsEnhanced extends NodeProps { + /** + * This function correspond to the same "itemRender" function that can be passed to + * the EuiSideNavItemType (see navigation_section_ui.tsx) + */ + renderItem?: () => ReactElement; +} + +/** + * @internal + */ +export interface ChromeProjectNavigationNodeEnhanced extends ChromeProjectNavigationNode { + /** + * This function correspond to the same "itemRender" function that can be passed to + * the EuiSideNavItemType (see navigation_section_ui.tsx) + */ + renderItem?: () => ReactElement; +} + +/** The preset that can be pass to the NavigationBucket component */ +export type NavigationGroupPreset = 'analytics' | 'devtools' | 'ml' | 'management'; + +/** + * @public + * + * Definition for the "Recently accessed" section of the side navigation. + */ +export interface RecentlyAccessedDefinition extends RecentlyAccessedProps { + type: 'recentlyAccessed'; +} + +/** + * @public + * + * A cloud link root item definition. Use it to add one or more links to the Cloud console + */ +export interface CloudLinkDefinition extends CloudLinkProps { + type: 'cloudLink'; +} + +/** + * @public + * + * A group root item definition. + */ +export interface GroupDefinition extends NodeDefinition { + type: 'navGroup'; + /** Flag to indicate if the group is initially collapsed or not. */ + defaultIsCollapsed?: boolean; + children?: NodeDefinition[]; + preset?: NavigationGroupPreset; +} + +/** + * @public + * + * The navigation definition for a root item in the side navigation. + */ +export type RootNavigationItemDefinition = + | RecentlyAccessedDefinition + | CloudLinkDefinition + | GroupDefinition; + +export type ProjectNavigationTreeDefinition = Array>; + +/** + * @public + * + * Definition for the complete navigation tree, including body and footer + */ +export interface NavigationTreeDefinition { + /** + * Main content of the navigation. Can contain any number of "cloudLink", "recentlyAccessed" + * or "group" items. Be mindeful though, with great power comes great responsibility. + * */ + body?: RootNavigationItemDefinition[]; + /** + * Footer content of the navigation. Can contain any number of "cloudLink", "recentlyAccessed" + * or "group" items. Be mindeful though, with great power comes great responsibility. + * */ + footer?: RootNavigationItemDefinition[]; +} + +/** + * @public + * + * A project navigation definition that can be passed to the `` component + * or when calling `setNavigation()` on the serverless plugin. + */ +export interface ProjectNavigationDefinition { + /** + * The URL href for the home link + */ + homeRef: string; + /** + * A navigation tree structure with object items containing labels, links, and sub-items + * for a project. Use it if you only need to configure your project navigation and leave + * all the other navigation items to the default (Recently viewed items, Management, etc.) + */ + projectNavigationTree?: ProjectNavigationTreeDefinition; + /** + * A navigation tree structure with object items containing labels, links, and sub-items + * that defines a complete side navigation. This configuration overrides `projectNavigationTree` + * if both are provided. + */ + navigationTree?: NavigationTreeDefinition; +} + +/** + * @internal + * + * Function to unregister a navigation node from its parent. + */ +export type UnRegisterFunction = (id: string) => void; + +/** + * @internal + * + * A function to register a navigation node on its parent. + */ +export type RegisterFunction = (navNode: ChromeProjectNavigationNodeEnhanced) => { + /** The function to unregister the node. */ + unregister: UnRegisterFunction; + /** The full path of the node in the navigation tree. */ + path: string[]; +}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/v2/components/navigation.test.tsx b/packages/shared-ux/chrome/navigation/src/ui/v2/components/navigation.test.tsx deleted file mode 100644 index 2f5a3781cb3a2..0000000000000 --- a/packages/shared-ux/chrome/navigation/src/ui/v2/components/navigation.test.tsx +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { render } from '@testing-library/react'; -import { type Observable, of } from 'rxjs'; -import type { ChromeNavLink } from '@kbn/core-chrome-browser'; - -import { getServicesMock } from '../../../../mocks/src/jest'; -import { NavigationProvider } from '../../../services'; -import { Navigation } from './navigation'; - -describe('', () => { - const services = getServicesMock(); - - describe('builds the navigation tree', () => { - test('should read the title from props, children or deeplink', async () => { - const navLinks$: Observable = of([ - { - id: 'item3', - title: 'Title from deeplink!', - baseUrl: '', - url: '', - href: '', - }, - ]); - - const onProjectNavigationChange = jest.fn(); - - render( - - - Title in children - - {/* Title will be read from the deeplink */} - - {/* Title will be read from the props */} - - {/* Title will be read from the children */} - - Override the deeplink with children - - {/* Should not appear */} - - - - ); - - expect(onProjectNavigationChange).toHaveBeenCalled(); - const lastCall = - onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; - const [navTree] = lastCall; - - expect(navTree).toEqual({ - navigationTree: [ - { - id: 'item1', - title: 'Title in children', - }, - { - id: 'item2', - title: 'Title in props', - }, - { - id: 'item3-a', - title: 'Title from deeplink!', - deepLink: { - id: 'item3', - title: 'Title from deeplink!', - baseUrl: '', - url: '', - href: '', - }, - }, - { - id: 'item3-b', - title: 'Override the deeplink with props', - deepLink: { - id: 'item3', - title: 'Title from deeplink!', - baseUrl: '', - url: '', - href: '', - }, - }, - { - id: 'item3-c', - title: 'Override the deeplink with children', - deepLink: { - id: 'item3', - title: 'Title from deeplink!', - baseUrl: '', - url: '', - href: '', - }, - }, - ], - }); - }); - - test('should render any level of depth', async () => { - const navLinks$: Observable = of([ - { - id: 'item3', - title: 'Title from deeplink!', - baseUrl: '', - url: '', - href: '', - }, - ]); - - const onProjectNavigationChange = jest.fn(); - - render( - - - - - {/* Will read the title from the deeplink */} - - - - - - - - ); - - expect(onProjectNavigationChange).toHaveBeenCalled(); - const lastCall = - onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; - const [navTree] = lastCall; - - expect(navTree).toEqual({ - navigationTree: [ - { - id: 'item1', - title: 'Item 1', - children: [ - { - id: 'item1', - title: 'Item 1', - children: [ - { - id: 'item3', - title: 'Title from deeplink!', - deepLink: { - baseUrl: '', - href: '', - id: 'item3', - title: 'Title from deeplink!', - url: '', - }, - children: [ - { - id: 'item1', - title: 'Item 1', - }, - ], - }, - ], - }, - ], - }, - ], - }); - }); - - test.skip('does not render in the UI the nodes that points to unexisting deeplinks', async () => { - // TODO: This test will be added when we'll have the UI and be able to add - // data-test-subj to all the nodes with their paths - }); - }); -}); diff --git a/packages/shared-ux/chrome/navigation/src/ui/v2/components/navigation_group.tsx b/packages/shared-ux/chrome/navigation/src/ui/v2/components/navigation_group.tsx deleted file mode 100644 index 71d33b62f432b..0000000000000 --- a/packages/shared-ux/chrome/navigation/src/ui/v2/components/navigation_group.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { createContext, useCallback, useMemo, useContext } from 'react'; - -import { EuiButton } from '@elastic/eui'; -import { useInitNavnode } from '../use_init_navnode'; -import { NodeProps, RegisterFunction } from '../types'; - -export const NavigationGroupContext = createContext(undefined); - -interface Context { - register: RegisterFunction; -} - -export function useNavigationGroup( - throwIfNotFound: T = true as T -): T extends true ? Context : Context | undefined { - const context = useContext(NavigationGroupContext); - if (!context && throwIfNotFound) { - throw new Error('useNavigationGroup must be used within a NavigationGroup provider'); - } - return context as T extends true ? Context : Context | undefined; -} - -function NavigationGroupComp(node: NodeProps) { - const { children, onRemove } = node; - const { navNode, registerChildNode } = useInitNavnode(node); - const { title, deepLink } = navNode ?? {}; - - const wrapTextWithLink = useCallback( - (text?: string) => - deepLink ? ( - - {text} - - ) : ( - text - ), - [deepLink] - ); - - const renderTempUIToTestRemoveBehavior = useCallback( - () => - onRemove ? ( - <> - {' '} - onRemove()}> - Remove - - - ) : null, - [onRemove] - ); - - const renderContent = useCallback(() => { - return ( - <> - {wrapTextWithLink(title)} - {renderTempUIToTestRemoveBehavior()} -
    {children}
- - ); - }, [children, renderTempUIToTestRemoveBehavior, title, wrapTextWithLink]); - - const contextValue = useMemo(() => { - return { - register: registerChildNode, - }; - }, [registerChildNode]); - - if (!navNode) { - return null; - } - - return ( - - {/* Note: temporary UI. In future PR we'll have an EUI component here */} -
  • {renderContent()}
  • -
    - ); -} - -export const NavigationGroup = React.memo(NavigationGroupComp); diff --git a/packages/shared-ux/chrome/navigation/src/ui/v2/components/navigation_item.tsx b/packages/shared-ux/chrome/navigation/src/ui/v2/components/navigation_item.tsx deleted file mode 100644 index 0aedd52fb2e07..0000000000000 --- a/packages/shared-ux/chrome/navigation/src/ui/v2/components/navigation_item.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { EuiButton } from '@elastic/eui'; -import React, { useCallback } from 'react'; - -import { NodeProps } from '../types'; -import { useInitNavnode } from '../use_init_navnode'; - -function NavigationItemComp(node: NodeProps) { - const { children, onRemove } = node; - const { navNode } = useInitNavnode(node); - const { title, deepLink } = navNode ?? {}; - - // Note: temporary UI. In future PR we'll have an EUI component here - const wrapTextWithLink = useCallback( - (text?: string) => - deepLink ? ( - - {text} - - ) : ( - text - ), - [deepLink] - ); - - const renderContent = useCallback(() => { - if (!children) { - return wrapTextWithLink(title); - } - - if (typeof children === 'string') { - return wrapTextWithLink(children); - } else if (typeof children === 'function') { - return children(deepLink); - } - - return children; - }, [children, deepLink, title, wrapTextWithLink]); - - const renderTempUIToTestRemoveBehavior = () => - onRemove ? ( - <> - {' '} - onRemove()}> - Remove - - - ) : null; - - if (!navNode) { - return null; - } - - return ( - // Note: temporary UI. In future PR we'll have an EUI component here -
  • - {renderContent()} - {renderTempUIToTestRemoveBehavior()} -
  • - ); -} - -export const NavigationItem = React.memo(NavigationItemComp); diff --git a/packages/shared-ux/chrome/navigation/src/ui/v2/default_navigation.test.tsx b/packages/shared-ux/chrome/navigation/src/ui/v2/default_navigation.test.tsx deleted file mode 100644 index 89ef5c8017734..0000000000000 --- a/packages/shared-ux/chrome/navigation/src/ui/v2/default_navigation.test.tsx +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { render } from '@testing-library/react'; -import { type Observable, of } from 'rxjs'; -import type { ChromeNavLink, ChromeProjectNavigationNode } from '@kbn/core-chrome-browser'; - -import { getServicesMock } from '../../../mocks/src/jest'; -import { NavigationProvider } from '../../services'; -import { DefaultNavigation } from './default_navigation'; - -describe('', () => { - const services = getServicesMock(); - - describe('builds the navigation tree', () => { - test('should read the title from config or deeplink', async () => { - const navLinks$: Observable = of([ - { - id: 'item2', - title: 'Title from deeplink!', - baseUrl: '', - url: '', - href: '', - }, - ]); - - const onProjectNavigationChange = jest.fn(); - - const navTreeConfig: ChromeProjectNavigationNode[] = [ - { - id: 'item1', - title: 'Item 1', - }, - { - id: 'item2-a', - link: 'item2', // Title from deeplink - }, - { - id: 'item2-b', - link: 'item2', - title: 'Override the deeplink with props', - }, - { - link: 'disabled', - title: 'Should NOT be there', - }, - ]; - - render( - - - - ); - - expect(onProjectNavigationChange).toHaveBeenCalled(); - const lastCall = - onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; - const [navTreeGenerated] = lastCall; - - expect(navTreeGenerated).toEqual({ - navigationTree: [ - { - id: 'item1', - title: 'Item 1', - }, - { - id: 'item2-a', - title: 'Title from deeplink!', - deepLink: { - id: 'item2', - title: 'Title from deeplink!', - baseUrl: '', - url: '', - href: '', - }, - }, - { - id: 'item2-b', - title: 'Override the deeplink with props', - deepLink: { - id: 'item2', - title: 'Title from deeplink!', - baseUrl: '', - url: '', - href: '', - }, - }, - ], - }); - }); - - test('should render any level of depth', async () => { - const navLinks$: Observable = of([ - { - id: 'item2', - title: 'Title from deeplink!', - baseUrl: '', - url: '', - href: '', - }, - ]); - - const onProjectNavigationChange = jest.fn(); - - const navTreeConfig: ChromeProjectNavigationNode[] = [ - { - id: 'item1', - title: 'Item 1', - children: [ - { - id: 'item1', - title: 'Item 1', - children: [ - { - link: 'item2', // Title from deeplink - children: [ - { - id: 'item1', - title: 'Item 1', - }, - ], - }, - ], - }, - ], - }, - ]; - - render( - - - - ); - - expect(onProjectNavigationChange).toHaveBeenCalled(); - const lastCall = - onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; - const [navTreeGenerated] = lastCall; - - expect(navTreeGenerated).toEqual({ - navigationTree: [ - { - id: 'item1', - title: 'Item 1', - children: [ - { - id: 'item1', - title: 'Item 1', - children: [ - { - id: 'item2', - title: 'Title from deeplink!', - deepLink: { - id: 'item2', - title: 'Title from deeplink!', - baseUrl: '', - url: '', - href: '', - }, - children: [ - { - id: 'item1', - title: 'Item 1', - }, - ], - }, - ], - }, - ], - }, - ], - }); - }); - - test.skip('does not render in the UI the nodes that points to unexisting deeplinks', async () => { - // TODO: This test will be added when we'll have the UI and be able to add - // data-test-subj to all the nodes with their paths - }); - }); -}); diff --git a/packages/shared-ux/chrome/navigation/src/ui/v2/default_navigation.tsx b/packages/shared-ux/chrome/navigation/src/ui/v2/default_navigation.tsx deleted file mode 100644 index b3f4145d52d54..0000000000000 --- a/packages/shared-ux/chrome/navigation/src/ui/v2/default_navigation.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { FC, useCallback, useState } from 'react'; -import { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser'; - -import { Navigation } from './components'; - -interface Props { - navTree: ChromeProjectNavigationNode[]; -} - -export const DefaultNavigation: FC = ({ navTree }) => { - // Temp logic to demo removing items from the tree - const [removedItems, setRemovedItems] = useState>(new Set()); - - const onRemove = useCallback((path: string[]) => { - setRemovedItems((prevItems) => { - const newItems = new Set(prevItems); - newItems.add(path.join('.')); - return newItems; - }); - }, []); - - const renderItems = useCallback( - (items: ChromeProjectNavigationNode[], path: string[] = []) => { - const filtered = items.filter(({ id: _id, link = '' }) => { - const id = _id ?? link; - const itemPath = (id ? [...path, id] : path).join('.'); - return !removedItems.has(itemPath); - }); - - return filtered.map((item) => { - const id = item.id ?? item.link; - - if (!id) { - throw new Error( - `At least one of id or link must be defined for navigation item ${item.title}` - ); - } - - return ( - - {item.children ? ( - onRemove([...path, id])} - > - {renderItems(item.children, [...path, id])} - - ) : ( - onRemove([...path, id])} - /> - )} - - ); - }); - }, - [removedItems, onRemove] - ); - - const filteredNavTree = navTree.filter(({ id: _id, link }) => { - const id = _id ?? link; - return !removedItems.has(id ?? ''); - }); - - return ( - { - onRemove([id]); - }} - > - {renderItems(filteredNavTree)} - - ); -}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/v2/navigation.stories.tsx b/packages/shared-ux/chrome/navigation/src/ui/v2/navigation.stories.tsx deleted file mode 100644 index 4aff0251f85c0..0000000000000 --- a/packages/shared-ux/chrome/navigation/src/ui/v2/navigation.stories.tsx +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { of } from 'rxjs'; -import { ComponentMeta } from '@storybook/react'; -import { action } from '@storybook/addon-actions'; -import { ChromeNavLink, ChromeProjectNavigationNode } from '@kbn/core-chrome-browser'; - -import { NavigationStorybookMock } from '../../../mocks'; -import mdx from '../../../README.mdx'; -import { NavigationProvider } from '../../services'; -import { DefaultNavigation } from './default_navigation'; -import type { ChromeNavigationViewModel, NavigationServices } from '../../../types'; -import { Navigation } from './components'; - -const storybookMock = new NavigationStorybookMock(); - -const navTree: ChromeProjectNavigationNode[] = [ - { - id: 'group1', - title: 'Group 1', - children: [ - { - id: 'item1', - title: 'Group 1: Item 1', - link: 'group1:item1', - }, - { - id: 'groupA', - link: 'group1:groupA', - children: [ - { - id: 'item1', - title: 'Group 1 > Group A > Item 1', - }, - { - id: 'groupI', - title: 'Group 1 : Group A : Group I', - children: [ - { - id: 'item1', - title: 'Group 1 > Group A > Group 1 > Item 1', - link: 'group1:groupA:groupI:item1', - }, - { - id: 'item2', - title: 'Group 1 > Group A > Group 1 > Item 2', - }, - ], - }, - { - id: 'item2', - title: 'Group 1 > Group A > Item 2', - }, - ], - }, - { - id: 'item3', - title: 'Group 1: Item 3', - }, - ], - }, - { - id: 'group2', - link: 'group2', - title: 'Group 2', - children: [ - { - id: 'item1', - title: 'Group 2: Item 1', - link: 'group2:item1', - }, - { - id: 'item2', - title: 'Group 2: Item 2', - link: 'group2:item2', - }, - { - id: 'item3', - title: 'Group 2: Item 3', - link: 'group2:item3', - }, - ], - }, - { - id: 'item1', - link: 'item1', - }, - { - id: 'item2', - title: 'Item 2', - link: 'bad', - }, - { - id: 'item3', - title: "I don't have a 'link' prop", - }, - { - id: 'item4', - title: 'Item 4', - }, -]; - -const baseDeeplink: ChromeNavLink = { - id: 'foo', - title: 'Title from deep link', - href: 'https://elastic.co', - url: '', - baseUrl: '', -}; - -const createDeepLink = (id: string, title: string = baseDeeplink.title) => { - return { - ...baseDeeplink, - id, - title, - }; -}; - -const deepLinks: ChromeNavLink[] = [ - createDeepLink('item1'), - createDeepLink('item2', 'Foo'), - createDeepLink('group1:item1'), - createDeepLink('group1:groupA:groupI:item1'), - createDeepLink('group1:groupA', 'Group title from deep link'), - createDeepLink('group2', 'Group title from deep link'), - createDeepLink('group2:item1'), - createDeepLink('group2:item3'), -]; - -export const FromObjectConfig = (args: ChromeNavigationViewModel & NavigationServices) => { - const services = storybookMock.getServices({ - ...args, - navLinks$: of(deepLinks), - onProjectNavigationChange: (updated) => { - action('Update chrome navigation')(JSON.stringify(updated, null, 2)); - }, - }); - - return ( - - - - ); -}; - -export const FromReactNodes = (args: ChromeNavigationViewModel & NavigationServices) => { - const services = storybookMock.getServices({ - ...args, - navLinks$: of(deepLinks), - onProjectNavigationChange: (updated) => { - action('Update chrome navigation')(JSON.stringify(updated, null, 2)); - }, - }); - - return ( - - - - - - - - - - Title from react node - - - ); -}; - -export default { - title: 'Chrome/Navigation/v2', - description: 'Navigation container to render items for cross-app linking', - parameters: { - docs: { - page: mdx, - }, - }, - component: FromObjectConfig, -} as ComponentMeta; diff --git a/packages/shared-ux/chrome/navigation/src/ui/v2/types.ts b/packages/shared-ux/chrome/navigation/src/ui/v2/types.ts deleted file mode 100644 index 1f3c8a094d663..0000000000000 --- a/packages/shared-ux/chrome/navigation/src/ui/v2/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { ChromeNavLink, ChromeProjectNavigationNode } from '@kbn/core-chrome-browser'; -import { ReactNode } from 'react'; - -export interface InternalNavigationNode extends Omit { - id: string; - title: string; - deepLink?: ChromeNavLink; -} - -export type UnRegisterFunction = () => void; - -export type RegisterFunction = (navNode: InternalNavigationNode) => { - unregister: UnRegisterFunction; - path: string[]; -}; - -export interface NodeProps { - children?: ((deepLink?: ChromeNavLink) => ReactNode) | ReactNode; - id?: string; - title?: string; - link?: string; - // Temp to test removing nav nodes - onRemove?: () => void; -} diff --git a/packages/shared-ux/chrome/navigation/src/ui/v2/use_init_navnode.ts b/packages/shared-ux/chrome/navigation/src/ui/v2/use_init_navnode.ts deleted file mode 100644 index 6a28f0e07b764..0000000000000 --- a/packages/shared-ux/chrome/navigation/src/ui/v2/use_init_navnode.ts +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { ChromeNavLink } from '@kbn/core-chrome-browser'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import useObservable from 'react-use/lib/useObservable'; - -import { useNavigation as useNavigationServices } from '../../services'; -import { InternalNavigationNode, NodeProps, RegisterFunction, UnRegisterFunction } from './types'; -import { useRegisterTreeNode } from './use_register_tree_node'; - -function getIdFromNavigationNode({ id: _id, link, title }: NodeProps): string { - const id = _id ?? link; - - if (!id) { - throw new Error(`Id or link prop missing for navigation item [${title}]`); - } - - return id; -} - -function createInternalNavNode( - id: string, - _navNode: NodeProps, - deepLinks: Readonly -): InternalNavigationNode | null { - const { children, onRemove, link, ...navNode } = _navNode; - const deepLink = deepLinks.find((dl) => dl.id === link); - const isLinkActive = isNodeActive({ link, deepLink }); - - const titleFromDeepLinkOrChildren = typeof children === 'string' ? children : deepLink?.title; - let title = navNode.title ?? titleFromDeepLinkOrChildren; - - if (!title || title.trim().length === 0) { - if (isLinkActive) { - throw new Error(`Title prop missing for navigation item [${id}]`); - } else { - // No title provided but the node is disabled, so we can safely set it to an empty string - title = ''; - } - } - - if (!isLinkActive) { - return null; - } - - return { - ...navNode, - id, - title, - deepLink, - }; -} - -function isNodeActive({ link, deepLink }: { link?: string; deepLink?: ChromeNavLink }) { - if (link && !deepLink) { - // If a link is provided, but no deepLink is found, don't render anything - return false; - } - return true; -} - -export const useInitNavnode = (node: NodeProps) => { - /** - * Map of children nodes - */ - const childrenNodes = useRef>({}); - /** - * Flag to indicate if the current node has been registered - */ - const isRegistered = useRef(false); - /** - * Reference to the unregister function - */ - const unregisterRef = useRef(); - /** - * Map to keep track of the order of the children when they mount. - * This allows us to keep in sync the nav tree sent to the Chrome service - * with the order of the DOM elements - */ - const orderChildrenRef = useRef>({}); - /** - * Index to keep track of the order of the children when they mount. - */ - const idx = useRef(0); - - /** - * The current node path, including all of its parents. We'll use it to match it against - * the list of active routes based on current URL location (passed by the Chrome service) - */ - const nodePath = useRef([]); - - const { navLinks$ } = useNavigationServices(); - const deepLinks = useObservable(navLinks$, []); - const { register: registerNodeOnParent } = useRegisterTreeNode(); - - const id = getIdFromNavigationNode(node); - - const internalNavNode = useMemo( - () => createInternalNavNode(id, node, deepLinks), - [node, id, deepLinks] - ); - - const register = useCallback(() => { - if (internalNavNode) { - const children = Object.values(childrenNodes.current).sort((a, b) => { - const aOrder = orderChildrenRef.current[a.id]; - const bOrder = orderChildrenRef.current[b.id]; - return aOrder - bOrder; - }); - - const { unregister, path } = registerNodeOnParent({ - ...internalNavNode, - children: children.length ? children : undefined, - }); - - nodePath.current = [...path, internalNavNode.id]; - unregisterRef.current = unregister; - isRegistered.current = true; - } - }, [internalNavNode, registerNodeOnParent]); - - const registerChildNode = useCallback( - (childNode) => { - childrenNodes.current[childNode.id] = childNode; - orderChildrenRef.current[childNode.id] = idx.current++; - - if (isRegistered.current) { - register(); - } - - const unregisterFn = () => { - // Remove the child from this children map - const updatedItems = { ...childrenNodes.current }; - delete updatedItems[childNode.id]; - childrenNodes.current = updatedItems; - - if (isRegistered.current) { - // Update the parent tree - register(); - } - }; - - return { - unregister: unregisterFn, - path: [...nodePath.current], - }; - }, - [register] - ); - - const unregister = useCallback(() => { - isRegistered.current = false; - if (unregisterRef.current) { - unregisterRef.current(); - } - }, []); - - useEffect(() => { - if (internalNavNode) { - register(); - } else { - unregister(); - } - }, [internalNavNode, unregister, register]); - - useEffect(() => unregister, [unregister]); - - return useMemo( - () => ({ - navNode: internalNavNode, - registerChildNode, - }), - [internalNavNode, registerChildNode] - ); -}; diff --git a/src/core/server/integration_tests/saved_objects/migrations/group4/jest.integration.config.js b/src/core/server/integration_tests/saved_objects/migrations/group4/jest.integration.config.js new file mode 100644 index 0000000000000..9b2c7be87ac8e --- /dev/null +++ b/src/core/server/integration_tests/saved_objects/migrations/group4/jest.integration.config.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + // TODO replace the line below with + // preset: '@kbn/test/jest_integration_node + // to do so, we must fix all integration tests first + // see https://github.com/elastic/kibana/pull/130255/ + preset: '@kbn/test/jest_integration', + rootDir: '../../../../../../..', + roots: ['/src/core/server/integration_tests/saved_objects/migrations/group4'], + // must override to match all test given there is no `integration_tests` subfolder + testMatch: ['**/*.test.{js,mjs,ts,tsx}'], +}; diff --git a/src/core/server/integration_tests/saved_objects/migrations/group4/v2_with_mv_same_stack_version.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group4/v2_with_mv_same_stack_version.test.ts new file mode 100644 index 0000000000000..c20a9d1aa6f75 --- /dev/null +++ b/src/core/server/integration_tests/saved_objects/migrations/group4/v2_with_mv_same_stack_version.test.ts @@ -0,0 +1,184 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import fs from 'fs/promises'; +import { range, sortBy } from 'lodash'; +import { type TestElasticsearchUtils } from '@kbn/core-test-helpers-kbn-server'; +import { SavedObjectsBulkCreateObject } from '@kbn/core-saved-objects-api-server'; +import { modelVersionToVirtualVersion } from '@kbn/core-saved-objects-base-server-internal'; +import '../jest_matchers'; +import { getKibanaMigratorTestKit, startElasticsearch } from '../kibana_migrator_test_kit'; +import { delay, createType, parseLogFile } from '../test_utils'; +import { getBaseMigratorParams } from '../fixtures/zdt_base.fixtures'; + +const logFilePath = Path.join(__dirname, 'v2_with_mv_same_stack_version.test.log'); + +const NB_DOCS_PER_TYPE = 25; + +describe('V2 algorithm - using model versions - upgrade without stack version increase', () => { + let esServer: TestElasticsearchUtils['es']; + + beforeAll(async () => { + await fs.unlink(logFilePath).catch(() => {}); + esServer = await startElasticsearch(); + }); + + afterAll(async () => { + await esServer?.stop(); + await delay(10); + }); + + const getTestModelVersionType = ({ beforeUpgrade }: { beforeUpgrade: boolean }) => { + const type = createType({ + name: 'test_mv', + namespaceType: 'single', + migrations: {}, + switchToModelVersionAt: '8.8.0', + modelVersions: { + 1: { + changes: [], + }, + }, + mappings: { + properties: { + field1: { type: 'text' }, + field2: { type: 'text' }, + }, + }, + }); + + if (!beforeUpgrade) { + Object.assign>(type, { + modelVersions: { + ...type.modelVersions, + 2: { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + field3: { type: 'text' }, + }, + }, + { + type: 'data_backfill', + transform: (document) => { + document.attributes.field3 = 'test_mv-backfilled'; + return { document }; + }, + }, + ], + }, + }, + mappings: { + ...type.mappings, + properties: { + ...type.mappings.properties, + field3: { type: 'text' }, + }, + }, + }); + } + + return type; + }; + + const createBaseline = async () => { + const { runMigrations, savedObjectsRepository } = await getKibanaMigratorTestKit({ + ...getBaseMigratorParams({ + migrationAlgorithm: 'v2', + kibanaVersion: '8.8.0', + }), + types: [getTestModelVersionType({ beforeUpgrade: true })], + }); + await runMigrations(); + + const mvObjs = range(NB_DOCS_PER_TYPE).map((number) => ({ + id: `mv-${String(number).padStart(3, '0')}`, + type: 'test_mv', + attributes: { + field1: `f1-${number}`, + field2: `f2-${number}`, + }, + })); + + await savedObjectsRepository.bulkCreate(mvObjs); + }; + + it('migrates the documents', async () => { + await createBaseline(); + + const modelVersionType = getTestModelVersionType({ beforeUpgrade: false }); + + const { runMigrations, client, savedObjectsRepository } = await getKibanaMigratorTestKit({ + ...getBaseMigratorParams({ migrationAlgorithm: 'v2', kibanaVersion: '8.8.0' }), + logFilePath, + types: [modelVersionType], + }); + await runMigrations(); + + const indices = await client.indices.get({ index: '.kibana*' }); + expect(Object.keys(indices)).toEqual(['.kibana_8.8.0_001']); + + const index = indices['.kibana_8.8.0_001']; + const mappings = index.mappings ?? {}; + const mappingMeta = mappings._meta ?? {}; + + expect(mappings.properties).toEqual( + expect.objectContaining({ + test_mv: modelVersionType.mappings, + }) + ); + + expect(mappingMeta).toEqual({ + indexTypesMap: { + '.kibana': ['test_mv'], + }, + migrationMappingPropertyHashes: expect.any(Object), + }); + + const { saved_objects: testMvDocs } = await savedObjectsRepository.find({ + type: 'test_mv', + perPage: 1000, + }); + + expect(testMvDocs).toHaveLength(NB_DOCS_PER_TYPE); + + const testMvData = sortBy(testMvDocs, 'id').map((object) => ({ + id: object.id, + type: object.type, + attributes: object.attributes, + version: object.typeMigrationVersion, + })); + + expect(testMvData).toEqual( + range(NB_DOCS_PER_TYPE).map((number) => ({ + id: `mv-${String(number).padStart(3, '0')}`, + type: 'test_mv', + attributes: { + field1: `f1-${number}`, + field2: `f2-${number}`, + field3: 'test_mv-backfilled', + }, + version: modelVersionToVirtualVersion(2), + })) + ); + + const records = await parseLogFile(logFilePath); + expect(records).toContainLogEntries( + [ + 'INIT -> WAIT_FOR_YELLOW_SOURCE', + 'CHECK_TARGET_MAPPINGS -> UPDATE_TARGET_MAPPINGS_PROPERTIES', + 'Migration completed', + ], + { + ordered: true, + } + ); + }); +}); diff --git a/src/core/server/integration_tests/saved_objects/migrations/group4/v2_with_mv_stack_version_bump.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group4/v2_with_mv_stack_version_bump.test.ts new file mode 100644 index 0000000000000..e259e0f12d178 --- /dev/null +++ b/src/core/server/integration_tests/saved_objects/migrations/group4/v2_with_mv_stack_version_bump.test.ts @@ -0,0 +1,278 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import fs from 'fs/promises'; +import { range, sortBy } from 'lodash'; +import { type TestElasticsearchUtils } from '@kbn/core-test-helpers-kbn-server'; +import { SavedObjectsBulkCreateObject } from '@kbn/core-saved-objects-api-server'; +import { modelVersionToVirtualVersion } from '@kbn/core-saved-objects-base-server-internal'; +import '../jest_matchers'; +import { getKibanaMigratorTestKit, startElasticsearch } from '../kibana_migrator_test_kit'; +import { delay, createType, parseLogFile } from '../test_utils'; +import { getBaseMigratorParams } from '../fixtures/zdt_base.fixtures'; + +const logFilePath = Path.join(__dirname, 'v2_with_mv_stack_version_bump.test.log'); + +const NB_DOCS_PER_TYPE = 100; + +describe('V2 algorithm - using model versions - stack version bump scenario', () => { + let esServer: TestElasticsearchUtils['es']; + + beforeAll(async () => { + await fs.unlink(logFilePath).catch(() => {}); + esServer = await startElasticsearch(); + }); + + afterAll(async () => { + await esServer?.stop(); + await delay(10); + }); + + const getTestSwitchType = ({ beforeUpgrade }: { beforeUpgrade: boolean }) => { + const type = createType({ + name: 'test_switch', + namespaceType: 'single', + migrations: { + '8.7.0': (doc) => { + return doc; + }, + }, + modelVersions: {}, + mappings: { + properties: { + field1: { type: 'text' }, + field2: { type: 'text' }, + }, + }, + }); + + if (!beforeUpgrade) { + Object.assign>(type, { + switchToModelVersionAt: '8.8.0', + modelVersions: { + 1: { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + field3: { type: 'text' }, + }, + }, + { + type: 'data_backfill', + transform: (document) => { + document.attributes.field3 = 'test_switch-backfilled'; + return { document }; + }, + }, + ], + }, + }, + mappings: { + ...type.mappings, + properties: { + ...type.mappings.properties, + field3: { type: 'text' }, + }, + }, + }); + } + + return type; + }; + + const getTestModelVersionType = ({ beforeUpgrade }: { beforeUpgrade: boolean }) => { + const type = createType({ + name: 'test_mv', + namespaceType: 'single', + migrations: {}, + switchToModelVersionAt: '8.8.0', + modelVersions: { + 1: { + changes: [], + }, + }, + mappings: { + properties: { + field1: { type: 'text' }, + field2: { type: 'text' }, + }, + }, + }); + + if (!beforeUpgrade) { + Object.assign>(type, { + modelVersions: { + ...type.modelVersions, + 2: { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + field3: { type: 'text' }, + }, + }, + { + type: 'data_backfill', + transform: (document) => { + document.attributes.field3 = 'test_mv-backfilled'; + return { document }; + }, + }, + ], + }, + }, + mappings: { + ...type.mappings, + properties: { + ...type.mappings.properties, + field3: { type: 'text' }, + }, + }, + }); + } + + return type; + }; + + const createBaseline = async () => { + const { runMigrations, savedObjectsRepository } = await getKibanaMigratorTestKit({ + ...getBaseMigratorParams({ + migrationAlgorithm: 'v2', + kibanaVersion: '8.8.0', + }), + types: [ + getTestSwitchType({ beforeUpgrade: true }), + getTestModelVersionType({ beforeUpgrade: true }), + ], + }); + await runMigrations(); + + const switchObjs = range(NB_DOCS_PER_TYPE).map((number) => ({ + id: `switch-${String(number).padStart(3, '0')}`, + type: 'test_switch', + attributes: { + field1: `f1-${number}`, + field2: `f2-${number}`, + }, + })); + + await savedObjectsRepository.bulkCreate(switchObjs); + + const mvObjs = range(NB_DOCS_PER_TYPE).map((number) => ({ + id: `mv-${String(number).padStart(3, '0')}`, + type: 'test_mv', + attributes: { + field1: `f1-${number}`, + field2: `f2-${number}`, + }, + })); + + await savedObjectsRepository.bulkCreate(mvObjs); + }; + + it('migrates the documents', async () => { + await createBaseline(); + + const switchType = getTestSwitchType({ beforeUpgrade: false }); + const modelVersionType = getTestModelVersionType({ beforeUpgrade: false }); + + const { runMigrations, client, savedObjectsRepository } = await getKibanaMigratorTestKit({ + ...getBaseMigratorParams({ migrationAlgorithm: 'v2' }), + logFilePath, + types: [switchType, modelVersionType], + }); + await runMigrations(); + + const indices = await client.indices.get({ index: '.kibana*' }); + expect(Object.keys(indices)).toEqual(['.kibana_8.8.0_001']); + + const index = indices['.kibana_8.8.0_001']; + const mappings = index.mappings ?? {}; + const mappingMeta = mappings._meta ?? {}; + + expect(mappings.properties).toEqual( + expect.objectContaining({ + test_switch: switchType.mappings, + test_mv: modelVersionType.mappings, + }) + ); + + expect(mappingMeta).toEqual({ + indexTypesMap: { + '.kibana': ['test_mv', 'test_switch'], + }, + migrationMappingPropertyHashes: expect.any(Object), + }); + + const { saved_objects: testSwitchDocs } = await savedObjectsRepository.find({ + type: 'test_switch', + perPage: 1000, + }); + const { saved_objects: testMvDocs } = await savedObjectsRepository.find({ + type: 'test_mv', + perPage: 1000, + }); + + expect(testSwitchDocs).toHaveLength(NB_DOCS_PER_TYPE); + expect(testMvDocs).toHaveLength(NB_DOCS_PER_TYPE); + + const testSwitchDocsData = sortBy(testSwitchDocs, 'id').map((object) => ({ + id: object.id, + type: object.type, + attributes: object.attributes, + version: object.typeMigrationVersion, + })); + + expect(testSwitchDocsData).toEqual( + range(NB_DOCS_PER_TYPE).map((number) => ({ + id: `switch-${String(number).padStart(3, '0')}`, + type: 'test_switch', + attributes: { + field1: `f1-${number}`, + field2: `f2-${number}`, + field3: 'test_switch-backfilled', + }, + version: modelVersionToVirtualVersion(1), + })) + ); + + const testMvData = sortBy(testMvDocs, 'id').map((object) => ({ + id: object.id, + type: object.type, + attributes: object.attributes, + version: object.typeMigrationVersion, + })); + + expect(testMvData).toEqual( + range(NB_DOCS_PER_TYPE).map((number) => ({ + id: `mv-${String(number).padStart(3, '0')}`, + type: 'test_mv', + attributes: { + field1: `f1-${number}`, + field2: `f2-${number}`, + field3: 'test_mv-backfilled', + }, + version: modelVersionToVirtualVersion(2), + })) + ); + + const records = await parseLogFile(logFilePath); + expect(records).toContainLogEntries( + [ + 'INIT -> WAIT_FOR_YELLOW_SOURCE', + 'CLEANUP_UNKNOWN_AND_EXCLUDED_WAIT_FOR_TASK -> PREPARE_COMPATIBLE_MIGRATION', + 'Migration completed', + ], + { + ordered: true, + } + ); + }); +}); diff --git a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx index bf26ffaade95a..64f857d0d9aed 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx @@ -251,7 +251,7 @@ function DiscoverDocumentsComponent({ useNewFieldsApi={useNewFieldsApi} rowHeightState={rowHeight} onUpdateRowHeight={onUpdateRowHeight} - isSortEnabled={!isPlainRecord} + isSortEnabled={true} isPlainRecord={isPlainRecord} query={query} rowsPerPageState={rowsPerPage} diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid.test.tsx index 97e951635288f..33f507b43333b 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid.test.tsx @@ -209,4 +209,27 @@ describe('DiscoverGrid', () => { expect(findTestSubject(component, 'gridEditFieldButton').exists()).toBe(false); }); }); + + describe('sorting', () => { + it('should enable in memory sorting with plain records', () => { + const component = getComponent({ + ...getProps(), + columns: ['message'], + isPlainRecord: true, + }); + + expect( + ( + findTestSubject(component, 'docTable') + .find('EuiDataGridInMemoryRenderer') + .first() + .props() as Record + ).inMemory + ).toMatchInlineSnapshot(` + Object { + "level": "sorting", + } + `); + }); + }); }); diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid.tsx index 34ddd0c995666..69748b449b05c 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid.tsx @@ -362,13 +362,18 @@ export const DiscoverGrid = ({ */ const sortingColumns = useMemo(() => sort.map(([id, direction]) => ({ id, direction })), [sort]); + const [inmemorySortingColumns, setInmemorySortingColumns] = useState([]); const onTableSort = useCallback( (sortingColumnsData) => { - if (isSortEnabled && onSort) { - onSort(sortingColumnsData.map(({ id, direction }: SortObj) => [id, direction])); + if (isSortEnabled) { + if (isPlainRecord) { + setInmemorySortingColumns(sortingColumnsData); + } else if (onSort) { + onSort(sortingColumnsData.map(({ id, direction }: SortObj) => [id, direction])); + } } }, - [onSort, isSortEnabled] + [onSort, isSortEnabled, isPlainRecord, setInmemorySortingColumns] ); const showMultiFields = services.uiSettings.get(SHOW_MULTIFIELDS); @@ -437,6 +442,7 @@ export const DiscoverGrid = ({ showTimeCol, defaultColumns, isSortEnabled, + isPlainRecord, services: { uiSettings, toastNotifications, @@ -455,6 +461,7 @@ export const DiscoverGrid = ({ settings, defaultColumns, isSortEnabled, + isPlainRecord, uiSettings, toastNotifications, dataViewFieldEditor, @@ -479,10 +486,13 @@ export const DiscoverGrid = ({ ); const sorting = useMemo(() => { if (isSortEnabled) { - return { columns: sortingColumns, onSort: onTableSort }; + return { + columns: isPlainRecord ? inmemorySortingColumns : sortingColumns, + onSort: onTableSort, + }; } return { columns: sortingColumns, onSort: () => {} }; - }, [sortingColumns, onTableSort, isSortEnabled]); + }, [isSortEnabled, sortingColumns, isPlainRecord, inmemorySortingColumns, onTableSort]); const canSetExpandedDoc = Boolean(setExpandedDoc && DocumentView); @@ -619,6 +629,7 @@ export const DiscoverGrid = ({ sorting={sorting as EuiDataGridSorting} toolbarVisibility={toolbarVisibility} rowHeightsOptions={rowHeightsOptions} + inMemory={isPlainRecord ? { level: 'sorting' } : undefined} gridStyle={GRID_STYLE} /> diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx index fd7122fccbd95..c4c68bf0132d0 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx @@ -21,6 +21,7 @@ describe('Discover grid columns', function () { showTimeCol: false, defaultColumns: false, isSortEnabled: true, + isPlainRecord: false, valueToStringConverter: discoverGridContextMock.valueToStringConverter, rowsCount: 100, services: { @@ -140,6 +141,7 @@ describe('Discover grid columns', function () { showTimeCol: false, defaultColumns: true, isSortEnabled: true, + isPlainRecord: false, valueToStringConverter: discoverGridContextMock.valueToStringConverter, rowsCount: 100, services: { @@ -253,6 +255,7 @@ describe('Discover grid columns', function () { showTimeCol: true, defaultColumns: false, isSortEnabled: true, + isPlainRecord: false, valueToStringConverter: discoverGridContextMock.valueToStringConverter, rowsCount: 100, services: { @@ -429,4 +432,190 @@ describe('Discover grid columns', function () { ] `); }); + + it('returns eui grid with inmemory sorting', async () => { + const actual = getEuiGridColumns({ + columns: ['extension', 'message'], + settings: {}, + dataView: dataViewWithTimefieldMock, + showTimeCol: true, + defaultColumns: false, + isSortEnabled: true, + isPlainRecord: true, + valueToStringConverter: discoverGridContextMock.valueToStringConverter, + rowsCount: 100, + services: { + uiSettings: discoverServiceMock.uiSettings, + toastNotifications: discoverServiceMock.toastNotifications, + }, + hasEditDataViewPermission: () => + discoverServiceMock.dataViewFieldEditor.userPermissions.editIndexPattern(), + onFilter: () => {}, + }); + expect(actual).toMatchInlineSnapshot(` + Array [ + Object { + "actions": Object { + "additional": Array [ + Object { + "data-test-subj": "gridCopyColumnNameToClipBoardButton", + "iconProps": Object { + "size": "m", + }, + "iconType": "copyClipboard", + "label": , + "onClick": [Function], + "size": "xs", + }, + Object { + "data-test-subj": "gridCopyColumnValuesToClipBoardButton", + "iconProps": Object { + "size": "m", + }, + "iconType": "copyClipboard", + "label": , + "onClick": [Function], + "size": "xs", + }, + ], + "showHide": false, + "showMoveLeft": true, + "showMoveRight": true, + }, + "cellActions": Array [ + [Function], + [Function], + [Function], + ], + "display":
    + + + timestamp + + + + +
    , + "displayAsText": "timestamp", + "id": "timestamp", + "initialWidth": 210, + "isSortable": true, + "schema": "datetime", + }, + Object { + "actions": Object { + "additional": Array [ + Object { + "data-test-subj": "gridCopyColumnNameToClipBoardButton", + "iconProps": Object { + "size": "m", + }, + "iconType": "copyClipboard", + "label": , + "onClick": [Function], + "size": "xs", + }, + Object { + "data-test-subj": "gridCopyColumnValuesToClipBoardButton", + "iconProps": Object { + "size": "m", + }, + "iconType": "copyClipboard", + "label": , + "onClick": [Function], + "size": "xs", + }, + ], + "showHide": Object { + "iconType": "cross", + "label": "Remove column", + }, + "showMoveLeft": true, + "showMoveRight": true, + }, + "cellActions": Array [ + [Function], + [Function], + [Function], + ], + "displayAsText": "extension", + "id": "extension", + "isSortable": true, + "schema": "string", + }, + Object { + "actions": Object { + "additional": Array [ + Object { + "data-test-subj": "gridCopyColumnNameToClipBoardButton", + "iconProps": Object { + "size": "m", + }, + "iconType": "copyClipboard", + "label": , + "onClick": [Function], + "size": "xs", + }, + Object { + "data-test-subj": "gridCopyColumnValuesToClipBoardButton", + "iconProps": Object { + "size": "m", + }, + "iconType": "copyClipboard", + "label": , + "onClick": [Function], + "size": "xs", + }, + ], + "showHide": Object { + "iconType": "cross", + "label": "Remove column", + }, + "showMoveLeft": true, + "showMoveRight": true, + }, + "cellActions": Array [ + [Function], + ], + "displayAsText": "message", + "id": "message", + "isSortable": true, + "schema": "string", + }, + ] + `); + }); }); diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.tsx index 6e4c0ec619e2b..b341d6236d235 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.tsx @@ -65,6 +65,7 @@ function buildEuiGridColumn({ dataView, defaultColumns, isSortEnabled, + isPlainRecord, toastNotifications, hasEditDataViewPermission, valueToStringConverter, @@ -77,6 +78,7 @@ function buildEuiGridColumn({ dataView: DataView; defaultColumns: boolean; isSortEnabled: boolean; + isPlainRecord?: boolean; toastNotifications: ToastsStart; hasEditDataViewPermission: () => boolean; valueToStringConverter: ValueToStringConverter; @@ -99,7 +101,7 @@ function buildEuiGridColumn({ const column: EuiDataGridColumn = { id: columnName, schema: getSchemaByKbnType(dataViewField?.type), - isSortable: isSortEnabled && dataViewField?.sortable === true, + isSortable: isSortEnabled && (isPlainRecord || dataViewField?.sortable === true), displayAsText: columnDisplayName, actions: { showHide: @@ -176,6 +178,7 @@ export function getEuiGridColumns({ showTimeCol, defaultColumns, isSortEnabled, + isPlainRecord, services, hasEditDataViewPermission, valueToStringConverter, @@ -189,6 +192,7 @@ export function getEuiGridColumns({ showTimeCol: boolean; defaultColumns: boolean; isSortEnabled: boolean; + isPlainRecord?: boolean; services: { uiSettings: IUiSettingsClient; toastNotifications: ToastsStart; @@ -213,6 +217,7 @@ export function getEuiGridColumns({ dataView, defaultColumns, isSortEnabled, + isPlainRecord, toastNotifications: services.toastNotifications, hasEditDataViewPermission, valueToStringConverter, diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx index fa0a74f5d0205..eb6788f9bfe7e 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx @@ -258,7 +258,7 @@ export class SavedSearchEmbeddable this.searchProps!.isLoading = false; this.searchProps!.isPlainRecord = true; this.searchProps!.showTimeCol = false; - this.searchProps!.isSortEnabled = false; + this.searchProps!.isSortEnabled = true; return; } diff --git a/src/plugins/unified_search/public/autocomplete/providers/value_suggestion_provider.ts b/src/plugins/unified_search/public/autocomplete/providers/value_suggestion_provider.ts index 3b64ea759316b..7ce405d124533 100644 --- a/src/plugins/unified_search/public/autocomplete/providers/value_suggestion_provider.ts +++ b/src/plugins/unified_search/public/autocomplete/providers/value_suggestion_provider.ts @@ -65,7 +65,7 @@ export const setupValueSuggestionProvider = ( ) => { usageCollector?.trackRequest(); return core.http - .fetch(`/api/kibana/suggestions/values/${index}`, { + .fetch(`/internal/kibana/suggestions/values/${index}`, { method: 'POST', body: JSON.stringify({ query, @@ -75,6 +75,7 @@ export const setupValueSuggestionProvider = ( method, }), signal, + version: '1', }) .then((r) => { usageCollector?.trackResult(); diff --git a/src/plugins/unified_search/server/autocomplete/value_suggestions_route.ts b/src/plugins/unified_search/server/autocomplete/value_suggestions_route.ts index 74a7e5202a541..00a5d3eba3a13 100644 --- a/src/plugins/unified_search/server/autocomplete/value_suggestions_route.ts +++ b/src/plugins/unified_search/server/autocomplete/value_suggestions_route.ts @@ -16,55 +16,62 @@ import { termsEnumSuggestions } from './terms_enum'; import { termsAggSuggestions } from './terms_agg'; export function registerValueSuggestionsRoute(router: IRouter, config$: Observable) { - router.post( - { - path: '/api/kibana/suggestions/values/{index}', - validate: { - params: schema.object( - { - index: schema.string(), - }, - { unknowns: 'allow' } - ), - body: schema.object( - { - field: schema.string(), - query: schema.string(), - filters: schema.maybe(schema.any()), - fieldMeta: schema.maybe(schema.any()), - method: schema.maybe( - schema.oneOf([schema.literal('terms_agg'), schema.literal('terms_enum')]) + router.versioned + .post({ + path: '/internal/kibana/suggestions/values/{index}', + access: 'internal', + }) + .addVersion( + { + version: '1', + validate: { + request: { + params: schema.object( + { + index: schema.string(), + }, + { unknowns: 'allow' } + ), + body: schema.object( + { + field: schema.string(), + query: schema.string(), + filters: schema.maybe(schema.any()), + fieldMeta: schema.maybe(schema.any()), + method: schema.maybe( + schema.oneOf([schema.literal('terms_agg'), schema.literal('terms_enum')]) + ), + }, + { unknowns: 'allow' } ), }, - { unknowns: 'allow' } - ), + }, }, - }, - async (context, request, response) => { - const config = await firstValueFrom(config$); - const { field: fieldName, query, filters, fieldMeta, method } = request.body; - const { index } = request.params; - const abortSignal = getRequestAbortedSignal(request.events.aborted$); - const { savedObjects, elasticsearch } = await context.core; + async (context, request, response) => { + const config = await firstValueFrom(config$); + const { field: fieldName, query, filters, fieldMeta, method } = request.body; + const { index } = request.params; + const abortSignal = getRequestAbortedSignal(request.events.aborted$); + const { savedObjects, elasticsearch } = await context.core; - try { - const fn = method === 'terms_agg' ? termsAggSuggestions : termsEnumSuggestions; - const body = await fn( - config, - savedObjects.client, - elasticsearch.client.asCurrentUser, - index, - fieldName, - query, - filters, - fieldMeta, - abortSignal - ); - return response.ok({ body }); - } catch (e) { - const kbnErr = getKbnServerError(e); - return reportServerError(response, kbnErr); + try { + const fn = method === 'terms_agg' ? termsAggSuggestions : termsEnumSuggestions; + const body = await fn( + config, + savedObjects.client, + elasticsearch.client.asCurrentUser, + index, + fieldName, + query, + filters, + fieldMeta, + abortSignal + ); + return response.ok({ body }); + } catch (e) { + const kbnErr = getKbnServerError(e); + return reportServerError(response, kbnErr); + } } - } - ); + ); } diff --git a/test/api_integration/apis/suggestions/suggestions.js b/test/api_integration/apis/suggestions/suggestions.js index ea8da57eda065..928fd995a4b5d 100644 --- a/test/api_integration/apis/suggestions/suggestions.js +++ b/test/api_integration/apis/suggestions/suggestions.js @@ -6,6 +6,7 @@ * Side Public License, v 1. */ import expect from '@kbn/expect'; +import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; export default function ({ getService }) { const esArchiver = getService('esArchiver'); @@ -32,7 +33,8 @@ export default function ({ getService }) { }); it('should return 200 without a query', () => supertest - .post('/api/kibana/suggestions/values/basic_index') + .post('/internal/kibana/suggestions/values/basic_index') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .send({ field: 'baz.keyword', query: '', @@ -45,7 +47,8 @@ export default function ({ getService }) { it('should return 200 without a query and with method set to terms_agg', () => supertest - .post('/api/kibana/suggestions/values/basic_index') + .post('/internal/kibana/suggestions/values/basic_index') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .send({ field: 'baz.keyword', method: 'terms_agg', @@ -59,7 +62,8 @@ export default function ({ getService }) { it('should return 200 without a query and with method set to terms_enum', () => supertest - .post('/api/kibana/suggestions/values/basic_index') + .post('/internal/kibana/suggestions/values/basic_index') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .send({ field: 'baz.keyword', method: 'terms_enum', @@ -73,7 +77,8 @@ export default function ({ getService }) { it('should return 200 with special characters', () => supertest - .post('/api/kibana/suggestions/values/basic_index') + .post('/internal/kibana/suggestions/values/basic_index') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .send({ field: 'baz.keyword', query: ' supertest - .post('/api/kibana/suggestions/values/basic_index') + .post('/internal/kibana/suggestions/values/basic_index') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .send({ field: 'nestedField.child', query: 'nes', @@ -94,7 +100,8 @@ export default function ({ getService }) { it('should return 404 if index is not found', () => supertest - .post('/api/kibana/suggestions/values/not_found') + .post('/internal/kibana/suggestions/values/not_found') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .send({ field: 'baz.keyword', query: '1', @@ -103,7 +110,8 @@ export default function ({ getService }) { it('should return 400 without a query', () => supertest - .post('/api/kibana/suggestions/values/basic_index') + .post('/internal/kibana/suggestions/values/basic_index') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .send({ field: 'baz.keyword', }) @@ -111,7 +119,8 @@ export default function ({ getService }) { it('should return 400 with a bad method', () => supertest - .post('/api/kibana/suggestions/values/basic_index') + .post('/internal/kibana/suggestions/values/basic_index') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .send({ field: 'baz.keyword', query: '', @@ -138,7 +147,8 @@ export default function ({ getService }) { it('filter is applied on a document level with terms_agg', () => supertest - .post('/api/kibana/suggestions/values/logstash-*') + .post('/internal/kibana/suggestions/values/logstash-*') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .send({ field: 'extension.raw', query: '', @@ -163,7 +173,8 @@ export default function ({ getService }) { it('filter returns all results because it was applied on an index level with terms_enum', () => supertest - .post('/api/kibana/suggestions/values/logstash-*') + .post('/internal/kibana/suggestions/values/logstash-*') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .send({ field: 'extension.raw', query: '', @@ -188,7 +199,8 @@ export default function ({ getService }) { it('filter is applied on an index level with terms_enum - find in range', () => supertest - .post('/api/kibana/suggestions/values/logstash-*') + .post('/internal/kibana/suggestions/values/logstash-*') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .send({ field: 'request.raw', query: '/uploads/anatoly-art', @@ -212,7 +224,8 @@ export default function ({ getService }) { it('filter is applied on an index level with terms_enum - DONT find in range', () => { supertest - .post('/api/kibana/suggestions/values/logstash-*') + .post('/internal/kibana/suggestions/values/logstash-*') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .send({ field: 'request.raw', query: '/uploads/anatoly-art', diff --git a/test/functional/apps/discover/group2/_sql_view.ts b/test/functional/apps/discover/group2/_sql_view.ts index e2cfb68cef5b5..6374ee405c70a 100644 --- a/test/functional/apps/discover/group2/_sql_view.ts +++ b/test/functional/apps/discover/group2/_sql_view.ts @@ -77,8 +77,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await testSubjects.exists('unifiedHistogramQueryHits')).to.be(true); expect(await testSubjects.exists('discoverAlertsButton')).to.be(false); expect(await testSubjects.exists('shareTopNavButton')).to.be(true); + expect(await testSubjects.exists('dataGridColumnSortingButton')).to.be(true); expect(await testSubjects.exists('docTableExpandToggleColumn')).to.be(true); - expect(await testSubjects.exists('dataGridColumnSortingButton')).to.be(false); expect(await testSubjects.exists('fieldListFiltersFieldTypeFilterToggle')).to.be(true); await testSubjects.click('field-@message-showDetails'); expect(await testSubjects.exists('discoverFieldListPanelEditItem')).to.be(false); diff --git a/test/tsconfig.json b/test/tsconfig.json index d3454cc99745a..165093dce8572 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -67,6 +67,7 @@ "@kbn/dev-proc-runner", "@kbn/enterprise-search-plugin", "@kbn/core-saved-objects-server", - "@kbn/discover-plugin" + "@kbn/discover-plugin", + "@kbn/core-http-common" ] } diff --git a/x-pack/plugins/cases/common/utils/owner.test.ts b/x-pack/plugins/cases/common/utils/owner.test.ts index 09016880c0a95..94e2c50ea7f08 100644 --- a/x-pack/plugins/cases/common/utils/owner.test.ts +++ b/x-pack/plugins/cases/common/utils/owner.test.ts @@ -6,16 +6,30 @@ */ import { OWNER_INFO } from '../constants'; -import { isValidOwner } from './owner'; +import { getCaseOwnerByAppId, isValidOwner } from './owner'; -describe('isValidOwner', () => { - const owners = Object.keys(OWNER_INFO) as Array; +describe('owner utils', () => { + describe('isValidOwner', () => { + const owners = Object.keys(OWNER_INFO) as Array; - it.each(owners)('returns true for valid owner: %s', (owner) => { - expect(isValidOwner(owner)).toBe(true); + it.each(owners)('returns true for valid owner: %s', (owner) => { + expect(isValidOwner(owner)).toBe(true); + }); + + it('return false for invalid owner', () => { + expect(isValidOwner('not-valid')).toBe(false); + }); }); - it('return false for invalid owner', () => { - expect(isValidOwner('not-valid')).toBe(false); + describe('getCaseOwnerByAppId', () => { + const tests = Object.values(OWNER_INFO).map((info) => [info.id, info.appId]); + + it.each(tests)('for owner %s it returns %s', (owner, appId) => { + expect(getCaseOwnerByAppId(appId)).toBe(owner); + }); + + it('return undefined for invalid application ID', () => { + expect(getCaseOwnerByAppId('not-valid')).toBe(undefined); + }); }); }); diff --git a/x-pack/plugins/cases/common/utils/owner.ts b/x-pack/plugins/cases/common/utils/owner.ts index 44068f36f0d3f..cd817a59a375e 100644 --- a/x-pack/plugins/cases/common/utils/owner.ts +++ b/x-pack/plugins/cases/common/utils/owner.ts @@ -9,3 +9,6 @@ import { OWNER_INFO } from '../constants'; export const isValidOwner = (owner: string): owner is keyof typeof OWNER_INFO => Object.keys(OWNER_INFO).includes(owner); + +export const getCaseOwnerByAppId = (currentAppId?: string) => + Object.values(OWNER_INFO).find((info) => info.appId === currentAppId)?.id; diff --git a/x-pack/plugins/cases/kibana.jsonc b/x-pack/plugins/cases/kibana.jsonc index a37320379b76d..6604dc63402ef 100644 --- a/x-pack/plugins/cases/kibana.jsonc +++ b/x-pack/plugins/cases/kibana.jsonc @@ -28,7 +28,8 @@ "ruleRegistry", "files", "savedObjectsFinder", - "savedObjectsManagement" + "savedObjectsManagement", + "uiActions", ], "optionalPlugins": [ "home", 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 ab06c1be0bf02..b1248488e5286 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/services.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/services.ts @@ -8,7 +8,7 @@ import type { CoreStart } from '@kbn/core/public'; import type { CasesUiConfigType } from '../../../../common/ui/types'; -type GlobalServices = Pick; +type GlobalServices = Pick; export class KibanaServices { private static kibanaVersion?: string; @@ -16,14 +16,16 @@ export class KibanaServices { private static config?: CasesUiConfigType; public static init({ + application, + config, http, kibanaVersion, - config, + theme, }: GlobalServices & { kibanaVersion: string; config: CasesUiConfigType; }) { - this.services = { http }; + this.services = { application, http, theme }; this.kibanaVersion = kibanaVersion; this.config = config; } diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index eecde112f3907..5126aadc32fea 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -66,7 +66,6 @@ const useKibanaMock = useKibana as jest.MockedFunction; const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock; const useUpdateCaseMock = useUpdateCase as jest.Mock; const useLicenseMock = useLicense as jest.Mock; - const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); const mockKibana = () => { @@ -165,7 +164,6 @@ describe('AllCasesListGeneric', () => { it('should render AllCasesList', async () => { useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => true }); - appMockRenderer.render(); await waitFor(() => { @@ -260,6 +258,21 @@ describe('AllCasesListGeneric', () => { }); }); + it('should not call onCreateCasePressed if onRowClick is not provided when create case from case page', async () => { + useGetCasesMock.mockReturnValue({ + ...defaultGetCases, + data: { + ...defaultGetCases.data, + cases: [], + }, + }); + appMockRenderer.render(); + userEvent.click(screen.getByTestId('cases-table-add-case')); + await waitFor(() => { + expect(onRowClick).not.toHaveBeenCalled(); + }); + }); + it('should tableHeaderSortButton AllCasesList', async () => { appMockRenderer.render(); @@ -347,9 +360,10 @@ describe('AllCasesListGeneric', () => { it('should call onRowClick with no cases and isSelectorView=true when create case is clicked', async () => { appMockRenderer.render(); userEvent.click(screen.getByTestId('cases-table-add-case-filter-bar')); - + const isCreateCase = true; await waitFor(() => { expect(onRowClick).toHaveBeenCalled(); + expect(onRowClick).toBeCalledWith(undefined, isCreateCase); }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index e63315d7e6299..d0fc6f038e1e6 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -69,7 +69,7 @@ const mapToReadableSolutionName = (solution: string): Solution => { export interface AllCasesListProps { hiddenStatuses?: CaseStatusWithAllStatus[]; isSelectorView?: boolean; - onRowClick?: (theCase?: CaseUI) => void; + onRowClick?: (theCase?: CaseUI, isCreateCase?: boolean) => void; } export const AllCasesList = React.memo( @@ -250,6 +250,10 @@ export const AllCasesList = React.memo( mapToReadableSolutionName(solution) ); + const onCreateCasePressed = useCallback(() => { + onRowClick?.(undefined, true); + }, [onRowClick]); + return ( <> ( severity: filterOptions.severity, }} hiddenStatuses={hiddenStatuses} - onCreateCasePressed={onRowClick} + onCreateCasePressed={onCreateCasePressed} isSelectorView={isSelectorView} isLoading={isLoadingCurrentUserProfile} currentUserProfile={currentUserProfile} @@ -284,7 +288,7 @@ export const AllCasesList = React.memo( void; - onClose?: () => void; + onClose?: (theCase?: CaseUI, isCreateCase?: boolean) => void; + onCreateCaseClicked?: () => void; } const Modal = styled(EuiModal)` @@ -37,20 +38,18 @@ export const AllCasesSelectorModal = React.memo( ({ hiddenStatuses, onRowClick, onClose }) => { const [isModalOpen, setIsModalOpen] = useState(true); const closeModal = useCallback(() => { - if (onClose) { - onClose(); - } + onClose?.(); setIsModalOpen(false); }, [onClose]); const onClick = useCallback( - (theCase?: CaseUI) => { - closeModal(); - if (onRowClick) { - onRowClick(theCase); - } + (theCase?: CaseUI, isCreateCase?: boolean) => { + onClose?.(theCase, isCreateCase); + setIsModalOpen(false); + + onRowClick?.(theCase); }, - [closeModal, onRowClick] + [onClose, onRowClick] ); return isModalOpen ? ( diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx index 8e5a27ae06b5e..301baba1d1ccd 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx @@ -106,13 +106,13 @@ export const useCasesAddToExistingCaseModal = (props: AddToExistingCaseModalProp } }, [ - props, + appId, + casesToasts, closeModal, + createAttachments, createNewCaseFlyout, + props, startTransaction, - appId, - createAttachments, - casesToasts, ] ); @@ -130,11 +130,11 @@ export const useCasesAddToExistingCaseModal = (props: AddToExistingCaseModalProp onRowClick: (theCase?: CaseUI) => { handleOnRowClick(theCase, getAttachments); }, - onClose: () => { + onClose: (theCase?: CaseUI, isCreateCase?: boolean) => { closeModal(); if (props.onClose) { - return props.onClose(); + return props.onClose(theCase, isCreateCase); } }, }, diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx index 850c3b3060a9f..791782001a5f9 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx @@ -36,6 +36,7 @@ import { defaultInfiniteUseFindCaseUserActions, defaultUseFindCaseUserActions } import { ActionTypes } from '../../../../common/api'; import { useGetCaseUserActionsStats } from '../../../containers/use_get_case_user_actions_stats'; import { useInfiniteFindCaseUserActions } from '../../../containers/use_infinite_find_case_user_actions'; +import { useOnUpdateField } from '../use_on_update_field'; jest.mock('../../../containers/use_infinite_find_case_user_actions'); jest.mock('../../../containers/use_find_case_user_actions'); @@ -51,6 +52,7 @@ jest.mock('../../../containers/use_get_tags'); jest.mock('../../../containers/user_profiles/use_bulk_get_user_profiles'); jest.mock('../../../containers/use_get_case_connectors'); jest.mock('../../../containers/use_get_case_users'); +jest.mock('../use_on_update_field'); (useGetTags as jest.Mock).mockReturnValue({ data: ['coke', 'pepsi'], refetch: jest.fn() }); @@ -118,11 +120,12 @@ const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock; const usePostPushToServiceMock = usePostPushToService as jest.Mock; const useGetCaseConnectorsMock = useGetCaseConnectors as jest.Mock; const useGetCaseUsersMock = useGetCaseUsers as jest.Mock; +const useOnUpdateFieldMock = useOnUpdateField as jest.Mock; // FLAKY: https://github.com/elastic/kibana/issues/151979 // FLAKY: https://github.com/elastic/kibana/issues/151980 // FLAKY: https://github.com/elastic/kibana/issues/151981 -describe.skip('Case View Page activity tab', () => { +describe('Case View Page activity tab', () => { const caseConnectors = getCaseConnectorsMockResponse(); beforeAll(() => { @@ -138,6 +141,10 @@ describe.skip('Case View Page activity tab', () => { isLoading: false, data: caseConnectors, }); + useOnUpdateFieldMock.mockReturnValue({ + isLoading: false, + useOnUpdateField: jest.fn, + }); }); let appMockRender: AppMockRenderer; @@ -228,6 +235,30 @@ describe.skip('Case View Page activity tab', () => { expect(result.queryByTestId('user-actions-list')).not.toBeInTheDocument(); }); + it('should show a loading when updating severity ', async () => { + useOnUpdateFieldMock.mockReturnValue({ isLoading: true, loadingKey: 'severity' }); + + const result = appMockRender.render(); + + expect( + result + .getByTestId('case-severity-selection') + .classList.contains('euiSuperSelectControl-isLoading') + ).toBeTruthy(); + }); + + it('should not show a loading for severity when updating tags', async () => { + useOnUpdateFieldMock.mockReturnValue({ isLoading: true, loadingKey: 'tags' }); + + const result = appMockRender.render(); + + expect( + result + .getByTestId('case-severity-selection') + .classList.contains('euiSuperSelectControl-isLoading') + ).not.toBeTruthy(); + }); + it('should not render the assignees on basic license', () => { appMockRender = createAppMockRenderer({ license: basicLicense }); diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx index 11ef882ec5a42..d4802d4090221 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx @@ -271,7 +271,7 @@ export const CaseViewActivity = ({ ) : null} diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/__mocks__/action_wrapper.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/__mocks__/action_wrapper.tsx new file mode 100644 index 0000000000000..6b1dda42d8fe1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/__mocks__/action_wrapper.tsx @@ -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. + */ + +import React from 'react'; + +export const ActionWrapper = jest + .fn() + .mockImplementation(({ children }) =>
    {children}
    ); diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.test.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.test.tsx new file mode 100644 index 0000000000000..31f34de24d5ed --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.test.tsx @@ -0,0 +1,152 @@ +/* + * 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 { render } from '@testing-library/react'; +import React from 'react'; +import { SECURITY_SOLUTION_OWNER } from '../../../../common'; +import { canUseCases } from '../../../client/helpers/can_use_cases'; +import CasesProvider from '../../cases_context'; +import { ActionWrapper } from './action_wrapper'; +import { getMockCaseUiActionProps } from './mocks'; + +jest.mock('../../cases_context', () => + jest.fn().mockImplementation(({ children, ...props }) =>
    {children}
    ) +); + +jest.mock('../../../client/helpers/can_use_cases', () => { + const actual = jest.requireActual('../../../client/helpers/can_use_cases'); + return { + ...actual, + canUseCases: jest.fn(), + }; +}); + +const mockCasePermissions = jest.fn().mockReturnValue({ create: true, update: true }); + +describe('ActionWrapper', () => { + const props = { ...getMockCaseUiActionProps(), currentAppId: 'securitySolutionUI' }; + + beforeEach(() => { + jest.clearAllMocks(); + (canUseCases as jest.Mock).mockReturnValue(mockCasePermissions); + }); + + it('reads cases permissions', () => { + render( + +
    + + ); + expect(mockCasePermissions).toHaveBeenCalledWith([SECURITY_SOLUTION_OWNER]); + }); + + it('renders CasesProvider with correct props for Security solution', () => { + render( + +
    + + ); + expect((CasesProvider as jest.Mock).mock.calls[0][0].value).toMatchInlineSnapshot(` + Object { + "features": Object { + "alerts": Object { + "sync": true, + }, + }, + "owner": Array [ + "securitySolution", + ], + "permissions": Object { + "create": true, + "update": true, + }, + } + `); + }); + + it('renders CasesProvider with correct props for stack management', () => { + render( + +
    + + ); + + expect((CasesProvider as jest.Mock).mock.calls[0][0].value).toMatchInlineSnapshot(` + Object { + "features": Object { + "alerts": Object { + "sync": false, + }, + }, + "owner": Array [ + "cases", + ], + "permissions": Object { + "create": true, + "update": true, + }, + } + `); + }); + + it('renders CasesProvider with correct props for observability', () => { + render( + +
    + + ); + + expect((CasesProvider as jest.Mock).mock.calls[0][0].value).toMatchInlineSnapshot(` + Object { + "features": Object { + "alerts": Object { + "sync": false, + }, + }, + "owner": Array [ + "observability", + ], + "permissions": Object { + "create": true, + "update": true, + }, + } + `); + }); + + it('renders CasesProvider with correct props for an application without cases', () => { + render( + +
    + + ); + + expect((CasesProvider as jest.Mock).mock.calls[0][0].value).toMatchInlineSnapshot(` + Object { + "features": Object { + "alerts": Object { + "sync": false, + }, + }, + "owner": Array [], + "permissions": Object { + "create": true, + "update": true, + }, + } + `); + }); + + it('should check permission with undefined if owner is not found', () => { + render( + +
    + + ); + expect(mockCasePermissions).toBeCalledWith(undefined); + }); +}); diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.tsx new file mode 100644 index 0000000000000..5dd448b9b73e3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.tsx @@ -0,0 +1,91 @@ +/* + * 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 { PropsWithChildren } from 'react'; +import React from 'react'; +import { Router } from 'react-router-dom'; +import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; + +import { useIsDarkTheme } from '../../../common/use_is_dark_theme'; +import { SECURITY_SOLUTION_OWNER } from '../../../../common'; +import type { CasesUIActionProps } from './types'; +import { KibanaContextProvider, useKibana } from '../../../common/lib/kibana'; +import CasesProvider from '../../cases_context'; +import { getCaseOwnerByAppId } from '../../../../common/utils/owner'; +import { canUseCases } from '../../../client/helpers/can_use_cases'; + +export const DEFAULT_DARK_MODE = 'theme:darkMode' as const; + +interface Props { + caseContextProps: CasesUIActionProps['caseContextProps']; + currentAppId?: string; +} + +const ActionWrapperWithContext: React.FC> = ({ + children, + caseContextProps, + currentAppId, +}) => { + const { application } = useKibana().services; + const isDarkTheme = useIsDarkTheme(); + + const owner = getCaseOwnerByAppId(currentAppId); + const casePermissions = canUseCases(application.capabilities)(owner ? [owner] : undefined); + // TODO: Remove when https://github.com/elastic/kibana/issues/143201 is developed + const syncAlerts = owner === SECURITY_SOLUTION_OWNER; + + return ( + + + {children} + + + ); +}; + +ActionWrapperWithContext.displayName = 'ActionWrapperWithContext'; + +type ActionWrapperComponentProps = PropsWithChildren< + CasesUIActionProps & { currentAppId?: string } +>; + +const ActionWrapperComponent: React.FC = ({ + core, + plugins, + storage, + history, + children, + caseContextProps, + currentAppId, +}) => { + return ( + + + + {children} + + + + ); +}; + +ActionWrapperComponent.displayName = 'ActionWrapper'; + +export const ActionWrapper = React.memo(ActionWrapperComponent); diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.test.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.test.tsx new file mode 100644 index 0000000000000..08a96ccb8587b --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.test.tsx @@ -0,0 +1,217 @@ +/* + * 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 { LENS_EMBEDDABLE_TYPE } from '@kbn/lens-plugin/public'; +import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public'; +import type { Action } from '@kbn/ui-actions-plugin/public'; +import ReactDOM, { unmountComponentAtNode } from 'react-dom'; + +import { createAddToExistingCaseLensAction } from './add_to_existing_case'; +import type { ActionContext, DashboardVisualizationEmbeddable } from './types'; +import { useCasesAddToExistingCaseModal } from '../../all_cases/selector_modal/use_cases_add_to_existing_case_modal'; +import React from 'react'; +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import { + getMockApplications$, + getMockCaseUiActionProps, + getMockCurrentAppId$, + mockAttributes, + MockEmbeddable, + mockTimeRange, +} from './mocks'; +import { CommentType } from '../../../../common'; +import { useKibana } from '../../../common/lib/kibana'; +import { waitFor } from '@testing-library/dom'; +import { canUseCases } from '../../../client/helpers/can_use_cases'; +import { getCaseOwnerByAppId } from '../../../../common/utils/owner'; + +const element = document.createElement('div'); +document.body.appendChild(element); + +jest.mock('../../all_cases/selector_modal/use_cases_add_to_existing_case_modal', () => ({ + useCasesAddToExistingCaseModal: jest.fn(), +})); + +jest.mock('../../../client/helpers/can_use_cases', () => { + const actual = jest.requireActual('../../../client/helpers/can_use_cases'); + return { + ...actual, + canUseCases: jest.fn(), + }; +}); + +jest.mock('@kbn/kibana-react-plugin/public', () => ({ + toMountPoint: jest.fn(), + KibanaThemeProvider: jest.fn().mockImplementation(({ children }) => <>{children}), +})); + +jest.mock('../../../common/lib/kibana', () => { + return { + useKibana: jest.fn(), + KibanaContextProvider: jest + .fn() + .mockImplementation(({ children, ...props }) =>
    {children}
    ), + }; +}); + +jest.mock('react-dom', () => { + const original = jest.requireActual('react-dom'); + return { ...original, unmountComponentAtNode: jest.fn() }; +}); + +jest.mock('./action_wrapper'); + +jest.mock('../../../../common/utils/owner', () => ({ + getCaseOwnerByAppId: jest.fn().mockReturnValue('securitySolution'), +})); + +describe('createAddToExistingCaseLensAction', () => { + const mockEmbeddable = new MockEmbeddable(LENS_EMBEDDABLE_TYPE, { + id: 'mockId', + attributes: mockAttributes, + timeRange: mockTimeRange, + }) as unknown as DashboardVisualizationEmbeddable; + + const context = { + embeddable: mockEmbeddable, + } as unknown as ActionContext; + + const caseUiActionProps = getMockCaseUiActionProps(); + + const mockUseCasesAddToExistingCaseModal = useCasesAddToExistingCaseModal as jest.Mock; + const mockOpenModal = jest.fn(); + const mockMount = jest.fn(); + let action: Action; + const mockCasePermissions = jest.fn(); + beforeEach(() => { + mockUseCasesAddToExistingCaseModal.mockReturnValue({ + open: mockOpenModal, + }); + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + currentAppId$: getMockCurrentAppId$(), + applications$: getMockApplications$(), + }, + }, + }); + (canUseCases as jest.Mock).mockReturnValue( + mockCasePermissions.mockReturnValue({ create: true, update: true }) + ); + (toMountPoint as jest.Mock).mockImplementation((node) => { + ReactDOM.render(node, element); + return mockMount; + }); + jest.clearAllMocks(); + action = createAddToExistingCaseLensAction(caseUiActionProps); + }); + + test('it should return display name', () => { + expect(action.getDisplayName(context)).toEqual('Add to existing case'); + }); + + it('should return icon type', () => { + expect(action.getIconType(context)).toEqual('casesApp'); + }); + + describe('isCompatible', () => { + it('should return false if error embeddable', async () => { + expect( + await action.isCompatible({ + ...context, + embeddable: new ErrorEmbeddable('some error', { + id: '123', + }) as unknown as DashboardVisualizationEmbeddable, + }) + ).toEqual(false); + }); + + it('should return false if not lens embeddable', async () => { + expect( + await action.isCompatible({ + ...context, + embeddable: new MockEmbeddable('not_lens') as unknown as DashboardVisualizationEmbeddable, + }) + ).toEqual(false); + }); + + it('should return false if no permission', async () => { + mockCasePermissions.mockReturnValue({ create: false, update: false }); + expect(await action.isCompatible(context)).toEqual(false); + }); + + it('should return true if is lens embeddable', async () => { + expect(await action.isCompatible(context)).toEqual(true); + }); + + it('should check permission with undefined if owner is not found', async () => { + (getCaseOwnerByAppId as jest.Mock).mockReturnValue(undefined); + await action.isCompatible(context); + expect(mockCasePermissions).toBeCalledWith(undefined); + }); + }); + + describe('execute', () => { + beforeEach(async () => { + await action.execute(context); + }); + + it('should execute', () => { + expect(toMountPoint).toHaveBeenCalled(); + expect(mockMount).toHaveBeenCalled(); + }); + }); + + describe('Add to existing case modal', () => { + beforeEach(async () => { + await action.execute(context); + }); + + it('should open modal with an attachment', async () => { + await waitFor(() => { + expect(mockOpenModal).toHaveBeenCalled(); + + const getAttachments = mockOpenModal.mock.calls[0][0].getAttachments; + expect(getAttachments()).toEqual( + expect.objectContaining([ + { + comment: `!{lens${JSON.stringify({ + timeRange: mockTimeRange, + attributes: mockAttributes, + })}}`, + type: CommentType.user as const, + }, + ]) + ); + }); + }); + + it('should have correct onClose handler - when close modal clicked', () => { + const onClose = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onClose; + onClose(); + expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled(); + }); + + it('should have correct onClose handler - when case selected', () => { + const onClose = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onClose; + onClose({ id: 'case-id', title: 'case-title' }); + expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled(); + }); + + it('should have correct onClose handler - when case created', () => { + const onClose = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onClose; + onClose(null, true); + expect(unmountComponentAtNode as jest.Mock).not.toHaveBeenCalled(); + }); + + it('should have correct onSuccess handler', () => { + const onSuccess = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onSuccess; + onSuccess(); + expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.tsx new file mode 100644 index 0000000000000..ef5a7c794f1f5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useEffect, useMemo } from 'react'; +import { unmountComponentAtNode } from 'react-dom'; + +import { createAction } from '@kbn/ui-actions-plugin/public'; +import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public'; + +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; + +import type { CaseUI } from '../../../../common'; +import { isLensEmbeddable, hasInput, getLensCaseAttachment } from './utils'; + +import type { ActionContext, CasesUIActionProps, DashboardVisualizationEmbeddable } from './types'; +import { useCasesAddToExistingCaseModal } from '../../all_cases/selector_modal/use_cases_add_to_existing_case_modal'; +import { ADD_TO_EXISTING_CASE_DISPLAYNAME } from './translations'; +import { ActionWrapper } from './action_wrapper'; +import { canUseCases } from '../../../client/helpers/can_use_cases'; +import { getCaseOwnerByAppId } from '../../../../common/utils/owner'; + +export const ACTION_ID = 'embeddable_addToExistingCase'; +export const DEFAULT_DARK_MODE = 'theme:darkMode' as const; + +interface Props { + embeddable: DashboardVisualizationEmbeddable; + onSuccess: () => void; + onClose: (theCase?: CaseUI) => void; +} + +const AddExistingCaseModalWrapper: React.FC = ({ embeddable, onClose, onSuccess }) => { + const modal = useCasesAddToExistingCaseModal({ + onClose, + onSuccess, + }); + + const attachments = useMemo(() => { + const { attributes, timeRange } = embeddable.getInput(); + + return [getLensCaseAttachment({ attributes, timeRange })]; + }, [embeddable]); + useEffect(() => { + modal.open({ getAttachments: () => attachments }); + }, [attachments, modal]); + + return null; +}; + +AddExistingCaseModalWrapper.displayName = 'AddExistingCaseModalWrapper'; + +export const createAddToExistingCaseLensAction = ({ + core, + plugins, + storage, + history, + caseContextProps, +}: CasesUIActionProps) => { + const { application: applicationService, theme } = core; + + let currentAppId: string | undefined; + + applicationService?.currentAppId$.subscribe((appId) => { + currentAppId = appId; + }); + + return createAction({ + id: ACTION_ID, + type: 'actionButton', + getIconType: () => 'casesApp', + getDisplayName: () => ADD_TO_EXISTING_CASE_DISPLAYNAME, + isCompatible: async ({ embeddable }) => { + const owner = getCaseOwnerByAppId(currentAppId); + const casePermissions = canUseCases(applicationService.capabilities)( + owner ? [owner] : undefined + ); + + return ( + !isErrorEmbeddable(embeddable) && + isLensEmbeddable(embeddable) && + casePermissions.update && + casePermissions.create && + hasInput(embeddable) + ); + }, + execute: async ({ embeddable }) => { + const targetDomElement = document.createElement('div'); + + const cleanupDom = (shouldCleanup?: boolean) => { + if (targetDomElement != null && shouldCleanup) { + unmountComponentAtNode(targetDomElement); + } + }; + + const onClose = (theCase?: CaseUI, isCreateCase?: boolean) => { + const closeModalClickedScenario = theCase == null && !isCreateCase; + const caseSelectedScenario = theCase != null; + // When `Creating` a case from the `add to existing case modal`, + // we close the modal and then open the flyout. + // If we clean up dom when closing the modal, then the flyout won't open. + // Thus we do not clean up dom when `Creating` a case. + const shouldCleanup = closeModalClickedScenario || caseSelectedScenario; + cleanupDom(shouldCleanup); + }; + + const onSuccess = () => { + cleanupDom(true); + }; + const mount = toMountPoint( + + + , + { theme$: theme.theme$ } + ); + + mount(targetDomElement); + }, + }); +}; diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.test.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.test.tsx new file mode 100644 index 0000000000000..7e99bbaae24c6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.test.tsx @@ -0,0 +1,208 @@ +/* + * 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 { LENS_EMBEDDABLE_TYPE } from '@kbn/lens-plugin/public'; +import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public'; +import type { Action } from '@kbn/ui-actions-plugin/public'; + +import { createAddToNewCaseLensAction } from './add_to_new_case'; +import type { ActionContext, DashboardVisualizationEmbeddable } from './types'; +import { useCasesAddToNewCaseFlyout } from '../../create/flyout/use_cases_add_to_new_case_flyout'; +import React from 'react'; +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import { + getMockApplications$, + getMockCaseUiActionProps, + getMockCurrentAppId$, + mockAttributes, + MockEmbeddable, + mockTimeRange, +} from './mocks'; +import ReactDOM, { unmountComponentAtNode } from 'react-dom'; +import { useKibana } from '../../../common/lib/kibana'; +import { CommentType } from '../../../../common'; +import { waitFor } from '@testing-library/dom'; +import { canUseCases } from '../../../client/helpers/can_use_cases'; +import { getCaseOwnerByAppId } from '../../../../common/utils/owner'; + +const element = document.createElement('div'); +document.body.appendChild(element); + +jest.mock('@kbn/kibana-react-plugin/public', () => ({ + toMountPoint: jest.fn(), +})); + +jest.mock('../../../common/lib/kibana', () => { + return { + useKibana: jest.fn(), + KibanaContextProvider: jest + .fn() + .mockImplementation(({ children, ...props }) =>
    {children}
    ), + }; +}); + +jest.mock('../../create/flyout/use_cases_add_to_new_case_flyout', () => ({ + useCasesAddToNewCaseFlyout: jest.fn(), +})); + +jest.mock('../../../client/helpers/can_use_cases', () => { + const actual = jest.requireActual('../../../client/helpers/can_use_cases'); + return { + ...actual, + canUseCases: jest.fn(), + }; +}); + +jest.mock('react-dom', () => { + const original = jest.requireActual('react-dom'); + return { ...original, unmountComponentAtNode: jest.fn() }; +}); + +jest.mock('./action_wrapper'); + +jest.mock('../../../../common/utils/owner', () => ({ + getCaseOwnerByAppId: jest.fn().mockReturnValue('securitySolution'), +})); + +describe('createAddToNewCaseLensAction', () => { + const mockEmbeddable = new MockEmbeddable(LENS_EMBEDDABLE_TYPE, { + id: 'mockId', + attributes: mockAttributes, + timeRange: mockTimeRange, + }) as unknown as DashboardVisualizationEmbeddable; + + const context = { + embeddable: mockEmbeddable, + } as unknown as ActionContext; + + const caseUiActionProps = getMockCaseUiActionProps(); + + const mockUseCasesAddToNewCaseFlyout = useCasesAddToNewCaseFlyout as jest.Mock; + const mockOpenFlyout = jest.fn(); + const mockMount = jest.fn(); + let action: Action; + const mockCasePermissions = jest.fn(); + + beforeEach(() => { + mockUseCasesAddToNewCaseFlyout.mockReturnValue({ + open: mockOpenFlyout, + }); + + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + currentAppId$: getMockCurrentAppId$(), + applications$: getMockApplications$(), + }, + }, + }); + + (canUseCases as jest.Mock).mockReturnValue( + mockCasePermissions.mockReturnValue({ create: true, update: true }) + ); + + (toMountPoint as jest.Mock).mockImplementation((node) => { + ReactDOM.render(node, element); + return mockMount; + }); + + jest.clearAllMocks(); + action = createAddToNewCaseLensAction(caseUiActionProps); + }); + + test('it should return display name', () => { + expect(action.getDisplayName(context)).toEqual('Add to new case'); + }); + + it('should return icon type', () => { + expect(action.getIconType(context)).toEqual('casesApp'); + }); + + describe('isCompatible', () => { + it('should return false if error embeddable', async () => { + expect( + await action.isCompatible({ + ...context, + embeddable: new ErrorEmbeddable('some error', { + id: '123', + }) as unknown as DashboardVisualizationEmbeddable, + }) + ).toEqual(false); + }); + + it('should return false if not lens embeddable', async () => { + expect( + await action.isCompatible({ + ...context, + embeddable: new MockEmbeddable('not_lens') as unknown as DashboardVisualizationEmbeddable, + }) + ).toEqual(false); + }); + + it('should return false if no permission', async () => { + mockCasePermissions.mockReturnValue({ create: false, update: false }); + expect(await action.isCompatible(context)).toEqual(false); + }); + + it('should return true if is lens embeddable', async () => { + expect(await action.isCompatible(context)).toEqual(true); + }); + + it('should check permission with undefined if owner is not found', async () => { + (getCaseOwnerByAppId as jest.Mock).mockReturnValue(undefined); + await action.isCompatible(context); + expect(mockCasePermissions).toBeCalledWith(undefined); + }); + }); + + describe('execute', () => { + beforeEach(async () => { + await action.execute(context); + }); + + it('should execute', () => { + expect(toMountPoint).toHaveBeenCalled(); + expect(mockMount).toHaveBeenCalled(); + }); + }); + + describe('Add to new case flyout', () => { + beforeEach(async () => { + await action.execute(context); + }); + + it('should open flyout', async () => { + await waitFor(() => { + expect(mockOpenFlyout).toHaveBeenCalledWith( + expect.objectContaining({ + attachments: [ + { + comment: `!{lens${JSON.stringify({ + timeRange: mockTimeRange, + attributes: mockAttributes, + })}}`, + type: CommentType.user as const, + }, + ], + }) + ); + }); + }); + + it('should have correct onClose handler', () => { + const onClose = mockUseCasesAddToNewCaseFlyout.mock.calls[0][0].onClose; + onClose(); + expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled(); + }); + + it('should have correct onSuccess handler', () => { + const onSuccess = mockUseCasesAddToNewCaseFlyout.mock.calls[0][0].onSuccess; + onSuccess(); + expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.tsx new file mode 100644 index 0000000000000..f09ccaa3a4baf --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.tsx @@ -0,0 +1,122 @@ +/* + * 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, { useEffect, useMemo } from 'react'; +import { unmountComponentAtNode } from 'react-dom'; + +import { createAction } from '@kbn/ui-actions-plugin/public'; +import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public'; + +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import { getCaseOwnerByAppId } from '../../../../common/utils/owner'; +import { hasInput, isLensEmbeddable, getLensCaseAttachment } from './utils'; + +import type { ActionContext, CasesUIActionProps, DashboardVisualizationEmbeddable } from './types'; +import { ADD_TO_CASE_SUCCESS, ADD_TO_NEW_CASE_DISPLAYNAME } from './translations'; +import { useCasesAddToNewCaseFlyout } from '../../create/flyout/use_cases_add_to_new_case_flyout'; +import { ActionWrapper } from './action_wrapper'; +import { canUseCases } from '../../../client/helpers/can_use_cases'; + +export const ACTION_ID = 'embeddable_addToNewCase'; +export const DEFAULT_DARK_MODE = 'theme:darkMode' as const; + +interface Props { + embeddable: DashboardVisualizationEmbeddable; + onSuccess: () => void; + onClose: () => void; +} + +const AddToNewCaseFlyoutWrapper: React.FC = ({ embeddable, onClose, onSuccess }) => { + const { attributes, timeRange } = embeddable.getInput(); + const createNewCaseFlyout = useCasesAddToNewCaseFlyout({ + onClose, + onSuccess, + toastContent: ADD_TO_CASE_SUCCESS, + }); + + const attachments = useMemo( + () => [getLensCaseAttachment({ attributes, timeRange })], + [attributes, timeRange] + ); + + useEffect(() => { + createNewCaseFlyout.open({ attachments }); + }, [attachments, createNewCaseFlyout]); + + return null; +}; + +AddToNewCaseFlyoutWrapper.displayName = 'AddToNewCaseFlyoutWrapper'; + +export const createAddToNewCaseLensAction = ({ + core, + plugins, + storage, + history, + caseContextProps, +}: CasesUIActionProps) => { + const { application: applicationService, theme } = core; + + let currentAppId: string | undefined; + + applicationService?.currentAppId$.subscribe((appId) => { + currentAppId = appId; + }); + + return createAction({ + id: ACTION_ID, + type: 'actionButton', + getIconType: () => 'casesApp', + getDisplayName: () => ADD_TO_NEW_CASE_DISPLAYNAME, + isCompatible: async ({ embeddable }) => { + const owner = getCaseOwnerByAppId(currentAppId); + const casePermissions = canUseCases(applicationService.capabilities)( + owner ? [owner] : undefined + ); + + return ( + !isErrorEmbeddable(embeddable) && + isLensEmbeddable(embeddable) && + casePermissions.update && + casePermissions.create && + hasInput(embeddable) + ); + }, + execute: async ({ embeddable }) => { + const targetDomElement = document.createElement('div'); + + const cleanupDom = () => { + if (targetDomElement != null) { + unmountComponentAtNode(targetDomElement); + } + }; + + const onFlyoutClose = () => { + cleanupDom(); + }; + + const mount = toMountPoint( + + + , + { theme$: theme.theme$ } + ); + + mount(targetDomElement); + }, + }); +}; diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/index.ts b/x-pack/plugins/cases/public/components/visualizations/actions/index.ts new file mode 100644 index 0000000000000..e96d41d4466e5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/index.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 { registerUIActions as registerActions } from './register'; diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/mocks.ts b/x-pack/plugins/cases/public/components/visualizations/actions/mocks.ts new file mode 100644 index 0000000000000..808935fddd2e8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/mocks.ts @@ -0,0 +1,96 @@ +/* + * 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 { CoreTheme, PublicAppInfo } from '@kbn/core/public'; +import { BehaviorSubject, of } from 'rxjs'; +import type { TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import { createBrowserHistory } from 'history'; +import type { CasesUIActionProps } from './types'; + +const mockTheme: CoreTheme = { + darkMode: false, +}; + +const createThemeMock = (): CoreTheme => { + return { ...mockTheme }; +}; + +export const createTheme$Mock = () => { + return of(createThemeMock()); +}; + +export class MockEmbeddable { + public type; + private input; + constructor( + type: string, + input?: { + attributes: TypedLensByValueInput['attributes']; + id: string; + timeRange: { from: string; to: string; fromStr: string; toStr: string }; + } + ) { + this.type = type; + this.input = input; + } + getFilters() {} + getQuery() {} + getInput() { + return this.input; + } +} + +export const mockAttributes = { + title: 'mockTitle', + description: 'mockDescription', + references: [], + state: { + visualization: { + id: 'mockId', + type: 'mockType', + title: 'mockTitle', + visualizationType: 'mockVisualizationType', + references: [], + state: { + datasourceStates: { + indexpattern: {}, + }, + }, + }, + }, +} as unknown as TypedLensByValueInput['attributes']; + +export const mockTimeRange = { from: '', to: '', fromStr: '', toStr: '' }; + +export const getMockCurrentAppId$ = () => new BehaviorSubject('securitySolutionUI'); +export const getMockApplications$ = () => + new BehaviorSubject>( + new Map([['securitySolutionUI', { category: { label: 'Test' } } as unknown as PublicAppInfo]]) + ); + +export const getMockCaseUiActionProps = () => { + const core = { + application: { currentAppId$: getMockCurrentAppId$(), capabilities: {} }, + theme: { theme$: createTheme$Mock() }, + uiSettings: { + get: jest.fn().mockReturnValue(true), + }, + }; + const plugins = {}; + const storage = {}; + const history = createBrowserHistory(); + const caseContextProps = {}; + + const caseUiActionProps = { + core, + plugins, + storage, + history, + caseContextProps, + } as unknown as CasesUIActionProps; + + return caseUiActionProps; +}; diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/register.ts b/x-pack/plugins/cases/public/components/visualizations/actions/register.ts new file mode 100644 index 0000000000000..f470a0a34fdcd --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/register.ts @@ -0,0 +1,48 @@ +/* + * 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 { CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public'; + +import { createAddToNewCaseLensAction } from './add_to_new_case'; +import { createAddToExistingCaseLensAction } from './add_to_existing_case'; +import type { CasesUIActionProps } from './types'; + +export const registerUIActions = ({ + core, + plugins, + caseContextProps, + history, + storage, +}: CasesUIActionProps) => { + registerLensActions({ core, plugins, caseContextProps, history, storage }); +}; + +const registerLensActions = ({ + core, + plugins, + caseContextProps, + history, + storage, +}: CasesUIActionProps) => { + const addToNewCaseAction = createAddToNewCaseLensAction({ + core, + plugins, + caseContextProps, + history, + storage, + }); + plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, addToNewCaseAction); + + const addToExistingCaseAction = createAddToExistingCaseLensAction({ + core, + plugins, + caseContextProps, + history, + storage, + }); + plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, addToExistingCaseAction); +}; diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/translations.ts b/x-pack/plugins/cases/public/components/visualizations/actions/translations.ts new file mode 100644 index 0000000000000..ed7ee1370d875 --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/translations.ts @@ -0,0 +1,29 @@ +/* + * 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 ADD_TO_CASE_SUCCESS = i18n.translate( + 'xpack.cases.visualizationActions.addToExistingCaseSuccessContent', + { + defaultMessage: 'Successfully added visualization to the case', + } +); + +export const ADD_TO_NEW_CASE_DISPLAYNAME = i18n.translate( + 'xpack.cases.actions.visualizationActions.addToNewCase.displayName', + { + defaultMessage: 'Add to new case', + } +); + +export const ADD_TO_EXISTING_CASE_DISPLAYNAME = i18n.translate( + 'xpack.cases.actions.visualizationActions.addToExistingCase.displayName', + { + defaultMessage: 'Add to existing case', + } +); diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/types.ts b/x-pack/plugins/cases/public/components/visualizations/actions/types.ts new file mode 100644 index 0000000000000..53a2e9d5fd06e --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/types.ts @@ -0,0 +1,44 @@ +/* + * 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 '@kbn/data-plugin/common'; +import type { CoreStart } from '@kbn/core-lifecycle-browser'; +import type { IEmbeddable } from '@kbn/embeddable-plugin/public'; +import type { TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import type * as H from 'history'; +import type { Storage } from '@kbn/kibana-utils-plugin/public'; + +import type { ActionExecutionContext } from '@kbn/ui-actions-plugin/public'; +import type { CasesPluginStart } from '../../../types'; +import type { CasesContextProps } from '../../cases_context'; + +export type CasesUIActionContextProps = Pick< + CasesContextProps, + | 'externalReferenceAttachmentTypeRegistry' + | 'persistableStateAttachmentTypeRegistry' + | 'getFilesClient' +>; + +export interface CasesUIActionProps { + core: CoreStart; + plugins: CasesPluginStart; + caseContextProps: CasesUIActionContextProps; + history: H.History; + storage: Storage; +} + +export interface EmbeddableInput { + attributes: TypedLensByValueInput['attributes']; + id: string; + timeRange: TimeRange; +} + +export type DashboardVisualizationEmbeddable = IEmbeddable; + +export type ActionContext = ActionExecutionContext<{ + embeddable: DashboardVisualizationEmbeddable; +}>; diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/utils.test.ts b/x-pack/plugins/cases/public/components/visualizations/actions/utils.test.ts new file mode 100644 index 0000000000000..3f61ac958dd18 --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/utils.test.ts @@ -0,0 +1,60 @@ +/* + * 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 { LENS_EMBEDDABLE_TYPE } from '@kbn/lens-plugin/public'; +import { isLensEmbeddable, hasInput, getLensCaseAttachment } from './utils'; + +describe('utils', () => { + describe('isLensEmbeddable', () => { + it('return true if it is a lens embeddable', () => { + // @ts-expect-error: extra attributes are not needed + expect(isLensEmbeddable({ type: LENS_EMBEDDABLE_TYPE })).toBe(true); + }); + + it('return false if it is not a lens embeddable', () => { + // @ts-expect-error: extra attributes are not needed + expect(isLensEmbeddable({ type: 'not-exist' })).toBe(false); + }); + }); + + describe('hasInput', () => { + it('return true if it has correct input', () => { + const embeddable = { getInput: () => ({ attributes: {}, timeRange: {} }) }; + + // @ts-expect-error: extra attributes are not needed + expect(hasInput(embeddable)).toBe(true); + }); + + it('return false if attributes are null', () => { + const embeddable = { getInput: () => ({ attributes: null, timeRange: {} }) }; + + // @ts-expect-error: extra attributes are not needed + expect(hasInput(embeddable)).toBe(false); + }); + + it('return false if timeRange is null', () => { + const embeddable = { getInput: () => ({ attributes: {}, timeRange: null }) }; + + // @ts-expect-error: extra attributes are not needed + expect(hasInput(embeddable)).toBe(false); + }); + }); + + describe('getLensCaseAttachment', () => { + it('create a case lens attachment correctly', () => { + const embeddable = { attributes: {}, timeRange: {} }; + + // @ts-expect-error: extra attributes are not needed + expect(getLensCaseAttachment(embeddable)).toMatchInlineSnapshot(` + Object { + "comment": "!{lens{\\"timeRange\\":{},\\"attributes\\":{}}}", + "type": "user", + } + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/utils.ts b/x-pack/plugins/cases/public/components/visualizations/actions/utils.ts new file mode 100644 index 0000000000000..fcf48bfaf0d8b --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/utils.ts @@ -0,0 +1,27 @@ +/* + * 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 { IEmbeddable } from '@kbn/embeddable-plugin/public'; +import { LENS_EMBEDDABLE_TYPE, type Embeddable as LensEmbeddable } from '@kbn/lens-plugin/public'; +import { CommentType } from '../../../../common'; +import type { DashboardVisualizationEmbeddable, EmbeddableInput } from './types'; + +export const isLensEmbeddable = (embeddable: IEmbeddable): embeddable is LensEmbeddable => { + return embeddable.type === LENS_EMBEDDABLE_TYPE; +}; + +export const hasInput = (embeddable: DashboardVisualizationEmbeddable) => { + const { attributes, timeRange } = embeddable.getInput(); + return attributes != null && timeRange != null; +}; + +export const getLensCaseAttachment = ({ timeRange, attributes }: Omit) => ({ + comment: `!{lens${JSON.stringify({ + timeRange, + attributes, + })}}`, + type: CommentType.user as const, +}); diff --git a/x-pack/plugins/cases/public/plugin.ts b/x-pack/plugins/cases/public/plugin.ts index 8897b9ccbd046..1f9d3edf35776 100644 --- a/x-pack/plugins/cases/public/plugin.ts +++ b/x-pack/plugins/cases/public/plugin.ts @@ -8,7 +8,8 @@ import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; import type { ManagementAppMountParams } from '@kbn/management-plugin/public'; import { Storage } from '@kbn/kibana-utils-plugin/public'; -import type { CasesUiStart, CasesPluginSetup, CasesPluginStart, CasesUiSetup } from './types'; +import { createBrowserHistory } from 'history'; + import { KibanaServices } from './common/lib/kibana'; import type { CasesUiConfigType } from '../common/ui/types'; import { APP_ID, APP_PATH } from '../common/constants'; @@ -28,7 +29,9 @@ import { getUICapabilities } from './client/helpers/capabilities'; import { ExternalReferenceAttachmentTypeRegistry } from './client/attachment_framework/external_reference_registry'; import { PersistableStateAttachmentTypeRegistry } from './client/attachment_framework/persistable_state_registry'; import { registerCaseFileKinds } from './files'; +import type { CasesPluginSetup, CasesPluginStart, CasesUiSetup, CasesUiStart } from './types'; import { registerInternalAttachments } from './internal_attachments'; +import { registerActions } from './components/visualizations/actions'; /** * @public @@ -57,7 +60,6 @@ export class CasesUiPlugin registerInternalAttachments(externalReferenceAttachmentTypeRegistry); const config = this.initializerContext.config.get(); registerCaseFileKinds(config.files, plugins.files); - if (plugins.home) { plugins.home.featureCatalogue.register({ id: APP_ID, @@ -127,6 +129,18 @@ export class CasesUiPlugin getFilesClient: plugins.files.filesClientFactory.asScoped, }); + registerActions({ + core, + plugins, + caseContextProps: { + externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry, + persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry, + getFilesClient: plugins.files.filesClientFactory.asScoped, + }, + history: createBrowserHistory(), + storage: this.storage, + }); + return { api: createClientAPI({ http: core.http }), ui: { diff --git a/x-pack/plugins/cases/public/types.ts b/x-pack/plugins/cases/public/types.ts index f8fd9270d7cba..4f401dce1df45 100644 --- a/x-pack/plugins/cases/public/types.ts +++ b/x-pack/plugins/cases/public/types.ts @@ -24,6 +24,8 @@ import type { ApmBase } from '@elastic/apm-rum'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { FilesSetup, FilesStart } from '@kbn/files-plugin/public'; import type { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public'; +import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; + import type { CasesBulkGetRequest, CasesBulkGetResponse, @@ -62,18 +64,19 @@ export interface CasesPluginSetup { } export interface CasesPluginStart { + apm?: ApmBase; data: DataPublicPluginStart; embeddable: EmbeddableStart; + features: FeaturesPluginStart; files: FilesStart; - licensing?: LicensingPluginStart; lens: LensPublicStart; - storage: Storage; - triggersActionsUi: TriggersActionsStart; - features: FeaturesPluginStart; + licensing?: LicensingPluginStart; + savedObjectsManagement: SavedObjectsManagementPluginStart; security: SecurityPluginStart; spaces?: SpacesPluginStart; - apm?: ApmBase; - savedObjectsManagement: SavedObjectsManagementPluginStart; + storage: Storage; + triggersActionsUi: TriggersActionsStart; + uiActions: UiActionsStart; } /** diff --git a/x-pack/plugins/cases/tsconfig.json b/x-pack/plugins/cases/tsconfig.json index 118370770e138..4314e82ce6ba7 100644 --- a/x-pack/plugins/cases/tsconfig.json +++ b/x-pack/plugins/cases/tsconfig.json @@ -63,6 +63,8 @@ "@kbn/saved-objects-finder-plugin", "@kbn/saved-objects-management-plugin", "@kbn/utility-types-jest", + "@kbn/ui-actions-plugin", + "@kbn/core-lifecycle-browser", "@kbn/core-saved-objects-api-server-mocks", "@kbn/core-theme-browser", ], diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.test.tsx index 8ed57e646326f..09160ce3b6967 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.test.tsx @@ -416,6 +416,26 @@ describe('field level security', () => { expect(testProps.indicesAPIClient.getFields).toHaveBeenCalledWith('newPattern'); }); + test('does not query availble fields for remote cluster indices', async () => { + const testProps = { + ...props, + indexType: 'remote_indices' as const, + indexPrivilege: { + ...props.indexPrivilege, + clusters: ['test-cluster'], + names: ['foo', 'bar-*'], + }, + indicesAPIClient: indicesAPIClientMock.create(), + allowFieldLevelSecurity: true, + }; + + testProps.indicesAPIClient.getFields.mockResolvedValue(['a', 'b', 'c']); + + mountWithIntl(); + await nextTick(); + expect(testProps.indicesAPIClient.getFields).not.toHaveBeenCalled(); + }); + test('it displays a warning when no fields are granted', () => { const testProps = { ...props, diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.tsx index 58d1bd8061bf8..2540316caac89 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/index_privilege_form.tsx @@ -227,7 +227,12 @@ export class IndexPrivilegeForm extends Component { }; private loadFLSOptions = (indexNames: string[], force = false) => { - if (!force && (this.isFieldListLoading || indexNames.length === 0)) return; + if ( + this.props.indexType === 'remote_indices' || + (!force && (this.isFieldListLoading || indexNames.length === 0)) + ) { + return; + } this.isFieldListLoading = true; this.setState({ diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.test.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.test.tsx index f0ef8f8d00da7..fb18a3a6a4459 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.test.tsx @@ -157,4 +157,31 @@ describe('HostAlertsTable', () => { }, ]); }); + + it('should render cellActions when count is bigger than zero', () => { + mockUseHostAlertsItemsReturn({ + items: [parsedVulnerableHostsAlertsResult[0]], + }); + const { getAllByTestId } = renderComponent(); + + expect(getAllByTestId('cellActions-renderContent-host.name').length).toBe(5); + }); + + it('should not render cellActions when count is zero', () => { + mockUseHostAlertsItemsReturn({ + items: [ + { + hostName: 'Host-342m5gl1g2', + totalAlerts: 100, + critical: 0, + high: 0, + low: 0, + medium: 0, + }, + ], + }); + const { getAllByTestId } = renderComponent(); + + expect(getAllByTestId('cellActions-renderContent-host.name').length).toBe(1); + }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.tsx index 5a3d2462e3e9a..8ac908bad945a 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.tsx @@ -179,30 +179,33 @@ const getTableColumns: GetTableColumns = (handleClick) => [ name: i18n.STATUS_CRITICAL_LABEL, render: (count: number, { hostName }) => ( - - handleClick({ hostName, severity: 'critical' })} + {count > 0 ? ( + - - - + handleClick({ hostName, severity: 'critical' })} + > + + + + ) : ( + + )} ), }, @@ -211,29 +214,30 @@ const getTableColumns: GetTableColumns = (handleClick) => [ name: i18n.STATUS_HIGH_LABEL, render: (count: number, { hostName }) => ( - - handleClick({ hostName, severity: 'high' })} + {count > 0 ? ( + - - - + handleClick({ hostName, severity: 'high' })}> + + + + ) : ( + + )} ), }, @@ -242,29 +246,30 @@ const getTableColumns: GetTableColumns = (handleClick) => [ name: i18n.STATUS_MEDIUM_LABEL, render: (count: number, { hostName }) => ( - - handleClick({ hostName, severity: 'medium' })} + {count > 0 ? ( + - - - + handleClick({ hostName, severity: 'medium' })}> + + + + ) : ( + + )} ), }, @@ -273,29 +278,30 @@ const getTableColumns: GetTableColumns = (handleClick) => [ name: i18n.STATUS_LOW_LABEL, render: (count: number, { hostName }) => ( - - handleClick({ hostName, severity: 'low' })} + {count > 0 ? ( + - - - + handleClick({ hostName, severity: 'low' })}> + + + + ) : ( + + )} ), }, diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.tsx index 875556f12fb8d..d63e5b88e3660 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.tsx @@ -176,30 +176,33 @@ const getTableColumns: GetTableColumns = (handleClick) => [ name: i18n.STATUS_CRITICAL_LABEL, render: (count: number, { userName }) => ( - - handleClick({ userName, severity: 'critical' })} + {count > 0 ? ( + - - - + handleClick({ userName, severity: 'critical' })} + > + + + + ) : ( + + )} ), }, @@ -208,29 +211,30 @@ const getTableColumns: GetTableColumns = (handleClick) => [ name: i18n.STATUS_HIGH_LABEL, render: (count: number, { userName }) => ( - - handleClick({ userName, severity: 'high' })} + {count > 0 ? ( + - - - + handleClick({ userName, severity: 'high' })}> + + + + ) : ( + + )} ), }, @@ -239,29 +243,30 @@ const getTableColumns: GetTableColumns = (handleClick) => [ name: i18n.STATUS_MEDIUM_LABEL, render: (count: number, { userName }) => ( - - handleClick({ userName, severity: 'medium' })} + {count > 0 ? ( + - - - + handleClick({ userName, severity: 'medium' })}> + + + + ) : ( + + )} ), }, @@ -270,29 +275,30 @@ const getTableColumns: GetTableColumns = (handleClick) => [ name: i18n.STATUS_LOW_LABEL, render: (count: number, { userName }) => ( - - handleClick({ userName, severity: 'low' })} + {count > 0 ? ( + - - - + handleClick({ userName, severity: 'low' })}> + + + + ) : ( + + )} ), }, diff --git a/x-pack/plugins/serverless_observability/public/components/side_navigation/index.tsx b/x-pack/plugins/serverless_observability/public/components/side_navigation/index.tsx index f4393eee4417e..e318e5301f262 100644 --- a/x-pack/plugins/serverless_observability/public/components/side_navigation/index.tsx +++ b/x-pack/plugins/serverless_observability/public/components/side_navigation/index.tsx @@ -8,110 +8,128 @@ import { CoreStart } from '@kbn/core/public'; import { ServerlessPluginStart } from '@kbn/serverless/public'; import { - ChromeNavigationNodeViewModel, - Navigation, + DefaultNavigation, NavigationKibanaProvider, + NavigationTreeDefinition, + getPresets, } from '@kbn/shared-ux-chrome-navigation'; import React from 'react'; -// #TODO translate titles? -const navItems: ChromeNavigationNodeViewModel[] = [ - { - id: 'services-infra', - items: [ - { id: 'services', title: 'Services', href: '/app/apm/services' }, - { - id: 'infra', - title: 'Infrastructure', - href: '/app/metrics/inventory', - }, - ], - }, - { - id: 'alerts-cases-slos', - items: [ - { - id: 'alerts', - title: 'Alerts', - href: '/app/observability/alerts', - }, - { - id: 'Cases', - title: 'Cases', - href: '/app/observability/cases', - }, - { - id: 'slos', - title: 'SLOs', - href: '/app/observability/slos', - }, - ], - }, - { - id: 'signals', - title: 'Signals', - items: [ - { - id: 'traces', - title: 'Traces', - href: '/app/apm/traces', - }, - { - id: 'logs', - title: 'Logs', - href: '/app/logs/stream', - }, - ], - }, - { - id: 'toolbox', - title: 'Toolbox', - items: [ - { - id: 'visualization', - title: 'Visualization', - href: '/app/visualize', - }, - { - id: 'dashboards', - title: 'Dashboards', - href: '/app/dashboards', - }, - ], - }, - { - id: 'on-boarding', - items: [ - { - id: 'get-started', - title: 'Get started', - icon: 'launch', - href: '/app/observabilityOnboarding', - }, - ], - }, -]; +const navigationTree: NavigationTreeDefinition = { + body: [ + { + type: 'cloudLink', + preset: 'projects', + }, + { + type: 'navGroup', + id: 'observability_project_nav', + title: 'Observability', + icon: 'logoObservability', + defaultIsCollapsed: false, + children: [ + { + id: 'services-infra', + children: [ + { id: 'services', title: 'Services', href: '/app/apm/services' }, + { + id: 'infra', + title: 'Infrastructure', + href: '/app/metrics/inventory', + }, + ], + }, + { + id: 'alerts-cases-slos', + children: [ + { + id: 'alerts', + title: 'Alerts', + href: '/app/observability/alerts', + }, + { + id: 'Cases', + title: 'Cases', + href: '/app/observability/cases', + }, + { + id: 'slos', + title: 'SLOs', + href: '/app/observability/slos', + }, + ], + }, + { + id: 'signals', + title: 'Signals', + children: [ + { + id: 'traces', + title: 'Traces', + href: '/app/apm/traces', + }, + { + id: 'logs', + title: 'Logs', + href: '/app/logs/stream', + }, + ], + }, + { + id: 'toolbox', + title: 'Toolbox', + children: [ + { + id: 'visualization', + title: 'Visualization', + href: '/app/visualize', + }, + { + id: 'dashboards', + title: 'Dashboards', + href: '/app/dashboards', + }, + ], + }, + { + id: 'on-boarding', + children: [ + { + id: 'get-started', + title: 'Get started', + icon: 'launch', + href: '/app/observabilityOnboarding', + }, + ], + }, + ], + }, + { + type: 'navGroup', + ...getPresets('analytics'), + }, + { + type: 'navGroup', + ...getPresets('ml'), + }, + ], + footer: [ + { + type: 'navGroup', + ...getPresets('management'), + }, + ], +}; export const getObservabilitySideNavComponent = (core: CoreStart, { serverless }: { serverless: ServerlessPluginStart }) => () => { - const activeNavItemId = 'observability_project_nav.root'; - return ( - ); diff --git a/x-pack/plugins/serverless_search/public/layout/nav.tsx b/x-pack/plugins/serverless_search/public/layout/nav.tsx index fb032eb19cbdb..a0a6083ccce21 100644 --- a/x-pack/plugins/serverless_search/public/layout/nav.tsx +++ b/x-pack/plugins/serverless_search/public/layout/nav.tsx @@ -7,157 +7,133 @@ import { CoreStart } from '@kbn/core/public'; import { - ChromeNavigationNodeViewModel, - Navigation, + DefaultNavigation, NavigationKibanaProvider, + NavigationTreeDefinition, + getPresets, } from '@kbn/shared-ux-chrome-navigation'; import React from 'react'; import { i18n } from '@kbn/i18n'; import { ServerlessPluginStart } from '@kbn/serverless/public'; -const NAVIGATION_PLATFORM_CONFIG = { - analytics: { enabled: false }, - ml: { enabled: false }, - devTools: { enabled: false }, - management: { enabled: false }, -}; +const devTools = getPresets('devtools'); -const navItems: ChromeNavigationNodeViewModel[] = [ - { - id: 'search_getting_started', - title: i18n.translate('xpack.serverlessSearch.nav.gettingStarted', { - defaultMessage: 'Getting started', - }), - href: '/app/elasticsearch', - }, - { - id: 'dev_tools', - title: i18n.translate('xpack.serverlessSearch.nav.devTools', { defaultMessage: 'Dev Tools' }), - items: [ - { - id: 'dev_tools_console', - title: i18n.translate('xpack.serverlessSearch.nav.devTools.console', { - defaultMessage: 'Console', - }), - href: '/app/dev_tools#/console', - }, - { - id: 'dev_tools_profiler', - title: i18n.translate('xpack.serverlessSearch.nav.devTools.searchProfiler', { - defaultMessage: 'Search Profiler', - }), - href: '/app/dev_tools#/searchprofiler', - }, - { - id: 'dev_tools_grok_debugger', - title: i18n.translate('xpack.serverlessSearch.nav.devTools.grokDebugger', { - defaultMessage: 'Grok debugger', - }), - href: '/app/dev_tools#/grokdebugger', - }, - { - id: 'dev_tools_painless_lab', - title: i18n.translate('xpack.serverlessSearch.nav.devTools.painlessLab', { - defaultMessage: 'Painless Lab', - }), - href: '/app/dev_tools#/painless_lab', - }, - ], - }, - { - id: 'explore', - title: i18n.translate('xpack.serverlessSearch.nav.explore', { defaultMessage: 'Explore' }), - items: [ - { - id: 'explore_discover', - title: i18n.translate('xpack.serverlessSearch.nav.explore.discover', { - defaultMessage: 'Discover', - }), - href: '/app/discover', - }, - { - id: 'explore_dashboard', - title: i18n.translate('xpack.serverlessSearch.nav.explore.dashboard', { - defaultMessage: 'Dashboard', - }), - href: '/app/dashboards', - }, - { - id: 'explore_visualize_library', - title: i18n.translate('xpack.serverlessSearch.nav.explore.visualizeLibrary', { - defaultMessage: 'Visualize Library', - }), - href: '/app/visualize', - }, - ], - }, - { - id: 'content', - title: i18n.translate('xpack.serverlessSearch.nav.content', { defaultMessage: 'Content' }), - items: [ - { - id: 'content_indices', - title: i18n.translate('xpack.serverlessSearch.nav.content.indices', { - defaultMessage: 'Indices', - }), - // TODO: this will be updated to a new Indices page - href: '/app/management/data/index_management/indices', - }, - { - id: 'content_transforms', - title: i18n.translate('xpack.serverlessSearch.nav.content.transforms', { - defaultMessage: 'Transforms', - }), - // TODO: this will be updated to a new Transforms page - href: '/app/management/ingest/ingest_pipelines', - }, - { - id: 'content_indexing_api', - title: i18n.translate('xpack.serverlessSearch.nav.content.indexingApi', { - defaultMessage: 'Indexing API', - }), - // TODO: this page does not exist yet, linking to getting started for now - href: '/app/elasticsearch', - }, - ], - }, - { - id: 'security', - title: i18n.translate('xpack.serverlessSearch.nav.security', { defaultMessage: 'Security' }), - items: [ - { - id: 'security_api_keys', - title: i18n.translate('xpack.serverlessSearch.nav.security.apiKeys', { - defaultMessage: 'API Keys', - }), - href: '/app/management/security/api_keys', - }, - ], - }, -]; +const navigationTree: NavigationTreeDefinition = { + body: [ + { + type: 'cloudLink', + preset: 'projects', + }, + { + type: 'navGroup', + id: 'search_project_nav', + title: 'Elasticsearch', + icon: 'logoElasticsearch', + defaultIsCollapsed: false, + children: [ + { + id: 'search_getting_started', + title: i18n.translate('xpack.serverlessSearch.nav.gettingStarted', { + defaultMessage: 'Getting started', + }), + href: '/app/elasticsearch', + }, + { + id: 'dev_tools', + title: i18n.translate('xpack.serverlessSearch.nav.devTools', { + defaultMessage: 'Dev Tools', + }), + children: devTools.children[0].children, + }, + { + id: 'explore', + title: i18n.translate('xpack.serverlessSearch.nav.explore', { + defaultMessage: 'Explore', + }), + children: [ + { + id: 'explore_discover', + title: i18n.translate('xpack.serverlessSearch.nav.explore.discover', { + defaultMessage: 'Discover', + }), + href: '/app/discover', + }, + { + id: 'explore_dashboard', + title: i18n.translate('xpack.serverlessSearch.nav.explore.dashboard', { + defaultMessage: 'Dashboard', + }), + href: '/app/dashboards', + }, + { + id: 'explore_visualize_library', + title: i18n.translate('xpack.serverlessSearch.nav.explore.visualizeLibrary', { + defaultMessage: 'Visualize Library', + }), + href: '/app/visualize', + }, + ], + }, + { + id: 'content', + title: i18n.translate('xpack.serverlessSearch.nav.content', { + defaultMessage: 'Content', + }), + children: [ + { + id: 'content_indices', + title: i18n.translate('xpack.serverlessSearch.nav.content.indices', { + defaultMessage: 'Indices', + }), + // TODO: this will be updated to a new Indices page + href: '/app/management/data/index_management/indices', + }, + { + id: 'content_transforms', + title: i18n.translate('xpack.serverlessSearch.nav.content.transforms', { + defaultMessage: 'Transforms', + }), + // TODO: this will be updated to a new Transforms page + href: '/app/management/ingest/ingest_pipelines', + }, + { + id: 'content_indexing_api', + title: i18n.translate('xpack.serverlessSearch.nav.content.indexingApi', { + defaultMessage: 'Indexing API', + }), + // TODO: this page does not exist yet, linking to getting started for now + href: '/app/elasticsearch', + }, + ], + }, + { + id: 'security', + title: i18n.translate('xpack.serverlessSearch.nav.security', { + defaultMessage: 'Security', + }), + children: [ + { + id: 'security_api_keys', + title: i18n.translate('xpack.serverlessSearch.nav.security.apiKeys', { + defaultMessage: 'API Keys', + }), + href: '/app/management/security/api_keys', + }, + ], + }, + ], + }, + ], +}; export const createServerlessSearchSideNavComponent = (core: CoreStart, { serverless }: { serverless: ServerlessPluginStart }) => () => { - // Currently, this allows the "Search" section of the side nav to render as pre-expanded. - // This will soon be powered from state received from core.chrome - const activeNavItemId = 'search_project_nav.search_getting_started'; - return ( - diff --git a/x-pack/test/apm_api_integration/tests/data_view/static.spec.ts b/x-pack/test/apm_api_integration/tests/data_view/static.spec.ts index 23eb05acf315e..a49e698c86c65 100644 --- a/x-pack/test/apm_api_integration/tests/data_view/static.spec.ts +++ b/x-pack/test/apm_api_integration/tests/data_view/static.spec.ts @@ -10,6 +10,7 @@ import type { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace'; import expect from '@kbn/expect'; import { APM_STATIC_DATA_VIEW_ID } from '@kbn/apm-plugin/common/data_view_constants'; import { DataView } from '@kbn/data-views-plugin/common'; +import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import request from 'superagent'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { SupertestReturnType, ApmApiError } from '../../common/apm_api_supertest'; @@ -46,8 +47,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { function getDataViewSuggestions(field: string) { return supertest - .post(`/api/kibana/suggestions/values/${dataViewPattern}`) + .post(`/internal/kibana/suggestions/values/${dataViewPattern}`) .set('kbn-xsrf', 'foo') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .send({ query: '', field, method: 'terms_agg' }); }