diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7899a0afa723..9346def6aafd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @ananzh @kavilla @seanneumann @AMoo-Miki @ashwin-pc @joshuarrrr @abbyhu2000 @zengyan-amazon @kristenTian @zhongnansu @manasvinibs +* @ananzh @kavilla @seanneumann @AMoo-Miki @ashwin-pc @joshuarrrr @abbyhu2000 @zengyan-amazon @kristenTian @zhongnansu @manasvinibs @ZilongX @Flyingliuhub diff --git a/CHANGELOG.md b/CHANGELOG.md index b07970998b3f..b4b173aff837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [VisBuilder] Fix Firefox legend selection issue ([#3732](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3732)) - [VisBuilder] Fix type errors ([#3732](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3732)) - [VisBuilder] Fix indexpattern selection in filter bar ([#3751](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3751)) +- Remove `lmdb-store` to fix backport issue ([#4266](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4266)) ### 🚞 Infrastructure @@ -90,6 +91,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple DataSource] Present the authentication type choices in a drop-down ([#3693](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3693)) - [Table Visualization] Move format table, consolidate types and add unit tests ([#3397](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3397)) - [Table Visualization] Remove custom styling for text-align:center in favor of OUI utility class. ([#4164](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4164)) +- Replace the use of `bluebird` in `saved_objects` plugin ([#4026](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4026)) ### 🔩 Tests diff --git a/MAINTAINERS.md b/MAINTAINERS.md index ecb3f2928616..ae42e47b014b 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -17,6 +17,8 @@ This document contains a list of maintainers in this repo. See [opensearch-proje | Kristen Tian | [kristenTian](https://github.com/kristenTian) | Amazon | | Zhongnan Su | [zhongnansu](https://github.com/zhongnansu) | Amazon | | Manasvini B Suryanarayana | [manasvinibs](https://github.com/manasvinibs) | Amazon | +| Tao Liu | [Flyingliuhub](https://github.com/Flyingliuhub) | Amazon | +| Zilong Xia | [ZilongX](https://github.com/ZilongX) | Amazon | ## Emeritus diff --git a/examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx b/examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx index b01d04c1608b..1f6ba03e966b 100644 --- a/examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx +++ b/examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx @@ -36,6 +36,7 @@ import { PanelViewWithSharingLong } from './panel_view_with_sharing_long'; import { PanelEdit } from './panel_edit'; import { PanelEditWithDrilldowns } from './panel_edit_with_drilldowns'; import { PanelEditWithDrilldownsAndContextActions } from './panel_edit_with_drilldowns_and_context_actions'; +import { PanelGroupOptionsAndContextActions } from './panel_group_options_and_context_actions'; export const ContextMenuExamples: React.FC = () => { return ( @@ -59,7 +60,6 @@ export const ContextMenuExamples: React.FC = () => { - @@ -71,6 +71,11 @@ export const ContextMenuExamples: React.FC = () => { + + + + + ); }; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/panel_group_options_and_context_actions.tsx b/examples/ui_actions_explorer/public/context_menu_examples/panel_group_options_and_context_actions.tsx new file mode 100644 index 000000000000..20dc73406c55 --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/panel_group_options_and_context_actions.tsx @@ -0,0 +1,83 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as React from 'react'; +import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui'; +import useAsync from 'react-use/lib/useAsync'; +import { buildContextMenuForActions, Action } from '../../../../src/plugins/ui_actions/public'; +import { sampleAction } from './util'; + +export const PanelGroupOptionsAndContextActions: React.FC = () => { + const [open, setOpen] = React.useState(false); + + const context = {}; + const trigger: any = 'TEST_TRIGGER'; + const drilldownGrouping: Action['grouping'] = [ + { + id: 'drilldowns', + getDisplayName: () => 'Uncategorized group', + getIconType: () => 'popout', + order: 20, + }, + ]; + const exampleGroup: Action['grouping'] = [ + { + id: 'example', + getDisplayName: () => 'Example group', + getIconType: () => 'cloudStormy', + order: 20, + category: 'visAug', + }, + ]; + const alertingGroup: Action['grouping'] = [ + { + id: 'alerting', + getDisplayName: () => 'Alerting', + getIconType: () => 'cloudStormy', + order: 20, + category: 'visAug', + }, + ]; + const anomaliesGroup: Action['grouping'] = [ + { + id: 'anomalies', + getDisplayName: () => 'Anomalies', + getIconType: () => 'cloudStormy', + order: 30, + category: 'visAug', + }, + ]; + const actions = [ + sampleAction('test-1', 100, 'Edit visualization', 'pencil'), + sampleAction('test-2', 99, 'Clone panel', 'partial'), + + sampleAction('test-9', 10, 'Create drilldown', 'plusInCircle', drilldownGrouping), + sampleAction('test-10', 9, 'Manage drilldowns', 'list', drilldownGrouping), + + sampleAction('test-11', 10, 'Example action', 'dashboardApp', exampleGroup), + sampleAction('test-11', 10, 'Alertin action 1', 'dashboardApp', alertingGroup), + sampleAction('test-12', 9, 'Alertin action 2', 'dashboardApp', alertingGroup), + sampleAction('test-13', 8, 'Anomalies 1', 'cloudStormy', anomaliesGroup), + sampleAction('test-14', 7, 'Anomalies 2', 'link', anomaliesGroup), + ]; + + const panels = useAsync(() => + buildContextMenuForActions({ + actions: actions.map((action) => ({ action, context, trigger })), + }) + ); + + return ( + setOpen((x) => !x)}>Grouping with categories} + isOpen={open} + panelPaddingSize="none" + anchorPosition="downLeft" + closePopover={() => setOpen(false)} + > + + + ); +}; diff --git a/packages/osd-optimizer/package.json b/packages/osd-optimizer/package.json index d174e6207b40..6302f61357a1 100644 --- a/packages/osd-optimizer/package.json +++ b/packages/osd-optimizer/package.json @@ -28,7 +28,6 @@ "jest-diff": "^27.5.1", "js-yaml": "^3.14.0", "json-stable-stringify": "^1.0.1", - "lmdb-store": "^1.6.11", "node-sass": "^8.0.0", "lmdb": "^2.8.0", "normalize-path": "^3.0.0", diff --git a/release-notes/opensearch-dashboards.release-notes-2.8.0.md b/release-notes/opensearch-dashboards.release-notes-2.8.0.md new file mode 100644 index 000000000000..9de27211245e --- /dev/null +++ b/release-notes/opensearch-dashboards.release-notes-2.8.0.md @@ -0,0 +1,30 @@ +## Version 2.8.0 Release Notes + +### 🛡 Security + +- [CVE-2023-2251] Bump yaml to `2.2.2` ([#3947](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3947)) + +### 📈 Features/Enhancements + +- [Multiple Datasource] Support Amazon OpenSearch Serverless ([#3957](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3957)) +- Add support for Node.js >=14.20.1 <19 ([#4071](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4071)) +- Bundle Node.js 14 as a fallback for operating systems that cannot run Node.js 18 ([#4151](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4151)) +- Enhance grouping for context menus ([#3924](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3924)) + +### 🐛 Bug Fixes + +- [BUG] Fix bottom bar visibility using createPortal ([#3978](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3978)) + +### 🚞 Infrastructure + +- Add threshold to code coverage config to prevent workflow failures ([#4040](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4040)) +- [CI] Skip checksum verification on OpenSearch snapshot for cypress tests ([#4188](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4188)) + +### 🛠 Maintenance + +- Use `exec` in the CLI shell scripts to prevent new process creation ([#3955](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3955)) +- Remove timeline application ([#3971](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3971)) + +## 🎉 Welcome + +Thank you to all the first-time contributors who made this release possible: @sikhote, @SergeyMyssak! diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index ae7e85b98d67..a48ff12e859a 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -275,6 +275,7 @@ export class LegacyService implements CoreService { addClientWrapper: setupDeps.core.savedObjects.addClientWrapper, registerType: setupDeps.core.savedObjects.registerType, getImportExportObjectLimit: setupDeps.core.savedObjects.getImportExportObjectLimit, + setRepositoryFactoryProvider: setupDeps.core.savedObjects.setRepositoryFactoryProvider, }, status: { isStatusPageAnonymous: setupDeps.core.status.isStatusPageAnonymous, diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index 521a8dd2f7b0..7782fd93041e 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -204,6 +204,7 @@ export function createPluginSetupContext( addClientWrapper: deps.savedObjects.addClientWrapper, registerType: deps.savedObjects.registerType, getImportExportObjectLimit: deps.savedObjects.getImportExportObjectLimit, + setRepositoryFactoryProvider: deps.savedObjects.setRepositoryFactoryProvider, }, status: { core$: deps.status.core$, diff --git a/src/core/server/saved_objects/saved_objects_service.mock.ts b/src/core/server/saved_objects/saved_objects_service.mock.ts index ae36b83c0cdd..74168c436c3d 100644 --- a/src/core/server/saved_objects/saved_objects_service.mock.ts +++ b/src/core/server/saved_objects/saved_objects_service.mock.ts @@ -79,6 +79,7 @@ const createSetupContractMock = () => { addClientWrapper: jest.fn(), registerType: jest.fn(), getImportExportObjectLimit: jest.fn(), + setRepositoryFactoryProvider: jest.fn(), }; setupContract.getImportExportObjectLimit.mockReturnValue(100); diff --git a/src/core/server/saved_objects/saved_objects_service.test.ts b/src/core/server/saved_objects/saved_objects_service.test.ts index 42ee52567e5f..98d1da393319 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -41,7 +41,7 @@ import { errors as opensearchErrors } from '@opensearch-project/opensearch'; import { SavedObjectsService } from './saved_objects_service'; import { mockCoreContext } from '../core_context.mock'; import { Env } from '../config'; -import { configServiceMock } from '../mocks'; +import { configServiceMock, savedObjectsRepositoryMock } from '../mocks'; import { opensearchServiceMock } from '../opensearch/opensearch_service.mock'; import { opensearchClientMock } from '../opensearch/client/mocks'; import { httpServiceMock } from '../http/http_service.mock'; @@ -49,6 +49,7 @@ import { httpServerMock } from '../http/http_server.mocks'; import { SavedObjectsClientFactoryProvider } from './service/lib'; import { NodesVersionCompatibility } from '../opensearch/version_check/ensure_opensearch_version'; import { SavedObjectsRepository } from './service/lib/repository'; +import { SavedObjectRepositoryFactoryProvider } from './service/lib/scoped_client_provider'; jest.mock('./service/lib/repository'); @@ -169,6 +170,27 @@ describe('SavedObjectsService', () => { expect(typeRegistryInstanceMock.registerType).toHaveBeenCalledWith(type); }); }); + + describe('#setRepositoryFactoryProvider', () => { + it('throws error if a repository is already registered', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + const setup = await soService.setup(createSetupDeps()); + + const firstRepository: SavedObjectRepositoryFactoryProvider = () => + savedObjectsRepositoryMock.create(); + const secondRepository: SavedObjectRepositoryFactoryProvider = () => + savedObjectsRepositoryMock.create(); + + setup.setRepositoryFactoryProvider(firstRepository); + + expect(() => { + setup.setRepositoryFactoryProvider(secondRepository); + }).toThrowErrorMatchingInlineSnapshot( + `"custom repository factory is already set, and can only be set once"` + ); + }); + }); }); describe('#start()', () => { @@ -281,6 +303,15 @@ describe('SavedObjectsService', () => { }).toThrowErrorMatchingInlineSnapshot( `"cannot call \`registerType\` after service startup."` ); + + const customRpository: SavedObjectRepositoryFactoryProvider = () => + savedObjectsRepositoryMock.create(); + + expect(() => { + setup.setRepositoryFactoryProvider(customRpository); + }).toThrowErrorMatchingInlineSnapshot( + '"cannot call `setRepositoryFactoryProvider` after service startup."' + ); }); describe('#getTypeRegistry', () => { @@ -368,6 +399,36 @@ describe('SavedObjectsService', () => { expect(includedHiddenTypes).toEqual(['someHiddenType']); }); + + it('Should not create SavedObjectsRepository when custom repository is registered ', async () => { + const coreContext = createCoreContext({ skipMigration: false }); + const soService = new SavedObjectsService(coreContext); + const coreSetup = createSetupDeps(); + const setup = await soService.setup(coreSetup); + + const customRpository: SavedObjectRepositoryFactoryProvider = () => + savedObjectsRepositoryMock.create(); + setup.setRepositoryFactoryProvider(customRpository); + + const coreStart = createStartDeps(); + const { createInternalRepository } = await soService.start(coreStart); + createInternalRepository(); + + expect(SavedObjectsRepository.createRepository as jest.Mocked).not.toHaveBeenCalled(); + }); + + it('Should create SavedObjectsRepository when no custom repository is registered ', async () => { + const coreContext = createCoreContext({ skipMigration: false }); + const soService = new SavedObjectsService(coreContext); + const coreSetup = createSetupDeps(); + await soService.setup(coreSetup); + + const coreStart = createStartDeps(); + const { createInternalRepository } = await soService.start(coreStart); + createInternalRepository(); + + expect(SavedObjectsRepository.createRepository as jest.Mocked).toHaveBeenCalled(); + }); }); }); }); diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 92649d36beb4..b6fc21617bcc 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -56,6 +56,7 @@ import { ISavedObjectsRepository, SavedObjectsRepository } from './service/lib/r import { SavedObjectsClientFactoryProvider, SavedObjectsClientWrapperFactory, + SavedObjectRepositoryFactoryProvider, } from './service/lib/scoped_client_provider'; import { Logger } from '../logging'; import { SavedObjectTypeRegistry, ISavedObjectTypeRegistry } from './saved_objects_type_registry'; @@ -166,6 +167,14 @@ export interface SavedObjectsServiceSetup { * Returns the maximum number of objects allowed for import or export operations. */ getImportExportObjectLimit: () => number; + + /** + * Set the default {@link SavedObjectRepositoryFactoryProvider | factory provider} for creating Saved Objects repository. + * Only one repository can be set, subsequent calls to this method will fail. + */ + setRepositoryFactoryProvider: ( + respositoryFactoryProvider: SavedObjectRepositoryFactoryProvider + ) => void; } /** @@ -291,6 +300,8 @@ export class SavedObjectsService private typeRegistry = new SavedObjectTypeRegistry(); private started = false; + private respositoryFactoryProvider?: SavedObjectRepositoryFactoryProvider; + constructor(private readonly coreContext: CoreContext) { this.logger = coreContext.logger.get('savedobjects-service'); } @@ -348,6 +359,15 @@ export class SavedObjectsService this.typeRegistry.registerType(type); }, getImportExportObjectLimit: () => this.config!.maxImportExportSize, + setRepositoryFactoryProvider: (repositoryProvider) => { + if (this.started) { + throw new Error('cannot call `setRepositoryFactoryProvider` after service startup.'); + } + if (this.respositoryFactoryProvider) { + throw new Error('custom repository factory is already set, and can only be set once'); + } + this.respositoryFactoryProvider = repositoryProvider; + }, }; } @@ -422,13 +442,21 @@ export class SavedObjectsService opensearchClient: OpenSearchClient, includedHiddenTypes: string[] = [] ) => { - return SavedObjectsRepository.createRepository( - migrator, - this.typeRegistry, - opensearchDashboardsConfig.index, - opensearchClient, - includedHiddenTypes - ); + if (this.respositoryFactoryProvider) { + return this.respositoryFactoryProvider({ + migrator, + typeRegistry: this.typeRegistry, + includedHiddenTypes, + }); + } else { + return SavedObjectsRepository.createRepository( + migrator, + this.typeRegistry, + opensearchDashboardsConfig.index, + opensearchClient, + includedHiddenTypes + ); + } }; const repositoryFactory: SavedObjectsRepositoryFactory = { diff --git a/src/core/server/saved_objects/service/lib/scoped_client_provider.ts b/src/core/server/saved_objects/service/lib/scoped_client_provider.ts index 439cd31afa9c..fea1a1641a6f 100644 --- a/src/core/server/saved_objects/service/lib/scoped_client_provider.ts +++ b/src/core/server/saved_objects/service/lib/scoped_client_provider.ts @@ -31,8 +31,31 @@ import { PriorityCollection } from './priority_collection'; import { SavedObjectsClientContract } from '../../types'; import { SavedObjectsRepositoryFactory } from '../../saved_objects_service'; -import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import { + ISavedObjectTypeRegistry, + SavedObjectTypeRegistry, +} from '../../saved_objects_type_registry'; import { OpenSearchDashboardsRequest } from '../../../http'; +import { ISavedObjectsRepository } from './repository'; +import { IOpenSearchDashboardsMigrator } from '../../migrations'; + +/** + * Options passed to each SavedObjectRepositoryFactoryProvider to aid in creating the repository instance. + * @public + */ +export interface SavedObjectsRepositoryOptions { + migrator: IOpenSearchDashboardsMigrator; + typeRegistry: SavedObjectTypeRegistry; + includedHiddenTypes: string[]; +} + +/** + * Provider to invoke to a factory function for creating ISavedObjectRepository {@link ISavedObjectRepository} instances. + * @public + */ +export type SavedObjectRepositoryFactoryProvider = ( + options: SavedObjectsRepositoryOptions +) => ISavedObjectsRepository; /** * Options passed to each SavedObjectsClientWrapperFactory to aid in creating the wrapper instance. diff --git a/src/plugins/saved_objects/public/saved_object/saved_object.test.ts b/src/plugins/saved_objects/public/saved_object/saved_object.test.ts index 7e10a68c2d1f..3b1eea303f23 100644 --- a/src/plugins/saved_objects/public/saved_object/saved_object.test.ts +++ b/src/plugins/saved_objects/public/saved_object/saved_object.test.ts @@ -28,7 +28,6 @@ * under the License. */ -import Bluebird from 'bluebird'; import { createSavedObjectClass } from './saved_object'; import { SavedObject, @@ -76,16 +75,16 @@ describe('Saved Object', () => { */ function stubOpenSearchResponse(mockDocResponse: SimpleSavedObject) { // Stub out search for duplicate title: - savedObjectsClientStub.get = jest.fn().mockReturnValue(Bluebird.resolve(mockDocResponse)); - savedObjectsClientStub.update = jest.fn().mockReturnValue(Bluebird.resolve(mockDocResponse)); + savedObjectsClientStub.get = jest.fn().mockReturnValue(Promise.resolve(mockDocResponse)); + savedObjectsClientStub.update = jest.fn().mockReturnValue(Promise.resolve(mockDocResponse)); savedObjectsClientStub.find = jest .fn() - .mockReturnValue(Bluebird.resolve({ savedObjects: [], total: 0 })); + .mockReturnValue(Promise.resolve({ savedObjects: [], total: 0 })); savedObjectsClientStub.bulkGet = jest .fn() - .mockReturnValue(Bluebird.resolve({ savedObjects: [mockDocResponse] })); + .mockReturnValue(Promise.resolve({ savedObjects: [mockDocResponse] })); } function stubSavedObjectsClientCreate( @@ -94,7 +93,7 @@ describe('Saved Object', () => { ) { savedObjectsClientStub.create = jest .fn() - .mockReturnValue(resolve ? Bluebird.resolve(resp) : Bluebird.reject(resp)); + .mockReturnValue(resolve ? Promise.resolve(resp) : Promise.reject(resp)); } /** @@ -193,7 +192,7 @@ describe('Saved Object', () => { return createInitializedSavedObject({ type: 'dashboard', id: myId }).then((savedObject) => { savedObjectsClientStub.create = jest.fn().mockImplementation(() => { expect(savedObject.id).toBe(myId); - return Bluebird.resolve({ id: myId }); + return Promise.resolve({ id: myId }); }); savedObject.copyOnSave = false; @@ -227,7 +226,7 @@ describe('Saved Object', () => { return createInitializedSavedObject({ type: 'dashboard', id }).then((savedObject) => { savedObjectsClientStub.create = jest.fn().mockImplementation(() => { expect(savedObject.isSaving).toBe(true); - return Bluebird.resolve({ + return Promise.resolve({ type: 'dashboard', id, _version: 'foo', @@ -246,7 +245,7 @@ describe('Saved Object', () => { return createInitializedSavedObject({ type: 'dashboard' }).then((savedObject) => { savedObjectsClientStub.create = jest.fn().mockImplementation(() => { expect(savedObject.isSaving).toBe(true); - return Bluebird.reject(''); + return Promise.reject(); }); expect(savedObject.isSaving).toBe(false); @@ -681,7 +680,7 @@ describe('Saved Object', () => { ); indexPattern.title = indexPattern.id!; savedObject.searchSource!.setField('index', indexPattern); - return Bluebird.resolve(indexPattern); + return Promise.resolve(indexPattern); }); expect(!!savedObject.searchSource!.getField('index')).toBe(false); diff --git a/src/plugins/ui_actions/README.md b/src/plugins/ui_actions/README.md index 28e3b2d63d2e..4431a47a06ed 100644 --- a/src/plugins/ui_actions/README.md +++ b/src/plugins/ui_actions/README.md @@ -97,3 +97,12 @@ Use the UI actions explorer in the Developer examples to learn more about the se ```sh yarn start --run-examples ``` + +## Action Properties + +Refer to [./public/actions/action.ts](./public/actions/action.ts) for all properties, keeping in mind it extends the [presentable](./public/util/presentable.ts) interface. Here are some properties that provide special functionality and customization. + +- `order` is used when there is more than one action matched to a trigger and within context menus. Higher numbers are displayed first. +- `getDisplayName` is a function that can return either a string or a JSX element. Returning a JSX element allows flexibility with formatting. +- `getIconType` can be used to add an icon before the display name. +- `grouping` determines where this item should appear as a submenu. Each group can also contain a category, which is used within context menus to organize similar groups into the same section of the menu. See examples explorer for more details about what this looks like within a context menu. diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts index b9afca9fb99c..e70561bea221 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts @@ -448,3 +448,123 @@ test('groups with deep nesting', async () => { ] `); }); + +// Tests with: +// a regular action +// a group with 2 actions uncategorized +// a group with 2 actions with a category of "test-category" and low order of 10 +// a group with 1 actions with a category of "test-category" and high order of 20 +test('groups with categories and order', async () => { + const grouping1 = [ + { + id: 'test-group', + getDisplayName: () => 'Test group', + getIconType: () => 'bell', + }, + ]; + const grouping2 = [ + { + id: 'test-group-2', + getDisplayName: () => 'Test group 2', + getIconType: () => 'bell', + category: 'test-category', + order: 10, + }, + ]; + const grouping3 = [ + { + id: 'test-group-3', + getDisplayName: () => 'Test group 3', + getIconType: () => 'bell', + category: 'test-category', + order: 20, + }, + ]; + + const actions = [ + createTestAction({ + dispayName: 'Foo 1', + }), + createTestAction({ + dispayName: 'Bar 1', + grouping: grouping1, + }), + createTestAction({ + dispayName: 'Bar 2', + grouping: grouping1, + }), + createTestAction({ + dispayName: 'Qux 1', + grouping: grouping2, + }), + createTestAction({ + dispayName: 'Qux 2', + grouping: grouping2, + }), + // It is expected that, because there is only 1 action within this group, + // it will be added to the mainMenu as a single item, but next to other + // groups of the same category. When a group has a category, but only one + // item, we just add that single item; otherwise, we add a link to the group + createTestAction({ + dispayName: 'Waldo 1', + grouping: grouping3, + }), + ]; + const menu = await buildContextMenuForActions({ + actions: actions.map((action) => ({ action, context: {}, trigger: 'TEST' as any })), + }); + + expect(menu.map(resultMapper)).toMatchInlineSnapshot(` + Array [ + Object { + "items": Array [ + Object { + "name": "Foo 1", + }, + Object { + "isSeparator": true, + }, + Object { + "name": "Test group", + }, + Object { + "isSeparator": true, + }, + Object { + "name": "Waldo 1", + }, + Object { + "name": "Test group 2", + }, + ], + }, + Object { + "items": Array [ + Object { + "name": "Bar 1", + }, + Object { + "name": "Bar 2", + }, + ], + }, + Object { + "items": Array [ + Object { + "name": "Qux 1", + }, + Object { + "name": "Qux 2", + }, + ], + }, + Object { + "items": Array [ + Object { + "name": "Waldo 1", + }, + ], + }, + ] + `); +}); diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index 6d69be1f3faa..81710767e0a9 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -64,6 +64,8 @@ type PanelDescriptor = EuiContextMenuPanelDescriptor & { _level?: number; _icon?: string; items: ItemDescriptor[]; + _category?: string; + _order?: number; }; const onClick = (action: Action, context: ActionExecutionContext, close: () => void) => ( @@ -125,7 +127,7 @@ const removeItemMetaFields = (items: ItemDescriptor[]): EuiContextMenuPanelItemD const removePanelMetaFields = (panels: PanelDescriptor[]): EuiContextMenuPanelDescriptor[] => { const euiPanels: EuiContextMenuPanelDescriptor[] = []; for (const panel of panels) { - const { _level: omit, _icon: omit2, ...rest } = panel; + const { _level: omit, _icon: omit2, _category: omit3, _order: omit4, ...rest } = panel; euiPanels.push({ ...rest, items: removeItemMetaFields(rest.items) }); } return euiPanels; @@ -179,6 +181,8 @@ export async function buildContextMenuForActions({ items: [], _level: i, _icon: group.getIconType ? group.getIconType(context) : 'empty', + _category: group.category, + _order: group.order, }; // If there are multiple groups and this is not the first group, @@ -231,17 +235,57 @@ export async function buildContextMenuForActions({ // Any additional items are hidden behind a "more" item wrapMainPanelItemsIntoSubmenu(panels, 'mainMenu'); + // This will be used to store items that eventually are placed into the + // mainMenu panel. Specifying a category allows for placing groups into the + // mainMenu so they appear without the separator between them. + const categories = {}; + for (const panel of Object.values(panels)) { - // If the panel is a root-level panel, such as the parent of a group, - // then create mainMenu item for this panel - if (panel._level === 0) { + // Do nothing if not root-level panel, such as the parent of a group + if (panel._level !== 0) { + continue; + } + + // Proceed to create mainMenu item for this panel + + // If a category is specified, store either a link to the panel or the + // item within to that category. We will deal with the category after + // looping through all panels. + if (panel._category) { + // Create array to store category items + if (!categories[panel._category]) { + categories[panel._category] = []; + } + + // If multiple items in the panel, store a link to this panel into the category. + // Otherwise, just store the single item into the category. + if (panel.items.length > 1) { + categories[panel._category].push({ + order: panel._order, + items: [ + { + name: panel.title || panel.id, + icon: panel._icon || 'empty', + panel: panel.id, + }, + ], + }); + } else { + categories[panel._category].push({ + order: panel._order || 0, + items: panel.items, + }); + } + } else { + // If no category, continue with adding items to the mainMenu + // Add separator with unique key if needed if (panels.mainMenu.items.length) { panels.mainMenu.items.push({ isSeparator: true, key: `${panel.id}separator` }); } // If a panel has more than one child, then allow items to be grouped - // and link to it in the mainMenu. Otherwise, flatten the group. + // and link to it in the mainMenu. Otherwise, link to the single item. // Note: this only happens on the root level panels, not for inner groups. if (panel.items.length > 1) { panels.mainMenu.items.push({ @@ -255,6 +299,27 @@ export async function buildContextMenuForActions({ } } + // For each category, add a separator before each one and then add category items. + // This is for the mainMenu panel. + Object.keys(categories).forEach((key) => { + // Get the items sorted by group order, allowing for groups within categories + // to be ordered. A category consists of an order and its items. + // Higher orders are sorted to the top. + const sortedEntries = categories[key].sort((a, b) => b.order - a.order); + const sortedItems = sortedEntries.reduce( + (items, category) => [...items, ...category.items], + [] + ); + + // Add separator with unique key if needed + if (panels.mainMenu.items.length) { + panels.mainMenu.items.push({ isSeparator: true, key: `${key}separator` }); + } + + panels.mainMenu.items.push(...sortedItems); + }); + const panelList = Object.values(panels); + return removePanelMetaFields(panelList); } diff --git a/src/plugins/ui_actions/public/util/presentable.ts b/src/plugins/ui_actions/public/util/presentable.ts index 428644e1c2c6..9aaeada8a16e 100644 --- a/src/plugins/ui_actions/public/util/presentable.ts +++ b/src/plugins/ui_actions/public/util/presentable.ts @@ -94,6 +94,14 @@ export interface PresentableGroup Pick, 'getDisplayName' | 'getDisplayNameTooltip' | 'getIconType' | 'order'> > { id: string; + /** + * This allows groups to be categorized with other groups. Within a UI action + * context menu, this means that an item, which links to a group, will be + * placed in the menu adjacent to similar items that link to groups of the + * same category. + * See PanelGroupOptionsAndContextActions example to learn more. + */ + category?: string; } export type PresentableGrouping = Array>; diff --git a/yarn.lock b/yarn.lock index 85ce73dec424..94f14911f9fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11904,17 +11904,6 @@ listr@^0.14.1: p-map "^2.0.0" rxjs "^6.3.3" -lmdb-store@^1.6.11: - version "1.6.14" - resolved "https://registry.yarnpkg.com/lmdb-store/-/lmdb-store-1.6.14.tgz#8aa5f36fb04195f8639a3b01b32f6696867f2bc9" - integrity sha512-4woZfvfgolMEngjoMJrwePjdLotr3QKGJsDWURlJmKBed5JtE00IfAKo7ryPowl4ksGcs21pcdLkwrPnKomIuA== - dependencies: - msgpackr "^1.5.0" - nan "^2.14.2" - node-gyp-build "^4.2.3" - ordered-binary "^1.0.0" - weak-lru-cache "^1.0.0" - lmdb@^2.8.0: version "2.8.0" resolved "https://registry.yarnpkg.com/lmdb/-/lmdb-2.8.0.tgz#0f0aee59e35e6f890d75c02d0860e260bc95955a" @@ -12969,13 +12958,6 @@ msgpackr@1.9.1: optionalDependencies: msgpackr-extract "^3.0.2" -msgpackr@^1.5.0: - version "1.9.2" - resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.9.2.tgz#cd301f85de948111eb34553bfb402f21650f368d" - integrity sha512-xtDgI3Xv0AAiZWLRGDchyzBwU6aq0rwJ+W+5Y4CZhEWtkl/hJtFFLc+3JtGTw7nz1yquxs7nL8q/yA2aqpflIQ== - optionalDependencies: - msgpackr-extract "^3.0.2" - multimatch@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-4.0.0.tgz#8c3c0f6e3e8449ada0af3dd29efb491a375191b3" @@ -13012,7 +12994,7 @@ nan@^2.12.1: resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ== -nan@^2.14.2, nan@^2.15.0, nan@^2.17.0: +nan@^2.15.0, nan@^2.17.0: version "2.17.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== @@ -13190,11 +13172,6 @@ node-gyp-build-optional-packages@5.1.0: dependencies: detect-libc "^2.0.1" -node-gyp-build@^4.2.3: - version "4.6.0" - resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.6.0.tgz#0c52e4cbf54bbd28b709820ef7b6a3c2d6209055" - integrity sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ== - node-gyp@^8.4.1: version "8.4.1" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-8.4.1.tgz#3d49308fc31f768180957d6b5746845fbd429937" @@ -13674,7 +13651,7 @@ ora@^4.0.4: strip-ansi "^6.0.0" wcwidth "^1.0.1" -ordered-binary@^1.0.0, ordered-binary@^1.4.0: +ordered-binary@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/ordered-binary/-/ordered-binary-1.4.0.tgz#6bb53d44925f3b8afc33d1eed0fa15693b211389" integrity sha512-EHQ/jk4/a9hLupIKxTfUsQRej1Yd/0QLQs3vGvIqg5ZtCYSzNhkzHoZc7Zf4e4kUlDaC3Uw8Q/1opOLNN2OKRQ== @@ -18568,7 +18545,7 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" -weak-lru-cache@^1.0.0, weak-lru-cache@^1.2.2: +weak-lru-cache@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz#fdbb6741f36bae9540d12f480ce8254060dccd19" integrity sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==