From 265a176390f337ddc2f3cc20dcd367e77b9abd9f Mon Sep 17 00:00:00 2001 From: Miki Date: Mon, 12 Aug 2024 10:42:38 -0700 Subject: [PATCH 01/14] Introduce the redesign page and applications headers behind a switch (#7637) * Bump OUI to 1.9.0 Signed-off-by: Miki * Introduce the redesigned page header Signed-off-by: Miki Update UX of breadcrumbs, menu toggle, and the new contribution points Signed-off-by: Miki Add renderElement option in HeaderControls Signed-off-by: Shenoy Pratik Update application mocks and rearrange header layout Signed-off-by: Shenoy Pratik Break and restyle breadcrumb Signed-off-by: Zhongnan Su Implement header updates Signed-off-by: Miki * Introduce HeaderVariant Signed-off-by: Miki * Organize Header's layout Signed-off-by: Miki * Fix header control spacing Signed-off-by: Miki * Conditionally append breadcrumb to recent popover Fix mock for recent items Co-authored-by: Zhongnan Su Co-authored-by: Shenoy Pratik * Update top nav render and add app header Use ScreenTitle instead of appname from topnav menu Signed-off-by: Shenoy Pratik * Compress QueryStringInput appearance Signed-off-by: Miki * Update header for applications Signed-off-by: Miki * Eliminate colors from the borders of grouped action menu items Signed-off-by: Miki * Update TopNavControl*Data type to controlType for consistency Signed-off-by: Miki * Add tests for chrome Header Signed-off-by: Shenoy Pratik * Update Breadcrumbs tests Signed-off-by: Shenoy Pratik * Add tests for HeaderControlsContainer Signed-off-by: Miki * Add tests for TopNavControls and TopNavControlItem Signed-off-by: Shenoy Pratik * Updated tests for TopNavMenu and TopNavMenuItem Signed-off-by: Shenoy Pratik * Fix `uiSettingsServiceMock` missing `start` Signed-off-by: Miki * Add the `target` property to TopNavControlItem Signed-off-by: Miki * Update Navigation mock and start contract Signed-off-by: Miki * Add createGetterSetter mock in dashboards app state Signed-off-by: Shenoy Pratik * Add tests for setting and unsetting header variant Signed-off-by: Miki * Add tests for setting header controls Signed-off-by: Miki * Re-skin DataSource selection's trigger button Signed-off-by: Miki * Conditionally change where theme management menu item shows up Signed-off-by: Miki * Conditionally change where the help menu items shows up Signed-off-by: Miki * Make IndexPatternTable page conditionally use the new page header Signed-off-by: Miki * Make Discover conditionally use the new application header Signed-off-by: Miki * Make Dashboards conditionally use the new application header Signed-off-by: Miki * Add changelog fragment Signed-off-by: Shenoy Pratik Signed-off-by: Miki * Add tracking issue for empty label for DataSourceMenuPopoverButton Signed-off-by: Miki * Use EUI aliases in CSS variables Signed-off-by: Miki * Remove TopNavMenuLink Signed-off-by: Miki * Make sure OuiHeader doesn't contribute to the background Also: * Remove unused code Signed-off-by: Miki * Better border hiding for DSM popover button Signed-off-by: Miki * Make popover button overflow later Signed-off-by: Miki --------- Signed-off-by: Miki Signed-off-by: Shenoy Pratik Co-authored-by: Zhongnan Su Co-authored-by: Shenoy Pratik --- changelogs/fragments/7637.yml | 2 + package.json | 2 +- packages/osd-ui-framework/package.json | 2 +- packages/osd-ui-shared-deps/package.json | 2 +- .../application_service.test.ts.snap | 6 + .../application/application_service.mock.ts | 18 + .../application/application_service.test.ts | 32 +- .../application/application_service.tsx | 142 + .../integration_tests/router.test.tsx | 6 + src/core/public/application/types.ts | 34 +- .../application/ui/app_container.test.tsx | 24 + .../public/application/ui/app_container.tsx | 25 + src/core/public/application/ui/app_router.tsx | 39 +- src/core/public/chrome/chrome_service.mock.ts | 3 + src/core/public/chrome/chrome_service.test.ts | 69 +- src/core/public/chrome/chrome_service.tsx | 90 +- src/core/public/chrome/constants.ts | 14 + src/core/public/chrome/index.ts | 2 +- .../header/__snapshots__/header.test.tsx.snap | 14776 ++++++++++++---- .../header_breadcrumbs.test.tsx.snap | 15 + .../__snapshots__/recent_items.test.tsx.snap | 2 +- src/core/public/chrome/ui/header/_index.scss | 2 +- .../collapsible_nav_group_enabled_top.tsx | 1 - src/core/public/chrome/ui/header/header.scss | 61 + .../public/chrome/ui/header/header.test.tsx | 59 +- src/core/public/chrome/ui/header/header.tsx | 463 +- .../ui/header/header_action_menu.test.tsx | 10 +- .../chrome/ui/header/header_action_menu.tsx | 4 +- .../chrome/ui/header/header_breadcrumbs.scss | 35 + .../ui/header/header_breadcrumbs.test.tsx | 26 +- .../chrome/ui/header/header_breadcrumbs.tsx | 21 +- .../ui/header/header_controls_container.scss | 29 + .../header/header_controls_container.test.tsx | 131 + .../ui/header/header_controls_container.tsx | 95 + .../chrome/ui/header/header_help_menu.tsx | 20 +- .../public/chrome/ui/header/recent_items.scss | 22 + .../chrome/ui/header/recent_items.test.tsx | 9 +- .../public/chrome/ui/header/recent_items.tsx | 24 +- src/core/public/core_system.ts | 24 +- src/core/public/index.ts | 2 + src/core/public/mocks.ts | 20 +- src/core/public/plugins/plugin_context.ts | 6 + .../ui_settings/ui_settings_service.mock.ts | 2 + .../public/header_user_theme_menu.tsx | 48 +- .../advanced_settings/public/plugin.ts | 2 +- .../public/register_nav_control.tsx | 4 +- .../components/dashboard_editor.tsx | 15 +- .../dashboard_top_nav.test.tsx.snap | 212 +- .../dashboard_top_nav.test.tsx | 4 +- .../dashboard_top_nav/dashboard_top_nav.tsx | 75 +- .../top_nav/get_top_nav_config.ts | 192 +- .../utils/create_dashboard_app_state.test.tsx | 1 + .../ui/filter_bar/_global_filter_group.scss | 20 +- .../ui/query_string_input/_query_bar.scss | 4 +- .../query_string_input/query_bar_top_row.tsx | 11 +- .../query_string_input/query_string_input.tsx | 2 +- .../public/components/button_title.scss | 7 - .../data_source_aggregated_view.test.tsx.snap | 203 + .../data_source_aggregated_view.tsx | 1 + .../data_source_error_menu.test.tsx.snap | 6 + .../create_data_source_menu.test.tsx.snap | 123 +- .../data_source_menu.test.tsx.snap | 76 +- .../data_source_selectable.test.tsx.snap | 82 +- .../data_source_selectable.tsx | 4 +- .../data_source_selector.tsx | 2 +- .../data_source_view.test.tsx.snap | 172 +- .../data_source_view/data_source_view.tsx | 1 + .../popover_button/popover_button.scss | 80 + .../popover_button/popover_button.test.tsx | 16 +- .../popover_button/popover_button.tsx | 23 +- .../default_discover_table/_table_cell.scss | 4 +- .../default_discover_table/_table_header.scss | 2 +- .../components/top_nav/get_top_nav_links.tsx | 240 +- .../view_components/canvas/index.tsx | 14 +- .../view_components/canvas/top_nav.tsx | 20 +- .../opensearch_dashboards.json | 2 +- .../index_pattern_table.tsx | 69 +- .../mount_management_section.tsx | 14 +- .../index_pattern_management/public/plugin.ts | 2 + .../index_pattern_management/public/types.ts | 2 + src/plugins/navigation/public/index.ts | 18 +- src/plugins/navigation/public/mocks.ts | 1 + src/plugins/navigation/public/plugin.ts | 11 +- .../top_nav_controls.test.tsx.snap | 122 + .../public/top_nav_menu/_index.scss | 66 + .../top_nav_menu/create_top_nav_menu.tsx | 20 +- .../navigation/public/top_nav_menu/index.ts | 25 +- .../top_nav_menu/top_nav_control_data.tsx | 93 + .../top_nav_control_item.test.tsx | 108 + .../top_nav_menu/top_nav_control_item.tsx | 150 + .../top_nav_menu/top_nav_controls.test.tsx | 67 + .../public/top_nav_menu/top_nav_controls.tsx | 71 + .../public/top_nav_menu/top_nav_menu.test.tsx | 105 +- .../public/top_nav_menu/top_nav_menu.tsx | 126 +- .../public/top_nav_menu/top_nav_menu_data.tsx | 65 +- .../top_nav_menu/top_nav_menu_item.test.tsx | 60 +- .../public/top_nav_menu/top_nav_menu_item.tsx | 118 +- src/plugins/navigation/public/types.ts | 7 +- .../region_map_options.test.tsx.snap | 12 +- .../saved_objects_table.test.tsx.snap | 6 + .../plugins/osd_tp_run_pipeline/package.json | 2 +- .../osd_sample_panel_action/package.json | 2 +- .../osd_tp_custom_visualizations/package.json | 2 +- yarn.lock | 8 +- 104 files changed, 14834 insertions(+), 4557 deletions(-) create mode 100644 changelogs/fragments/7637.yml create mode 100644 src/core/public/chrome/ui/header/header.scss create mode 100644 src/core/public/chrome/ui/header/header_breadcrumbs.scss create mode 100644 src/core/public/chrome/ui/header/header_controls_container.scss create mode 100644 src/core/public/chrome/ui/header/header_controls_container.test.tsx create mode 100644 src/core/public/chrome/ui/header/header_controls_container.tsx create mode 100644 src/core/public/chrome/ui/header/recent_items.scss delete mode 100644 src/plugins/data_source_management/public/components/button_title.scss create mode 100644 src/plugins/data_source_management/public/components/popover_button/popover_button.scss create mode 100644 src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_controls.test.tsx.snap create mode 100644 src/plugins/navigation/public/top_nav_menu/top_nav_control_data.tsx create mode 100644 src/plugins/navigation/public/top_nav_menu/top_nav_control_item.test.tsx create mode 100644 src/plugins/navigation/public/top_nav_menu/top_nav_control_item.tsx create mode 100644 src/plugins/navigation/public/top_nav_menu/top_nav_controls.test.tsx create mode 100644 src/plugins/navigation/public/top_nav_menu/top_nav_controls.tsx diff --git a/changelogs/fragments/7637.yml b/changelogs/fragments/7637.yml new file mode 100644 index 000000000000..02db1c24d3d2 --- /dev/null +++ b/changelogs/fragments/7637.yml @@ -0,0 +1,2 @@ +feat: +- Introduce the redesign page and applications headers behind a switch ([#7637](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7637)) diff --git a/package.json b/package.json index 3392b44d0072..1934c949fefe 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,7 @@ "dependencies": { "@aws-crypto/client-node": "^3.1.1", "@elastic/datemath": "5.0.3", - "@elastic/eui": "npm:@opensearch-project/oui@1.8.1", + "@elastic/eui": "npm:@opensearch-project/oui@1.9.0", "@elastic/good": "^9.0.1-kibana3", "@elastic/numeral": "npm:@amoo-miki/numeral@2.6.0", "@elastic/request-crypto": "2.0.0", diff --git a/packages/osd-ui-framework/package.json b/packages/osd-ui-framework/package.json index f1decec5302c..7cfe87f7e2fd 100644 --- a/packages/osd-ui-framework/package.json +++ b/packages/osd-ui-framework/package.json @@ -23,7 +23,7 @@ "enzyme-adapter-react-16": "^1.9.1" }, "devDependencies": { - "@elastic/eui": "npm:@opensearch-project/oui@1.8.1", + "@elastic/eui": "npm:@opensearch-project/oui@1.9.0", "@osd/babel-preset": "1.0.0", "@osd/optimizer": "1.0.0", "comment-stripper": "^0.0.4", diff --git a/packages/osd-ui-shared-deps/package.json b/packages/osd-ui-shared-deps/package.json index 45611a198bc9..05ccf6b50255 100644 --- a/packages/osd-ui-shared-deps/package.json +++ b/packages/osd-ui-shared-deps/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@elastic/charts": "31.1.0", - "@elastic/eui": "npm:@opensearch-project/oui@1.8.1", + "@elastic/eui": "npm:@opensearch-project/oui@1.9.0", "@elastic/numeral": "npm:@amoo-miki/numeral@2.6.0", "@opensearch/datemath": "5.0.3", "@osd/i18n": "1.0.0", diff --git a/src/core/public/application/__snapshots__/application_service.test.ts.snap b/src/core/public/application/__snapshots__/application_service.test.ts.snap index a6c9eb27e338..687977044cde 100644 --- a/src/core/public/application/__snapshots__/application_service.test.ts.snap +++ b/src/core/public/application/__snapshots__/application_service.test.ts.snap @@ -81,7 +81,13 @@ exports[`#start() getComponent returns renderable JSX tree 1`] = ` } mounters={Map {}} setAppActionMenu={[Function]} + setAppBadgeControls={[Function]} + setAppBottomControls={[Function]} + setAppCenterControls={[Function]} + setAppDescriptionControls={[Function]} setAppLeaveHandler={[Function]} + setAppLeftControls={[Function]} + setAppRightControls={[Function]} setIsMounting={[Function]} /> `; diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index b70a34095f0c..e3897f746cfb 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -65,6 +65,12 @@ const createStartContractMock = (): jest.Mocked => { navigateToUrl: jest.fn(), getUrlForApp: jest.fn(), registerMountContext: jest.fn(), + setAppLeftControls: jest.fn(), + setAppCenterControls: jest.fn(), + setAppRightControls: jest.fn(), + setAppBadgeControls: jest.fn(), + setAppDescriptionControls: jest.fn(), + setAppBottomControls: jest.fn(), }; }; @@ -98,6 +104,18 @@ const createInternalStartContractMock = (): jest.Mocked(undefined), + currentLeftControls$: new BehaviorSubject(undefined), + currentCenterControls$: new BehaviorSubject(undefined), + currentRightControls$: new BehaviorSubject(undefined), + currentBadgeControls$: new BehaviorSubject(undefined), + currentDescriptionControls$: new BehaviorSubject(undefined), + currentBottomControls$: new BehaviorSubject(undefined), + setAppLeftControls: jest.fn(), + setAppCenterControls: jest.fn(), + setAppRightControls: jest.fn(), + setAppBadgeControls: jest.fn(), + setAppDescriptionControls: jest.fn(), + setAppBottomControls: jest.fn(), getComponent: jest.fn(), getUrlForApp: jest.fn(), navigateToApp: jest.fn().mockImplementation((appId) => currentAppId$.next(appId)), diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index 52786446ad55..a614e39205c9 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -35,7 +35,7 @@ import { } from './application_service.test.mocks'; import { createElement } from 'react'; -import { BehaviorSubject, Subject } from 'rxjs'; +import { BehaviorSubject, Subject, Observable } from 'rxjs'; import { bufferCount, take, takeUntil } from 'rxjs/operators'; import { shallow, mount } from 'enzyme'; @@ -51,7 +51,9 @@ import { AppStatus, AppUpdater, WorkspaceAvailability, + InternalApplicationStart, } from './types'; +import { MountPoint } from '../types'; import { act } from 'react-dom/test-utils'; import { workspacesServiceMock } from '../mocks'; @@ -937,6 +939,34 @@ describe('#start()', () => { expect(setupDeps.redirectTo).not.toHaveBeenCalled(); }); }); + + describe('AppControls', () => { + test.each(['Left', 'Center', 'Right', 'Badge', 'Description', 'Bottom'])( + 'records the App%sControls', + async (container) => { + const { register } = service.setup(setupDeps); + + register(Symbol(), createApp({ id: `app${container}` })); + const appStart = await service.start(startDeps); + const setControls = appStart[ + `setApp${container}Controls` as keyof InternalApplicationStart + ] as (mount: MountPoint | undefined) => void; + const currentControls$ = appStart[ + `current${container}Controls$` as keyof InternalApplicationStart + ] as Observable; + + const oldMountPoint = jest.fn(); + const expectedMountPoint = jest.fn(); + + await appStart.navigateToApp(`app${container}`); + setControls(oldMountPoint); + setControls(expectedMountPoint); + + const mountPoint = await currentControls$.pipe(take(1)).toPromise(); + expect(mountPoint).toBe(expectedMountPoint); + } + ); + }); }); describe('#stop()', () => { diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 76747490a305..630d97476b05 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -37,6 +37,7 @@ import { RecursiveReadonly } from '@osd/utility-types'; import { MountPoint } from '../types'; import { HttpSetup, HttpStart } from '../http'; import { OverlayStart } from '../overlays'; +import { HeaderControlsContainer } from '../chrome/constants'; import { ContextSetup, IContextContainer } from '../context'; import { PluginOpaqueId } from '../plugins'; import { AppRouter } from './ui'; @@ -104,6 +105,12 @@ interface AppUpdaterWrapper { interface AppInternalState { leaveHandler?: AppLeaveHandler; actionMenu?: MountPoint; + leftControls?: MountPoint; + centerControls?: MountPoint; + rightControls?: MountPoint; + badgeControls?: MountPoint; + descriptionControls?: MountPoint; + bottomControls?: MountPoint; } /** @@ -117,6 +124,15 @@ export class ApplicationService { private readonly appInternalStates = new Map(); private currentAppId$ = new BehaviorSubject(undefined); private currentActionMenu$ = new BehaviorSubject(undefined); + + // HeaderControls + private currentLeftControls$ = new BehaviorSubject(undefined); + private currentCenterControls$ = new BehaviorSubject(undefined); + private currentRightControls$ = new BehaviorSubject(undefined); + private currentBadgeControls$ = new BehaviorSubject(undefined); + private currentDescriptionControls$ = new BehaviorSubject(undefined); + private currentBottomControls$ = new BehaviorSubject(undefined); + private readonly statusUpdaters$ = new BehaviorSubject>(new Map()); private readonly subscriptions: Subscription[] = []; private stop$ = new Subject(); @@ -291,6 +307,15 @@ export class ApplicationService { this.currentAppId$.subscribe(() => this.refreshCurrentActionMenu()); + this.currentAppId$.subscribe(() => this.refreshCurrentControls(HeaderControlsContainer.LEFT)); + this.currentAppId$.subscribe(() => this.refreshCurrentControls(HeaderControlsContainer.CENTER)); + this.currentAppId$.subscribe(() => this.refreshCurrentControls(HeaderControlsContainer.RIGHT)); + this.currentAppId$.subscribe(() => this.refreshCurrentControls(HeaderControlsContainer.BADGE)); + this.currentAppId$.subscribe(() => + this.refreshCurrentControls(HeaderControlsContainer.DESCRIPTION) + ); + this.currentAppId$.subscribe(() => this.refreshCurrentControls(HeaderControlsContainer.BOTTOM)); + return { applications$: applications$.pipe( map((apps) => new Map([...apps.entries()].map(([id, app]) => [id, getAppInfo(app)]))), @@ -306,6 +331,46 @@ export class ApplicationService { distinctUntilChanged(), takeUntil(this.stop$) ), + + // HeaderControls + currentLeftControls$: this.currentLeftControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + currentCenterControls$: this.currentCenterControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + currentRightControls$: this.currentRightControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + currentBadgeControls$: this.currentBadgeControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + currentDescriptionControls$: this.currentDescriptionControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + currentBottomControls$: this.currentBottomControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + + setAppLeftControls: (mount: MountPoint | undefined) => + this.setAppLeftControls(this.currentAppId$.value, mount), + setAppCenterControls: (mount: MountPoint | undefined) => + this.setAppCenterControls(this.currentAppId$.value, mount), + setAppRightControls: (mount: MountPoint | undefined) => + this.setAppRightControls(this.currentAppId$.value, mount), + setAppBadgeControls: (mount: MountPoint | undefined) => + this.setAppBadgeControls(this.currentAppId$.value, mount), + setAppDescriptionControls: (mount: MountPoint | undefined) => + this.setAppDescriptionControls(this.currentAppId$.value, mount), + setAppBottomControls: (mount: MountPoint | undefined) => + this.setAppBottomControls(this.currentAppId$.value, mount), + history: this.history!, registerMountContext: this.mountContext.registerContext, getUrlForApp: ( @@ -339,6 +404,12 @@ export class ApplicationService { appStatuses$={applicationStatuses$} setAppLeaveHandler={this.setAppLeaveHandler} setAppActionMenu={this.setAppActionMenu} + setAppLeftControls={this.setAppLeftControls} + setAppCenterControls={this.setAppCenterControls} + setAppRightControls={this.setAppRightControls} + setAppBadgeControls={this.setAppBadgeControls} + setAppDescriptionControls={this.setAppDescriptionControls} + setAppBottomControls={this.setAppBottomControls} setIsMounting={(isMounting) => httpLoadingCount$.next(isMounting ? 1 : 0)} /> ); @@ -367,6 +438,71 @@ export class ApplicationService { this.currentActionMenu$.next(currentActionMenu); }; + private setAppLeftControls = (appPath: string | undefined, mount: MountPoint | undefined) => + this.setAppControls(appPath, mount, HeaderControlsContainer.LEFT); + + private setAppCenterControls = (appPath: string | undefined, mount: MountPoint | undefined) => + this.setAppControls(appPath, mount, HeaderControlsContainer.CENTER); + + private setAppRightControls = (appPath: string | undefined, mount: MountPoint | undefined) => + this.setAppControls(appPath, mount, HeaderControlsContainer.RIGHT); + + private setAppBadgeControls = (appPath: string | undefined, mount: MountPoint | undefined) => + this.setAppControls(appPath, mount, HeaderControlsContainer.BADGE); + + private setAppDescriptionControls = ( + appPath: string | undefined, + mount: MountPoint | undefined + ) => this.setAppControls(appPath, mount, HeaderControlsContainer.DESCRIPTION); + + private setAppBottomControls = (appPath: string | undefined, mount: MountPoint | undefined) => + this.setAppControls(appPath, mount, HeaderControlsContainer.BOTTOM); + + private setAppControls = ( + appPath: string | undefined, + mount: MountPoint | undefined, + container: HeaderControlsContainer + ) => { + if (!appPath) return; + + this.appInternalStates.set(appPath, { + ...(this.appInternalStates.get(appPath) ?? {}), + [`${container}Controls`]: mount, + }); + + this.refreshCurrentControls(container); + }; + + private refreshCurrentControls = (container: HeaderControlsContainer) => { + const appId = this.currentAppId$.getValue(); + switch (container) { + case HeaderControlsContainer.LEFT: + return this.currentLeftControls$.next( + appId ? this.appInternalStates.get(appId)?.leftControls : undefined + ); + case HeaderControlsContainer.CENTER: + return this.currentCenterControls$.next( + appId ? this.appInternalStates.get(appId)?.centerControls : undefined + ); + case HeaderControlsContainer.RIGHT: + return this.currentRightControls$.next( + appId ? this.appInternalStates.get(appId)?.rightControls : undefined + ); + case HeaderControlsContainer.BADGE: + return this.currentBadgeControls$.next( + appId ? this.appInternalStates.get(appId)?.badgeControls : undefined + ); + case HeaderControlsContainer.DESCRIPTION: + return this.currentDescriptionControls$.next( + appId ? this.appInternalStates.get(appId)?.descriptionControls : undefined + ); + case HeaderControlsContainer.BOTTOM: + return this.currentBottomControls$.next( + appId ? this.appInternalStates.get(appId)?.bottomControls : undefined + ); + } + }; + private async shouldNavigate(overlays: OverlayStart): Promise { const currentAppId = this.currentAppId$.value; if (currentAppId === undefined) { @@ -402,6 +538,12 @@ export class ApplicationService { this.stop$.next(); this.currentAppId$.complete(); this.currentActionMenu$.complete(); + this.currentLeftControls$.complete(); + this.currentCenterControls$.complete(); + this.currentRightControls$.complete(); + this.currentBadgeControls$.complete(); + this.currentDescriptionControls$.complete(); + this.currentBottomControls$.complete(); this.statusUpdaters$.complete(); this.subscriptions.forEach((sub) => sub.unsubscribe()); window.removeEventListener('beforeunload', this.onBeforeUnload); diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx index 7e1bc437ca9d..88876f65f054 100644 --- a/src/core/public/application/integration_tests/router.test.tsx +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -70,6 +70,12 @@ describe('AppRouter', () => { appStatuses$={mountersToAppStatus$()} setAppLeaveHandler={noop} setAppActionMenu={noop} + setAppLeftControls={noop} + setAppCenterControls={noop} + setAppRightControls={noop} + setAppBadgeControls={noop} + setAppDescriptionControls={noop} + setAppBottomControls={noop} setIsMounting={noop} /> ); diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 7546b49620a4..63cbc5605561 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -35,7 +35,7 @@ import { RecursiveReadonly } from '@osd/utility-types'; import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; import { MountPoint } from '../types'; import { Capabilities } from './capabilities'; -import { ChromeStart } from '../chrome'; +import { ChromeStart, HeaderVariant } from '../chrome'; import { IContextProvider } from '../context'; import { DocLinksStart } from '../doc_links'; import { HttpStart } from '../http'; @@ -227,6 +227,11 @@ export interface App { */ chromeless?: boolean; + /** + * The application-wide header variant to use. Defaults to `page`. + */ + headerVariant?: HeaderVariant; + /** * A mount function called when the user navigates to this app's route. May have signature of {@link AppMount} or * {@link AppMountDeprecated}. @@ -535,6 +540,13 @@ export interface AppMountParameters { * ``` */ setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; + + setHeaderLeftControls: (menuMount: MountPoint | undefined) => void; + setHeaderCenterControls: (menuMount: MountPoint | undefined) => void; + setHeaderRightControls: (menuMount: MountPoint | undefined) => void; + setHeaderBadgeControls: (menuMount: MountPoint | undefined) => void; + setHeaderDescriptionControls: (menuMount: MountPoint | undefined) => void; + setHeaderBottomControls: (menuMount: MountPoint | undefined) => void; /** * Optional datasource id to pass while mounting app */ @@ -828,6 +840,13 @@ export interface ApplicationStart { * An observable that emits the current application id and each subsequent id update. */ currentAppId$: Observable; + + setAppLeftControls: (mount: MountPoint | undefined) => void; + setAppCenterControls: (mount: MountPoint | undefined) => void; + setAppRightControls: (mount: MountPoint | undefined) => void; + setAppBadgeControls: (mount: MountPoint | undefined) => void; + setAppDescriptionControls: (mount: MountPoint | undefined) => void; + setAppBottomControls: (mount: MountPoint | undefined) => void; } /** @internal */ @@ -858,6 +877,19 @@ export interface InternalApplicationStart extends Omit; + /** + * The potential header controls set by the currently mounted app. + * Consumed by the chrome header. + * + * @internal + */ + currentLeftControls$: Observable; + currentCenterControls$: Observable; + currentRightControls$: Observable; + currentBadgeControls$: Observable; + currentDescriptionControls$: Observable; + currentBottomControls$: Observable; + /** * The global history instance, exposed only to Core. * @internal diff --git a/src/core/public/application/ui/app_container.test.tsx b/src/core/public/application/ui/app_container.test.tsx index 3e658fa25665..2c33ca040e85 100644 --- a/src/core/public/application/ui/app_container.test.tsx +++ b/src/core/public/application/ui/app_container.test.tsx @@ -43,6 +43,12 @@ describe('AppContainer', () => { const setAppLeaveHandler = jest.fn(); const setAppActionMenu = jest.fn(); const setIsMounting = jest.fn(); + const setAppLeftControls = jest.fn(); + const setAppRightControls = jest.fn(); + const setAppCenterControls = jest.fn(); + const setAppBadgeControls = jest.fn(); + const setAppDescriptionControls = jest.fn(); + const setAppBottomControls = jest.fn(); beforeEach(() => { setAppLeaveHandler.mockClear(); @@ -89,6 +95,12 @@ describe('AppContainer', () => { mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} setAppActionMenu={setAppActionMenu} + setAppLeftControls={setAppLeftControls} + setAppCenterControls={setAppCenterControls} + setAppRightControls={setAppRightControls} + setAppBadgeControls={setAppBadgeControls} + setAppDescriptionControls={setAppDescriptionControls} + setAppBottomControls={setAppBottomControls} setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location @@ -130,6 +142,12 @@ describe('AppContainer', () => { mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} setAppActionMenu={setAppActionMenu} + setAppLeftControls={setAppLeftControls} + setAppCenterControls={setAppCenterControls} + setAppRightControls={setAppRightControls} + setAppBadgeControls={setAppBadgeControls} + setAppDescriptionControls={setAppDescriptionControls} + setAppBottomControls={setAppBottomControls} setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location @@ -172,6 +190,12 @@ describe('AppContainer', () => { mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} setAppActionMenu={setAppActionMenu} + setAppLeftControls={setAppLeftControls} + setAppCenterControls={setAppCenterControls} + setAppRightControls={setAppRightControls} + setAppBadgeControls={setAppBadgeControls} + setAppDescriptionControls={setAppDescriptionControls} + setAppBottomControls={setAppBottomControls} setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index b7d0619a0f9f..8e81db2af34a 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -52,6 +52,12 @@ interface Props { appStatus: AppStatus; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; setAppActionMenu: (appId: string, mount: MountPoint | undefined) => void; + setAppLeftControls: (appId: string, mount: MountPoint | undefined) => void; + setAppCenterControls: (appId: string, mount: MountPoint | undefined) => void; + setAppRightControls: (appId: string, mount: MountPoint | undefined) => void; + setAppBadgeControls: (appId: string, mount: MountPoint | undefined) => void; + setAppDescriptionControls: (appId: string, mount: MountPoint | undefined) => void; + setAppBottomControls: (appId: string, mount: MountPoint | undefined) => void; createScopedHistory: (appUrl: string) => ScopedHistory; setIsMounting: (isMounting: boolean) => void; } @@ -62,6 +68,12 @@ export const AppContainer: FunctionComponent = ({ appPath, setAppLeaveHandler, setAppActionMenu, + setAppLeftControls, + setAppCenterControls, + setAppRightControls, + setAppBadgeControls, + setAppDescriptionControls, + setAppBottomControls, createScopedHistory, appStatus, setIsMounting, @@ -99,6 +111,13 @@ export const AppContainer: FunctionComponent = ({ element: elementRef.current!, onAppLeave: (handler) => setAppLeaveHandler(appId, handler), setHeaderActionMenu: (menuMount) => setAppActionMenu(appId, menuMount), + setHeaderLeftControls: (menuMount) => setAppLeftControls(appId, menuMount), + setHeaderCenterControls: (menuMount) => setAppCenterControls(appId, menuMount), + setHeaderRightControls: (menuMount) => setAppRightControls(appId, menuMount), + setHeaderBadgeControls: (menuMount) => setAppBadgeControls(appId, menuMount), + setHeaderDescriptionControls: (menuMount) => + setAppDescriptionControls(appId, menuMount), + setHeaderBottomControls: (menuMount) => setAppBottomControls(appId, menuMount), })) || null; } catch (e) { // TODO: add error UI @@ -122,6 +141,12 @@ export const AppContainer: FunctionComponent = ({ createScopedHistory, setAppLeaveHandler, setAppActionMenu, + setAppLeftControls, + setAppRightControls, + setAppCenterControls, + setAppBadgeControls, + setAppDescriptionControls, + setAppBottomControls, appPath, setIsMounting, ]); diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx index 9cfada1f3334..e5de0b406479 100644 --- a/src/core/public/application/ui/app_router.tsx +++ b/src/core/public/application/ui/app_router.tsx @@ -45,6 +45,12 @@ interface Props { appStatuses$: Observable>; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; setAppActionMenu: (appId: string, mount: MountPoint | undefined) => void; + setAppLeftControls: (appId: string, mount: MountPoint | undefined) => void; + setAppCenterControls: (appId: string, mount: MountPoint | undefined) => void; + setAppRightControls: (appId: string, mount: MountPoint | undefined) => void; + setAppBadgeControls: (appId: string, mount: MountPoint | undefined) => void; + setAppDescriptionControls: (appId: string, mount: MountPoint | undefined) => void; + setAppBottomControls: (appId: string, mount: MountPoint | undefined) => void; setIsMounting: (isMounting: boolean) => void; } @@ -57,6 +63,12 @@ export const AppRouter: FunctionComponent = ({ mounters, setAppLeaveHandler, setAppActionMenu, + setAppLeftControls, + setAppCenterControls, + setAppRightControls, + setAppBadgeControls, + setAppDescriptionControls, + setAppBottomControls, appStatuses$, setIsMounting, }) => { @@ -79,7 +91,19 @@ export const AppRouter: FunctionComponent = ({ appPath={path} appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ appId, mounter, setAppLeaveHandler, setAppActionMenu, setIsMounting }} + {...{ + appId, + mounter, + setAppLeaveHandler, + setAppLeftControls, + setAppCenterControls, + setAppRightControls, + setAppBadgeControls, + setAppDescriptionControls, + setAppBottomControls, + setAppActionMenu, + setIsMounting, + }} /> )} /> @@ -101,7 +125,18 @@ export const AppRouter: FunctionComponent = ({ appId={id ?? appId} appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ mounter, setAppLeaveHandler, setAppActionMenu, setIsMounting }} + {...{ + mounter, + setAppLeaveHandler, + setAppLeftControls, + setAppCenterControls, + setAppRightControls, + setAppBadgeControls, + setAppDescriptionControls, + setAppBottomControls, + setAppActionMenu, + setIsMounting, + }} /> ); }} diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index 8e8d8c893cc9..c63232186672 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -86,6 +86,8 @@ const createStartContractMock = () => { setAppTitle: jest.fn(), setIsVisible: jest.fn(), getIsVisible$: jest.fn(), + setHeaderVariant: jest.fn(), + getHeaderVariant$: jest.fn(), addApplicationClass: jest.fn(), removeApplicationClass: jest.fn(), getApplicationClasses$: jest.fn(), @@ -104,6 +106,7 @@ const createStartContractMock = () => { }; startContract.navLinks.getAll.mockReturnValue([]); startContract.getIsVisible$.mockReturnValue(new BehaviorSubject(false)); + startContract.getHeaderVariant$.mockReturnValue(new BehaviorSubject(undefined)); startContract.getApplicationClasses$.mockReturnValue(new BehaviorSubject(['class-name'])); startContract.getBadge$.mockReturnValue(new BehaviorSubject({} as ChromeBadge)); startContract.getBreadcrumbs$.mockReturnValue(new BehaviorSubject([{} as ChromeBreadcrumb])); diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index 09ecbaedb55b..082ffbfa16ed 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -42,12 +42,17 @@ import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; import { ChromeService } from './chrome_service'; import { getAppInfo } from '../application/utils'; import { overlayServiceMock, workspacesServiceMock } from '../mocks'; +import { HeaderVariant } from './constants'; class FakeApp implements App { public title: string; public mount = () => () => {}; - constructor(public id: string, public chromeless?: boolean) { + constructor( + public id: string, + public chromeless?: boolean, + public headerVariant?: HeaderVariant + ) { this.title = `${this.id} App`; } } @@ -282,6 +287,68 @@ describe('start', () => { }); }); + describe('header variant', () => { + it('emits undefined when no application is mounted', async () => { + const { chrome, service } = await start(); + const promise = chrome.getHeaderVariant$().pipe(toArray()).toPromise(); + + chrome.setHeaderVariant(HeaderVariant.PAGE); + chrome.setHeaderVariant(HeaderVariant.APPLICATION); + chrome.setHeaderVariant(HeaderVariant.PAGE); + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(`Array []`); + }); + + it('emits application-wide value until manually overridden', async () => { + const startDeps = defaultStartDeps([ + new FakeApp('alpha', undefined, HeaderVariant.APPLICATION), + ]); + const { navigateToApp } = startDeps.application; + const { chrome, service } = await start({ startDeps }); + + const promise = chrome.getHeaderVariant$().pipe(toArray()).toPromise(); + + await navigateToApp('alpha'); + + chrome.setHeaderVariant(HeaderVariant.PAGE); + chrome.setHeaderVariant(HeaderVariant.APPLICATION); + + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(` + Array [ + "${HeaderVariant.APPLICATION}", + "${HeaderVariant.PAGE}", + "${HeaderVariant.APPLICATION}", + ] + `); + }); + + it('emits application-wide value after override is removed', async () => { + const startDeps = defaultStartDeps([new FakeApp('alpha', undefined, HeaderVariant.PAGE)]); + const { navigateToApp } = startDeps.application; + const { chrome, service } = await start({ startDeps }); + + const promise = chrome.getHeaderVariant$().pipe(toArray()).toPromise(); + + await navigateToApp('alpha'); + + chrome.setHeaderVariant(HeaderVariant.APPLICATION); + chrome.setHeaderVariant(); + + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(` + Array [ + "${HeaderVariant.PAGE}", + "${HeaderVariant.APPLICATION}", + "${HeaderVariant.PAGE}", + ] + `); + }); + }); + describe('application classes', () => { it('updates/emits the application classes', async () => { const { chrome, service } = await start(); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 3b61ee3ee945..c56388a9da32 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -30,8 +30,17 @@ import { EuiBreadcrumb, IconType } from '@elastic/eui'; import React from 'react'; -import { FormattedMessage } from '@osd/i18n/react'; -import { BehaviorSubject, combineLatest, merge, Observable, of, ReplaySubject } from 'rxjs'; +import ReactDOM from 'react-dom'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; +import { + BehaviorSubject, + combineLatest, + merge, + Observable, + of, + ReplaySubject, + Subscription, +} from 'rxjs'; import { flatMap, map, takeUntil } from 'rxjs/operators'; import { EuiLink } from '@elastic/eui'; import { mountReactNode } from '../utils/mount'; @@ -41,13 +50,13 @@ import { HttpStart } from '../http'; import { InjectedMetadataStart } from '../injected_metadata'; import { NotificationsStart } from '../notifications'; import { IUiSettingsClient } from '../ui_settings'; -import { OPENSEARCH_DASHBOARDS_ASK_OPENSEARCH_LINK } from './constants'; +import { HeaderVariant, OPENSEARCH_DASHBOARDS_ASK_OPENSEARCH_LINK } from './constants'; import { ChromeDocTitle, DocTitleService } from './doc_title'; import { ChromeNavControls, NavControlsService } from './nav_controls'; import { ChromeNavLinks, NavLinksService, ChromeNavLink } from './nav_links'; import { ChromeRecentlyAccessed, RecentlyAccessedService } from './recently_accessed'; import { Header } from './ui'; -import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu'; +import { ChromeHelpExtensionMenuLink, HeaderHelpMenu } from './ui/header/header_help_menu'; import { Branding, WorkspacesStart } from '../'; import { getLogos } from '../../common'; import type { Logos } from '../../common/types'; @@ -119,12 +128,16 @@ type CollapsibleNavHeaderRender = () => JSX.Element | null; export class ChromeService { private isVisible$!: Observable; private isForceHidden$!: BehaviorSubject; + private headerVariant$!: Observable; + private headerVariantOverride$!: BehaviorSubject; private readonly stop$ = new ReplaySubject(1); private readonly navControls = new NavControlsService(); private readonly navLinks = new NavLinksService(); private readonly recentlyAccessed = new RecentlyAccessedService(); private readonly docTitle = new DocTitleService(); private readonly navGroup = new ChromeNavGroupService(); + private useUpdatedHeader = false; + private updatedHeaderSubscription: Subscription | undefined; private collapsibleNavHeaderRender?: CollapsibleNavHeaderRender; constructor(private readonly params: ConstructorParams) {} @@ -161,6 +174,28 @@ export class ChromeService { ); } + private initHeaderVariant(application: StartDeps['application']) { + this.headerVariantOverride$ = new BehaviorSubject(undefined); + + const appHeaderVariant$ = application.currentAppId$.pipe( + flatMap((appId) => + application.applications$.pipe( + map( + (applications) => + (appId && applications.has(appId) && applications.get(appId)!.headerVariant) as + | HeaderVariant + | undefined + ) + ) + ) + ); + + this.headerVariant$ = combineLatest([appHeaderVariant$, this.headerVariantOverride$]).pipe( + map(([appHeaderVariant, headerVariantOverride]) => headerVariantOverride || appHeaderVariant), + takeUntil(this.stop$) + ); + } + public setup({ uiSettings }: SetupDeps): ChromeSetup { const navGroup = this.navGroup.setup({ uiSettings }); return { @@ -188,6 +223,13 @@ export class ChromeService { workspaces, }: StartDeps): Promise { this.initVisibility(application); + this.initHeaderVariant(application); + + this.updatedHeaderSubscription = uiSettings + .get$('home:useNewHomePage', false) + .subscribe((value) => { + this.useUpdatedHeader = value; + }); const appTitle$ = new BehaviorSubject('Overview'); const applicationClasses$ = new BehaviorSubject>(new Set()); @@ -230,6 +272,29 @@ export class ChromeService { const logos = getLogos(injectedMetadata.getBranding(), http.basePath.serverBasePath); + // Add Help menu + if (this.useUpdatedHeader) { + navControls.registerLeftBottom({ + order: 9000, + mount: (element: HTMLElement) => { + ReactDOM.render( + + + , + element + ); + return () => ReactDOM.unmountComponentAtNode(element); + }, + }); + } + const isIE = () => { const ua = window.navigator.userAgent; const msie = ua.indexOf('MSIE '); // IE 10 or older @@ -298,6 +363,7 @@ export class ChromeService { helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))} homeHref={application.getUrlForApp('home')} isVisible$={this.isVisible$} + headerVariant$={this.headerVariant$} opensearchDashboardsVersion={injectedMetadata.getOpenSearchDashboardsVersion()} navLinks$={navLinks.getNavLinks$()} recentlyAccessed$={recentlyAccessed.get$()} @@ -319,6 +385,7 @@ export class ChromeService { navGroupsMap$={navGroup.getNavGroupsMap$()} setCurrentNavGroup={navGroup.setCurrentNavGroup} workspaceList$={workspaces.workspaceList$} + useUpdatedHeader={this.useUpdatedHeader} /> ), @@ -328,6 +395,10 @@ export class ChromeService { setIsVisible: (isVisible: boolean) => this.isForceHidden$.next(!isVisible), + getHeaderVariant$: () => this.headerVariant$, + + setHeaderVariant: (variant?: HeaderVariant) => this.headerVariantOverride$.next(variant), + getApplicationClasses$: () => applicationClasses$.pipe( map((set) => [...set]), @@ -385,6 +456,7 @@ export class ChromeService { public stop() { this.navLinks.stop(); this.navGroup.stop(); + this.updatedHeaderSubscription?.unsubscribe(); this.stop$.next(); } } @@ -465,6 +537,16 @@ export interface ChromeStart { */ setIsVisible(isVisible: boolean): void; + /** + * Get an observable of the current header variant. + */ + getHeaderVariant$(): Observable; + + /** + * Set or unset the temporary variant for the header. + */ + setHeaderVariant(variant?: HeaderVariant): void; + /** * Get the current set of classNames that will be set on the application container. */ diff --git a/src/core/public/chrome/constants.ts b/src/core/public/chrome/constants.ts index 4f98257ea5f8..ce65af852e07 100644 --- a/src/core/public/chrome/constants.ts +++ b/src/core/public/chrome/constants.ts @@ -37,3 +37,17 @@ export enum RightNavigationOrder { Settings = 10, DevTool = 20, } + +export enum HeaderControlsContainer { + LEFT = 'left', + CENTER = 'center', + RIGHT = 'right', + BADGE = 'badge', + DESCRIPTION = 'description', + BOTTOM = 'bottom', +} + +export enum HeaderVariant { + PAGE = 'page', + APPLICATION = 'application', +} diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index 5347266d9c33..5403634705c4 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -58,6 +58,6 @@ export { } from './recently_accessed'; export { ChromeNavControl, ChromeNavControls } from './nav_controls'; export { ChromeDocTitle } from './doc_title'; -export { RightNavigationOrder } from './constants'; +export { RightNavigationOrder, HeaderVariant } from './constants'; export { ChromeRegistrationNavLink, ChromeNavGroupUpdater, NavGroupItemInMap } from './nav_group'; export { fulfillRegistrationLinksToChromeNavLinks, LinkItemType, getSortedNavLinks } from './utils'; diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 5489d9fcfdc5..82aa02fe79a0 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -201,6 +201,60 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, }, }, + "currentBadgeControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentBottomControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentCenterControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentDescriptionControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentLeftControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentRightControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, "getComponent": [MockFunction], "getUrlForApp": [MockFunction], "history": Object { @@ -225,6 +279,12 @@ exports[`Header handles visibility and lock changes 1`] = ` "navigateToApp": [MockFunction], "navigateToUrl": [MockFunction], "registerMountContext": [MockFunction], + "setAppBadgeControls": [MockFunction], + "setAppBottomControls": [MockFunction], + "setAppCenterControls": [MockFunction], + "setAppDescriptionControls": [MockFunction], + "setAppLeftControls": [MockFunction], + "setAppRightControls": [MockFunction], } } badge$={ @@ -337,6 +397,43 @@ exports[`Header handles visibility and lock changes 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, ], "thrownError": null, } @@ -541,6 +638,55 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, } } + headerVariant$={ + BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } helpExtension$={ BehaviorSubject { "_isScalar": false, @@ -2101,6 +2247,7 @@ exports[`Header handles visibility and lock changes 1`] = ` } } survey="/" + useUpdatedHeader={false} workspaceList$={ BehaviorSubject { "_isScalar": false, @@ -4084,6 +4231,43 @@ exports[`Header handles visibility and lock changes 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, ], "thrownError": null, } @@ -4137,6 +4321,7 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, } } + useUpdatedHeader={false} > @@ -4258,9 +4444,10 @@ exports[`Header handles visibility and lock changes 1`] = ` >
@@ -7022,7 +7210,7 @@ exports[`Header handles visibility and lock changes 1`] = ` `; -exports[`Header renders condensed header 1`] = ` +exports[`Header renders application header without title and breadcrumbs 1`] = `
- -
+ - -
- -
- - - - - - - - - - , - } - } - className="euiHeaderSectionItemButton" - color="text" + aria-pressed="false" + class="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItemButton newAppTopNavExpander" data-test-subj="toggleNavButton" - onClick={[Function]} + type="button" > - - - -
-
- + + , + } + } + className="euiHeaderSectionItemButton newAppTopNavExpander" + color="text" + data-test-subj="toggleNavButton" + onClick={[Function]} > -
- + + + + + + + + + + + + + + +
+ +
+ -
-
- -
- + } + workspaceList$={ + BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, }, "isStopped": false, - "syncErrorThrowable": false, + "syncErrorThrowable": true, "syncErrorThrown": false, "syncErrorValue": null, }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], - "thrownError": null, - } - } - logos={ - Object { - "AnimatedMark": Object { - "dark": Object { - "type": "default", - "url": "/ui/logos/opensearch_spinner_on_dark.svg", - }, - "light": Object { - "type": "default", - "url": "/ui/logos/opensearch_spinner_on_light.svg", - }, - "type": "default", - "url": "/ui/logos/opensearch_spinner_on_light.svg", - }, - "Application": Object { - "dark": Object { - "type": "default", - "url": "/ui/logos/opensearch_dashboards_on_dark.svg", - }, - "light": Object { - "type": "default", - "url": "/ui/logos/opensearch_dashboards_on_light.svg", - }, - "type": "default", - "url": "/ui/logos/opensearch_dashboards_on_light.svg", - }, - "CenterMark": Object { - "dark": Object { - "type": "default", - "url": "/ui/logos/opensearch_center_mark_on_dark.svg", - }, - "light": Object { - "type": "default", - "url": "/ui/logos/opensearch_center_mark_on_light.svg", - }, - "type": "default", - "url": "/ui/logos/opensearch_center_mark_on_light.svg", - }, - "Mark": Object { - "dark": Object { - "type": "default", - "url": "/ui/logos/opensearch_mark_on_dark.svg", - }, - "light": Object { - "type": "default", - "url": "/ui/logos/opensearch_mark_on_light.svg", - }, - "type": "default", - "url": "/ui/logos/opensearch_mark_on_light.svg", - }, - "OpenSearch": Object { - "dark": Object { - "type": "default", - "url": "/ui/logos/opensearch_on_dark.svg", - }, - "light": Object { - "type": "default", - "url": "/ui/logos/opensearch_on_light.svg", - }, - "type": "default", - "url": "/ui/logos/opensearch_on_light.svg", - }, - "colorScheme": "light", - } - } - navLinks$={ - BehaviorSubject { - "_isScalar": false, - "_value": Array [], - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [ - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], - "thrownError": null, + ], + "thrownError": null, + } } - } - navigateToApp={[MockFunction]} - > - - + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + initialFocus={false} + isOpen={false} + ownFocus={true} + panelPaddingSize="s" + repositionOnScroll={true} > - - - - + + -
-
-
-
+ class="euiHeaderSectionItemButton__content" + > + + +
-
-
- , - } - } - className="euiHeaderSectionItemButton header__homeLoaderNavButton" - color="text" - data-test-subj="homeLoader" - href="/" - onBlur={[Function]} - onClick={[Function]} - onFocus={[Function]} - > - - , } } + className="euiHeaderSectionItemButton headerRecentItemsButton" + color="text" + data-test-subj="recentItemsSectionButton" + onClick={[Function]} + size="s" > - - -
- - -
-
- - - - - -
+
+
- - -
-
- - - -
-
-
-
-
-
- - + + + +
+
+ + +
+ + +
+ +
+ +
+ +
+ + - - - - - - -
- -
-
- -
- -
- -
- -
- - -
+ + - + -
-
- -
+
+ +
+ + - + -
-
- -
- +
+
+
+ +
+
+
+
+ + + + + +`; + +exports[`Header renders condensed header 1`] = ` +
+
+
+ +
+ +
+ +
+ + + + + + + + + + , + } + } + className="euiHeaderSectionItemButton" + color="text" + data-test-subj="toggleNavButton" + onClick={[Function]} + > + + + +
+
+ +
+ +
+
+ +
+ + + + + + + + +
+ +
+
+
+
+
+
+ , + } + } + className="euiHeaderSectionItemButton header__homeLoaderNavButton" + color="text" + data-test-subj="homeLoader" + href="/" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + > + + + + + +
+ + + + + +
+
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + +
+ +
+
+ +
+ +
+ +
+ +
+ + +
+ +
+
+ +
+ +
+
+ +
+ + + + + + + + } + closePopover={[Function]} + data-test-subj="helpMenuButton" + display="inlineBlock" + hasArrow={true} + id="headerHelpMenu" + isOpen={false} + ownFocus={true} + panelPaddingSize="s" + repositionOnScroll={true} + > +
+
+ + + + + + + + + + + + , + } + } + className="euiHeaderSectionItemButton" + color="text" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + > + + + + + +
+
+
+
+
+
+
+
+ +
+ +
+ + + +
+
+`; + +exports[`Header renders page header with application title 1`] = ` +
+
+
+
+ +
+ + + + + + + + + + , + } + } + className="euiHeaderSectionItemButton newPageTopNavExpander" + color="text" + data-test-subj="toggleNavButton" + onClick={[Function]} + > + + + + +
+ +
+ + } + workspaceList$={ + BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], "closed": false, - "destination": Subscriber { + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], "_parentOrParents": null, - "_subscriptions": Array [ - [Circular], - ], + "_parentSubscriber": [Circular], + "_subscriptions": null, "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], }, "isStopped": false, - "syncErrorThrowable": true, + "syncErrorThrowable": false, "syncErrorThrown": false, "syncErrorValue": null, }, - "isStopped": true, - "observables": Array [ - BehaviorSubject { - "_isScalar": false, - "_value": undefined, - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [ - InnerSubscriber { - "_parentOrParents": [Circular], - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "index": 1, - "isStopped": false, - "outerIndex": 0, - "outerValue": undefined, - "parent": [Circular], - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], - "thrownError": null, - }, - [Circular], - ], - "resultSelector": undefined, + "isStopped": false, "syncErrorThrowable": true, "syncErrorThrown": false, "syncErrorValue": null, - "toRespond": 0, - "values": Array [ - undefined, - "", - ], }, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], - "thrownError": null, + ], + "thrownError": null, + } } - } - opensearchDashboardsDocLink="/docs" - opensearchDashboardsVersion="1.0.0" - surveyLink="/" - useDefaultContent={true} + > + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + initialFocus={false} + isOpen={false} + ownFocus={true} + panelPaddingSize="s" + repositionOnScroll={true} + > +
+
+ + + + + + + + + + , + } + } + className="euiHeaderSectionItemButton headerRecentItemsButton" + color="text" + data-test-subj="recentItemsSectionButton" + onClick={[Function]} + size="xs" + > + + + +
+
+
+
+
+
+
+
+ + + +
+ + + + +
+ + +
+ +
+ +
+ +
+

+ testTitle +

+
+
+
+
+ +
+ +
+ +
+ + +
+ +
+
+ +
+ +
+ +
+ + +
+ +
+
+
+ + +
+ +
+ +
+
+ +
+ +
+ +
+ + +
+ +
+
+ +
+ +
+ +
+ + +
+ +
+
+ +
+ - - - - - +
+ +
+ + +
+ -
-
- - - - - - - - - - - - , - } - } - className="euiHeaderSectionItemButton" - color="text" - onBlur={[Function]} - onClick={[Function]} - onFocus={[Function]} - > - - - - - -
-
- - - -
-
-
- -
- + } + side="right" + /> +
+
+
+
+
+ + +
+ +
+ +
+ + +
+ +
+ +
+ +
@@ -15303,9 +22231,10 @@ exports[`Header toggles primary navigation menu when clicked 1`] = ` >
diff --git a/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap index 5080b23e99c2..441f43729e98 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap @@ -33,3 +33,18 @@ Array [ `; exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable 3`] = `null`; + +exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable with updated header 1`] = `null`; + +exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable with updated header 2`] = ` + + First + +`; + +exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable with updated header 3`] = `null`; diff --git a/src/core/public/chrome/ui/header/__snapshots__/recent_items.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/recent_items.test.tsx.snap index 208b6e181bbb..b9813f1e7d1a 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/recent_items.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/recent_items.test.tsx.snap @@ -10,7 +10,7 @@ exports[`Recent items should render base element normally 1`] = ` class="euiPopover__anchor" > + class="euiButtonContent euiButton__content" + > + + + + + +
@@ -154,25 +163,34 @@ Object {
- + class="euiButtonContent euiButton__content" + > + + + + + +
, @@ -251,25 +269,34 @@ Object {
- + class="euiButtonContent euiButton__content" + > + + + + + +
diff --git a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap index 4dc6ce29141a..19ace22584f7 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap @@ -75,25 +75,31 @@ Object {
- + class="euiButtonContent euiButton__content" + > + + + + +
@@ -106,25 +112,31 @@ Object {
- + class="euiButtonContent euiButton__content" + > + + + + +
, diff --git a/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap index 443ec2faa18c..046869724c8c 100644 --- a/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap @@ -209,27 +209,36 @@ Object {
- + + +
@@ -380,27 +389,36 @@ Object {
- + + +
, diff --git a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx index 3b679c2c047b..8ba9867a898e 100644 --- a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx @@ -11,6 +11,7 @@ import { EuiPanel, EuiSelectable, EuiPopoverTitle, + EuiSelectableOption, } from '@elastic/eui'; import { ApplicationStart, @@ -37,7 +38,6 @@ import { DataSourceItem } from '../data_source_item'; import { NoDataSource } from '../no_data_source'; import './data_source_selectable.scss'; import { DataSourceDropDownHeader } from '../drop_down_header'; -import '../button_title.scss'; import './data_source_selectable.scss'; import { DataSourceMenuPopoverButton } from '../popover_button/popover_button'; @@ -311,7 +311,7 @@ export class DataSourceSelectable extends React.Component< listProps={{ onFocusBadge: false, }} - options={this.state.dataSourceOptions} + options={this.state.dataSourceOptions as Array>} onChange={(newOptions) => this.onChange(newOptions)} singleSelection={'always'} data-test-subj={'dataSourceSelectable'} diff --git a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx index c2d2fe94d992..dbec565dc7b5 100644 --- a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx @@ -250,7 +250,7 @@ export class DataSourceSelector extends React.Component< isDisabled={this.props.disabled} fullWidth={this.props.fullWidth || false} data-test-subj={'dataSourceSelectorComboBox'} - renderOption={(option) => ( + renderOption={(option: EuiComboBoxOptionOption) => ( @@ -60,6 +61,7 @@ exports[`DataSourceView Should return error when provided datasource has been fi button={ @@ -115,6 +117,7 @@ exports[`DataSourceView When selected option is local cluster and hide local Clu button={ @@ -161,6 +164,7 @@ exports[`DataSourceView should call getDataSourceById when only pass id with no button={ @@ -217,6 +221,7 @@ exports[`DataSourceView should render normally with local cluster not hidden 1`] button={ @@ -277,122 +282,31 @@ Object { >
- -
- - -
-
- ); }; diff --git a/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss b/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss index 980a335f35e2..e5af22b21874 100644 --- a/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss +++ b/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss @@ -1,4 +1,4 @@ -$osdDocTableCellPadding: calc($ouiSizeM / 2); // corresponds to DataGrid medium cellPadding (6px) +$osdDocTableCellPadding: calc($euiSizeM / 2); // corresponds to DataGrid medium cellPadding (6px) .osdDocTable__detailsParent { border-top: none !important; @@ -47,7 +47,7 @@ $osdDocTableCellPadding: calc($ouiSizeM / 2); // corresponds to DataGrid medium top: 0; height: 100%; width: 100%; - background-image: linear-gradient(to right, transparent 0, $ouiColorEmptyShade 16px); + background-image: linear-gradient(to right, transparent 0, $euiColorEmptyShade 16px); z-index: 1; } diff --git a/src/plugins/discover/public/application/components/default_discover_table/_table_header.scss b/src/plugins/discover/public/application/components/default_discover_table/_table_header.scss index 59fa7c4dd4d0..92c60f9eafe7 100644 --- a/src/plugins/discover/public/application/components/default_discover_table/_table_header.scss +++ b/src/plugins/discover/public/application/components/default_discover_table/_table_header.scss @@ -4,7 +4,7 @@ // nested for specificity .docTableHeaderField { - padding: calc($ouiSizeM / 2); // corresponds to DataGrid medium cellPadding + padding: calc($euiSizeM / 2); // corresponds to DataGrid medium cellPadding } } diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx index 3e0b00846930..647b989f42e4 100644 --- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { DiscoverViewServices } from '../../../build_services'; import { SavedSearch } from '../../../saved_searches'; import { Adapters } from '../../../../../inspector/public'; -import { TopNavMenuData } from '../../../../../navigation/public'; +import { TopNavMenuData, TopNavMenuIconData } from '../../../../../navigation/public'; import { ISearchSource, unhashUrl } from '../../../opensearch_dashboards_services'; import { OnSaveProps, @@ -26,7 +26,7 @@ import { getRootBreadcrumbs } from '../../helpers/breadcrumbs'; import { syncQueryStateWithUrl } from '../../../../../data/public'; import { OpenSearchPanel } from './open_search_panel'; -export const getTopNavLinks = ( +const getLegacyTopNavLinks = ( services: DiscoverViewServices, inspectorAdapters: Adapters, savedSearch: SavedSearch, @@ -273,6 +273,242 @@ export const getTopNavLinks = ( return topNavLinksArray; }; +export const getTopNavLinks = ( + services: DiscoverViewServices, + inspectorAdapters: Adapters, + savedSearch: SavedSearch, + isEnhancementEnabled: boolean = false +) => { + const { + history, + inspector, + core, + capabilities, + share, + toastNotifications, + chrome, + store, + data: { query }, + osdUrlStateStorage, + uiSettings, + } = services; + + const showActionsInGroup = uiSettings.get('home:useNewHomePage'); + if (!showActionsInGroup) + return getLegacyTopNavLinks(services, inspectorAdapters, savedSearch, isEnhancementEnabled); + + const topNavLinksMap = new Map(); + + // New + const newSearch: TopNavMenuIconData = { + tooltip: i18n.translate('discover.localMenu.localMenu.newSearchTitle', { + defaultMessage: 'New', + }), + run() { + core.application.navigateToApp('discover', { + path: '#/', + }); + }, + testId: 'discoverNewButton', + ariaLabel: i18n.translate('discover.topNav.discoverNewButtonLabel', { + defaultMessage: `New Search`, + }), + iconType: 'plusInCircle', + controlType: 'icon', + }; + topNavLinksMap.set('new', newSearch); + + // Open + const openSearch: TopNavMenuIconData = { + tooltip: i18n.translate('discover.localMenu.openTitle', { + defaultMessage: 'Open', + }), + testId: 'discoverOpenButton', + ariaLabel: i18n.translate('discover.topNav.discoverOpenButtonLabel', { + defaultMessage: `Open Saved Search`, + }), + run: () => { + const flyoutSession = services.overlays.openFlyout( + toMountPoint( + + flyoutSession?.close?.().then()} + makeUrl={(searchId) => `#/view/${encodeURIComponent(searchId)}`} + /> + + ) + ); + }, + iconType: 'folderOpen', + controlType: 'icon', + }; + topNavLinksMap.set('open', openSearch); + + // Save + if (capabilities.discover?.save) { + const saveSearch: TopNavMenuIconData = { + tooltip: i18n.translate('discover.localMenu.saveTitle', { + defaultMessage: 'Save', + }), + testId: 'discoverSaveButton', + ariaLabel: i18n.translate('discover.topNav.discoverSaveButtonLabel', { + defaultMessage: `Save search`, + }), + run: async () => { + const onSave = async ({ + newTitle, + newCopyOnSave, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }: OnSaveProps) => { + const currentTitle = savedSearch.title; + savedSearch.title = newTitle; + savedSearch.copyOnSave = newCopyOnSave; + const saveOptions = { + confirmOverwrite: false, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }; + + const state: DiscoverState = store!.getState().discover; // store is defined before the view is loaded + + savedSearch.columns = state.columns; + savedSearch.sort = state.sort; + + try { + const id = await savedSearch.save(saveOptions); + + // If the title is a duplicate, the id will be an empty string. Checking for this condition here + if (id) { + toastNotifications.addSuccess({ + title: i18n.translate('discover.notifications.savedSearchTitle', { + defaultMessage: `Search '{savedSearchTitle}' was saved`, + values: { + savedSearchTitle: savedSearch.title, + }, + }), + 'data-test-subj': 'saveSearchSuccess', + }); + + if (id !== state.savedSearch) { + history().push(`/view/${encodeURIComponent(id)}`); + } else { + chrome.docTitle.change(savedSearch.lastSavedTitle); + chrome.setBreadcrumbs([...getRootBreadcrumbs(), { text: savedSearch.title }]); + } + + // set App state to clean + store!.dispatch({ type: setSavedSearchId.type, payload: id }); + + // starts syncing `_g` portion of url with query services + syncQueryStateWithUrl(query, osdUrlStateStorage); + + return { id }; + } + } catch (error) { + toastNotifications.addDanger({ + title: i18n.translate('discover.notifications.notSavedSearchTitle', { + defaultMessage: `Search '{savedSearchTitle}' was not saved.`, + values: { + savedSearchTitle: savedSearch.title, + }, + }), + text: (error as Error).message, + }); + + // Reset the original title + savedSearch.title = currentTitle; + + return { error }; + } + }; + + const saveModal = ( + {}} + title={savedSearch.title} + showCopyOnSave={!!savedSearch.id} + objectType="search" + description={i18n.translate('discover.localMenu.saveSaveSearchDescription', { + defaultMessage: + 'Save your Discover search so you can use it in visualizations and dashboards', + })} + showDescription={false} + /> + ); + showSaveModal(saveModal, core.i18n.Context); + }, + iconType: 'save', + controlType: 'icon', + }; + topNavLinksMap.set('save', saveSearch); + } + + // Share + if (share) { + const shareSearch: TopNavMenuIconData = { + tooltip: i18n.translate('discover.localMenu.shareTitle', { + defaultMessage: 'Share', + }), + testId: 'shareTopNavButton', + ariaLabel: i18n.translate('discover.topNav.discoverShareButtonLabel', { + defaultMessage: `Share search`, + }), + run: async (anchorElement) => { + const state: DiscoverState = store!.getState().discover; // store is defined before the view is loaded + const sharingData = await getSharingData({ + searchSource: savedSearch.searchSource, + state, + services, + }); + share?.toggleShareContextMenu({ + anchorElement, + allowEmbed: false, + allowShortUrl: capabilities.discover?.createShortUrl as boolean, + shareableUrl: unhashUrl(window.location.href), + objectId: savedSearch.id, + objectType: 'search', + sharingData: { + ...sharingData, + title: savedSearch.title, + }, + isDirty: !savedSearch.id || state.isDirty || false, + }); + }, + iconType: 'share', + controlType: 'icon', + }; + topNavLinksMap.set('share', shareSearch); + } + + const inspectSearch: TopNavMenuIconData = { + tooltip: i18n.translate('discover.localMenu.inspectTitle', { + defaultMessage: 'Inspect', + }), + testId: 'openInspectorButton', + ariaLabel: i18n.translate('discover.topNav.discoverInspectorButtonLabel', { + defaultMessage: `Open Inspector for search`, + }), + run() { + inspector.open(inspectorAdapters, { + title: savedSearch?.title || undefined, + }); + }, + iconType: 'inspect', + controlType: 'icon', + }; + topNavLinksMap.set('inspect', inspectSearch); + + // Order their appearance + return ['save', 'open', 'new', 'inspect', 'share'].reduce((acc, item) => { + const itemDef = topNavLinksMap.get(item); + if (itemDef) acc.push(itemDef); + + return acc; + }, [] as TopNavMenuData[]); +}; + // TODO: This does not seem to affect the share menu. need to look into it in future // const getFieldCounts = async () => { // // the field counts aren't set until we have the data back, diff --git a/src/plugins/discover/public/application/view_components/canvas/index.tsx b/src/plugins/discover/public/application/view_components/canvas/index.tsx index 26e32c6c2df0..500c8ebdc80c 100644 --- a/src/plugins/discover/public/application/view_components/canvas/index.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx @@ -34,13 +34,18 @@ import { OpenSearchSearchHit } from '../../../application/doc_views/doc_views_ty import { buildColumns } from '../../utils/columns'; import './discover_canvas.scss'; import { getNewDiscoverSetting, setNewDiscoverSetting } from '../../components/utils/local_storage'; +import { HeaderVariant } from '../../../../../../core/public'; // eslint-disable-next-line import/no-default-export export default function DiscoverCanvas({ setHeaderActionMenu, history, optionalRef }: ViewProps) { const panelRef = useRef(null); const { data$, refetch$, indexPattern } = useDiscoverContext(); const { - services: { uiSettings, storage, capabilities }, + services: { + uiSettings, + capabilities, + chrome: { setHeaderVariant }, + }, } = useOpenSearchDashboards(); const { columns } = useSelector((state) => { const stateColumns = state.discover.columns; @@ -110,6 +115,13 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history, optionalR } }, [dispatch, filteredColumns, indexPattern]); + useEffect(() => { + setHeaderVariant?.(HeaderVariant.APPLICATION); + return () => { + setHeaderVariant?.(); + }; + }, [setHeaderVariant]); + const timeField = indexPattern?.timeFieldName ? indexPattern.timeFieldName : undefined; const scrollToTop = () => { if (panelRef.current) { diff --git a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx index 76d6c9789095..fa3ee3524994 100644 --- a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx @@ -7,6 +7,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Query, TimeRange } from 'src/plugins/data/common'; import { createPortal } from 'react-dom'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; import { AppMountParameters } from '../../../../../../core/public'; import { connectStorageToQueryState, opensearchFilters } from '../../../../../data/public'; import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; @@ -20,6 +21,7 @@ import { useDispatch, setSavedQuery, useSelector } from '../../utils/state_manag import './discover_canvas.scss'; import { useDataSetManager } from '../utils/use_dataset_manager'; +import { TopNavMenuItemRenderType } from '../../../../../navigation/public'; export interface TopNavProps { opts: { @@ -35,6 +37,7 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro const { services } = useOpenSearchDashboards(); const { inspectorAdapters, savedSearch, indexPattern } = useDiscoverContext(); const [indexPatterns, setIndexPatterns] = useState(undefined); + const [screenTitle, setScreenTitle] = useState(''); const state = useSelector((s) => s.discover); const dispatch = useDispatch(); @@ -51,6 +54,8 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro uiSettings, } = services; + const showActionsInGroup = uiSettings.get('home:useNewHomePage'); + const topNavLinks = savedSearch ? getTopNavLinks(services, inspectorAdapters, savedSearch, isEnhancementsEnabled) : []; @@ -100,6 +105,15 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro } }, [chrome, getUrlForApp, savedSearch?.id, savedSearch?.title]); + useEffect(() => { + setScreenTitle( + savedSearch?.title || + i18n.translate('discover.savedSearch.newTitle', { + defaultMessage: 'Untitled', + }) + ); + }, [savedSearch?.title]); + const showDatePicker = useMemo(() => (indexPattern ? indexPattern.isTimeBased() : false), [ indexPattern, ]); @@ -134,8 +148,8 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro className={isEnhancementsEnabled ? 'topNav hidden' : ''} appName={PLUGIN_ID} config={topNavLinks} - showSearchBar - showDatePicker={showDatePicker} + showSearchBar={TopNavMenuItemRenderType.IN_PLACE} + showDatePicker={showDatePicker && TopNavMenuItemRenderType.IN_PORTAL} showSaveQuery={showSaveQuery} useDefaultBehaviors setMenuMountPoint={opts.setHeaderActionMenu} @@ -144,6 +158,8 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro savedQueryId={state.savedQuery} onSavedQueryIdChange={updateSavedQueryId} datePickerRef={opts?.optionalRef?.datePickerRef} + groupActions={showActionsInGroup} + screenTitle={screenTitle} /> ); diff --git a/src/plugins/index_pattern_management/opensearch_dashboards.json b/src/plugins/index_pattern_management/opensearch_dashboards.json index 3f5dc47001b3..27d770436247 100644 --- a/src/plugins/index_pattern_management/opensearch_dashboards.json +++ b/src/plugins/index_pattern_management/opensearch_dashboards.json @@ -4,7 +4,7 @@ "server": true, "ui": true, "optionalPlugins": ["dataSource"], - "requiredPlugins": ["management", "data", "urlForwarding"], + "requiredPlugins": ["management", "navigation", "data", "urlForwarding"], "requiredBundles": ["opensearchDashboardsReact", "opensearchDashboardsUtils"], "supportedOSDataSourceVersions": ">=1.0.0" } diff --git a/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx b/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx index 737182031f84..3a1d6323323c 100644 --- a/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx +++ b/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx @@ -98,6 +98,7 @@ export const IndexPatternTable = ({ canSave, history }: Props) => { uiSettings, indexPatternManagementStart, chrome, + navigationUI: { HeaderControl }, docLinks, application, http, @@ -105,6 +106,7 @@ export const IndexPatternTable = ({ canSave, history }: Props) => { data, dataSourceEnabled, } = useOpenSearchDashboards().services; + const [indexPatterns, setIndexPatterns] = useState([]); const [creationOptions, setCreationOptions] = useState([]); const [sources, setSources] = useState([]); @@ -217,15 +219,51 @@ export const IndexPatternTable = ({ canSave, history }: Props) => { }), ]; - const createButton = canSave ? ( - - { + if (!canSave) return null; + + const button = ( + + + + ); + + return showActionsInHeader ? ( + - + ) : ( + {button} + ); + })(); + + const description = ( + + ); + const pageTitleAndDescription = showActionsInHeader ? ( + ) : ( - <> + + +

{title}

+
+ + +

{description}

+
+
); if (isLoadingSources || isLoadingIndexPatterns) { @@ -263,21 +301,8 @@ export const IndexPatternTable = ({ canSave, history }: Props) => { <> - - -

{title}

-
- - -

- -

-
-
- {createButton} + {pageTitleAndDescription} + {createButton}
@@ -118,7 +121,12 @@ export async function mountManagementSection( {params.wrapInPage ? ( - + {content} ) : ( diff --git a/src/plugins/index_pattern_management/public/plugin.ts b/src/plugins/index_pattern_management/public/plugin.ts index 3e98374b3c80..b830680e9756 100644 --- a/src/plugins/index_pattern_management/public/plugin.ts +++ b/src/plugins/index_pattern_management/public/plugin.ts @@ -48,6 +48,7 @@ import { import { ManagementSetup } from '../../management/public'; import { DEFAULT_NAV_GROUPS, AppStatus, DEFAULT_APP_CATEGORIES } from '../../../core/public'; import { getScopedBreadcrumbs } from '../../opensearch_dashboards_react/public'; +import { NavigationPublicPluginStart } from '../../navigation/public'; export interface IndexPatternManagementSetupDependencies { management: ManagementSetup; @@ -57,6 +58,7 @@ export interface IndexPatternManagementSetupDependencies { export interface IndexPatternManagementStartDependencies { data: DataPublicPluginStart; + navigation: NavigationPublicPluginStart; dataSource?: DataSourcePluginStart; } diff --git a/src/plugins/index_pattern_management/public/types.ts b/src/plugins/index_pattern_management/public/types.ts index 7b2cd8575a7e..9a0d92de52cd 100644 --- a/src/plugins/index_pattern_management/public/types.ts +++ b/src/plugins/index_pattern_management/public/types.ts @@ -40,6 +40,7 @@ import { SavedObjectReference, } from 'src/core/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; import { EuiTableFieldDataColumnType } from '@elastic/eui'; import { ManagementAppMountParams } from '../../management/public'; import { IndexPatternManagementStart } from './index'; @@ -50,6 +51,7 @@ export interface IndexPatternManagmentContext { application: ApplicationStart; savedObjects: SavedObjectsStart; uiSettings: IUiSettingsClient; + navigationUI: NavigationPublicPluginStart['ui']; notifications: NotificationsStart; overlays: OverlayStart; http: HttpSetup; diff --git a/src/plugins/navigation/public/index.ts b/src/plugins/navigation/public/index.ts index c409f066730e..fe89f39c44df 100644 --- a/src/plugins/navigation/public/index.ts +++ b/src/plugins/navigation/public/index.ts @@ -35,7 +35,23 @@ export function plugin(initializerContext: PluginInitializerContext) { return new NavigationPublicPlugin(initializerContext); } -export { TopNavMenuData, TopNavMenu } from './top_nav_menu'; +export { + TopNavMenu, + TopNavMenuData, + TopNavMenuButtonData, + TopNavMenuSwitchData, + TopNavMenuIconData, + TopNavMenuLegacyData, + TopNavMenuItemRenderType, + TopNavControls, + TopNavControlData, + TopNavControlButtonData, + TopNavControlLinkData, + TopNavControlIconData, + TopNavControlTextData, + TopNavControlDescriptionData, + TopNavControlComponentData, +} from './top_nav_menu'; export { NavigationPublicPluginSetup, NavigationPublicPluginStart } from './types'; diff --git a/src/plugins/navigation/public/mocks.ts b/src/plugins/navigation/public/mocks.ts index 3c80117b9e7e..24347f988b55 100644 --- a/src/plugins/navigation/public/mocks.ts +++ b/src/plugins/navigation/public/mocks.ts @@ -45,6 +45,7 @@ const createStartContract = (): jest.Mocked => { const startContract = { ui: { TopNavMenu: jest.fn(), + HeaderControl: jest.fn(), }, }; return startContract; diff --git a/src/plugins/navigation/public/plugin.ts b/src/plugins/navigation/public/plugin.ts index 031fdb5153ae..a626f67210d1 100644 --- a/src/plugins/navigation/public/plugin.ts +++ b/src/plugins/navigation/public/plugin.ts @@ -28,13 +28,13 @@ * under the License. */ -import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; +import { TopNavMenuExtensionsRegistry, createTopNav, createTopNavControl } from './top_nav_menu'; import { + NavigationPluginStartDependencies, NavigationPublicPluginSetup, NavigationPublicPluginStart, - NavigationPluginStartDependencies, } from './types'; -import { TopNavMenuExtensionsRegistry, createTopNav } from './top_nav_menu'; export class NavigationPublicPlugin implements Plugin { @@ -51,14 +51,15 @@ export class NavigationPublicPlugin } public start( - { i18n }: CoreStart, + { i18n, chrome }: CoreStart, { data }: NavigationPluginStartDependencies ): NavigationPublicPluginStart { const extensions = this.topNavMenuExtensionsRegistry.getAll(); return { ui: { - TopNavMenu: createTopNav(data, extensions, i18n), + TopNavMenu: createTopNav(data, extensions, i18n, chrome.navGroup.getNavGroupEnabled()), + HeaderControl: createTopNavControl(i18n), }, }; } diff --git a/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_controls.test.tsx.snap b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_controls.test.tsx.snap new file mode 100644 index 000000000000..1c62a3c148dc --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_controls.test.tsx.snap @@ -0,0 +1,122 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TopNavControls renders TopNavControlItems when controls are provided 1`] = ` + +`; diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index 988153a1c6af..a4de5f473fb0 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,3 +1,69 @@ +@use "sass:map"; + .osdTopNavMenu { margin-right: $euiSizeXS; } + +.osdTopNavMenuGroupedActions { + background-color: $euiColorEmptyShade; + + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + & > .euiSwitch, + & > .euiButton, + & > .euiButtonIcon, + & > .euiToolTipAnchor > .euiSwitch, + & > .euiToolTipAnchor > .euiButton, + & > .euiToolTipAnchor > .euiButtonIcon { + border-radius: 0; + border: $euiFormInputGroupBorder; + } + + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + & > :not(:first-child), + & > .euiToolTipAnchor:not(:first-child) > .euiSwitch, + & > .euiToolTipAnchor:not(:first-child) > .euiButton, + & > .euiToolTipAnchor:not(:first-child) > .euiButtonIcon { + border-left-width: 0; + border-bottom-left-radius: 0; + border-top-left-radius: 0; + } + + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + & > :not(:last-child), + & > .euiToolTipAnchor:not(:last-child) > .euiSwitch, + & > .euiToolTipAnchor:not(:last-child) > .euiButton, + & > .euiToolTipAnchor:not(:last-child) > .euiButtonIcon { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + + // border-right-color: map.get($euiSwitchColors, "text") !important; + } + + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + & > .osdTopNavGroup-isDisabled:not(:last-child):has(+ .osdTopNavGroup-isDisabled), + & > .osdTopNavGroup-isDisabled.euiToolTipAnchor:not(:last-child):has(+ .osdTopNavGroup-isDisabled) > .euiButton, + & > .osdTopNavGroup-isDisabled.euiToolTipAnchor:not(:last-child):has(+ .osdTopNavGroup-isDisabled) > .euiButtonIcon { + // border-right-color: $euiButtonColorDisabled !important; + } +} + +.osdTopNavGroup { + &--button { + min-width: auto; + } +} + +.osdTopNavGroup-isDisabled { + cursor: not-allowed; +} + +.osdTopNavMenuScreenTitle { + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + .euiText { + line-height: $euiFormControlCompressedHeight; + white-space: nowrap; + max-width: 18ch; + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/src/plugins/navigation/public/top_nav_menu/create_top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/create_top_nav_menu.tsx index dc4ce63e514a..7af0bc49c0b2 100644 --- a/src/plugins/navigation/public/top_nav_menu/create_top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/create_top_nav_menu.tsx @@ -28,16 +28,18 @@ * under the License. */ -import React from 'react'; import { I18nStart } from 'opensearch-dashboards/public'; +import React from 'react'; import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { TopNavMenuProps, TopNavMenu } from './top_nav_menu'; +import { TopNavControls, TopNavControlsProps } from './top_nav_controls'; +import { TopNavMenu, TopNavMenuProps } from './top_nav_menu'; import { RegisteredTopNavMenuData } from './top_nav_menu_data'; export function createTopNav( data: DataPublicPluginStart, extraConfig: RegisteredTopNavMenuData[], - i18n: I18nStart + i18n: I18nStart, + groupActions?: boolean ) { return (props: TopNavMenuProps) => { const relevantConfig = extraConfig.filter( @@ -47,7 +49,17 @@ export function createTopNav( return ( - + + + ); + }; +} + +export function createTopNavControl(i18n: I18nStart) { + return (props: TopNavControlsProps) => { + return ( + + ); }; diff --git a/src/plugins/navigation/public/top_nav_menu/index.ts b/src/plugins/navigation/public/top_nav_menu/index.ts index fdbf95b95c21..1b84f5503610 100644 --- a/src/plugins/navigation/public/top_nav_menu/index.ts +++ b/src/plugins/navigation/public/top_nav_menu/index.ts @@ -28,9 +28,28 @@ * under the License. */ -export { createTopNav } from './create_top_nav_menu'; -export { TopNavMenu, TopNavMenuProps } from './top_nav_menu'; -export { TopNavMenuData } from './top_nav_menu_data'; +export { createTopNav, createTopNavControl } from './create_top_nav_menu'; +export { TopNavMenu, TopNavMenuProps, TopNavMenuItemRenderType } from './top_nav_menu'; +export { TopNavControls, TopNavControlsProps } from './top_nav_controls'; +export { + TopNavControlData, + TopNavControlButtonData, + TopNavControlLinkData, + TopNavControlIconData, + TopNavControlTextData, + TopNavControlDescriptionData, + TopNavControlComponentData, +} from './top_nav_control_data'; +export { + TopNavMenuData, + TopNavMenuButtonData, + TopNavMenuSwitchData, + TopNavMenuIconData, + TopNavMenuLegacyData, + TopNavMenuSwitchAction, + TopNavMenuClickAction, + TopNavMenuAction, +} from './top_nav_menu_data'; export { TopNavMenuExtensionsRegistrySetup, TopNavMenuExtensionsRegistry, diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_control_data.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_control_data.tsx new file mode 100644 index 000000000000..83fa46e69508 --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_control_data.tsx @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiButtonProps, EuiTextProps, EuiHeaderLinkProps, EuiButtonIconProps } from '@elastic/eui'; + +export type TopNavControlAction = (targetElement: HTMLElement) => void; + +type RequireAtLeastOne = Pick> & + { + [K in Keys]-?: Required> & Partial>>; + }[Keys]; + +interface TopNavControlButtonOrLinkOrIconData { + // @deprecated + id?: string; + testId?: string; + className?: string; + isDisabled?: boolean | (() => boolean); + tooltip?: string | (() => string | undefined); + ariaLabel?: string; + target?: '_blank'; + iconSize?: EuiButtonProps['iconSize']; +} + +export type TopNavControlLinkData = TopNavControlButtonOrLinkOrIconData & + RequireAtLeastOne< + { + label: string; + isLoading?: boolean; + href?: string; + run?: TopNavControlAction; + iconType?: EuiHeaderLinkProps['iconType']; + iconSide?: EuiHeaderLinkProps['iconSide']; + color?: EuiHeaderLinkProps['color']; + controlType: 'link'; + }, + 'href' | 'run' + >; + +export type TopNavControlButtonData = TopNavControlButtonOrLinkOrIconData & + RequireAtLeastOne< + { + label: string; + isLoading?: boolean; + href?: string; + run?: TopNavControlAction; + iconType?: EuiButtonProps['iconType']; + iconSide?: EuiButtonProps['iconSide']; + color?: EuiButtonProps['color']; + fill?: EuiButtonProps['fill']; + controlType?: 'button'; + }, + 'href' | 'run' + >; + +export type TopNavControlIconData = TopNavControlButtonOrLinkOrIconData & + RequireAtLeastOne< + { + iconType: EuiButtonIconProps['iconType']; + ariaLabel: string; + href?: string; + run?: TopNavControlAction; + display?: EuiButtonIconProps['display']; + color?: EuiButtonIconProps['color']; + controlType: 'icon'; + }, + 'href' | 'run' + >; + +export interface TopNavControlTextData { + text: string; + className?: string; + textAlign?: EuiTextProps['textAlign']; + color?: EuiTextProps['color']; +} + +export interface TopNavControlDescriptionData { + description: string; +} + +export interface TopNavControlComponentData { + renderComponent: React.ReactElement; +} + +export type TopNavControlData = + | TopNavControlButtonData + | TopNavControlLinkData + | TopNavControlIconData + | TopNavControlTextData + | TopNavControlDescriptionData + | TopNavControlComponentData; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.test.tsx new file mode 100644 index 000000000000..06266170d158 --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.test.tsx @@ -0,0 +1,108 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { EuiButton, EuiButtonIcon, EuiHeaderLink, EuiText, EuiToolTip } from '@elastic/eui'; +import { ShallowWrapper } from 'enzyme'; +import React from 'react'; +import { shallowWithIntl } from '../../../../test_utils/public/enzyme_helpers'; +import { TopNavControlData } from './top_nav_control_data'; +import { TopNavControlItem } from './top_nav_control_item'; + +// Mock props for different scenarios +const buttonProps: TopNavControlData = { + controlType: 'button', + label: 'Button', + run: jest.fn(), +}; + +const linkProps: TopNavControlData = { + controlType: 'link', + label: 'Link', + href: 'http://example.com', +}; + +const iconProps: TopNavControlData = { + controlType: 'icon', + iconType: 'user', + ariaLabel: 'Icon', + run: jest.fn(), +}; + +const textProps: TopNavControlData = { + text: 'Text Content', +}; + +const descriptionProps: TopNavControlData = { + description: 'Description Content', +}; + +const componentProps: TopNavControlData = { + renderComponent:
Custom Component
, +}; + +describe('TopNavControlItem', () => { + it('renders a button control', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiButton)).toHaveLength(1); + expect(wrapper.find(EuiButton).prop('onClick')).toBeDefined(); + }); + + it('renders a link control', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiHeaderLink)).toHaveLength(1); + expect(wrapper.find(EuiHeaderLink).prop('href')).toEqual(linkProps.href); + }); + + it('renders an icon control', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiButtonIcon)).toHaveLength(1); + expect(wrapper.find(EuiButtonIcon).prop('iconType')).toEqual(iconProps.iconType); + }); + + it('renders text content', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiText)).toHaveLength(1); + expect(wrapper.find(EuiText).children().text()).toEqual(textProps.text); + }); + + it('renders description content', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiText)).toHaveLength(1); + expect(wrapper.find(EuiText).children().text()).toEqual(descriptionProps.description); + }); + + it('renders a custom component', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.contains(componentProps.renderComponent)).toBe(true); + }); + + it('handles disabled state correctly', () => { + const disabledProps = { ...buttonProps, isDisabled: true }; + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiButton).prop('isDisabled')).toBe(true); + }); + + it('handles tooltip correctly', () => { + const tooltipProps = { ...buttonProps, tooltip: 'Tooltip text' }; + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiToolTip)).toHaveLength(1); + expect(wrapper.find(EuiToolTip).prop('content')).toEqual('Tooltip text'); + }); + + it('calls run function on button click', () => { + const mockEvent = { currentTarget: document.createElement('button') } as React.MouseEvent< + HTMLButtonElement + >; + const wrapper: ShallowWrapper = shallowWithIntl(); + wrapper.find(EuiButton).simulate('click', mockEvent); + expect(buttonProps.run).toHaveBeenCalledWith(mockEvent.currentTarget); + }); +}); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.tsx new file mode 100644 index 000000000000..ada02dba27bf --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.tsx @@ -0,0 +1,150 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EuiButton, EuiHeaderLink, EuiButtonIcon, EuiText, EuiToolTip } from '@elastic/eui'; +import { upperFirst } from 'lodash'; +import React, { MouseEvent } from 'react'; +import { TopNavControlData } from './top_nav_control_data'; + +export function TopNavControlItem(props: TopNavControlData) { + if ('renderComponent' in props) return props.renderComponent; + + if ('text' in props) { + const { text, ...rest } = props; + return ( + + {text} + + ); + } + + if ('description' in props) { + return ( + + {props.description} + + ); + } + + function isDisabled(): boolean { + if ('isDisabled' in props) { + const val = typeof props.isDisabled === 'function' ? props.isDisabled() : props.isDisabled; + return val || false; + } + return false; + } + + function getTooltip(): string { + if ('tooltip' in props) { + const val = typeof props.tooltip === 'function' ? props.tooltip() : props.tooltip; + return val || ''; + } + return ''; + } + + function handleClick(e: MouseEvent) { + if ('run' in props && !isDisabled()) props.run?.(e.currentTarget); + } + + let component; + switch (props.controlType) { + case 'icon': + component = ( + + ); + break; + + case 'link': + component = ( + + {upperFirst(props.label || props.id)} + + ); + break; + + default: + component = ( + <> + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + {upperFirst(props.label || props.id)} + + + ); + } + + const tooltip = getTooltip(); + if (tooltip) { + return {component}; + } + return component; +} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_controls.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_controls.test.tsx new file mode 100644 index 000000000000..34419f6229af --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_controls.test.tsx @@ -0,0 +1,67 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { mountWithIntl } from '../../../../test_utils/public/enzyme_helpers'; +import { MountPointPortal } from '../../../opensearch_dashboards_react/public'; +import { TopNavControlData } from './top_nav_control_data'; +import { TopNavControls, TopNavControlsProps } from './top_nav_controls'; + +// Mock props for different scenarios +const controls: TopNavControlData[] = [ + { controlType: 'button', label: 'Button', run: jest.fn() }, + { controlType: 'link', label: 'Link', href: 'http://example.com' }, +]; + +describe('TopNavControls', () => { + it('renders null when controls is not provided', () => { + const props: TopNavControlsProps = {}; + const wrapper = mountWithIntl(); + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('renders null when controls is an empty array', () => { + const props: TopNavControlsProps = { controls: [] }; + const wrapper = mountWithIntl(); + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('renders TopNavControlItems when controls are provided', () => { + const props: TopNavControlsProps = { controls }; + const wrapper = mountWithIntl(); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders MountPointPortal when setMountPoint is provided', () => { + const setMountPoint = jest.fn(); + const props: TopNavControlsProps = { controls, setMountPoint }; + const wrapper = mountWithIntl(); + expect(wrapper.find(MountPointPortal)).toHaveLength(1); + }); +}); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_controls.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_controls.tsx new file mode 100644 index 000000000000..4b1731143b65 --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_controls.tsx @@ -0,0 +1,71 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { ReactElement } from 'react'; +import { EuiHeaderSectionItem } from '@elastic/eui'; + +import { MountPoint } from 'opensearch-dashboards/public'; +import { MountPointPortal } from '../../../opensearch_dashboards_react/public'; +import { TopNavControlItem } from './top_nav_control_item'; +import { TopNavControlData } from './top_nav_control_data'; + +export interface TopNavControlsProps { + controls?: TopNavControlData[]; + className?: string; + setMountPoint?: (menuMount: MountPoint | undefined) => void; +} + +export function TopNavControls(props: TopNavControlsProps): ReactElement | null { + const { controls } = props; + + if (!Array.isArray(controls) || controls.length === 0) { + return null; + } + + function renderItems(): ReactElement[] { + return controls!.map((menuItem: TopNavControlData, i: number) => { + return ( + + + + ); + }); + } + + function renderLayout() { + const { setMountPoint } = props; + + return setMountPoint ? ( + {renderItems()} + ) : null; + } + + return renderLayout(); +} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx index 99644e49bde0..6e9b4fc1810f 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx @@ -32,9 +32,10 @@ import React from 'react'; import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { MountPoint } from 'opensearch-dashboards/public'; -import { TopNavMenu } from './top_nav_menu'; +import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { TopNavMenu, TopNavMenuItemRenderType } from './top_nav_menu'; import { TopNavMenuData } from './top_nav_menu_data'; -import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; +import { applicationServiceMock, uiSettingsServiceMock } from '../../../../core/public/mocks'; import * as testUtils from '../../../data_source_management/public/components/utils'; import { DataSourceSelectionService } from '../../../data_source_management/public/service/data_source_selection_service'; @@ -222,5 +223,105 @@ describe('TopNavMenu', () => { // menu is rendered outside of the component expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0); }); + + it('mounts the data source menu with group actions enabled', async () => { + spyOn(testUtils, 'getApplication').and.returnValue(applicationServiceMock); + spyOn(testUtils, 'getUiSettings').and.returnValue( + uiSettingsServiceMock.createStartContract() + ); + spyOn(testUtils, 'getHideLocalCluster').and.returnValue(true); + spyOn(testUtils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); + + const component = mountWithIntl( + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + expect(component.find('.osdTopNavMenuScreenTitle').exists()).toBeFalsy(); + expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(0); + }); + + it('mounts without data source menu with group actions enabled and showSearchBar in portal', async () => { + spyOn(testUtils, 'getApplication').and.returnValue(applicationServiceMock); + spyOn(testUtils, 'getUiSettings').and.returnValue( + uiSettingsServiceMock.createStartContract() + ); + spyOn(testUtils, 'getHideLocalCluster').and.returnValue(false); + spyOn(testUtils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); + + const component = mountWithIntl( + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + await (() => { + expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(1); + expect(component.find('.osdTopNavMenuScreenTitle').exists()).toBeTruthy(); + }); + }); + + it('mounts without data source menu with group actions enabled and showSearchBar in place', async () => { + spyOn(testUtils, 'getApplication').and.returnValue(applicationServiceMock); + spyOn(testUtils, 'getUiSettings').and.returnValue( + uiSettingsServiceMock.createStartContract() + ); + spyOn(testUtils, 'getHideLocalCluster').and.returnValue(false); + spyOn(testUtils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); + + const component = mountWithIntl( + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + await (() => { + expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(1); + expect(component.find('.osdTopNavMenuScreenTitle').exists()).toBeTruthy(); + expect(component.find('.globalDatePicker').exists()).toBeTruthy(); + }); + }); }); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 7bce0e01470d..e8e3489fe285 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -28,32 +28,39 @@ * under the License. */ -import React, { ReactElement } from 'react'; -import { EuiHeaderLinks } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiHeaderLinks, EuiText } from '@elastic/eui'; import classNames from 'classnames'; +import React, { ReactElement, useRef } from 'react'; import { MountPoint } from '../../../../core/public'; -import { MountPointPortal } from '../../../opensearch_dashboards_react/public'; import { - StatefulSearchBarProps, DataPublicPluginStart, SearchBarProps, + StatefulSearchBarProps, } from '../../../data/public'; +import { DataSourceMenuProps, createDataSourceMenu } from '../../../data_source_management/public'; +import { MountPointPortal } from '../../../opensearch_dashboards_react/public'; import { TopNavMenuData } from './top_nav_menu_data'; import { TopNavMenuItem } from './top_nav_menu_item'; -import { DataSourceMenuProps, createDataSourceMenu } from '../../../data_source_management/public'; -export type TopNavMenuProps = StatefulSearchBarProps & - Omit & { +export enum TopNavMenuItemRenderType { + IN_PORTAL = 'in_portal', + IN_PLACE = 'in_place', + OMITTED = 'omitted', +} + +export type TopNavMenuProps = Omit & + Omit & { config?: TopNavMenuData[]; dataSourceMenuConfig?: DataSourceMenuProps; - showSearchBar?: boolean; + showSearchBar?: boolean | TopNavMenuItemRenderType; showQueryBar?: boolean; showQueryInput?: boolean; - showDatePicker?: boolean; + showDatePicker?: boolean | TopNavMenuItemRenderType; showFilterBar?: boolean; showDataSourceMenu?: boolean; data?: DataPublicPluginStart; + groupActions?: boolean; className?: string; datePickerRef?: any; /** @@ -90,11 +97,16 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { const { config, showSearchBar, + showDatePicker, showDataSourceMenu, dataSourceMenuConfig, + groupActions, + screenTitle, ...searchBarProps } = props; + const datePickerRef = useRef(null); + if ( (!config || config.length === 0) && (!showSearchBar || !props.data) && @@ -103,11 +115,17 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { return null; } - function renderItems(): ReactElement[] | null { + function renderItems(): ReactElement | ReactElement[] | null { if (!config || config.length === 0) return null; - return config.map((menuItem: TopNavMenuData, i: number) => { + const renderedItems = config.map((menuItem: TopNavMenuData, i: number) => { return ; }); + + return groupActions ? ( +
{renderedItems}
+ ) : ( + renderedItems + ); } function renderMenu(className: string): ReactElement | null { @@ -127,17 +145,84 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { return ; } - function renderSearchBar(): ReactElement | null { + function renderSearchBar(overrides: Partial = {}): ReactElement | null { // Validate presence of all required fields if (!showSearchBar || !props.data) return null; const { SearchBar } = props.data.ui; - return ; + return ( + + ); } function renderLayout() { const { setMenuMountPoint } = props; const menuClassName = classNames('osdTopNavMenu', props.className); + if (setMenuMountPoint) { + if (groupActions) { + switch (showSearchBar) { + case TopNavMenuItemRenderType.IN_PORTAL: + return ( + <> + + + + {screenTitle} + + {renderMenu(menuClassName)} + {renderSearchBar()} + + + + ); + + case false: + case TopNavMenuItemRenderType.OMITTED: + return ( + <> + + {renderMenu(menuClassName)} + + + ); + + // Show the SearchBar in-place + default: + if (showDatePicker === TopNavMenuItemRenderType.IN_PORTAL) { + return ( + <> + + + + {screenTitle} + + {renderMenu(menuClassName)} + +
+ + + + {renderSearchBar({ datePickerRef })} + + ); + } + + return ( + <> + + {renderMenu(menuClassName)} + + {renderSearchBar()} + + ); + } + } + + // Legacy rendering behavior when setMenuMountPoint is set return ( <> @@ -146,14 +231,14 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { {renderSearchBar()} ); - } else { - return ( - <> - {renderMenu(menuClassName)} - {renderSearchBar()} - - ); } + + return ( + <> + {renderMenu(menuClassName)} + {renderSearchBar()} + + ); } return renderLayout(); @@ -167,4 +252,5 @@ TopNavMenu.defaultProps = { showFilterBar: true, showDataSourceMenu: false, screenTitle: '', + groupActions: false, }; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx index c7a3220a896e..6596be3949e4 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx @@ -28,12 +28,15 @@ * under the License. */ -import { EuiButtonProps } from '@elastic/eui'; +import { EuiButtonProps, EuiButtonIconProps } from '@elastic/eui'; import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; export type TopNavMenuAction = (anchorElement: HTMLElement) => void; +export type TopNavMenuClickAction = (targetElement: HTMLElement) => void; +export type TopNavMenuSwitchAction = (targetElement: HTMLElement, checked: boolean) => void; -export interface TopNavMenuData { +// @deprecated +export interface TopNavMenuLegacyData { id?: string; label: string; run: TopNavMenuAction; @@ -50,6 +53,60 @@ export interface TopNavMenuData { type?: 'toggle' | 'button'; } -export interface RegisteredTopNavMenuData extends TopNavMenuData { - appName?: string; +type RequireAtLeastOne = Pick> & + { + [K in Keys]-?: Required> & Partial>>; + }[Keys]; + +interface TopNavMenuCommonData { + testId?: string; + className?: string; + disabled?: boolean | (() => boolean); + tooltip?: string | (() => string | undefined); } + +export type TopNavMenuButtonData = TopNavMenuCommonData & + RequireAtLeastOne< + { + label: string; + iconType?: EuiButtonProps['iconType']; + iconSide?: EuiButtonProps['iconSide']; + ariaLabel?: string; + isLoading?: boolean; + run?: TopNavMenuClickAction; + href?: string; + controlType: 'button'; + }, + 'href' | 'run' + >; + +export type TopNavMenuIconData = TopNavMenuCommonData & + RequireAtLeastOne< + { + iconType: EuiButtonIconProps['iconType']; + ariaLabel: string; + run?: TopNavMenuClickAction; + href?: string; + tooltip: string | (() => string | undefined); + controlType: 'icon'; + }, + 'href' | 'run' + >; + +export type TopNavMenuSwitchData = TopNavMenuCommonData & { + label: string; + ariaLabel?: string; + checked: boolean | (() => boolean); + run: TopNavMenuSwitchAction; + controlType: 'switch'; +}; + +export type TopNavMenuData = + | TopNavMenuLegacyData + | TopNavMenuButtonData + | TopNavMenuIconData + | TopNavMenuSwitchData; + +export type RegisteredTopNavMenuData = TopNavMenuData & { + appName?: string; +}; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx index 7e759d2a6c09..7dba80698893 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx @@ -28,9 +28,15 @@ * under the License. */ +import { EuiButton, EuiButtonIcon, EuiSwitch, EuiToolTip } from '@elastic/eui'; import React from 'react'; import { TopNavMenuItem } from './top_nav_menu_item'; -import { TopNavMenuData } from './top_nav_menu_data'; +import { + TopNavMenuData, + TopNavMenuButtonData, + TopNavMenuIconData, + TopNavMenuSwitchData, +} from './top_nav_menu_data'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; describe('TopNavMenu', () => { @@ -138,4 +144,56 @@ describe('TopNavMenu', () => { run: jest.fn(), }); }); + + const defaultProps = { + label: 'Test Label', + run: jest.fn(), + testId: 'test-id', + }; + + it('Should render a button with tooltip', () => { + const props = { + ...defaultProps, + controlType: 'button', + tooltip: 'Test Tooltip', + } as TopNavMenuButtonData; + const wrapper = shallowWithIntl(); + expect(wrapper.find(EuiToolTip).length).toBe(1); + expect(wrapper.find(EuiButton).length).toBe(1); + }); + + it('Should render an icon button', () => { + const props = { + ...defaultProps, + controlType: 'icon', + iconType: 'alert', + tooltip: 'Test Tooltip', + ariaLabel: 'Test', + } as TopNavMenuIconData; + const wrapper = shallowWithIntl(); + expect(wrapper.find(EuiButtonIcon).length).toBe(1); + }); + + it('Should render a switch', () => { + const props = { ...defaultProps, controlType: 'switch', checked: true } as TopNavMenuSwitchData; + const wrapper = shallowWithIntl(); + expect(wrapper.find(EuiSwitch).length).toBe(1); + }); + + it('Should handles button click', () => { + const props = { ...defaultProps, controlType: 'button' } as TopNavMenuButtonData; + const wrapper = shallowWithIntl(); + wrapper.find(EuiButton).simulate('click', { currentTarget: {} }); + expect(props.run).toHaveBeenCalled(); + }); + + it('Should disable the button when disabled is true', () => { + const props = { + ...defaultProps, + controlType: 'button', + disabled: true, + } as TopNavMenuButtonData; + const wrapper = shallowWithIntl(); + expect(wrapper.find(EuiButton).props().isDisabled).toBe(true); + }); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx index 629ae019407c..f594a092833a 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -30,10 +30,25 @@ import { upperFirst, isFunction } from 'lodash'; import React, { MouseEvent } from 'react'; -import { EuiToolTip, EuiButton, EuiHeaderLink, EuiCompressedSwitch } from '@elastic/eui'; -import { TopNavMenuData } from './top_nav_menu_data'; +import classNames from 'classnames'; +import { + EuiToolTip, + EuiButton, + EuiHeaderLink, + EuiCompressedSwitch, + EuiButtonIcon, + EuiSwitch, + EuiSwitchEvent, +} from '@elastic/eui'; +import { + TopNavMenuClickAction, + TopNavMenuData, + TopNavMenuLegacyData, + TopNavMenuSwitchAction, + TopNavMenuSwitchData, +} from './top_nav_menu_data'; -export function TopNavMenuItem(props: TopNavMenuData) { +function TopNavMenuLegacyItem(props: TopNavMenuLegacyData) { function isDisabled(): boolean { const val = isFunction(props.disableButton) ? props.disableButton() : props.disableButton; return val!; @@ -91,6 +106,103 @@ export function TopNavMenuItem(props: TopNavMenuData) { return component; } +export function TopNavMenuItem(props: TopNavMenuData) { + if (!('controlType' in props)) return TopNavMenuLegacyItem(props); + + const { disabled, tooltip, run } = props as Exclude; + + const isDisabled = () => Boolean(typeof disabled === 'function' ? disabled() : disabled); + + const handleClick = (e: MouseEvent) => { + if (!isDisabled()) (run as TopNavMenuClickAction)?.(e.currentTarget); + }; + + const getComponent = (addTypeClassName: boolean = false) => { + const className = classNames(props.className, { + [`osdTopNavGroup--${props.controlType}`]: addTypeClassName, + 'osdTopNavGroup-isDisabled': isDisabled(), + }); + switch (props.controlType) { + case 'button': + return ( + <> + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + {props.label} + + + ); + + case 'icon': + return ( + + ); + + case 'switch': + const { checked } = props as TopNavMenuSwitchData; + + const isChecked = () => Boolean(typeof checked === 'function' ? checked() : checked); + + const handleSwitch = (e: EuiSwitchEvent) => { + if (!isDisabled()) (run as TopNavMenuSwitchAction)?.(e.currentTarget, e.target.checked); + }; + + return ( + + ); + } + }; + + const tooltipContent = typeof tooltip === 'function' ? tooltip() : tooltip; + + if (tooltipContent) { + const className = classNames(`osdTopNavGroup--${props.controlType}`, { + 'osdTopNavGroup-isDisabled': isDisabled(), + }); + return ( + + {getComponent()} + + ); + } + + return getComponent(true); +} + TopNavMenuItem.defaultProps = { disableButton: false, tooltip: '', diff --git a/src/plugins/navigation/public/types.ts b/src/plugins/navigation/public/types.ts index 89b0a3ca6322..9e1c3ffc812a 100644 --- a/src/plugins/navigation/public/types.ts +++ b/src/plugins/navigation/public/types.ts @@ -28,7 +28,11 @@ * under the License. */ -import { TopNavMenuProps, TopNavMenuExtensionsRegistrySetup } from './top_nav_menu'; +import { + TopNavMenuProps, + TopNavMenuExtensionsRegistrySetup, + TopNavControlsProps, +} from './top_nav_menu'; import { DataPublicPluginStart } from '../../data/public'; export interface NavigationPublicPluginSetup { @@ -38,6 +42,7 @@ export interface NavigationPublicPluginSetup { export interface NavigationPublicPluginStart { ui: { TopNavMenu: React.ComponentType; + HeaderControl: React.ComponentType; }; } diff --git a/src/plugins/region_map/public/components/__snapshots__/region_map_options.test.tsx.snap b/src/plugins/region_map/public/components/__snapshots__/region_map_options.test.tsx.snap index 447d5876fa10..8f16f7a650f0 100644 --- a/src/plugins/region_map/public/components/__snapshots__/region_map_options.test.tsx.snap +++ b/src/plugins/region_map/public/components/__snapshots__/region_map_options.test.tsx.snap @@ -271,7 +271,7 @@ exports[`region_map_options renders the RegionMapOptions with custom option if c onMouseOver={[Function]} >
+
+
-
-

- Workspaces -

-
-
-

- Workspace allow you to save and organize library items, such as index patterns, visualizations, dashboards, saved searches, and share them with other OpenSearch Dashboards users. You can control which features are visible in each workspace, and which users and groups have read and write access to the library items in the workspace. -

-
-
-
-
-
-
-
-
+
- -
- -
+ aria-hidden="true" + class="euiFormControlLayoutCustomIcon__icon" + data-euiicon-type="search" + /> +
-
-
-
+
+
+
+
+
+
-
-
- -
+ +
- - - - - - - - + + + +
+
- - -
-
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + +
+
+ + + + + + + Description + + + + + + Use case + + + + + + Actions + + +
+
+ Name +
+
+ + + +
+
+
+ ID +
+
+ + id1 + +
+
+
+ Description +
+
+ +
+
+
+ Use case +
+
+ Analytics (All) +
+
+
+ + + + Edit + + + + + + Delete - - -
+ + +
+
- + +
+
+
+ ID +
+
+ + id2 + +
+
+
+ Description +
+
+ +
+
+
+ Use case +
+
+
+
+ + + Edit + + + + + + Delete - - -
+ + +
+
+ Name +
+
+ + +
+
+
+ ID +
+
+ + id3 + +
+
+
+ Description +
+
+ +
+
+
+ Use case +
+
+ Observability +
+
+
+ + - Description + Edit - -
+ - Use case + Delete - + +
+ +
+
+
+
+
+
+
-
- No items found + + 1 + -
-
+ + + + + +
diff --git a/src/plugins/workspace/public/components/workspace_list/index.test.tsx b/src/plugins/workspace/public/components/workspace_list/index.test.tsx index bbff833da7b1..2284008d9d36 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.test.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.test.tsx @@ -39,11 +39,18 @@ function getWrapWorkspaceListInContext( }, }; + const mockHeaderControl = ({ controls }) => { + return controls?.[0].description ?? controls?.[0].renderComponent ?? null; + }; + const services = { ...coreStartMock, workspaces: { workspaceList$: of(workspaceList), }, + navigationUI: { + HeaderControl: mockHeaderControl, + }, }; return ( @@ -59,12 +66,10 @@ function getWrapWorkspaceListInContext( describe('WorkspaceList', () => { it('should render title and table normally', () => { - const { getByText, getByRole, container } = render( - - ); - expect(getByText('Workspaces')).toBeInTheDocument(); + const { getByText, getByRole, container } = render(getWrapWorkspaceListInContext()); + expect( + getByText('Organize collaborative projects with use-case-specific workspaces.') + ).toBeInTheDocument(); expect(getByRole('table')).toBeInTheDocument(); expect(container).toMatchSnapshot(); }); diff --git a/src/plugins/workspace/public/components/workspace_list/index.tsx b/src/plugins/workspace/public/components/workspace_list/index.tsx index 3d6604122e48..931862b919a7 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.tsx @@ -6,8 +6,6 @@ import React, { useState, useMemo, useCallback } from 'react'; import { EuiPage, - EuiPageBody, - EuiPageHeader, EuiPageContent, EuiLink, EuiSmallButton, @@ -24,15 +22,10 @@ import { navigateToWorkspaceDetail } from '../utils/workspace'; import { WORKSPACE_CREATE_APP_ID } from '../../../common/constants'; -import { cleanWorkspaceId } from '../../../../../core/public'; import { DeleteWorkspaceModal } from '../delete_workspace_modal'; import { getFirstUseCaseOfFeatureConfigs } from '../../utils'; import { WorkspaceUseCase } from '../../types'; - -const WORKSPACE_LIST_PAGE_DESCRIPTION = i18n.translate('workspace.list.description', { - defaultMessage: - 'Workspace allow you to save and organize library items, such as index patterns, visualizations, dashboards, saved searches, and share them with other OpenSearch Dashboards users. You can control which features are visible in each workspace, and which users and groups have read and write access to the library items in the workspace.', -}); +import { NavigationPublicPluginStart } from '../../../../../plugins/navigation/public'; export interface WorkspaceListProps { registeredUseCases$: BehaviorSubject; @@ -40,8 +33,15 @@ export interface WorkspaceListProps { export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { const { - services: { workspaces, application, http }, - } = useOpenSearchDashboards(); + services: { + workspaces, + application, + http, + navigationUI: { HeaderControl }, + }, + } = useOpenSearchDashboards<{ + navigationUI: NavigationPublicPluginStart['ui']; + }>(); const registeredUseCases = useObservable(registeredUseCases$); const isDashboardAdmin = application?.capabilities?.dashboards?.isDashboardAdmin; @@ -79,6 +79,40 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { return workspaceList; }, [workspaceList, queryInput]); + const workspaceCreateUrl = useMemo(() => { + if (!application) { + return ''; + } + + const appUrl = application.getUrlForApp(WORKSPACE_CREATE_APP_ID, { + absolute: false, + }); + if (!appUrl) return ''; + + return appUrl; + }, [application]); + + const renderCreateWorkspaceButton = () => { + const button = ( + + {i18n.translate('workspace.list.buttons.createWorkspace', { + defaultMessage: 'Create workspace', + })} + + ); + return ( + + ); + }; + const columns = [ { field: 'name', @@ -143,19 +177,6 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { }, ]; - const workspaceCreateUrl = useMemo(() => { - if (!application || !http) { - return ''; - } - - const appUrl = application.getUrlForApp(WORKSPACE_CREATE_APP_ID, { - absolute: false, - }); - if (!appUrl) return ''; - - return cleanWorkspaceId(appUrl); - }, [application, http]); - const debouncedSetQueryInput = useMemo(() => { return debounce(setQueryInput, 300); }, [setQueryInput]); @@ -172,61 +193,48 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { box: { incremental: true, }, - toolsRight: [ - ...(isDashboardAdmin - ? [ - - {i18n.translate('workspace.workspaceList.buttons.createWorkspace', { - defaultMessage: 'Create workspace', - })} - , - ] - : []), - ], }; return ( - - - + + {isDashboardAdmin && renderCreateWorkspaceButton()} + + + setPagination((prev) => { + return { ...prev, pageIndex: index, pageSize: size }; + }) + } + pagination={pagination} + sorting={{ + sort: { + field: initialSortField, + direction: initialSortDirection, + }, + }} + isSelectable={true} + search={search} /> - - - setPagination((prev) => { - return { ...prev, pageIndex: index, pageSize: size }; - }) - } - pagination={pagination} - sorting={{ - sort: { - field: initialSortField, - direction: initialSortDirection, - }, - }} - isSelectable={true} - search={search} - /> - - + {deletedWorkspace && ( , { savedObjectsManagement, management, dataSourceManagement }: WorkspacePluginSetupDeps ) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); @@ -295,11 +297,13 @@ export class WorkspacePlugin } const mountWorkspaceApp = async (params: AppMountParameters, renderApp: WorkspaceAppType) => { - const [coreStart] = await core.getStartServices(); + const [coreStart, { navigation }] = await core.getStartServices(); + const services = { ...coreStart, workspaceClient, dataSourceManagement, + navigationUI: navigation.ui, }; return renderApp(params, services, { @@ -431,7 +435,7 @@ export class WorkspacePlugin }); } - public start(core: CoreStart, { contentManagement }: WorkspacePluginStartDeps) { + public start(core: CoreStart, { contentManagement, navigation }: WorkspacePluginStartDeps) { this.coreStart = core; this.currentWorkspaceIdSubscription = this._changeSavedObjectCurrentWorkspace(); diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts index 79fed7fa81ac..d5cfc224416f 100644 --- a/src/plugins/workspace/public/types.ts +++ b/src/plugins/workspace/public/types.ts @@ -6,10 +6,12 @@ import { CoreStart } from '../../../core/public'; import { WorkspaceClient } from './workspace_client'; import { DataSourceManagementPluginSetup } from '../../../plugins/data_source_management/public'; +import { NavigationPublicPluginStart } from '../../../plugins/navigation/public'; export type Services = CoreStart & { workspaceClient: WorkspaceClient; dataSourceManagement?: DataSourceManagementPluginSetup; + navigationUI?: NavigationPublicPluginStart['ui']; }; export interface WorkspaceUseCase { From 2165e036a23764b055ac21acadb18894145c9e72 Mon Sep 17 00:00:00 2001 From: yuboluo Date: Thu, 15 Aug 2024 17:59:06 +0800 Subject: [PATCH 08/14] [Workspace] Refactor workspace detail page (#7598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor workspace detail page Signed-off-by: yubonluo * Changeset file for PR #7598 created/updated * add datasource list page Signed-off-by: yubonluo * add workspace detail hash router Signed-off-by: yubonluo * reset useless update Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo * add unit tests Signed-off-by: yubonluo * add unit tests Signed-off-by: yubonluo * Fix left navigation overlapped with bottom bar Signed-off-by: yubonluo * update workspace detail use case Signed-off-by: yubonluo * It should only support change to the “ALL” use case Signed-off-by: yubonluo * fix error Signed-off-by: yubonluo * fix errors Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo * fix test error Signed-off-by: yubonluo --------- Signed-off-by: yubonluo Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7598.yml | 2 + src/plugins/workspace/common/types.ts | 5 +- src/plugins/workspace/public/application.tsx | 10 +- .../public/components/utils/workspace.test.ts | 27 +- .../public/components/utils/workspace.ts | 11 +- .../workspace_detail.test.tsx.snap | 606 +++++++++++++++++- .../association_data_source_modal.tsx | 121 ++++ .../opensearch_connections_table.tsx | 224 +++++++ .../select_data_source_panel.test.tsx | 304 +++++++++ .../select_data_source_panel.tsx | 183 ++++++ .../workspace_bottom_bar.test.tsx | 54 ++ .../workspace_detail/workspace_bottom_bar.tsx | 101 +++ .../workspace_detail.test.tsx | 200 +++++- .../workspace_detail/workspace_detail.tsx | 243 +++++-- .../workspace_detail_content.tsx | 48 -- .../workspace_detail_panel.tsx | 140 ++++ .../workspace_updater.test.tsx | 359 ----------- .../workspace_detail/workspace_updater.tsx | 174 ----- .../components/workspace_detail_app.tsx | 144 ++++- .../components/workspace_form/constants.ts | 70 +- .../public/components/workspace_form/index.ts | 1 + .../public/components/workspace_form/types.ts | 1 + .../workspace_form/use_workspace_form.test.ts | 19 + .../workspace_form/use_workspace_form.ts | 17 +- .../workspace_form/workspace_bottom_bar.tsx | 87 --- .../workspace_form/workspace_detail_form.tsx | 197 +++--- .../workspace_detail_form_details.tsx | 140 ++++ .../workspace_form/workspace_form.tsx | 15 +- .../workspace_form/workspace_form_context.tsx | 69 ++ .../workspace_permission_setting_input.tsx | 8 +- .../workspace_permission_setting_panel.tsx | 36 +- .../workspace_menu/workspace_menu.tsx | 16 +- src/plugins/workspace/public/utils.test.ts | 17 +- src/plugins/workspace/public/utils.ts | 27 +- .../workspace/public/workspace_client.ts | 3 + 35 files changed, 2748 insertions(+), 931 deletions(-) create mode 100644 changelogs/fragments/7598.yml create mode 100644 src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx create mode 100644 src/plugins/workspace/public/components/workspace_detail/opensearch_connections_table.tsx create mode 100644 src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.tsx create mode 100644 src/plugins/workspace/public/components/workspace_detail/workspace_bottom_bar.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_detail/workspace_bottom_bar.tsx delete mode 100644 src/plugins/workspace/public/components/workspace_detail/workspace_detail_content.tsx create mode 100644 src/plugins/workspace/public/components/workspace_detail/workspace_detail_panel.tsx delete mode 100644 src/plugins/workspace/public/components/workspace_detail/workspace_updater.test.tsx delete mode 100644 src/plugins/workspace/public/components/workspace_detail/workspace_updater.tsx delete mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_detail_form_details.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_form_context.tsx diff --git a/changelogs/fragments/7598.yml b/changelogs/fragments/7598.yml new file mode 100644 index 000000000000..85bcda12a9d4 --- /dev/null +++ b/changelogs/fragments/7598.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace] Refactor workspace detail page ([#7598](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7598)) \ No newline at end of file diff --git a/src/plugins/workspace/common/types.ts b/src/plugins/workspace/common/types.ts index 42c8ee31b0d1..cf621a09143e 100644 --- a/src/plugins/workspace/common/types.ts +++ b/src/plugins/workspace/common/types.ts @@ -5,7 +5,10 @@ import { DataSourceAttributes } from 'src/plugins/data_source/common/data_sources'; -export type DataSource = Pick & { +export type DataSource = Pick< + DataSourceAttributes, + 'title' | 'description' | 'dataSourceEngineType' +> & { // Id defined in SavedObjectAttribute could be single or array, here only should be single string. id: string; }; diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx index 3e9cb3a506eb..31965012d16c 100644 --- a/src/plugins/workspace/public/application.tsx +++ b/src/plugins/workspace/public/application.tsx @@ -5,6 +5,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { HashRouter as Router, Route, Switch, Redirect } from 'react-router-dom'; import { AppMountParameters, ScopedHistory } from '../../../core/public'; import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; import { WorkspaceFatalError } from './components/workspace_fatal_error'; @@ -14,6 +15,7 @@ import { Services } from './types'; import { WorkspaceCreatorProps } from './components/workspace_creator/workspace_creator'; import { WorkspaceDetailApp } from './components/workspace_detail_app'; import { WorkspaceDetailProps } from './components/workspace_detail/workspace_detail'; +import { DetailTab } from './components/workspace_form/constants'; export const renderCreatorApp = ( { element }: AppMountParameters, @@ -70,7 +72,13 @@ export const renderDetailApp = ( ) => { ReactDOM.render( - + + + + + + + , element ); diff --git a/src/plugins/workspace/public/components/utils/workspace.test.ts b/src/plugins/workspace/public/components/utils/workspace.test.ts index 5676b0d1aa99..f1ab368fe8ce 100644 --- a/src/plugins/workspace/public/components/utils/workspace.test.ts +++ b/src/plugins/workspace/public/components/utils/workspace.test.ts @@ -22,19 +22,28 @@ describe('workspace utils', () => { describe('navigateToWorkspaceDetail', () => { it('should redirect if newUrl is returned', () => { - Object.defineProperty(window, 'location', { - value: { - href: defaultUrl, - }, - writable: true, - }); // @ts-ignore - formatUrlWithWorkspaceId.mockImplementation(() => 'new_url'); + formatUrlWithWorkspaceId.mockImplementation(() => 'localhost:5601/w/id/app/workspace_detail'); navigateToWorkspaceDetail( { application: coreStartMock.application, http: coreStartMock.http }, - '' + 'id' + ); + expect(mockNavigateToUrl).toHaveBeenCalledWith( + 'localhost:5601/w/id/app/workspace_detail#/?tab=details' + ); + }); + + it('should redirect to collaborators if newUrl is returned and tab id is collaborators', () => { + // @ts-ignore + formatUrlWithWorkspaceId.mockImplementation(() => 'localhost:5601/w/id/app/workspace_detail'); + navigateToWorkspaceDetail( + { application: coreStartMock.application, http: coreStartMock.http }, + 'id', + 'collaborators' + ); + expect(mockNavigateToUrl).toHaveBeenCalledWith( + 'localhost:5601/w/id/app/workspace_detail#/?tab=collaborators' ); - expect(mockNavigateToUrl).toHaveBeenCalledWith('new_url'); }); it('should not redirect if newUrl is not returned', () => { diff --git a/src/plugins/workspace/public/components/utils/workspace.ts b/src/plugins/workspace/public/components/utils/workspace.ts index c7ba243bdf91..3acd26570f59 100644 --- a/src/plugins/workspace/public/components/utils/workspace.ts +++ b/src/plugins/workspace/public/components/utils/workspace.ts @@ -6,10 +6,15 @@ import { WORKSPACE_DETAIL_APP_ID } from '../../../common/constants'; import { CoreStart } from '../../../../../core/public'; import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { DetailTab } from '../workspace_form/constants'; type Core = Pick; -export const navigateToWorkspaceDetail = ({ application, http }: Core, id: string) => { +export const navigateToWorkspaceDetail = ( + { application, http }: Core, + id: string, + tabId: string = DetailTab.Details +) => { const newUrl = formatUrlWithWorkspaceId( application.getUrlForApp(WORKSPACE_DETAIL_APP_ID, { absolute: true, @@ -18,6 +23,8 @@ export const navigateToWorkspaceDetail = ({ application, http }: Core, id: strin http.basePath ); if (newUrl) { - application.navigateToUrl(newUrl); + const url = new URL(newUrl); + url.hash = `/?tab=${tabId}`; + application.navigateToUrl(url.toString()); } }; diff --git a/src/plugins/workspace/public/components/workspace_detail/__snapshots__/workspace_detail.test.tsx.snap b/src/plugins/workspace/public/components/workspace_detail/__snapshots__/workspace_detail.test.tsx.snap index e6da2eebcf46..b9e165a79c3a 100644 --- a/src/plugins/workspace/public/components/workspace_detail/__snapshots__/workspace_detail.test.tsx.snap +++ b/src/plugins/workspace/public/components/workspace_detail/__snapshots__/workspace_detail.test.tsx.snap @@ -3,8 +3,207 @@ exports[`WorkspaceDetail render workspace detail page normally 1`] = `
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ this is my foo workspace description +
+
+
+
+
+
+
+
+

+ Use case +

+

+

+
+
+
+

+ Owner +

+

+ user1 +    + +

+
+
+
+
+

+ Last updated +

+

+ Invalid date +

+
+
+
+
+

+ ID +

+

+ foo_id + + + +

+
+
+
+
+

+ Workspace overview +

+

+ + Overview + + +

+
+
+
+
+
@@ -19,28 +218,28 @@ exports[`WorkspaceDetail render workspace detail page normally 1`] = ` aria-controls="generated-id" aria-selected="true" class="euiTab euiTab-isSelected" - id="overview" + id="details" role="tab" type="button" > - Overview + Details
-
+
+

+ Details +

+
+
+ +
+
+
+
+
+

+ Name +

+
+
+
+
+ +
+
+
+
+ +
+
+
+ + 37 characters left. + +
+ Use a unique name for the workspace. Valid characters are a-z, A-Z, 0-9, (), [], _ (underscore), - (hyphen) and (space). +
+
+
+
+
+
+
+

+ Description - + + optional + +

+
+
+ Describe the workspace. +
+
+
+
- - About - + Description - + + optional + + +
+
+
-

- this is my foo workspace description -

+ + 164 characters left. +
+
+
+
+
+ class="euiFlexItem" + > +

+ Use case +

+
+
+
+
+ +
+
+
+
+
+ +
+
+ + Select an option: Observability, is selected + + +
+ + +
+
+
+
+
+
+
+ You can only choose use cases with more features than the current use case. +
+
+
+
+
+
+
+
+
+

+ Workspace icon color +

+
+
+ The background color of the icon that represents the workspace. +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+ + +
+
+
+
+
+
+
+
+
+
+
-
+
+
diff --git a/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx b/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx new file mode 100644 index 000000000000..999ac80f7ad0 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx @@ -0,0 +1,121 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Fragment, useEffect, useMemo, useState } from 'react'; +import React from 'react'; +import { + EuiText, + EuiModal, + EuiButton, + EuiModalBody, + EuiSelectable, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSelectableOption, +} from '@elastic/eui'; +import { FormattedMessage } from 'react-intl'; +import { getDataSourcesList } from '../../utils'; +import { DataSource } from '../../../common/types'; +import { SavedObjectsStart } from '../../../../../core/public'; + +export interface AssociationDataSourceModalProps { + savedObjects: SavedObjectsStart; + assignedDataSources: DataSource[]; + closeModal: () => void; + handleAssignDataSources: (dataSources: DataSource[]) => Promise; +} + +export const AssociationDataSourceModal = ({ + closeModal, + savedObjects, + assignedDataSources, + handleAssignDataSources, +}: AssociationDataSourceModalProps) => { + const [options, setOptions] = useState([]); + const [allDataSources, setAllDataSources] = useState([]); + + useEffect(() => { + getDataSourcesList(savedObjects.client, ['*']).then((result) => { + const filteredDataSources = result.filter( + ({ id }: DataSource) => !assignedDataSources.some((ds) => ds.id === id) + ); + setAllDataSources(filteredDataSources); + setOptions( + filteredDataSources.map((dataSource) => ({ + label: dataSource.title, + key: dataSource.id, + })) + ); + }); + }, [assignedDataSources, savedObjects]); + + const selectedDataSources = useMemo(() => { + const selectedIds = options + .filter((option: EuiSelectableOption) => option.checked) + .map((option: EuiSelectableOption) => option.key); + + return allDataSources.filter((ds) => selectedIds.includes(ds.id)); + }, [options, allDataSources]); + + return ( + + + +

+ +

+
+
+ + + + + setOptions(newOptions)} + > + {(list, search) => ( + + {search} + {list} + + )} + + + + + + + + handleAssignDataSources(selectedDataSources)} + isDisabled={!selectedDataSources || selectedDataSources.length === 0} + fill + > + + + +
+ ); +}; diff --git a/src/plugins/workspace/public/components/workspace_detail/opensearch_connections_table.tsx b/src/plugins/workspace/public/components/workspace_detail/opensearch_connections_table.tsx new file mode 100644 index 000000000000..ed6ce46965ac --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/opensearch_connections_table.tsx @@ -0,0 +1,224 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo, useState } from 'react'; +import { + EuiSpacer, + EuiButton, + EuiFlexItem, + EuiFlexGroup, + EuiFieldSearch, + EuiInMemoryTable, + EuiBasicTableColumn, + EuiTableSelectionType, + EuiTableActionsColumnType, + EuiConfirmModal, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { CoreStart, WorkspaceObject } from '../../../../../core/public'; +import { DataSource } from '../../../common/types'; +import { WorkspaceClient } from '../../workspace_client'; +import { convertPermissionSettingsToPermissions, useWorkspaceFormContext } from '../workspace_form'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; + +interface OpenSearchConnectionTableProps { + assignedDataSources: DataSource[]; + isDashboardAdmin: boolean; + currentWorkspace: WorkspaceObject; + setIsLoading: React.Dispatch>; +} + +export const OpenSearchConnectionTable = ({ + assignedDataSources, + isDashboardAdmin, + currentWorkspace, + setIsLoading, +}: OpenSearchConnectionTableProps) => { + const { + services: { notifications, workspaceClient }, + } = useOpenSearchDashboards<{ CoreStart: CoreStart; workspaceClient: WorkspaceClient }>(); + const { formData, setSelectedDataSources } = useWorkspaceFormContext(); + const [searchTerm, setSearchTerm] = useState(''); + const [SelectedItems, setSelectedItems] = useState([]); + const [assignItems, setAssignItems] = useState([]); + const [modalVisible, setModalVisible] = useState(false); + + const filteredDataSources = useMemo( + () => + assignedDataSources.filter((dataSource) => + dataSource.title.toLowerCase().includes(searchTerm.toLowerCase()) + ), + [searchTerm, assignedDataSources] + ); + + const onSelectionChange = (selectedItems: DataSource[]) => { + setSelectedItems(selectedItems); + setAssignItems(selectedItems); + }; + + const handleUnassignDataSources = async (dataSources: DataSource[]) => { + try { + setIsLoading(true); + setModalVisible(false); + const { permissionSettings, selectedDataSources, useCase, ...attributes } = formData; + const savedDataSources = (selectedDataSources ?? [])?.filter( + ({ id }: DataSource) => !dataSources.some((item) => item.id === id) + ); + + const result = await workspaceClient.update(currentWorkspace.id, attributes, { + dataSources: savedDataSources.map(({ id }: DataSource) => id), + permissions: convertPermissionSettingsToPermissions(permissionSettings), + }); + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.detail.dataSources.unassign.success', { + defaultMessage: 'Remove associated OpenSearch connections successfully', + }), + }); + setSelectedDataSources(savedDataSources); + } else { + throw new Error( + result?.error ? result?.error : 'Remove associated OpenSearch connections failed' + ); + } + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.detail.dataSources.unassign.failed', { + defaultMessage: 'Failed to remove associated OpenSearch connections', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + } finally { + setIsLoading(false); + } + }; + + const columns: Array> = [ + { + field: 'title', + name: i18n.translate('workspace.detail.dataSources.table.title', { + defaultMessage: 'Title', + }), + truncateText: true, + }, + { + field: 'dataSourceEngineType', + name: i18n.translate('workspace.detail.dataSources.table.type', { + defaultMessage: 'Type', + }), + truncateText: true, + }, + { + field: 'description', + name: i18n.translate('workspace.detail.dataSources.table.description', { + defaultMessage: 'Description', + }), + truncateText: true, + }, + ...(isDashboardAdmin + ? [ + { + name: i18n.translate('workspace.detail.dataSources.table.actions', { + defaultMessage: 'Actions', + }), + actions: [ + { + name: i18n.translate('workspace.detail.dataSources.table.actions.remove.name', { + defaultMessage: 'Remove association', + }), + isPrimary: true, + description: i18n.translate( + 'workspace.detail.dataSources.table.actions.remove.description', + { + defaultMessage: 'Remove association', + } + ), + icon: 'unlink', + type: 'icon', + onClick: (item: DataSource) => { + setAssignItems([item]); + setModalVisible(true); + }, + 'data-test-subj': 'workspace-detail-dataSources-table-actions-remove', + }, + ], + } as EuiTableActionsColumnType, + ] + : []), + ]; + + const selection: EuiTableSelectionType = { + selectable: () => isDashboardAdmin, + onSelectionChange, + }; + + const handleSearch = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value); + }; + return ( + <> + + {SelectedItems.length > 0 && !modalVisible && ( + + setModalVisible(true)} + data-test-subj="workspace-detail-dataSources-table-bulkRemove" + > + {i18n.translate('workspace.detail.dataSources.table.remove.button', { + defaultMessage: 'Remove {numberOfSelect} association(s)', + values: { numberOfSelect: SelectedItems.length }, + })} + + + )} + + + + + + + + {modalVisible && ( + { + setModalVisible(false); + }} + onConfirm={() => { + handleUnassignDataSources(assignItems); + }} + cancelButtonText={i18n.translate('workspace.detail.dataSources.modal.cancelButton', { + defaultMessage: 'Cancel', + })} + confirmButtonText={i18n.translate('workspace.detail.dataSources.Modal.confirmButton', { + defaultMessage: 'Remove connections', + })} + buttonColor="danger" + defaultFocusedButton="confirm" + /> + )} + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.test.tsx b/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.test.tsx new file mode 100644 index 000000000000..6a81482096ec --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.test.tsx @@ -0,0 +1,304 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { coreMock } from '../../../../../core/public/mocks'; +import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; +import { WorkspaceFormProvider, WorkspaceOperationType } from '../workspace_form'; +import { SelectDataSourceDetailPanel } from './select_data_source_panel'; +import * as utils from '../../utils'; +import { IntlProvider } from 'react-intl'; + +const mockCoreStart = coreMock.createStart(); + +const workspaceObject = { + id: 'foo_id', + name: 'foo', + description: 'this is my foo workspace description', + features: ['use-case-observability', 'workspace_detail'], +}; + +const dataSources = [ + { + id: 'ds-1', + title: 'ds-1-title', + description: 'ds-1-description', + }, + { + id: 'ds-2', + title: 'ds-2-title', + description: 'ds-2-description', + }, +]; +jest.spyOn(utils, 'getDataSourcesList').mockResolvedValue(dataSources); + +const defaultValues = { + id: workspaceObject.id, + name: workspaceObject.name, + features: workspaceObject.features, + selectedDataSources: [dataSources[0]], +}; + +const defaultProps = { + savedObjects: {}, + assignedDataSources: [], + detailTitle: 'Data Sources', + isDashboardAdmin: true, + currentWorkspace: workspaceObject, +}; + +const notificationToastsAddSuccess = jest.fn(); +const notificationToastsAddDanger = jest.fn(); + +const success = jest.fn().mockResolvedValue({ + success: true, +}); +const failed = jest.fn().mockResolvedValue({}); + +const WorkspaceDetailPage = (props: any) => { + const { Provider } = createOpenSearchDashboardsReactContext({ + ...mockCoreStart, + ...{ + notifications: { + ...mockCoreStart.notifications, + toasts: { + ...mockCoreStart.notifications.toasts, + addDanger: notificationToastsAddDanger, + addSuccess: notificationToastsAddSuccess, + }, + }, + workspaceClient: { + update: props.action, + }, + }, + }); + + return ( + + + + + + + + ); +}; + +describe('WorkspaceDetail', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders "No associated data sources" message when no data sources are assigned', () => { + const { getByText } = render(WorkspaceDetailPage(defaultProps)); + expect(getByText('No associated data sources')).toBeInTheDocument(); + expect( + getByText('No OpenSearch connections are available in this workspace.') + ).toBeInTheDocument(); + }); + + it('should not show Association OpenSearch Connections button when user is not OSD admin', () => { + const { getByText, queryByText } = render( + WorkspaceDetailPage({ + ...defaultProps, + isDashboardAdmin: false, + }) + ); + expect( + getByText('Contact your administrator to associate data sources with the workspace.') + ).toBeInTheDocument(); + expect(queryByText('Association OpenSearch Connections')).toBeNull(); + }); + + it('should click on Association OpenSearch Connections button', async () => { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 600, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 600, + }); + const { getByText } = render( + WorkspaceDetailPage({ ...defaultProps, assignedDataSources: [dataSources[0]] }) + ); + expect(getByText('Association OpenSearch Connections')).toBeInTheDocument(); + + fireEvent.click(getByText('Association OpenSearch Connections')); + await waitFor(() => { + expect( + getByText('Add OpenSearch connections that will be available in the workspace.') + ).toBeInTheDocument(); + expect(getByText('Close')).toBeInTheDocument(); + expect(getByText('Save changes')).toBeInTheDocument(); + expect(getByText('ds-2-title')).toBeInTheDocument(); + }); + fireEvent.click(getByText('Close')); + }); + + it('Association OpenSearch connections successfully', async () => { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 600, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 600, + }); + const { getByText } = render( + WorkspaceDetailPage({ + ...defaultProps, + assignedDataSources: [dataSources[0]], + action: success, + }) + ); + expect(getByText('Association OpenSearch Connections')).toBeInTheDocument(); + + fireEvent.click(getByText('Association OpenSearch Connections')); + await waitFor(() => { + expect( + getByText('Add OpenSearch connections that will be available in the workspace.') + ).toBeInTheDocument(); + expect(getByText('Close')).toBeInTheDocument(); + expect(getByText('Save changes')).toBeInTheDocument(); + expect(getByText('ds-2-title')).toBeInTheDocument(); + }); + fireEvent.click(getByText('ds-2-title')); + fireEvent.click(getByText('Save changes')); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + }); + + it('Association OpenSearch connections failed', async () => { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 600, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 600, + }); + const { getByText } = render( + WorkspaceDetailPage({ + ...defaultProps, + assignedDataSources: [dataSources[0]], + action: failed, + }) + ); + expect(getByText('Association OpenSearch Connections')).toBeInTheDocument(); + + fireEvent.click(getByText('Association OpenSearch Connections')); + await waitFor(() => { + expect( + getByText('Add OpenSearch connections that will be available in the workspace.') + ).toBeInTheDocument(); + expect(getByText('Close')).toBeInTheDocument(); + expect(getByText('Save changes')).toBeInTheDocument(); + expect(getByText('ds-2-title')).toBeInTheDocument(); + }); + fireEvent.click(getByText('ds-2-title')); + fireEvent.click(getByText('Save changes')); + await waitFor(() => { + expect(notificationToastsAddDanger).toHaveBeenCalled(); + }); + }); + + it('Remove OpenSearch connections successfully', async () => { + const { getByText, getByTestId } = render( + WorkspaceDetailPage({ + ...defaultProps, + assignedDataSources: [dataSources[0]], + action: success, + }) + ); + expect(getByText('ds-1-title')).toBeInTheDocument(); + const button = getByTestId('workspace-detail-dataSources-table-actions-remove'); + fireEvent.click(button); + expect(getByText('Remove OpenSearch connections')).toBeInTheDocument(); + fireEvent.click(getByText('Cancel')); + fireEvent.click(button); + fireEvent.click(getByText('Remove connections')); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + }); + + it('Remove OpenSearch connections failed', async () => { + const { getByText, getByTestId } = render( + WorkspaceDetailPage({ + ...defaultProps, + assignedDataSources: [dataSources[0]], + action: failed, + }) + ); + expect(getByText('ds-1-title')).toBeInTheDocument(); + const button = getByTestId('workspace-detail-dataSources-table-actions-remove'); + fireEvent.click(button); + fireEvent.click(getByText('Remove connections')); + await waitFor(() => { + expect(notificationToastsAddDanger).toHaveBeenCalled(); + }); + }); + + it('Remove selected OpenSearch connections successfully', async () => { + const { getByText, queryByTestId } = render( + WorkspaceDetailPage({ + ...defaultProps, + assignedDataSources: [dataSources[0]], + action: success, + }) + ); + expect(getByText('ds-1-title')).toBeInTheDocument(); + expect(queryByTestId('workspace-detail-dataSources-table-bulkRemove')).toBeNull(); + const checkbox = screen.getAllByRole('checkbox')[0]; + + // Simulate clicking the checkbox + fireEvent.click(checkbox); + expect(getByText('Remove 1 association(s)')).toBeInTheDocument(); + fireEvent.click(getByText('Remove 1 association(s)')); + fireEvent.click(getByText('Remove connections')); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + }); + + it('should handle input in the search box', async () => { + const { getByText, queryByText } = render( + WorkspaceDetailPage({ + ...defaultProps, + assignedDataSources: dataSources, + }) + ); + expect(getByText('ds-1-title')).toBeInTheDocument(); + expect(getByText('ds-2-title')).toBeInTheDocument(); + + const searchInput = screen.getByPlaceholderText('Search'); + // Simulate typing in the search input + fireEvent.change(searchInput, { target: { value: 'ds-1-title' } }); + expect(getByText('ds-1-title')).toBeInTheDocument(); + expect(queryByText('ds-2-title')).toBeNull(); + }); + + it('should not allow user to remove associations when user is not OSD admin', () => { + const { queryByTestId } = render( + WorkspaceDetailPage({ + ...defaultProps, + assignedDataSources: dataSources, + isDashboardAdmin: false, + }) + ); + expect(queryByTestId('workspace-detail-dataSources-table-action-Remove')).toBeNull(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.tsx b/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.tsx new file mode 100644 index 000000000000..db4c06fa75f4 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.tsx @@ -0,0 +1,183 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiText, + EuiTitle, + EuiPanel, + EuiSpacer, + EuiFlexItem, + EuiTextAlign, + EuiFlexGroup, + EuiSmallButton, + EuiHorizontalRule, + EuiLoadingSpinner, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from 'react-intl'; +import { DataSource } from '../../../common/types'; +import { WorkspaceClient } from '../../workspace_client'; +import { OpenSearchConnectionTable } from './opensearch_connections_table'; +import { AssociationDataSourceModal } from './association_data_source_modal'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { CoreStart, SavedObjectsStart, WorkspaceObject } from '../../../../../core/public'; +import { convertPermissionSettingsToPermissions, useWorkspaceFormContext } from '../workspace_form'; + +export interface SelectDataSourcePanelProps { + savedObjects: SavedObjectsStart; + assignedDataSources: DataSource[]; + detailTitle: string; + isDashboardAdmin: boolean; + currentWorkspace: WorkspaceObject; +} + +export const SelectDataSourceDetailPanel = ({ + assignedDataSources, + savedObjects, + detailTitle, + isDashboardAdmin, + currentWorkspace, +}: SelectDataSourcePanelProps) => { + const { + services: { notifications, workspaceClient }, + } = useOpenSearchDashboards<{ CoreStart: CoreStart; workspaceClient: WorkspaceClient }>(); + const { formData, setSelectedDataSources } = useWorkspaceFormContext(); + const [isLoading, setIsLoading] = useState(false); + const [isVisible, setIsVisible] = useState(false); + + const handleAssignDataSources = async (dataSources: DataSource[]) => { + try { + setIsLoading(true); + setIsVisible(false); + const { permissionSettings, selectedDataSources, useCase, ...attributes } = formData; + const savedDataSources: DataSource[] = [...selectedDataSources, ...dataSources]; + + const result = await workspaceClient.update(currentWorkspace.id, attributes, { + dataSources: savedDataSources.map((ds) => { + return ds.id; + }), + permissions: convertPermissionSettingsToPermissions(permissionSettings), + }); + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.detail.dataSources.assign.success', { + defaultMessage: 'Associate OpenSearch connections successfully', + }), + }); + setSelectedDataSources(savedDataSources); + } else { + throw new Error(result?.error ? result?.error : 'Associate OpenSearch connections failed'); + } + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.detail.dataSources.assign.failed', { + defaultMessage: 'Failed to associate OpenSearch connections', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + } finally { + setIsLoading(false); + } + }; + + const associationButton = ( + setIsVisible(true)} + isLoading={isLoading} + data-test-subj="workspace-detail-dataSources-assign-button" + > + {i18n.translate('workspace.detail.dataSources.assign.button', { + defaultMessage: 'Association OpenSearch Connections', + })} + + ); + + const loadingMessage = ( +
+ + + + + +
+ ); + + const noAssociationMessage = ( + + +

+ +

+
+ + + + + {isDashboardAdmin ? ( + <> + + {associationButton} + + ) : ( + + + + )} +
+ ); + + const renderTableContent = () => { + if (isLoading) { + return loadingMessage; + } + if (assignedDataSources.length === 0) { + return noAssociationMessage; + } + return ( + + ); + }; + + return ( + + + + +

{detailTitle}

+
+
+ {isDashboardAdmin && {associationButton}} +
+ + {renderTableContent()} + {isVisible && ( + setIsVisible(false)} + handleAssignDataSources={handleAssignDataSources} + /> + )} +
+ ); +}; diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_bottom_bar.test.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_bottom_bar.test.tsx new file mode 100644 index 000000000000..2214587d121a --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_bottom_bar.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { WorkspaceBottomBar } from './workspace_bottom_bar'; + +const mockHandleResetForm = jest.fn(); + +const defaultProps = { + formId: 'testForm', + numberOfChanges: 2, + numberOfErrors: 1, + handleResetForm: mockHandleResetForm, +}; + +describe('WorkspaceBottomBar', () => { + test('renders correctly with errors and unsaved changes', () => { + render(); + + expect(screen.getByText('2 Unsaved change(s)')).toBeInTheDocument(); + expect(screen.getByText('2 error(s)')).toBeInTheDocument(); + expect(screen.getByText('Discard changes')).toBeInTheDocument(); + expect(screen.getByText('Save changes')).toBeInTheDocument(); + }); + + test('disables the save button when there are no changes', () => { + render(); + const saveButton = screen.getByRole('button', { name: 'Save changes' }); + expect(saveButton).toBeDisabled(); + }); + + test('calls handleResetForm when discard changes button is clicked', () => { + render(); + fireEvent.click(screen.getByText('Discard changes')); + expect(mockHandleResetForm).toHaveBeenCalled(); + }); + + test('calls handleSubmit when save changes button is clicked', () => { + const handleSubmit = jest.fn(); + render( +
+ + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + // Assuming handleSubmit is called during form submission + expect(handleSubmit).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_bottom_bar.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_bottom_bar.tsx new file mode 100644 index 000000000000..fdc817dc6f80 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_bottom_bar.tsx @@ -0,0 +1,101 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiBottomBar, + EuiSmallButton, + EuiSmallButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import React from 'react'; +import ReactDOM from 'react-dom'; + +interface WorkspaceBottomBarProps { + formId: string; + numberOfChanges: number; + numberOfErrors: number; + handleResetForm: () => void; +} + +export const WorkspaceBottomBar = ({ + formId, + numberOfChanges, + numberOfErrors, + handleResetForm, +}: WorkspaceBottomBarProps) => { + const applicationElement = document.querySelector('.app-wrapper'); + const bottomBar = ( + + + + + + {numberOfErrors > 0 && ( + + {i18n.translate('workspace.form.bottomBar.errors', { + defaultMessage: '{numberOfChanges} error(s)', + values: { + numberOfChanges, + }, + })} + + )} + + + {numberOfChanges > 0 && ( + + {i18n.translate('workspace.form.bottomBar.unsavedChanges', { + defaultMessage: '{numberOfChanges} Unsaved change(s)', + values: { + numberOfChanges, + }, + })} + + )} + + + + + + + + {i18n.translate('workspace.form.bottomBar.disCardChanges', { + defaultMessage: 'Discard changes', + })} + + + + + {i18n.translate('workspace.form.bottomBar.saveChanges', { + defaultMessage: 'Save changes', + })} + + + + + + + ); + if (!applicationElement) { + return bottomBar; + } + + return ReactDOM.createPortal(bottomBar, applicationElement); +}; diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx index d27f0187e5b1..c717227554fc 100644 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { BehaviorSubject } from 'rxjs'; import { PublicAppInfo, WorkspaceObject } from 'opensearch-dashboards/public'; @@ -11,6 +11,8 @@ import { coreMock } from '../../../../../core/public/mocks'; import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; import { WORKSPACE_USE_CASES } from '../../../common/constants'; import { WorkspaceDetail } from './workspace_detail'; +import { WorkspaceFormProvider, WorkspaceOperationType } from '../workspace_form'; +import { MemoryRouter } from 'react-router-dom'; // all applications const PublicAPPInfoMap = new Map([ @@ -24,10 +26,40 @@ const workspaceObject = { id: 'foo_id', name: 'foo', description: 'this is my foo workspace description', - features: ['use-case-observability'], + features: ['use-case-observability', 'workspace_detail'], color: '', - icon: '', reserved: false, + permissions: { write: { users: ['user1', 'user2'] } }, +}; + +const defaultValues = { + id: workspaceObject.id, + name: workspaceObject.name, + description: workspaceObject.description, + features: workspaceObject.features, + color: workspaceObject.color, + permissionSettings: [ + { + id: 0, + type: 'user', + userId: 'user1', + modes: ['library_write', 'write'], + }, + { + id: 1, + type: 'user2', + group: '', + modes: ['library_write', 'write'], + }, + ], + selectedDataSources: [ + { + id: 'ds-1', + title: 'ds-1-title', + description: 'ds-1-description', + dataSourceEngineType: 'OpenSearch', + }, + ], }; const createWorkspacesSetupContractMockWithValue = (workspace?: WorkspaceObject) => { @@ -44,8 +76,23 @@ const createWorkspacesSetupContractMockWithValue = (workspace?: WorkspaceObject) }; }; +const deleteFn = jest.fn().mockReturnValue({ + success: true, +}); + const WorkspaceDetailPage = (props: any) => { const workspacesService = props.workspacesService || createWorkspacesSetupContractMockWithValue(); + const values = props.defaultValues || defaultValues; + const permissionEnabled = props.permissionEnabled ?? true; + const dataSourceManagement = + props.dataSourceEnabled !== false + ? { + ui: { + getDataSourceMenu: jest.fn(), + }, + } + : undefined; + const { Provider } = createOpenSearchDashboardsReactContext({ ...mockCoreStart, ...{ @@ -55,8 +102,9 @@ const WorkspaceDetailPage = (props: any) => { capabilities: { ...mockCoreStart.application.capabilities, workspaces: { - permissionEnabled: true, + permissionEnabled, }, + dashboards: { isDashboardAdmin: true }, }, }, workspaces: workspacesService, @@ -67,8 +115,10 @@ const WorkspaceDetailPage = (props: any) => { find: jest.fn().mockResolvedValue({ savedObjects: [], }), + delete: deleteFn, }, }, + dataSourceManagement, }, }); @@ -78,46 +128,154 @@ const WorkspaceDetailPage = (props: any) => { WORKSPACE_USE_CASES.essentials, WORKSPACE_USE_CASES.search, ]); - return ( - - - + + + + + + + ); }; describe('WorkspaceDetail', () => { + let mockHistoryPush: jest.Mock; + let mockLocation: Partial; + beforeEach(() => { + mockHistoryPush = jest.fn(); + mockLocation = { + pathname: '/current-path', + search: '', + hash: '', + }; + + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: jest.fn().mockReturnValue({ + push: mockHistoryPush, + location: mockLocation, + }), + useLocation: jest.fn().mockReturnValue(mockLocation), + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + it('render workspace detail page normally', async () => { const { container } = render(WorkspaceDetailPage({})); expect(container).toMatchSnapshot(); }); - it('default selected tab is overview', async () => { + it('default selected tab is Details', async () => { const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); render(WorkspaceDetailPage({ workspacesService: workspaceService })); + expect(document.querySelector('#details')).toHaveClass('euiTab-isSelected'); expect(screen.queryByTestId('workspaceTabs')).not.toBeNull(); - expect(document.querySelector('#overview')).toHaveClass('euiTab-isSelected'); }); - it('click on collaborators tab will workspace update page with permission', async () => { + it('click on Collaborators tab when permission control enabled', async () => { + const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); + const { getByText } = render(WorkspaceDetailPage({ workspacesService: workspaceService })); + fireEvent.click(getByText('Collaborators')); + expect(document.querySelector('#collaborators')).toHaveClass('euiTab-isSelected'); + }); + + it('click on Data Sources tab when dataSource enabled', async () => { const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); const { getByText } = render(WorkspaceDetailPage({ workspacesService: workspaceService })); - await act(async () => { - fireEvent.click(getByText('Collaborators')); + fireEvent.click(getByText('Data Sources')); + expect(document.querySelector('#dataSources')).toHaveClass('euiTab-isSelected'); + }); + + it('click on delete button will show delete modal', async () => { + const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); + const { getByText, getByTestId, queryByText } = render( + WorkspaceDetailPage({ workspacesService: workspaceService }) + ); + fireEvent.click(getByText('delete')); + expect(getByText('Delete workspace')).toBeInTheDocument(); + fireEvent.click(getByText('Cancel')); + expect(queryByText('Delete workspace')).toBeNull(); + fireEvent.click(getByText('delete')); + const input = getByTestId('delete-workspace-modal-input'); + fireEvent.change(input, { + target: { value: 'delete' }, + }); + const confirmButton = getByTestId('delete-workspace-modal-confirm'); + fireEvent.click(confirmButton); + }); + + it('click on Collaborators tab when permission control and dataSource disabled', async () => { + const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); + const { queryByText } = render( + WorkspaceDetailPage({ + workspacesService: workspaceService, + permissionEnabled: false, + dataSourceEnabled: false, + }) + ); + expect(queryByText('Collaborators')).toBeNull(); + expect(queryByText('Data Sources')).toBeNull(); + }); + + it('click on tab button will show navigate modal when number of changes > 1', async () => { + const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); + const { getByText, getByTestId, queryByText } = render( + WorkspaceDetailPage({ workspacesService: workspaceService }) + ); + fireEvent.click(getByText('Edit')); + expect(getByTestId('workspaceForm-workspaceDetails-discardChanges')).toBeInTheDocument(); + const input = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.change(input, { + target: { value: 'newName' }, }); + fireEvent.click(getByText('Collaborators')); + expect(getByText('Any unsaved changes will be lost.')).toBeInTheDocument(); + fireEvent.click(getByText('Cancel')); + expect(queryByText('Any unsaved changes will be lost.')).toBeNull(); + fireEvent.click(getByText('Collaborators')); + const button = getByText('Navigate away'); + fireEvent.click(button); expect(document.querySelector('#collaborators')).toHaveClass('euiTab-isSelected'); - await waitFor(() => { - expect(screen.queryByText('Manage access and permissions')).not.toBeNull(); + }); + + it('click on badge button will navigate to Collaborators tab when number of changes > 0', async () => { + const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); + const { getByText, getByTestId } = render( + WorkspaceDetailPage({ workspacesService: workspaceService }) + ); + expect(getByText('+1 more')).toBeInTheDocument(); + + fireEvent.click(getByText('Edit')); + expect(getByTestId('workspaceForm-workspaceDetails-discardChanges')).toBeInTheDocument(); + const input = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.change(input, { + target: { value: 'newName' }, }); + + fireEvent.click(getByText('+1 more')); + expect(getByText('Any unsaved changes will be lost.')).toBeInTheDocument(); + + fireEvent.click(getByText('Navigate away')); + expect(document.querySelector('#collaborators')).toHaveClass('euiTab-isSelected'); }); - it('click on settings tab will show workspace update page', async () => { + it('click on badge button will navigate to Collaborators tab when number of changes = 0', async () => { const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); const { getByText } = render(WorkspaceDetailPage({ workspacesService: workspaceService })); - fireEvent.click(getByText('Settings')); - expect(document.querySelector('#settings')).toHaveClass('euiTab-isSelected'); - await waitFor(() => { - expect(screen.queryByText('Enter details')).not.toBeNull(); - }); + expect(getByText('+1 more')).toBeInTheDocument(); + fireEvent.click(getByText('+1 more')); + expect(document.querySelector('#collaborators')).toHaveClass('euiTab-isSelected'); }); }); diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx index 59098a5e7219..a95756947424 100644 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx @@ -3,18 +3,37 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { EuiPage, EuiPageBody, EuiTabbedContent } from '@elastic/eui'; - -import { useObservable } from 'react-use'; +import React, { useEffect, useState } from 'react'; +import { + EuiPage, + EuiText, + EuiSpacer, + EuiFlexItem, + EuiPageBody, + EuiFlexGroup, + EuiPageHeader, + EuiPageContent, + EuiSmallButton, + EuiConfirmModal, + EuiTabbedContent, +} from '@elastic/eui'; import { i18n } from '@osd/i18n'; -import { CoreStart } from 'opensearch-dashboards/public'; -import { BehaviorSubject } from 'rxjs'; -import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { useObservable } from 'react-use'; +import { BehaviorSubject, of } from 'rxjs'; +import { useHistory, useLocation } from 'react-router-dom'; import { WorkspaceUseCase } from '../../types'; -import { WorkspaceDetailContent } from './workspace_detail_content'; -import { WorkspaceUpdater } from './workspace_updater'; -import { DetailTab } from '../workspace_form/constants'; +import { WorkspaceDetailForm, useWorkspaceFormContext } from '../workspace_form'; +import { WorkspaceDetailPanel } from './workspace_detail_panel'; +import { DeleteWorkspaceModal } from '../delete_workspace_modal'; +import { WORKSPACE_LIST_APP_ID } from '../../../common/constants'; +import { cleanWorkspaceId } from '../../../../../core/public/utils'; +import { DetailTab, DetailTabTitles, WorkspaceOperationType } from '../workspace_form/constants'; +import { CoreStart, WorkspaceAttribute } from '../../../../../core/public'; +import { getFirstUseCaseOfFeatureConfigs, getUseCaseUrl } from '../../utils'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; +import { SelectDataSourceDetailPanel } from './select_data_source_panel'; +import { WorkspaceBottomBar } from './workspace_bottom_bar'; export interface WorkspaceDetailProps { registeredUseCases$: BehaviorSubject; @@ -22,67 +41,203 @@ export interface WorkspaceDetailProps { export const WorkspaceDetail = (props: WorkspaceDetailProps) => { const { - services: { workspaces, application }, - } = useOpenSearchDashboards(); + services: { workspaces, application, http, savedObjects, dataSourceManagement, uiSettings }, + } = useOpenSearchDashboards<{ + CoreStart: CoreStart; + dataSourceManagement?: DataSourceManagementPluginSetup; + }>(); + + const { + formData, + isEditing, + formId, + numberOfErrors, + handleResetForm, + numberOfChanges, + setIsEditing, + } = useWorkspaceFormContext(); + const [deletedWorkspace, setDeletedWorkspace] = useState(null); + const [selectedTabId, setSelectedTabId] = useState(DetailTab.Details); + const [modalVisible, setModalVisible] = useState(false); + const [tabId, setTabId] = useState(DetailTab.Details); - const currentWorkspace = useObservable(workspaces.currentWorkspace$); + const availableUseCases = useObservable(props.registeredUseCases$, []); + const isDashboardAdmin = !!application?.capabilities?.dashboards?.isDashboardAdmin; + const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; + const currentUseCase = availableUseCases.find( + (useCase) => useCase.id === getFirstUseCaseOfFeatureConfigs(currentWorkspace?.features ?? []) + ); + const history = useHistory(); + const location = useLocation(); + + useEffect(() => { + const params = new URLSearchParams(location.search); + const tab = params.get('tab'); + if (tab) { + setSelectedTabId(tab); + } + }, [location.search]); - if (!currentWorkspace) { + if (!currentWorkspace || !application || !http || !savedObjects || !uiSettings) { return null; } + const useCaseUrl = getUseCaseUrl(currentUseCase, currentWorkspace, application, http); + + const handleTabClick = (tab: any) => { + if (numberOfChanges > 0) { + setTabId(tab.id); + setModalVisible(true); + return; + } + history.push(`?tab=${tab.id}`); + setIsEditing(false); + setSelectedTabId(tab.id); + }; + + const handleBadgeClick = () => { + if (selectedTabId !== DetailTab.Collaborators && numberOfChanges > 0) { + setTabId(DetailTab.Collaborators); + setModalVisible(true); + return; + } + history.push(`?tab=${DetailTab.Collaborators}`); + setSelectedTabId(DetailTab.Collaborators); + }; + + const createDetailTab = (id: DetailTab, detailTitle: string) => ({ + id, + name: detailTitle, + content: ( + + ), + }); + const detailTabs = [ - { - id: DetailTab.Overview, - name: i18n.translate('workspace.overview.tabTitle', { - defaultMessage: 'Overview', - }), - content: , - }, - { - id: DetailTab.Settings, - name: i18n.translate('workspace.overview.setting.tabTitle', { - defaultMessage: 'Settings', - }), - content: ( - - ), - }, - ...(isPermissionEnabled + createDetailTab(DetailTab.Details, DetailTabTitles.details), + ...(dataSourceManagement ? [ { - id: DetailTab.Collaborators, - name: i18n.translate('workspace.overview.collaborators.tabTitle', { - defaultMessage: 'Collaborators', - }), + id: DetailTab.DataSources, + name: DetailTabTitles.dataSources, content: ( - ), }, ] : []), + ...(isPermissionEnabled + ? [createDetailTab(DetailTab.Collaborators, DetailTabTitles.collaborators)] + : []), ]; + const deleteButton = ( + setDeletedWorkspace(currentWorkspace)} + > + {i18n.translate('workspace.detail.delete', { + defaultMessage: 'delete', + })} + + ); + return ( <> - + + + + {currentWorkspace.description} + + + + + + tab.id === selectedTabId)]} + onTabClick={handleTabClick} size="s" /> + {deletedWorkspace && ( + setDeletedWorkspace(null)} + onDeleteSuccess={() => { + window.setTimeout(() => { + window.location.assign( + cleanWorkspaceId( + application.getUrlForApp(WORKSPACE_LIST_APP_ID, { + absolute: false, + }) + ) + ); + }, 1000); + }} + /> + )} + {modalVisible && ( + setModalVisible(false)} + onConfirm={() => { + handleResetForm(); + setModalVisible(false); + history.push(`?tab=${tabId}`); + setSelectedTabId(tabId); + }} + cancelButtonText={i18n.translate('workspace.form.cancelButtonText', { + defaultMessage: 'Cancel', + })} + confirmButtonText={i18n.translate('workspace.form.confirmButtonText', { + defaultMessage: 'Navigate away', + })} + buttonColor="danger" + defaultFocusedButton="confirm" + > + {i18n.translate('workspace.form.cancelModal.body', { + defaultMessage: 'Any unsaved changes will be lost.', + })} + + )} + {isEditing && ( + + )} ); }; diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail_content.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail_content.tsx deleted file mode 100644 index 3e3010e4143d..000000000000 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_detail_content.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - EuiFlexItem, - EuiCard, - EuiFlexGroup, - EuiPage, - EuiPageContent, - EuiPageBody, - EuiSpacer, -} from '@elastic/eui'; -import React from 'react'; -import { useObservable } from 'react-use'; -import { of } from 'rxjs'; -import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; - -export const WorkspaceDetailContent = () => { - const { - services: { workspaces }, - } = useOpenSearchDashboards(); - - const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); - - return ( - - - - - - - - - - - - - - ); -}; diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail_panel.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail_panel.tsx new file mode 100644 index 000000000000..1f426743ac17 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail_panel.tsx @@ -0,0 +1,140 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiLink, + EuiText, + EuiCopy, + EuiBadge, + EuiFlexItem, + EuiFlexGroup, + EuiButtonIcon, + EuiColorPickerSwatch, +} from '@elastic/eui'; +import moment from 'moment'; +import { i18n } from '@osd/i18n'; +import { WorkspaceUseCase } from '../../types'; +import { WorkspaceObject } from '../../../../../core/public'; +import { WorkspaceAttributeWithPermission } from '../../../../../core/types'; + +const detailUseCase = i18n.translate('workspace.detail.useCase', { + defaultMessage: 'Use case', +}); + +const detailOwner = i18n.translate('workspace.detail.owner', { + defaultMessage: 'Owner', +}); + +const detailLastUpdated = i18n.translate('workspace.detail.lastUpdated', { + defaultMessage: 'Last updated', +}); + +const detailID = i18n.translate('workspace.detail.id', { + defaultMessage: 'ID', +}); + +const workspaceOverview = i18n.translate('workspace.detail.workspaceOverview', { + defaultMessage: 'Workspace overview', +}); + +const overview = i18n.translate('workspace.detail.overview', { + defaultMessage: 'Overview', +}); + +function getOwners(currentWorkspace: WorkspaceAttributeWithPermission) { + const { groups = [], users = [] } = currentWorkspace.permissions!.write; + return [...groups, ...users]; +} + +interface WorkspaceDetailPanelProps { + useCaseUrl: string; + handleBadgeClick: () => void; + currentUseCase: WorkspaceUseCase | undefined; + currentWorkspace: WorkspaceObject; + dateFormat: string; +} +export const WorkspaceDetailPanel = ({ + useCaseUrl, + currentUseCase, + handleBadgeClick, + currentWorkspace, + dateFormat, +}: WorkspaceDetailPanelProps) => { + const owners = getOwners(currentWorkspace); + const formatDate = (lastUpdatedTime: string) => { + return moment(lastUpdatedTime).format(dateFormat); + }; + + return ( + + + +

{detailUseCase}

+

+ + {currentUseCase?.title} +

+
+
+ + +

{detailOwner}

+

+ {owners?.at(0)}   + {owners && owners.length > 1 && ( + + +{owners?.length - 1} more + + )} +

+
+
+ + +

{detailLastUpdated}

+

{formatDate(currentWorkspace.lastUpdatedTime || '')}

+
+
+ + +

{detailID}

+

+ {currentWorkspace.id} + + {(copy) => ( + + )} + +

+
+
+ + +

{workspaceOverview}

+

+ + {overview} + +

+
+
+
+ ); +}; diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_updater.test.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_updater.test.tsx deleted file mode 100644 index e2ecbd0a32cd..000000000000 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_updater.test.tsx +++ /dev/null @@ -1,359 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { PublicAppInfo, WorkspaceObject } from 'opensearch-dashboards/public'; -import { fireEvent, render, waitFor, screen, act } from '@testing-library/react'; -import { BehaviorSubject } from 'rxjs'; - -import { coreMock, workspacesServiceMock } from '../../../../../core/public/mocks'; -import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; -import { DetailTab } from '../workspace_form/constants'; -import { WORKSPACE_USE_CASES } from '../../../common/constants'; -import { - WorkspaceUpdater as WorkspaceUpdaterComponent, - WorkspaceUpdaterProps, -} from './workspace_updater'; - -const workspaceClientUpdate = jest.fn().mockReturnValue({ result: true, success: true }); - -const navigateToApp = jest.fn(); -const notificationToastsAddSuccess = jest.fn(); -const notificationToastsAddDanger = jest.fn(); -const PublicAPPInfoMap = new Map([ - ['data-explorer', { id: 'data-explorer', title: 'Data Explorer' }], - ['dashboards', { id: 'dashboards', title: 'Dashboards' }], -]); -const createWorkspacesSetupContractMockWithValue = () => { - const currentWorkspaceId$ = new BehaviorSubject('workspaceId'); - const currentWorkspace = { - id: 'workspaceId', - name: 'test1', - description: 'test1', - features: ['use-case-observability'], - reserved: false, - permissions: { - library_write: { - users: ['foo'], - }, - write: { - users: ['foo'], - }, - }, - }; - const workspaceList$ = new BehaviorSubject([currentWorkspace]); - const currentWorkspace$ = new BehaviorSubject(currentWorkspace); - const initialized$ = new BehaviorSubject(false); - return { - currentWorkspaceId$, - workspaceList$, - currentWorkspace$, - initialized$, - }; -}; - -const dataSourcesList = [ - { - id: 'id1', - title: 'ds1', // This is used for mocking saved object function - get: () => { - return 'ds1'; - }, - }, - { - id: 'id2', - title: 'ds2', - get: () => { - return 'ds2'; - }, - }, -]; - -const mockCoreStart = coreMock.createStart(); - -const renderCompleted = () => expect(screen.queryByText('Enter details')).not.toBeNull(); - -const WorkspaceUpdater = ( - props: Partial & { - workspacesService?: ReturnType; - } -) => { - const workspacesService = props.workspacesService || createWorkspacesSetupContractMockWithValue(); - const { Provider } = createOpenSearchDashboardsReactContext({ - ...mockCoreStart, - ...{ - application: { - ...mockCoreStart.application, - capabilities: { - ...mockCoreStart.application.capabilities, - workspaces: { - permissionEnabled: true, - }, - dashboards: { - isDashboardAdmin: true, - }, - }, - navigateToApp, - getUrlForApp: jest.fn(() => '/app/workspace_detail'), - applications$: new BehaviorSubject>(PublicAPPInfoMap as any), - }, - workspaces: workspacesService, - notifications: { - ...mockCoreStart.notifications, - toasts: { - ...mockCoreStart.notifications.toasts, - addDanger: notificationToastsAddDanger, - addSuccess: notificationToastsAddSuccess, - }, - }, - workspaceClient: { - ...mockCoreStart.workspaces, - update: workspaceClientUpdate, - }, - savedObjects: { - ...mockCoreStart.savedObjects, - client: { - ...mockCoreStart.savedObjects.client, - find: jest.fn().mockResolvedValue({ - savedObjects: dataSourcesList, - }), - }, - }, - dataSourceManagement: {}, - }, - }); - const registeredUseCases$ = new BehaviorSubject([ - WORKSPACE_USE_CASES.observability, - WORKSPACE_USE_CASES['security-analytics'], - WORKSPACE_USE_CASES.essentials, - WORKSPACE_USE_CASES.search, - ]); - - return ( - - - - ); -}; - -function clearMockedFunctions() { - workspaceClientUpdate.mockClear(); - notificationToastsAddDanger.mockClear(); - notificationToastsAddSuccess.mockClear(); -} - -describe('WorkspaceUpdater', () => { - beforeEach(() => clearMockedFunctions()); - const { location } = window; - const setHrefSpy = jest.fn((href) => href); - - beforeAll(() => { - if (window.location) { - // @ts-ignore - delete window.location; - } - window.location = {} as Location; - Object.defineProperty(window.location, 'href', { - get: () => 'http://localhost/', - set: setHrefSpy, - }); - }); - - afterAll(() => { - window.location = location; - }); - - it('cannot render when the name of the current workspace is empty', async () => { - const mockedWorkspacesService = workspacesServiceMock.createSetupContract(); - const { container } = render( - - ); - expect(container).toMatchInlineSnapshot(`
`); - }); - - it('cannot update workspace with invalid name', async () => { - const { getByTestId } = render(); - - await waitFor(renderCompleted); - - const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); - fireEvent.input(nameInput, { - target: { value: '~' }, - }); - expect(workspaceClientUpdate).not.toHaveBeenCalled(); - }); - - it('cancel update workspace', async () => { - const { findByText, getByTestId } = render(); - await waitFor(renderCompleted); - - fireEvent.click(getByTestId('workspaceForm-bottomBar-cancelButton')); - await findByText('Discard changes?'); - fireEvent.click(getByTestId('confirmModalConfirmButton')); - expect(navigateToApp).toHaveBeenCalled(); - }); - - it('update workspace successfully', async () => { - const { getByTestId, getAllByLabelText } = render( - - ); - await waitFor(renderCompleted); - - const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); - fireEvent.input(nameInput, { - target: { value: 'test workspace name' }, - }); - - const descriptionInput = getByTestId('workspaceForm-workspaceDetails-descriptionInputText'); - fireEvent.input(descriptionInput, { - target: { value: 'test workspace description' }, - }); - const colorSelector = getByTestId( - 'euiColorPickerAnchor workspaceForm-workspaceDetails-colorPicker' - ); - fireEvent.input(colorSelector, { - target: { value: '#000000' }, - }); - - fireEvent.click(getByTestId('workspaceUseCase-observability')); - fireEvent.click(getByTestId('workspaceUseCase-analytics')); - - act(() => { - fireEvent.click(getAllByLabelText('Delete data source')[0]); - }); - - fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); - expect(workspaceClientUpdate).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - name: 'test workspace name', - color: '#000000', - description: 'test workspace description', - features: expect.arrayContaining(['use-case-analytics']), - }), - { - permissions: { - library_write: { - users: ['foo'], - }, - write: { - users: ['foo'], - }, - }, - dataSources: ['id2'], - } - ); - await waitFor(() => { - expect(notificationToastsAddSuccess).toHaveBeenCalled(); - }); - expect(notificationToastsAddDanger).not.toHaveBeenCalled(); - await waitFor(() => { - expect(setHrefSpy).toHaveBeenCalledWith(expect.stringMatching(/workspace_detail$/)); - }); - }); - - it('update workspace permission successfully', async () => { - const { getByTestId, getAllByTestId } = render( - - ); - await waitFor(() => expect(screen.queryByText('Manage access and permissions')).not.toBeNull()); - - const userIdInput = getAllByTestId('comboBoxSearchInput')[0]; - fireEvent.click(userIdInput); - - fireEvent.input(userIdInput, { - target: { value: 'test user id' }, - }); - fireEvent.blur(userIdInput); - - fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); - expect(workspaceClientUpdate).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - name: 'test1', - description: 'test1', - features: expect.arrayContaining(['use-case-observability']), - }), - { - permissions: { - library_write: { - users: ['test user id'], - }, - write: { - users: ['test user id'], - }, - }, - dataSources: ['id1', 'id2'], - } - ); - await waitFor(() => { - expect(notificationToastsAddSuccess).toHaveBeenCalled(); - }); - expect(notificationToastsAddDanger).not.toHaveBeenCalled(); - await waitFor(() => { - expect(setHrefSpy).toHaveBeenCalledWith(expect.stringMatching(/workspace_detail$/)); - }); - }); - - it('should show danger toasts after update workspace failed', async () => { - workspaceClientUpdate.mockReturnValue({ result: false, success: false }); - const { getByTestId } = render(); - await waitFor(renderCompleted); - - const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); - fireEvent.input(nameInput, { - target: { value: 'test workspace name' }, - }); - fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); - expect(workspaceClientUpdate).toHaveBeenCalled(); - await waitFor(() => { - expect(notificationToastsAddDanger).toHaveBeenCalled(); - }); - expect(notificationToastsAddSuccess).not.toHaveBeenCalled(); - }); - - it('should show danger toasts after update workspace threw error', async () => { - workspaceClientUpdate.mockImplementation(() => { - throw new Error('update workspace failed'); - }); - const { getByTestId } = render(); - await waitFor(renderCompleted); - - const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); - fireEvent.input(nameInput, { - target: { value: 'test workspace name' }, - }); - fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); - expect(workspaceClientUpdate).toHaveBeenCalled(); - await waitFor(() => { - expect(notificationToastsAddDanger).toHaveBeenCalled(); - }); - expect(notificationToastsAddSuccess).not.toHaveBeenCalled(); - }); - - it('should show danger toasts when currentWorkspace is missing after click update button', async () => { - const mockedWorkspacesService = workspacesServiceMock.createSetupContract(); - const { getByTestId } = render( - - ); - - await waitFor(renderCompleted); - - const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); - fireEvent.input(nameInput, { - target: { value: 'test workspace name' }, - }); - fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); - mockedWorkspacesService.currentWorkspace$ = new BehaviorSubject(null); - expect(workspaceClientUpdate).toHaveBeenCalled(); - await waitFor(() => { - expect(notificationToastsAddDanger).toHaveBeenCalled(); - }); - expect(notificationToastsAddSuccess).not.toHaveBeenCalled(); - }); -}); diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_updater.tsx deleted file mode 100644 index 9005646fd804..000000000000 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_updater.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useCallback, useEffect, useState } from 'react'; -import { EuiPage, EuiPageBody, EuiPageContent, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@osd/i18n'; -import { useObservable } from 'react-use'; -import { BehaviorSubject, of } from 'rxjs'; -import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; -import { WORKSPACE_DETAIL_APP_ID } from '../../../common/constants'; -import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; -import { WorkspaceAttributeWithPermission } from '../../../../../core/types'; -import { WorkspaceClient } from '../../workspace_client'; -import { - WorkspaceFormSubmitData, - WorkspaceOperationType, - convertPermissionsToPermissionSettings, - convertPermissionSettingsToPermissions, - WorkspaceDetailForm, -} from '../workspace_form'; -import { getDataSourcesList } from '../../utils'; -import { DataSource } from '../../../common/types'; -import { DetailTab } from '../workspace_form/constants'; -import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; -import { WorkspaceUseCase } from '../../types'; - -export interface WorkspaceUpdaterProps { - registeredUseCases$: BehaviorSubject; - detailTab?: DetailTab; -} - -function getFormDataFromWorkspace( - currentWorkspace: WorkspaceAttributeWithPermission | null | undefined -) { - if (!currentWorkspace) { - return null; - } - return { - ...currentWorkspace, - permissionSettings: currentWorkspace.permissions - ? convertPermissionsToPermissionSettings(currentWorkspace.permissions) - : currentWorkspace.permissions, - }; -} - -type FormDataFromWorkspace = ReturnType & { - selectedDataSources: DataSource[]; -}; - -export const WorkspaceUpdater = (props: WorkspaceUpdaterProps) => { - const { - services: { - application, - workspaces, - notifications, - http, - workspaceClient, - savedObjects, - dataSourceManagement, - }, - } = useOpenSearchDashboards<{ - workspaceClient: WorkspaceClient; - dataSourceManagement?: DataSourceManagementPluginSetup; - }>(); - - const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; - const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); - const availableUseCases = useObservable(props.registeredUseCases$, []); - const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState(); - - const handleWorkspaceFormSubmit = useCallback( - async (data: WorkspaceFormSubmitData) => { - let result; - if (!currentWorkspace) { - notifications?.toasts.addDanger({ - title: i18n.translate('Cannot find current workspace', { - defaultMessage: 'Cannot update workspace', - }), - }); - return; - } - - try { - const { permissionSettings, selectedDataSources, ...attributes } = data; - const selectedDataSourceIds = (selectedDataSources ?? []).map((ds: DataSource) => { - return ds.id; - }); - result = await workspaceClient.update(currentWorkspace.id, attributes, { - dataSources: selectedDataSourceIds, - permissions: convertPermissionSettingsToPermissions(permissionSettings), - }); - if (result?.success) { - notifications?.toasts.addSuccess({ - title: i18n.translate('workspace.update.success', { - defaultMessage: 'Update workspace successfully', - }), - }); - if (application && http) { - // Redirect page after one second, leave one second time to show update successful toast. - window.setTimeout(() => { - window.location.href = formatUrlWithWorkspaceId( - application.getUrlForApp(WORKSPACE_DETAIL_APP_ID, { - absolute: true, - }), - currentWorkspace.id, - http.basePath - ); - }, 1000); - } - return; - } else { - throw new Error(result?.error ? result?.error : 'update workspace failed'); - } - } catch (error) { - notifications?.toasts.addDanger({ - title: i18n.translate('workspace.update.failed', { - defaultMessage: 'Failed to update workspace', - }), - text: error instanceof Error ? error.message : JSON.stringify(error), - }); - return; - } - }, - [notifications?.toasts, currentWorkspace, http, application, workspaceClient] - ); - - useEffect(() => { - const rawFormData = getFormDataFromWorkspace(currentWorkspace); - - if (rawFormData && savedObjects && currentWorkspace) { - getDataSourcesList(savedObjects.client, [currentWorkspace.id]).then((selectedDataSources) => { - setCurrentWorkspaceFormData({ - ...rawFormData, - selectedDataSources, - }); - }); - } - }, [currentWorkspace, savedObjects]); - - if (!currentWorkspaceFormData) { - return null; - } - - return ( - - - - - {application && savedObjects && ( - - )} - - - - ); -}; diff --git a/src/plugins/workspace/public/components/workspace_detail_app.tsx b/src/plugins/workspace/public/components/workspace_detail_app.tsx index 1185f0c66d8f..1347b130575b 100644 --- a/src/plugins/workspace/public/components/workspace_detail_app.tsx +++ b/src/plugins/workspace/public/components/workspace_detail_app.tsx @@ -3,21 +3,63 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { I18nProvider } from '@osd/i18n/react'; import { i18n } from '@osd/i18n'; import { CoreStart } from 'opensearch-dashboards/public'; import { useObservable } from 'react-use'; import { EuiBreadcrumb } from '@elastic/eui'; +import { of } from 'rxjs'; import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; import { WorkspaceDetail, WorkspaceDetailProps } from './workspace_detail/workspace_detail'; +import { WorkspaceFormProvider } from './workspace_form'; +import { + WorkspaceFormSubmitData, + WorkspaceOperationType, + convertPermissionSettingsToPermissions, + convertPermissionsToPermissionSettings, +} from './workspace_form'; +import { DataSource } from '../../common/types'; +import { WorkspaceClient } from '../workspace_client'; +import { formatUrlWithWorkspaceId } from '../../../../core/public/utils'; +import { WORKSPACE_DETAIL_APP_ID } from '../../common/constants'; +import { getDataSourcesList } from '../utils'; +import { WorkspaceAttributeWithPermission } from '../../../../core/types'; + +function getFormDataFromWorkspace( + currentWorkspace: WorkspaceAttributeWithPermission | null | undefined +) { + if (!currentWorkspace) { + return null; + } + return { + ...currentWorkspace, + permissionSettings: currentWorkspace.permissions + ? convertPermissionsToPermissionSettings(currentWorkspace.permissions) + : currentWorkspace.permissions, + }; +} + +type FormDataFromWorkspace = ReturnType & { + selectedDataSources: DataSource[]; +}; export const WorkspaceDetailApp = (props: WorkspaceDetailProps) => { const { - services: { workspaces, chrome, application }, - } = useOpenSearchDashboards(); - - const currentWorkspace = useObservable(workspaces.currentWorkspace$); + services: { + workspaces, + chrome, + application, + savedObjects, + notifications, + workspaceClient, + http, + }, + } = useOpenSearchDashboards<{ CoreStart: CoreStart; workspaceClient: WorkspaceClient }>(); + const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState(); + const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); + const availableUseCases = useObservable(props.registeredUseCases$, []); + const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; /** * set breadcrumbs to chrome @@ -27,7 +69,7 @@ export const WorkspaceDetailApp = (props: WorkspaceDetailProps) => { { text: 'Home', onClick: () => { - application.navigateToApp('home'); + application?.navigateToApp('home'); }, }, ]; @@ -47,9 +89,93 @@ export const WorkspaceDetailApp = (props: WorkspaceDetailProps) => { chrome?.setBreadcrumbs(breadcrumbs); }, [chrome, currentWorkspace, application]); + useEffect(() => { + const rawFormData = getFormDataFromWorkspace(currentWorkspace); + + if (rawFormData && savedObjects && currentWorkspace) { + getDataSourcesList(savedObjects.client, [currentWorkspace.id]).then((selectedDataSources) => { + setCurrentWorkspaceFormData({ + ...rawFormData, + selectedDataSources, + }); + }); + } + }, [currentWorkspace, savedObjects]); + + const handleWorkspaceFormSubmit = useCallback( + async (data: WorkspaceFormSubmitData) => { + let result; + if (!currentWorkspace) { + notifications?.toasts.addDanger({ + title: i18n.translate('Cannot find current workspace', { + defaultMessage: 'Cannot update workspace', + }), + }); + return; + } + + try { + const { permissionSettings, selectedDataSources, ...attributes } = data; + const selectedDataSourceIds = (selectedDataSources ?? []).map((ds: DataSource) => { + return ds.id; + }); + + result = await workspaceClient.update(currentWorkspace.id, attributes, { + dataSources: selectedDataSourceIds, + permissions: convertPermissionSettingsToPermissions(permissionSettings), + }); + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.update.success', { + defaultMessage: 'Update workspace successfully', + }), + }); + if (application && http) { + // Redirect page after one second, leave one second time to show update successful toast. + window.setTimeout(() => { + window.location.href = formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_DETAIL_APP_ID, { + absolute: true, + }), + currentWorkspace.id, + http.basePath + ); + }, 1000); + } + return; + } else { + throw new Error(result?.error ? result?.error : 'update workspace failed'); + } + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.update.failed', { + defaultMessage: 'Failed to update workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return; + } + }, + [notifications?.toasts, currentWorkspace, http, application, workspaceClient] + ); + + if (!workspaces || !application || !http || !savedObjects || !currentWorkspaceFormData) { + return null; + } + return ( - - - + + + + + ); }; diff --git a/src/plugins/workspace/public/components/workspace_form/constants.ts b/src/plugins/workspace/public/components/workspace_form/constants.ts index 2a2c7142d6f0..a69afeacbbbc 100644 --- a/src/plugins/workspace/public/components/workspace_form/constants.ts +++ b/src/plugins/workspace/public/components/workspace_form/constants.ts @@ -46,11 +46,75 @@ export const selectDataSourceTitle = i18n.translate('workspace.form.selectDataSo }); export const usersAndPermissionsTitle = i18n.translate('workspace.form.usersAndPermissions.title', { - defaultMessage: 'Manage access and permissions', + defaultMessage: 'Workspaces access', }); +export const detailsName = i18n.translate('workspace.form.workspaceDetails.name.label', { + defaultMessage: 'Name', +}); + +export const detailsNameHelpText = i18n.translate('workspace.form.workspaceDetails.name.helpText', { + defaultMessage: + 'Valid characters are a-z, A-Z, 0-9, (), [], _ (underscore), - (hyphen) and (space).', +}); + +export const detailsNamePlaceholder = i18n.translate( + 'workspace.form.workspaceDetails.name.placeholder', + { + defaultMessage: 'Enter a name', + } +); + +export const detailsDescriptionIntroduction = i18n.translate( + 'workspace.form.workspaceDetails.description.introduction', + { + defaultMessage: 'Describe the workspace.', + } +); + +export const detailsDescriptionPlaceholder = i18n.translate( + 'workspace.form.workspaceDetails.description.placeholder', + { + defaultMessage: 'Describe the workspace', + } +); + +export const detailsUseCaseLabel = i18n.translate('workspace.form.workspaceDetails.useCase.label', { + defaultMessage: 'Use case', +}); + +export const detailsUseCaseHelpText = i18n.translate( + 'workspace.form.workspaceDetails.useCase.helpText', + { + defaultMessage: 'You can only choose use cases with more features than the current use case.', + } +); + +export const detailsColorLabel = i18n.translate('workspace.form.workspaceDetails.color.label', { + defaultMessage: 'Workspace icon color', +}); + +export const detailsColorHelpText = i18n.translate( + 'workspace.form.workspaceDetails.color.helpText', + { + defaultMessage: 'The background color of the icon that represents the workspace.', + } +); + export enum DetailTab { - Settings = 'settings', + Details = 'details', + DataSources = 'dataSources', Collaborators = 'collaborators', - Overview = 'overview', } + +export const DetailTabTitles: { [key in DetailTab]: string } = { + [DetailTab.Details]: i18n.translate('workspace.detail.tabTitle.details', { + defaultMessage: 'Details', + }), + [DetailTab.DataSources]: i18n.translate('workspace.detail.tabTitle.dataSources', { + defaultMessage: 'Data Sources', + }), + [DetailTab.Collaborators]: i18n.translate('workspace.detail.tabTitle.collaborators', { + defaultMessage: 'Collaborators', + }), +}; diff --git a/src/plugins/workspace/public/components/workspace_form/index.ts b/src/plugins/workspace/public/components/workspace_form/index.ts index 31addf5a641e..42164ca530e2 100644 --- a/src/plugins/workspace/public/components/workspace_form/index.ts +++ b/src/plugins/workspace/public/components/workspace_form/index.ts @@ -11,3 +11,4 @@ export { convertPermissionsToPermissionSettings, convertPermissionSettingsToPermissions, } from './utils'; +export { WorkspaceFormProvider, useWorkspaceFormContext } from './workspace_form_context'; diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts index cbcf7e8ded26..6f0c6a2d2f70 100644 --- a/src/plugins/workspace/public/components/workspace_form/types.ts +++ b/src/plugins/workspace/public/components/workspace_form/types.ts @@ -86,6 +86,7 @@ export interface WorkspaceFormProps { detailTab?: DetailTab; dataSourceManagement?: DataSourceManagementPluginSetup; availableUseCases: WorkspaceUseCase[]; + detailTitle?: string; } export interface WorkspaceDetailedFormProps extends WorkspaceFormProps { diff --git a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts index ae9ba5d6a4fa..c946cb35a68c 100644 --- a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts @@ -140,4 +140,23 @@ describe('useWorkspaceForm', () => { }); expect(renderResult.result.current.formData.useCase).toBe('search'); }); + + it('should reset workspace form', () => { + const { renderResult } = setup({ + id: 'test', + name: 'current-workspace-name', + features: ['use-case-observability'], + }); + expect(renderResult.result.current.formData.name).toBe('current-workspace-name'); + + act(() => { + renderResult.result.current.setName('update-workspace-name'); + }); + expect(renderResult.result.current.formData.name).toBe('update-workspace-name'); + + act(() => { + renderResult.result.current.handleResetForm(); + }); + expect(renderResult.result.current.formData.name).toBe('current-workspace-name'); + }); }); diff --git a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts index 8b03d246d568..422161bea948 100644 --- a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts @@ -37,6 +37,7 @@ export const useWorkspaceForm = ({ const [description, setDescription] = useState(defaultValues?.description); const [color, setColor] = useState(defaultValues?.color); const defaultValuesRef = useRef(defaultValues); + const [isEditing, setIsEditing] = useState(false); const initialPermissionSettingsRef = useRef( generatePermissionSettingsState(operationType, defaultValues?.permissionSettings) ); @@ -119,7 +120,7 @@ export const useWorkspaceForm = ({ onSubmit?.({ name: currentFormData.name!, description: currentFormData.description, - color: currentFormData.color, + color: currentFormData.color || '#FFFFFF', features: currentFormData.features, permissionSettings: currentFormData.permissionSettings as WorkspacePermissionSetting[], selectedDataSources: currentFormData.selectedDataSources, @@ -132,13 +133,27 @@ export const useWorkspaceForm = ({ setColor(text); }, []); + const handleResetForm = useCallback(() => { + const resetValues = defaultValuesRef.current; + setName(resetValues?.name ?? ''); + setDescription(resetValues?.description); + setColor(resetValues?.color); + setFeatureConfigs(appendDefaultFeatureIds(resetValues?.features ?? [])); + setPermissionSettings(initialPermissionSettingsRef.current); + setFormErrors({}); + setIsEditing(false); + }, []); + return { formId: formIdRef.current, formData, + isEditing, formErrors, + setIsEditing, applications, numberOfErrors, numberOfChanges, + handleResetForm, setName, setDescription, handleFormSubmit, diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx deleted file mode 100644 index 79819a217de8..000000000000 --- a/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - EuiBottomBar, - EuiSmallButton, - EuiSmallButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@osd/i18n'; -import React, { useState, useCallback } from 'react'; -import { ApplicationStart } from 'opensearch-dashboards/public'; -import { WorkspaceCancelModal } from './workspace_cancel_modal'; - -interface WorkspaceBottomBarProps { - formId: string; - application: ApplicationStart; - numberOfChanges: number; -} - -export const WorkspaceBottomBar = ({ - formId, - numberOfChanges, - application, -}: WorkspaceBottomBarProps) => { - const [isCancelModalVisible, setIsCancelModalVisible] = useState(false); - const closeCancelModal = useCallback(() => setIsCancelModalVisible(false), []); - const showCancelModal = useCallback(() => setIsCancelModalVisible(true), []); - - return ( -
- - - - - - - {numberOfChanges > 0 && ( - - {i18n.translate('workspace.form.bottomBar.unsavedChanges', { - defaultMessage: '{numberOfChanges} Unsaved change(s)', - values: { - numberOfChanges, - }, - })} - - )} - - - - - - {i18n.translate('workspace.form.bottomBar.cancel', { - defaultMessage: 'Cancel', - })} - - - - {i18n.translate('workspace.form.bottomBar.saveChanges', { - defaultMessage: 'Save changes', - })} - - - - - - {isCancelModalVisible && ( - - )} -
- ); -}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx index 271f90ca69fa..a8015120d407 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx @@ -4,76 +4,97 @@ */ import './workspace_detail_form.scss'; -import React, { useRef } from 'react'; -import { EuiPanel, EuiSpacer, EuiForm, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import React, { useEffect, useRef, useState } from 'react'; +import { + EuiSpacer, + EuiForm, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiText, + EuiPanel, + EuiSmallButton, + EuiHorizontalRule, +} from '@elastic/eui'; -import { WorkspaceBottomBar } from './workspace_bottom_bar'; -import { WorkspaceDetailedFormProps } from './types'; -import { useWorkspaceForm } from './use_workspace_form'; -import { WorkspaceUseCase } from './workspace_use_case'; +import { i18n } from '@osd/i18n'; +import { WorkspaceFormProps } from './types'; import { WorkspacePermissionSettingPanel } from './workspace_permission_setting_panel'; -import { SelectDataSourcePanel } from './select_data_source_panel'; -import { EnterDetailsPanel } from './workspace_enter_details_panel'; -import { - DetailTab, - WorkspaceOperationType, - selectDataSourceTitle, - usersAndPermissionsTitle, - workspaceDetailsTitle, - workspaceUseCaseTitle, -} from './constants'; -import { WorkspaceCreateActionPanel } from './workspace_create_action_panel'; +import { DetailTab, usersAndPermissionsTitle } from './constants'; import { WorkspaceFormErrorCallout } from './workspace_form_error_callout'; +import { useWorkspaceFormContext } from './workspace_form_context'; +import { WorkspaceDetailFormDetailsProps } from './workspace_detail_form_details'; interface FormGroupProps { - title: string; + title: React.ReactNode; children: React.ReactNode; + describe?: string; } -const FormGroup = ({ title, children }: FormGroupProps) => ( - - - -

{title}

-
-
- {children} -
+const FormGroup = ({ title, children, describe }: FormGroupProps) => ( + <> + + + +

{title}

+
+ + {describe} + +
+ + + {children} + +
+ + ); -export const WorkspaceDetailForm = (props: WorkspaceDetailedFormProps) => { - const { - detailTab, - application, - savedObjects, - defaultValues, - operationType, - availableUseCases, - dataSourceManagement: isDataSourceEnabled, - } = props; +export const WorkspaceDetailForm = (props: WorkspaceFormProps) => { + const { detailTab, detailTitle, defaultValues, availableUseCases } = props; const { formId, formData, + isEditing, formErrors, + setIsEditing, numberOfErrors, numberOfChanges, - setName, - setDescription, + handleResetForm, handleFormSubmit, - handleColorChange, - handleUseCaseChange, setPermissionSettings, - setSelectedDataSources, - } = useWorkspaceForm(props); - - const isDashboardAdmin = application?.capabilities?.dashboards?.isDashboardAdmin ?? false; - + } = useWorkspaceFormContext(); const disabledUserOrGroupInputIdsRef = useRef( defaultValues?.permissionSettings?.map((item) => item.id) ?? [] ); + const [isSaving, setIsSaving] = useState(false); + + // Handle beforeunload event + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!isSaving && isEditing && numberOfChanges > 0) { + event.preventDefault(); + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [isEditing, isSaving, numberOfChanges]); + return ( - + { + setIsSaving(true); + handleFormSubmit(event); + }} + component="form" + > {numberOfErrors > 0 && ( <> @@ -81,6 +102,38 @@ export const WorkspaceDetailForm = (props: WorkspaceDetailedFormProps) => { )} + + + +

{detailTitle}

+
+
+ + {isEditing ? ( + + {i18n.translate('workspace.detail.button.discardChanges', { + defaultMessage: 'Discard changes', + })} + + ) : ( + setIsEditing((prevIsEditing) => !prevIsEditing)} + data-test-subj="workspaceForm-workspaceDetails-edit" + > + {i18n.translate('workspace.detail.button.edit', { + defaultMessage: 'Edit', + })} + + )} + +
+ + {detailTab === DetailTab.Details && ( + + )} {detailTab === DetailTab.Collaborators && ( { permissionSettings={formData.permissionSettings} disabledUserOrGroupInputIds={disabledUserOrGroupInputIdsRef.current} data-test-subj={`workspaceForm-permissionSettingPanel`} + isEditing={isEditing} /> )} - {detailTab === DetailTab.Settings && ( - <> - - - - - - - - - {isDashboardAdmin && isDataSourceEnabled && ( - - - - )} - - )}
- {operationType === WorkspaceOperationType.Create && ( - - )} - {operationType === WorkspaceOperationType.Update && ( - - )}
); }; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_detail_form_details.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form_details.tsx new file mode 100644 index 000000000000..27de826141b2 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form_details.tsx @@ -0,0 +1,140 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiSuperSelect, + EuiColorPicker, + EuiCompressedFormRow, + EuiDescribedFormGroup, + EuiCompressedTextArea, +} from '@elastic/eui'; +import React, { useEffect, useState } from 'react'; +import { useObservable } from 'react-use'; +import { + detailsName, + detailsColorLabel, + detailsUseCaseLabel, + detailsColorHelpText, + detailsDescriptionPlaceholder, + detailsDescriptionIntroduction, + detailsUseCaseHelpText, +} from './constants'; +import { CoreStart } from '../../../../../core/public'; +import { getFirstUseCaseOfFeatureConfigs } from '../../utils'; +import { DEFAULT_NAV_GROUPS } from '../../../../../core/public'; +import { useWorkspaceFormContext } from './workspace_form_context'; +import { WorkspaceUseCase as WorkspaceUseCaseObject } from '../../types'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { WorkspaceNameField } from './fields/workspace_name_field'; +import { WorkspaceDescriptionField } from './fields/workspace_description_field'; + +interface WorkspaceDetailFormDetailsProps { + availableUseCases: Array< + Pick + >; +} + +export const WorkspaceDetailFormDetailsProps = ({ + availableUseCases, +}: WorkspaceDetailFormDetailsProps) => { + const { + setName, + formData, + isEditing, + formErrors, + setDescription, + handleColorChange, + handleUseCaseChange, + } = useWorkspaceFormContext(); + const { + services: { workspaces }, + } = useOpenSearchDashboards(); + const [value, setValue] = useState(formData.useCase); + const currentWorkspace = useObservable(workspaces.currentWorkspace$); + const currentUseCase = getFirstUseCaseOfFeatureConfigs(currentWorkspace?.features ?? []); + + useEffect(() => { + setValue(formData.useCase); + }, [formData.useCase]); + + const options = availableUseCases + .filter((item) => !item.systematic) + .concat(DEFAULT_NAV_GROUPS.all) + .filter(({ id }) => { + // Essential can be changed to other use cases; + // Analytics (all) cannot be changed back to a single use case; + // Other use cases can only be changed to Analytics (all) use case. + return currentUseCase === 'analytics' || id === 'all' || id === currentUseCase; + }) + .map((useCase) => ({ + value: useCase.id, + inputDisplay: useCase.title, + 'data-test-subj': useCase.id, + })); + + return ( + <> + {detailsName}}> + + + + Description - optional + + } + description={detailsDescriptionIntroduction} + > + + + {detailsUseCaseLabel}}> + + { + setValue(id); + handleUseCaseChange(id); + }} + disabled={!isEditing} + readOnly={!isEditing} + /> + + + {detailsColorLabel}} + description={detailsColorHelpText} + > + + + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx index e8cfd534d922..a968f560fb03 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx @@ -6,12 +6,10 @@ import React, { useRef } from 'react'; import { EuiPanel, EuiSpacer, EuiTitle, EuiForm } from '@elastic/eui'; -import { WorkspaceBottomBar } from './workspace_bottom_bar'; import { WorkspaceFormProps } from './types'; import { useWorkspaceForm } from './use_workspace_form'; import { WorkspacePermissionSettingPanel } from './workspace_permission_setting_panel'; import { WorkspaceUseCase } from './workspace_use_case'; -import { WorkspaceOperationType } from './constants'; import { WorkspaceFormErrorCallout } from './workspace_form_error_callout'; import { WorkspaceCreateActionPanel } from './workspace_create_action_panel'; import { SelectDataSourcePanel } from './select_data_source_panel'; @@ -28,10 +26,10 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { application, savedObjects, defaultValues, - operationType, permissionEnabled, dataSourceManagement: isDataSourceEnabled, availableUseCases, + operationType, } = props; const { formId, @@ -127,16 +125,7 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { )} - {operationType === WorkspaceOperationType.Create && ( - - )} - {operationType === WorkspaceOperationType.Update && ( - - )} +
); }; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form_context.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form_context.tsx new file mode 100644 index 000000000000..417921f170d3 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form_context.tsx @@ -0,0 +1,69 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { createContext, useContext, FormEventHandler, ReactNode } from 'react'; +import { EuiColorPickerOutput } from '@elastic/eui/src/components/color_picker/color_picker'; +import { DataSource } from '../../../common/types'; +import { WorkspaceFormProps, WorkspaceFormErrors, WorkspacePermissionSetting } from './types'; +import { PublicAppInfo } from '../../../../../core/public'; +import { useWorkspaceForm } from './use_workspace_form'; + +interface WorkspaceFormContextProps { + formId: string; + setName: React.Dispatch>; + setDescription: React.Dispatch>; + formData: any; + isEditing: boolean; + formErrors: WorkspaceFormErrors; + setIsEditing: React.Dispatch>; + applications: PublicAppInfo[]; + numberOfErrors: number; + numberOfChanges: number; + handleResetForm: () => void; + handleFormSubmit: FormEventHandler; + handleColorChange: (text: string, output: EuiColorPickerOutput) => void; + handleUseCaseChange: (newUseCase: string) => void; + setPermissionSettings: React.Dispatch< + React.SetStateAction< + Array & Partial> + > + >; + setSelectedDataSources: React.Dispatch>; +} + +const initialContextValue: WorkspaceFormContextProps = {} as WorkspaceFormContextProps; +export const WorkspaceFormContext = createContext(initialContextValue); +interface ContextProps extends WorkspaceFormProps { + children: ReactNode; +} + +export const WorkspaceFormProvider = ({ + children, + application, + defaultValues, + operationType, + onSubmit, + permissionEnabled, + savedObjects, + availableUseCases, +}: ContextProps) => { + const workspaceFormContextValue = useWorkspaceForm({ + application, + defaultValues, + operationType, + onSubmit, + permissionEnabled, + savedObjects, + availableUseCases, + }); + + return ( + + {children} + + ); +}; + +export const useWorkspaceFormContext = () => useContext(WorkspaceFormContext); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx index 8827f9bf9b74..40165530c3b2 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx @@ -63,6 +63,7 @@ export interface WorkspacePermissionSettingInputProps { userId?: string; group?: string; modes?: WorkspacePermissionMode[]; + isEditing?: boolean; deletable?: boolean; userOrGroupDisabled: boolean; onGroupOrUserIdChange: ( @@ -84,6 +85,7 @@ export const WorkspacePermissionSettingInput = ({ userId, group, modes, + isEditing = true, deletable = true, userOrGroupDisabled, onDelete, @@ -147,13 +149,13 @@ export const WorkspacePermissionSettingInput = ({ defaultMessage: 'Select a user group', }) } - isDisabled={userOrGroupDisabled} + isDisabled={userOrGroupDisabled || !isEditing} /> - {deletable && ( + {deletable && isEditing && ( & Partial> ) => void; + isEditing?: boolean; } interface UserOrGroupSectionProps extends WorkspacePermissionSettingPanelProps { @@ -44,6 +45,7 @@ const UserOrGroupSection = ({ type, errors, onChange, + isEditing, nextIdGenerator, permissionSettings, disabledUserOrGroupInputIds, @@ -143,24 +145,27 @@ const UserOrGroupSection = ({ onDelete={handleDelete} onGroupOrUserIdChange={handleGroupOrUserIdChange} onPermissionModesChange={handlePermissionModesChange} + isEditing={isEditing} /> ))} - - {type === WorkspacePermissionItemType.User - ? i18n.translate('workspace.form.permissionSettingPanel.addUser', { - defaultMessage: 'Add user', - }) - : i18n.translate('workspace.form.permissionSettingPanel.addUserGroup', { - defaultMessage: 'Add user group', - })} - + {isEditing && ( + + {type === WorkspacePermissionItemType.User + ? i18n.translate('workspace.form.permissionSettingPanel.addUser', { + defaultMessage: 'Add user', + }) + : i18n.translate('workspace.form.permissionSettingPanel.addUserGroup', { + defaultMessage: 'Add user group', + })} + + )}
); }; @@ -170,6 +175,7 @@ export const WorkspacePermissionSettingPanel = ({ onChange, permissionSettings, disabledUserOrGroupInputIds, + isEditing = true, }: WorkspacePermissionSettingPanelProps) => { const userPermissionSettings = useMemo( () => @@ -224,6 +230,7 @@ export const WorkspacePermissionSettingPanel = ({ type={WorkspacePermissionItemType.User} nextIdGenerator={nextIdGenerator} disabledUserOrGroupInputIds={disabledUserOrGroupInputIds} + isEditing={isEditing} />
); diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx index 3ef7270ddd67..c2e8e39331d4 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -26,11 +26,9 @@ import { WORKSPACE_CREATE_APP_ID, WORKSPACE_LIST_APP_ID, MAX_WORKSPACE_PICKER_NUM, - WORKSPACE_DETAIL_APP_ID, } from '../../../common/constants'; -import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; -import { ALL_USE_CASE_ID, CoreStart, WorkspaceObject } from '../../../../../core/public'; -import { getFirstUseCaseOfFeatureConfigs } from '../../utils'; +import { CoreStart, WorkspaceObject } from '../../../../../core/public'; +import { getFirstUseCaseOfFeatureConfigs, getUseCaseUrl } from '../../utils'; import { recentWorkspaceManager } from '../../recent_workspace_manager'; import { WorkspaceUseCase } from '../../types'; import { navigateToWorkspaceDetail } from '../utils/workspace'; @@ -131,15 +129,7 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { const getWorkspaceListGroup = (filterWorkspaceList: WorkspaceObject[], itemType: string) => { const listItems = filterWorkspaceList.map((workspace: WorkspaceObject) => { const useCase = getUseCase(workspace); - const appId = - (useCase?.id !== ALL_USE_CASE_ID && useCase?.features?.[0]) || WORKSPACE_DETAIL_APP_ID; - const useCaseURL = formatUrlWithWorkspaceId( - coreStart.application.getUrlForApp(appId, { - absolute: false, - }), - workspace.id, - coreStart.http.basePath - ); + const useCaseURL = getUseCaseUrl(useCase, workspace, coreStart.application, coreStart.http); return ( { savedObjects: [ { id: 'id1', - get: () => { - return 'mock_value'; + get: (param: string) => { + switch (param) { + case 'title': + return 'title1'; + case 'description': + return 'description1'; + case 'dataSourceEngineType': + return 'dataSourceEngineType1'; + case 'auth': + return 'mock_value'; + } }, }, ], @@ -389,8 +398,10 @@ describe('workspace utils: getDataSourcesList', () => { expect(await getDataSourcesList(mockedSavedObjectClient, [])).toStrictEqual([ { id: 'id1', - title: 'mock_value', + title: 'title1', auth: 'mock_value', + description: 'description1', + dataSourceEngineType: 'dataSourceEngineType1', }, ]); }); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index dc01e64182eb..8d6049feac28 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -11,6 +11,8 @@ import { ALL_USE_CASE_ID, CoreStart, ChromeBreadcrumb, + ApplicationStart, + HttpSetup, } from '../../../core/public'; import { App, @@ -23,6 +25,7 @@ import { } from '../../../core/public'; import { DEFAULT_SELECTED_FEATURES_IDS, WORKSPACE_DETAIL_APP_ID } from '../common/constants'; import { WorkspaceUseCase } from './types'; +import { formatUrlWithWorkspaceId } from '../../../core/public/utils'; import { SigV4ServiceName } from '../../../plugins/data_source/common/data_sources'; export const USE_CASE_PREFIX = 'use-case-'; @@ -203,7 +206,7 @@ export const getDataSourcesList = (client: SavedObjectsStart['client'], workspac return client .find({ type: 'data-source', - fields: ['id', 'title', 'auth'], + fields: ['id', 'title', 'auth', 'description', 'dataSourceEngineType'], perPage: 10000, workspaces, }) @@ -214,10 +217,14 @@ export const getDataSourcesList = (client: SavedObjectsStart['client'], workspac const id = source.id; const title = source.get('title'); const auth = source.get('auth'); + const description = source.get('description'); + const dataSourceEngineType = source.get('dataSourceEngineType'); return { id, title, auth, + description, + dataSourceEngineType, }; }); } else { @@ -362,3 +369,21 @@ export function prependWorkspaceToBreadcrumbs( }); } } + +export const getUseCaseUrl = ( + useCase: WorkspaceUseCase | undefined, + workspace: WorkspaceObject, + application: ApplicationStart, + http: HttpSetup +): string => { + const appId = + (useCase?.id !== ALL_USE_CASE_ID && useCase?.features?.[0]) || WORKSPACE_DETAIL_APP_ID; + const useCaseURL = formatUrlWithWorkspaceId( + application.getUrlForApp(appId, { + absolute: false, + }), + workspace.id, + http.basePath + ); + return useCaseURL; +}; diff --git a/src/plugins/workspace/public/workspace_client.ts b/src/plugins/workspace/public/workspace_client.ts index d9931f95c135..6482478c9240 100644 --- a/src/plugins/workspace/public/workspace_client.ts +++ b/src/plugins/workspace/public/workspace_client.ts @@ -232,6 +232,9 @@ export class WorkspaceClient { const result = await this.safeFetch(this.getPath(id), { method: 'DELETE' }); if (result.success) { + // After deleting workspace, need to reset current workspace ID. + this.workspaces.currentWorkspaceId$.next(''); + await this.updateWorkspaceList(); } From 9195f97ff493f6efb231fbdd77dda3afa64b6409 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 16 Aug 2024 00:33:52 +0800 Subject: [PATCH 09/14] [workspace]feat: add a unit test case to indicate React is anti-xss (#7699) * feat: add a unit test case to indicate React is anti-xss Signed-off-by: SuZhou-Joe * Changeset file for PR #7699 created/updated --------- Signed-off-by: SuZhou-Joe Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7699.yml | 2 ++ .../workspace_detail/workspace_detail.test.tsx | 14 ++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 changelogs/fragments/7699.yml diff --git a/changelogs/fragments/7699.yml b/changelogs/fragments/7699.yml new file mode 100644 index 000000000000..24554fb44461 --- /dev/null +++ b/changelogs/fragments/7699.yml @@ -0,0 +1,2 @@ +feat: +- Add a unit test case to indicate React is anti-xss ([#7699](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7699)) \ No newline at end of file diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx index c717227554fc..6c91c98608d5 100644 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx @@ -278,4 +278,18 @@ describe('WorkspaceDetail', () => { fireEvent.click(getByText('+1 more')); expect(document.querySelector('#collaborators')).toHaveClass('euiTab-isSelected'); }); + + it('will not render xss content', async () => { + const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {}); + const workspaceService = createWorkspacesSetupContractMockWithValue({ + ...workspaceObject, + name: '', + description: '', + }); + const { getByText } = render(WorkspaceDetailPage({ workspacesService: workspaceService })); + expect(getByText('')).toBeInTheDocument(); + expect(getByText('')).toBeInTheDocument(); + expect(alertSpy).toBeCalledTimes(0); + alertSpy.mockRestore(); + }); }); From 97ddd8a7e846c9f65b25d1f392b423d0b0cf6691 Mon Sep 17 00:00:00 2001 From: Zhongnan Su Date: Thu, 15 Aug 2024 10:12:22 -0700 Subject: [PATCH 10/14] Refactor search bar & filters to conditionally render new look with application header (#7687) * Refactor search bar & filters to conditionally render with new application header Signed-off-by: Zhongnan Su * add more test coverage Signed-off-by: Zhongnan Su * address comments Signed-off-by: Zhongnan Su * Changeset file for PR #7687 created/updated --------- Signed-off-by: Zhongnan Su Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7687.yml | 2 + src/plugins/data/common/constants.ts | 1 + .../ui/filter_bar/_global_filter_group.scss | 9 + .../data/public/ui/filter_bar/filter_bar.tsx | 74 +--- .../ui/filter_bar/filter_options.test.tsx | 188 ++++++++ .../public/ui/filter_bar/filter_options.tsx | 400 ++++++++++++++---- ...d_query_management_component.test.tsx.snap | 68 --- .../saved_query_management_component.test.tsx | 55 ++- .../saved_query_management_component.tsx | 293 ++++++------- .../data/public/ui/search_bar/search_bar.tsx | 41 +- 10 files changed, 731 insertions(+), 400 deletions(-) create mode 100644 changelogs/fragments/7687.yml create mode 100644 src/plugins/data/public/ui/filter_bar/filter_options.test.tsx delete mode 100644 src/plugins/data/public/ui/saved_query_management/__snapshots__/saved_query_management_component.test.tsx.snap diff --git a/changelogs/fragments/7687.yml b/changelogs/fragments/7687.yml new file mode 100644 index 000000000000..4577e74e4d4d --- /dev/null +++ b/changelogs/fragments/7687.yml @@ -0,0 +1,2 @@ +refactor: +- Refactor search bar & filters to conditionally render new look with application header ([#7687](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7687)) \ No newline at end of file diff --git a/src/plugins/data/common/constants.ts b/src/plugins/data/common/constants.ts index 863877322ad9..1de7ae5df034 100644 --- a/src/plugins/data/common/constants.ts +++ b/src/plugins/data/common/constants.ts @@ -63,4 +63,5 @@ export const UI_SETTINGS = { QUERY_ENHANCEMENTS_ENABLED: 'query:enhancements:enabled', QUERY_DATAFRAME_HYDRATION_STRATEGY: 'query:dataframe:hydrationStrategy', SEARCH_QUERY_LANGUAGE_BLOCKLIST: 'search:queryLanguageBlocklist', + NEW_HOME_PAGE: 'home:useNewHomePage', } as const; diff --git a/src/plugins/data/public/ui/filter_bar/_global_filter_group.scss b/src/plugins/data/public/ui/filter_bar/_global_filter_group.scss index b3bf36830ab7..96e6e4d6c97c 100644 --- a/src/plugins/data/public/ui/filter_bar/_global_filter_group.scss +++ b/src/plugins/data/public/ui/filter_bar/_global_filter_group.scss @@ -55,3 +55,12 @@ margin-top: $euiSize * -1; } } + +.globalFilterGroup__removeAllFilters { + color: $euiColorDangerText; +} + +.globalFilterGroup__filterPrefix { + padding-top: $euiSizeS; + padding-right: $euiSizeS; +} diff --git a/src/plugins/data/public/ui/filter_bar/filter_bar.tsx b/src/plugins/data/public/ui/filter_bar/filter_bar.tsx index 964b1fe82fb7..431fd72c065f 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_bar.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_bar.tsx @@ -35,6 +35,7 @@ import { EuiFlexItem, EuiPopover, EuiResizeObserver, + EuiText, } from '@elastic/eui'; import { FormattedMessage, InjectedIntl, injectI18n } from '@osd/i18n/react'; import classNames from 'classnames'; @@ -43,20 +44,10 @@ import { stringify } from '@osd/std'; import { FilterEditor } from './filter_editor'; import { FilterItem } from './filter_item'; -import { FilterOptions } from './filter_options'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { IIndexPattern } from '../..'; -import { - buildEmptyFilter, - Filter, - enableFilter, - disableFilter, - pinFilter, - toggleFilterDisabled, - toggleFilterNegated, - unpinFilter, - UI_SETTINGS, -} from '../../../common'; +import { buildEmptyFilter, Filter, UI_SETTINGS } from '../../../common'; +import { FilterOptions } from './filter_options'; interface Props { filters: Filter[]; @@ -74,6 +65,7 @@ function FilterBarUI(props: Props) { const [filterWidth, setFilterWidth] = useState(maxFilterWidth); const uiSettings = opensearchDashboards.services.uiSettings; + const useNewHeader = Boolean(uiSettings!.get(UI_SETTINGS.NEW_HOME_PAGE)); if (!uiSettings) return null; function onFiltersUpdated(filters: Filter[]) { @@ -177,41 +169,10 @@ function FilterBarUI(props: Props) { onFiltersUpdated(filters); } - function onEnableAll() { - const filters = props.filters.map(enableFilter); - onFiltersUpdated(filters); - } - - function onDisableAll() { - const filters = props.filters.map(disableFilter); - onFiltersUpdated(filters); - } - - function onPinAll() { - const filters = props.filters.map(pinFilter); - onFiltersUpdated(filters); - } - - function onUnpinAll() { - const filters = props.filters.map(unpinFilter); - onFiltersUpdated(filters); - } - - function onToggleAllNegated() { - const filters = props.filters.map(toggleFilterNegated); - onFiltersUpdated(filters); - } - - function onToggleAllDisabled() { - const filters = props.filters.map(toggleFilterDisabled); - onFiltersUpdated(filters); - } - - function onRemoveAll() { - onFiltersUpdated([]); - } - const classes = classNames('globalFilterBar', props.className); + const filterBarPrefixText = i18n.translate('data.search.filterBar.filterBarPrefixText', { + defaultMessage: 'Filters: ', + }); return ( - + {useNewHeader ? ( + + {filterBarPrefixText}: + + ) : ( + + )} diff --git a/src/plugins/data/public/ui/filter_bar/filter_options.test.tsx b/src/plugins/data/public/ui/filter_bar/filter_options.test.tsx new file mode 100644 index 000000000000..f48e744528e9 --- /dev/null +++ b/src/plugins/data/public/ui/filter_bar/filter_options.test.tsx @@ -0,0 +1,188 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { FilterOptions } from './filter_options'; +import { SavedQueryAttributes } from '../../query'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { Query } from 'src/plugins/data/common'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; + +// Mock useOpenSearchDashboards hook +jest.mock('../../../../opensearch_dashboards_react/public', () => ({ + useOpenSearchDashboards: jest.fn(), + withOpenSearchDashboards: (Component: any) => (props: any) => , +})); + +const mockProps = () => ({ + savedQueryService: { + saveQuery: jest.fn(), + getAllSavedQueries: jest.fn(), + findSavedQueries: jest.fn().mockResolvedValue({ total: 0, queries: [] }), + getSavedQuery: jest.fn(), + deleteSavedQuery: jest.fn(), + getSavedQueryCount: jest.fn(), + }, + onSave: jest.fn(), + onSaveAsNew: jest.fn(), + onLoad: jest.fn(), + onClearSavedQuery: jest.fn(), + onFiltersUpdated: jest.fn(), + showSaveQuery: true, + loadedSavedQuery: { + id: '1', + attributes: { + name: 'Test Query', + title: '', + description: '', + query: { query: '', language: 'kuery' } as Query, + } as SavedQueryAttributes, + }, + filters: [ + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + }, + ], + indexPatterns: [], + useSaveQueryMenu: false, +}); + +describe('Filter options menu', () => { + beforeEach(() => { + // Mocking `uiSettings.get` to return true for `useNewHeader` + (useOpenSearchDashboards as jest.Mock).mockReturnValue({ + services: { + uiSettings: { + get: jest.fn((key) => { + if (key === 'home:useNewHomePage') { + return true; + } + return false; + }), + }, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('render menu panel', () => { + const wrapper = mountWithIntl(); + const button = wrapper.find('[data-test-subj="showFilterActions"]').at(0); + + button.simulate('click'); + expect(wrapper.find('[data-test-subj="filter-options-menu-panel"]').exists()).toBeTruthy(); + }); + + it("render filter options with 'Add filter' button", () => { + const wrapper = mountWithIntl(); + const button = wrapper.find('[data-test-subj="showFilterActions"]').at(0); + button.simulate('click'); + wrapper.update(); + const addFilterButton = wrapper.find('[data-test-subj="addFilters"]').at(0); + addFilterButton.simulate('click'); + expect(wrapper.find('[data-test-subj="add-filter-panel"]').exists()).toBeTruthy(); + }); + + it("render filter options with 'Save Query' button", () => { + const wrapper = mountWithIntl(); + const button = wrapper.find('[data-test-subj="showFilterActions"]').at(0); + button.simulate('click'); + wrapper.update(); + const saveQueryButton = wrapper + .find('[data-test-subj="saved-query-management-save-button"]') + .at(0); + expect(saveQueryButton.exists()).toBeTruthy(); + saveQueryButton.simulate('click'); + expect(wrapper.find('[data-test-subj="save-query-panel"]').exists()).toBeTruthy(); + }); + + it('should call onFiltersUpdated when enable all filters button is clicked', () => { + const props = mockProps(); + const wrapper = mountWithIntl(); + const button = wrapper.find('[data-test-subj="showFilterActions"]').at(0); + button.simulate('click'); + wrapper.update(); + const enableAllFiltersButton = wrapper.find('[data-test-subj="enableAllFilters"]').at(0); + enableAllFiltersButton.simulate('click'); + expect(props.onFiltersUpdated).toHaveBeenCalled(); + }); + + it('should call onFiltersUpdated when disable all filters button is clicked', () => { + const props = mockProps(); + const wrapper = mountWithIntl(); + const button = wrapper.find('[data-test-subj="showFilterActions"]').at(0); + button.simulate('click'); + wrapper.update(); + const disableAllFiltersButton = wrapper.find('[data-test-subj="disableAllFilters"]').at(0); + disableAllFiltersButton.simulate('click'); + expect(props.onFiltersUpdated).toHaveBeenCalled(); + }); + + it('should call onFiltersUpdated when pin all filters button is clicked', () => { + const props = mockProps(); + const wrapper = mountWithIntl(); + const button = wrapper.find('[data-test-subj="showFilterActions"]').at(0); + button.simulate('click'); + wrapper.update(); + const pinAllFiltersButton = wrapper.find('[data-test-subj="pinAllFilters"]').at(0); + pinAllFiltersButton.simulate('click'); + expect(props.onFiltersUpdated).toHaveBeenCalled(); + }); + + it('should call onFiltersUpdated when unpin all filters button is clicked', () => { + const props = mockProps(); + const wrapper = mountWithIntl(); + const button = wrapper.find('[data-test-subj="showFilterActions"]').at(0); + button.simulate('click'); + wrapper.update(); + const unpinAllFiltersButton = wrapper.find('[data-test-subj="unpinAllFilters"]').at(0); + unpinAllFiltersButton.simulate('click'); + expect(props.onFiltersUpdated).toHaveBeenCalled(); + }); + + it('should call onFiltersUpdated when Invert all filters button is clicked', () => { + const props = mockProps(); + const wrapper = mountWithIntl(); + const button = wrapper.find('[data-test-subj="showFilterActions"]').at(0); + button.simulate('click'); + wrapper.update(); + const invertAllFiltersButton = wrapper + .find('[data-test-subj="invertInclusionAllFilters"]') + .at(0); + invertAllFiltersButton.simulate('click'); + expect(props.onFiltersUpdated).toHaveBeenCalled(); + }); + + it('should call onFiltersUpdated when Invert enabled/disabled filters button is clicked', () => { + const props = mockProps(); + const wrapper = mountWithIntl(); + const button = wrapper.find('[data-test-subj="showFilterActions"]').at(0); + button.simulate('click'); + wrapper.update(); + const invertEnabledDisabledFiltersButton = wrapper + .find('[data-test-subj="invertEnableDisableAllFilters"]') + .at(0); + invertEnabledDisabledFiltersButton.simulate('click'); + expect(props.onFiltersUpdated).toHaveBeenCalled(); + }); + + it('should call onFiltersUpdated when remove all filters button is clicked', () => { + const props = mockProps(); + const wrapper = mountWithIntl(); + const button = wrapper.find('[data-test-subj="showFilterActions"]').at(0); + button.simulate('click'); + wrapper.update(); + const removeAllFiltersButton = wrapper.find('[data-test-subj="removeAllFilters"]').at(0); + removeAllFiltersButton.simulate('click'); + expect(props.onFiltersUpdated).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/data/public/ui/filter_bar/filter_options.tsx b/src/plugins/data/public/ui/filter_bar/filter_options.tsx index b61bc1804dc3..82c2be66875d 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_options.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_options.tsx @@ -29,177 +29,401 @@ */ import { - EuiSmallButtonIcon, EuiContextMenu, EuiPopover, - EuiPopoverTitle, EuiToolTip, + EuiButton, + EuiPopoverFooter, + EuiFlexGroup, + EuiFlexItem, + EuiSmallButtonEmpty, + EuiIcon, + EuiResizeObserver, + EuiContextMenuPanel, } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl, injectI18n } from '@osd/i18n/react'; -import { Component } from 'react'; +import { stringify } from '@osd/std'; +import { InjectedIntl, injectI18n } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; import React from 'react'; +import { + buildEmptyFilter, + Filter, + enableFilter, + disableFilter, + pinFilter, + toggleFilterDisabled, + toggleFilterNegated, + unpinFilter, + UI_SETTINGS, + IIndexPattern, +} from '../../../common'; +import { FilterEditor } from './filter_editor'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { SavedQueryManagementComponent } from '../saved_query_management'; +import { SavedQuery, SavedQueryService } from '../../query'; interface Props { - onEnableAll: () => void; - onDisableAll: () => void; - onPinAll: () => void; - onUnpinAll: () => void; - onToggleAllNegated: () => void; - onToggleAllDisabled: () => void; - onRemoveAll: () => void; intl: InjectedIntl; + filters: Filter[]; + indexPatterns: IIndexPattern[]; + savedQueryService: SavedQueryService; + // Show when user has privileges to save + showSaveQuery?: boolean; + onSave: () => void; + onSaveAsNew: () => void; + onLoad: (savedQuery: SavedQuery) => void; + onClearSavedQuery: () => void; + onFiltersUpdated?: (filters: Filter[]) => void; + loadedSavedQuery?: SavedQuery; + useSaveQueryMenu: boolean; } +const maxFilterWidth = 600; -interface State { - isPopoverOpen: boolean; -} +const FilterOptionsUI = (props: Props) => { + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [renderedComponent, setRenderedComponent] = React.useState('menu'); + const [filterWidth, setFilterWidth] = React.useState(maxFilterWidth); + const [showSaveQueryButton, setShowSaveQueryButton] = React.useState(true); + const opensearchDashboards = useOpenSearchDashboards(); + const uiSettings = opensearchDashboards.services.uiSettings; + const isPinned = uiSettings!.get(UI_SETTINGS.FILTERS_PINNED_BY_DEFAULT); + const useNewHeader = Boolean(uiSettings!.get(UI_SETTINGS.NEW_HOME_PAGE)); + const [indexPattern] = props.indexPatterns; + const index = indexPattern && indexPattern.id; + const newFilter = buildEmptyFilter(isPinned, index); -class FilterOptionsUI extends Component { - public state: State = { - isPopoverOpen: false, + const togglePopover = () => { + setRenderedComponent('menu'); + setShowSaveQueryButton(true); + setIsPopoverOpen((prevState) => !prevState); }; - public togglePopover = () => { - this.setState((prevState) => ({ - isPopoverOpen: !prevState.isPopoverOpen, - })); + const closePopover = () => { + setIsPopoverOpen(false); }; - public closePopover = () => { - this.setState({ isPopoverOpen: false }); + function onFiltersUpdated(filters: Filter[]) { + if (props.onFiltersUpdated) { + props.onFiltersUpdated(filters); + } + } + + function onEnableAll() { + const filters = props.filters.map(enableFilter); + onFiltersUpdated(filters); + } + + function onDisableAll() { + const filters = props.filters.map(disableFilter); + onFiltersUpdated(filters); + } + + function onPinAll() { + const filters = props.filters.map(pinFilter); + onFiltersUpdated(filters); + } + + function onUnpinAll() { + const filters = props.filters.map(unpinFilter); + onFiltersUpdated(filters); + } + + function onToggleAllNegated() { + const filters = props.filters.map(toggleFilterNegated); + onFiltersUpdated(filters); + } + + function onToggleAllDisabled() { + const filters = props.filters.map(toggleFilterDisabled); + onFiltersUpdated(filters); + } + + function onRemoveAll() { + onFiltersUpdated([]); + } + + function onAdd(filter: Filter) { + setIsPopoverOpen(false); + const filters = [...props.filters, filter]; + onFiltersUpdated(filters); + } + + function onResize(dimensions: { height: number; width: number }) { + setFilterWidth(dimensions.width); + } + + const addFilterPanelItem = { + name: props.intl.formatMessage({ + id: 'data.filter.options.addFiltersButtonLabel', + defaultMessage: 'Add filters', + }), + icon: 'plusInCircle', + onClick: () => { + setRenderedComponent('addFilter'); + setShowSaveQueryButton(false); + }, + 'data-test-subj': 'addFilters', + disabled: false, }; - public render() { - const panelTree = { + const disableMenuOption = props.filters.length === 0 && useNewHeader; + + const panelTree = [ + { id: 0, + title: 'Filters', items: [ { - name: this.props.intl.formatMessage({ + name: props.intl.formatMessage({ id: 'data.filter.options.enableAllFiltersButtonLabel', defaultMessage: 'Enable all', }), icon: 'eye', onClick: () => { - this.closePopover(); - this.props.onEnableAll(); + closePopover(); + onEnableAll(); }, 'data-test-subj': 'enableAllFilters', + disabled: disableMenuOption, }, { - name: this.props.intl.formatMessage({ + name: props.intl.formatMessage({ id: 'data.filter.options.disableAllFiltersButtonLabel', defaultMessage: 'Disable all', }), icon: 'eyeClosed', onClick: () => { - this.closePopover(); - this.props.onDisableAll(); + closePopover(); + onDisableAll(); }, 'data-test-subj': 'disableAllFilters', + disabled: disableMenuOption, }, { - name: this.props.intl.formatMessage({ + name: props.intl.formatMessage({ id: 'data.filter.options.pinAllFiltersButtonLabel', defaultMessage: 'Pin all', }), icon: 'pin', onClick: () => { - this.closePopover(); - this.props.onPinAll(); + closePopover(); + onPinAll(); }, 'data-test-subj': 'pinAllFilters', + disabled: disableMenuOption, }, { - name: this.props.intl.formatMessage({ + name: props.intl.formatMessage({ id: 'data.filter.options.unpinAllFiltersButtonLabel', defaultMessage: 'Unpin all', }), icon: 'pin', onClick: () => { - this.closePopover(); - this.props.onUnpinAll(); + closePopover(); + onUnpinAll(); }, 'data-test-subj': 'unpinAllFilters', + disabled: disableMenuOption, }, { - name: this.props.intl.formatMessage({ + name: props.intl.formatMessage({ id: 'data.filter.options.invertNegatedFiltersButtonLabel', defaultMessage: 'Invert inclusion', }), icon: 'invert', onClick: () => { - this.closePopover(); - this.props.onToggleAllNegated(); + closePopover(); + onToggleAllNegated(); }, 'data-test-subj': 'invertInclusionAllFilters', + disabled: disableMenuOption, }, { - name: this.props.intl.formatMessage({ + name: props.intl.formatMessage({ id: 'data.filter.options.invertDisabledFiltersButtonLabel', defaultMessage: 'Invert enabled/disabled', }), icon: 'eye', onClick: () => { - this.closePopover(); - this.props.onToggleAllDisabled(); + closePopover(); + onToggleAllDisabled(); }, 'data-test-subj': 'invertEnableDisableAllFilters', + disabled: disableMenuOption, }, { - name: this.props.intl.formatMessage({ + name: props.intl.formatMessage({ id: 'data.filter.options.deleteAllFiltersButtonLabel', defaultMessage: 'Remove all', }), icon: 'trash', onClick: () => { - this.closePopover(); - this.props.onRemoveAll(); + closePopover(); + onRemoveAll(); }, 'data-test-subj': 'removeAllFilters', + disabled: disableMenuOption, + className: useNewHeader ? 'globalFilterGroup__removeAllFilters' : '', }, ], - }; - - return ( - - - - } - anchorPosition="rightUp" - panelPaddingSize="none" - repositionOnScroll - > - - - - - - ); + }, + ]; + + const handleSave = () => { + if (props.onSave) { + props.onSave(); + } + setIsPopoverOpen(false); + }; + + const saveQueryPanel = ( + { + setIsPopoverOpen(false); + }} + key={'savedQueryManagement'} + />, + ]} + data-test-subj="save-query-panel" + /> + ); + + const menuPanel = ( + + ); + const addFilterPanel = ( + + {(resizeRef) => ( +
+ + setIsPopoverOpen(false)} + key={stringify(newFilter)} + /> + +
+ )} + , + ]} + data-test-subj="add-filter-panel" + /> + ); + const renderComponent = () => { + switch (renderedComponent) { + case 'menu': + return menuPanel; + case 'addFilter': + return addFilterPanel; + case 'saveQuery': + return saveQueryPanel; + } + }; + + if (useNewHeader) { + panelTree[0].items.unshift(addFilterPanelItem); } -} + + const label = i18n.translate('data.search.searchBar.savedQueryPopoverButtonText', { + defaultMessage: 'See saved queries', + }); + + const savedQueryPopoverButton = ( + + + + ); + + const filterPopoverButton = ( + + + + {useNewHeader && } + + + ); + + return ( + + {useNewHeader ? renderComponent() : props.useSaveQueryMenu ? saveQueryPanel : menuPanel} + {useNewHeader && showSaveQueryButton && ( + + + + { + setRenderedComponent('saveQuery'); + setShowSaveQueryButton(false); + }} + > + {i18n.translate('data.search.searchBar.savedQueryPopoverSaveButtonText', { + defaultMessage: 'Save query', + })} + + + + + )} + + ); +}; export const FilterOptions = injectI18n(FilterOptionsUI); diff --git a/src/plugins/data/public/ui/saved_query_management/__snapshots__/saved_query_management_component.test.tsx.snap b/src/plugins/data/public/ui/saved_query_management/__snapshots__/saved_query_management_component.test.tsx.snap deleted file mode 100644 index 4656759e60de..000000000000 --- a/src/plugins/data/public/ui/saved_query_management/__snapshots__/saved_query_management_component.test.tsx.snap +++ /dev/null @@ -1,68 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Saved query management component has a popover button 1`] = ` - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="savedQueryPopover" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - repositionOnScroll={true} -> -
- - Saved Queries - - -

- There are no saved queries. Save query text and filters that you want to use again. -

-
- - - - - - - -
-
-`; diff --git a/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.test.tsx b/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.test.tsx index a7cdb6c57bbb..8b92609d419a 100644 --- a/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.test.tsx +++ b/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.test.tsx @@ -6,6 +6,8 @@ import React from 'react'; import { SavedQueryManagementComponent } from './saved_query_management_component'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { Query } from 'src/plugins/data/common'; +import { SavedQueryAttributes } from '../../query'; const mockProps = () => ({ savedQueryService: { @@ -20,15 +22,54 @@ const mockProps = () => ({ onSaveAsNew: jest.fn(), onLoad: jest.fn(), onClearSavedQuery: jest.fn(), + closeMenuPopover: jest.fn(), + showSaveQuery: true, + loadedSavedQuery: { + id: '1', + attributes: { + name: 'Test Query', + title: '', + description: '', + query: { query: '', language: 'kuery' } as Query, + } as SavedQueryAttributes, + }, }); describe('Saved query management component', () => { - it('has a popover button', () => { - const props = { - ...mockProps(), - }; - const component = shallowWithIntl(); - const savedQueryPopoverButton = component.find('#savedQueryPopover'); - expect(savedQueryPopoverButton).toMatchSnapshot(); + it('should render without errors', () => { + const props = mockProps(); + const wrapper = shallowWithIntl(); + expect(wrapper.exists()).toBe(true); + }); + + it('should call onSave when save button is clicked', () => { + const props = mockProps(); + const wrapper = shallowWithIntl(); + const saveButton = wrapper + .find('[data-test-subj="saved-query-management-save-changes-button"]') + .at(0); + saveButton.simulate('click'); + expect(props.onSave).toHaveBeenCalled(); + expect(props.closeMenuPopover).toHaveBeenCalled(); + }); + + it('should call onSaveAsNew when save as new button is clicked', () => { + const props = mockProps(); + const wrapper = shallowWithIntl(); + const saveAsNewButton = wrapper + .find('[data-test-subj="saved-query-management-save-as-new-button"]') + .at(0); + saveAsNewButton.simulate('click'); + expect(props.onSaveAsNew).toHaveBeenCalled(); + }); + + it('should call onClearSavedQuery when clear saved query button is clicked', () => { + const props = mockProps(); + const wrapper = shallowWithIntl(); + const clearSavedQueryButton = wrapper.find( + '[data-test-subj="saved-query-management-clear-button"]' + ); + clearSavedQueryButton.simulate('click'); + expect(props.onClearSavedQuery).toHaveBeenCalled(); }); }); diff --git a/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.tsx b/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.tsx index 8504ec858b0d..3048554fe2fc 100644 --- a/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.tsx +++ b/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.tsx @@ -29,10 +29,8 @@ */ import { - EuiPopover, EuiPopoverTitle, EuiPopoverFooter, - EuiSmallButtonEmpty, EuiButtonEmpty, EuiButton, EuiFlexGroup, @@ -41,7 +39,6 @@ import { EuiPagination, EuiText, EuiSpacer, - EuiIcon, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; @@ -59,6 +56,7 @@ interface Props { onSaveAsNew: () => void; onLoad: (savedQuery: SavedQuery) => void; onClearSavedQuery: () => void; + closeMenuPopover: () => void; } export function SavedQueryManagementComponent({ @@ -69,8 +67,8 @@ export function SavedQueryManagementComponent({ onLoad, onClearSavedQuery, savedQueryService, + closeMenuPopover, }: Props) { - const [isOpen, setIsOpen] = useState(false); const [savedQueries, setSavedQueries] = useState([] as SavedQuery[]); const [count, setTotalCount] = useState(0); const [activePage, setActivePage] = useState(0); @@ -95,16 +93,12 @@ export function SavedQueryManagementComponent({ setTotalCount(savedQueryCount); setSavedQueries(sortedSavedQueryItems); }; - if (isOpen) { - fetchCountAndSavedQueries(); - } - }, [isOpen, activePage, savedQueryService]); - - const handleTogglePopover = useCallback(() => setIsOpen((currentState) => !currentState), [ - setIsOpen, - ]); + fetchCountAndSavedQueries(); + }, [activePage, savedQueryService]); - const handleClosePopover = useCallback(() => setIsOpen(false), []); + const handleClosePopover = useCallback(() => { + closeMenuPopover(); + }, [closeMenuPopover]); const handleSave = useCallback(() => { handleClosePopover(); @@ -170,21 +164,6 @@ export function SavedQueryManagementComponent({ const goToPage = (pageNumber: number) => { setActivePage(pageNumber); }; - const label = i18n.translate('data.search.searchBar.savedQueryPopoverButtonText', { - defaultMessage: 'See saved queries', - }); - - const savedQueryPopoverButton = ( - - - - ); const savedQueryRows = () => { const savedQueriesWithoutCurrent = savedQueries.filter((savedQuery) => { @@ -208,150 +187,130 @@ export function SavedQueryManagementComponent({ }; return ( - - -
+ + {savedQueryPopoverTitleText} + + {savedQueries.length > 0 ? ( + + +

{savedQueryDescriptionText}

+
+
+ + {savedQueryRows()} + +
+ +
+ ) : ( + + +

{noSavedQueriesDescriptionText}

+
+ +
+ )} + + - - {savedQueryPopoverTitleText} - - {savedQueries.length > 0 ? ( + {showSaveQuery && loadedSavedQuery && ( - -

{savedQueryDescriptionText}

-
-
- + - {savedQueryRows()} - -
- -
- ) : ( - - -

{noSavedQueriesDescriptionText}

-
- + {i18n.translate('data.search.searchBar.savedQueryPopoverSaveChangesButtonText', { + defaultMessage: 'Save changes', + })} + + + + + {i18n.translate('data.search.searchBar.savedQueryPopoverSaveAsNewButtonText', { + defaultMessage: 'Save as new', + })} + +
)} - - - {showSaveQuery && loadedSavedQuery && ( - - - - {i18n.translate( - 'data.search.searchBar.savedQueryPopoverSaveChangesButtonText', - { - defaultMessage: 'Save changes', - } - )} - - - - - {i18n.translate( - 'data.search.searchBar.savedQueryPopoverSaveAsNewButtonText', - { - defaultMessage: 'Save as new', - } - )} - - - - )} - {showSaveQuery && !loadedSavedQuery && ( - - - {i18n.translate('data.search.searchBar.savedQueryPopoverSaveButtonText', { - defaultMessage: 'Save current query', - })} - - - )} - - - {loadedSavedQuery && ( - - {i18n.translate('data.search.searchBar.savedQueryPopoverClearButtonText', { - defaultMessage: 'Clear', - })} - + {showSaveQuery && !loadedSavedQuery && ( + + - - -
-
-
+ data-test-subj="saved-query-management-save-button" + > + {i18n.translate('data.search.searchBar.savedQueryPopoverSaveButtonText', { + defaultMessage: 'Save current query', + })} + +
+ )} + + + {loadedSavedQuery && ( + + {i18n.translate('data.search.searchBar.savedQueryPopoverClearButtonText', { + defaultMessage: 'Clear', + })} + + )} + +
+ +
); } diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index 3a83af415f59..3db269074015 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -33,7 +33,6 @@ import classNames from 'classnames'; import { compact, get, isEqual } from 'lodash'; import React, { Component } from 'react'; import ResizeObserver from 'resize-observer-polyfill'; -import { DataSource } from '../..'; import { OpenSearchDashboardsReactContextValue, withOpenSearchDashboards, @@ -45,8 +44,8 @@ import { FilterBar } from '../filter_bar/filter_bar'; import { QueryEditorTopRow } from '../query_editor'; import QueryBarTopRow from '../query_string_input/query_bar_top_row'; import { SavedQueryMeta, SaveQueryForm } from '../saved_query_form'; -import { SavedQueryManagementComponent } from '../saved_query_management'; import { Settings } from '../types'; +import { FilterOptions } from '../filter_bar/filter_options'; interface SearchBarInjectedDeps { opensearchDashboards: OpenSearchDashboardsReactContextValue; @@ -123,6 +122,7 @@ class SearchBarUI extends Component { private savedQueryService = this.services.data.query.savedQueries; public filterBarRef: Element | null = null; public filterBarWrapperRef: Element | null = null; + private useNewHeader = Boolean(this.services.uiSettings.get(UI_SETTINGS.NEW_HOME_PAGE)); public static getDerivedStateFromProps(nextProps: SearchBarProps, prevState: State) { if (isEqual(prevState.currentProps, nextProps)) { @@ -233,6 +233,7 @@ class SearchBarUI extends Component { return ( this.props.showFilterBar && this.props.filters && + (!this.useNewHeader || this.props.filters.length > 0) && this.props.indexPatterns && compact(this.props.indexPatterns).length > 0 && (this.props.settings?.getQueryEnhancements(this.state.query?.language!)?.searchBar @@ -412,17 +413,27 @@ class SearchBarUI extends Component { ); this.props.settings?.setUserQueryEnhancementsEnabled(isEnhancementsEnabledOverride); - const savedQueryManagement = this.state.query && this.props.onClearSavedQuery && ( - - ); + const searchBarMenu = (useSaveQueryMenu: boolean = false) => { + return ( + this.state.query && + this.props.onClearSavedQuery && ( + + ) + ); + }; let filterBar; if (this.shouldRenderFilterBar()) { @@ -464,7 +475,7 @@ class SearchBarUI extends Component { onSubmit={this.onQueryBarSubmit} indexPatterns={this.props.indexPatterns} isLoading={this.props.isLoading} - prepend={this.props.showFilterBar ? savedQueryManagement : undefined} + prepend={this.props.showFilterBar ? searchBarMenu(!this.useNewHeader) : undefined} showDatePicker={this.props.showDatePicker} dateRangeFrom={this.state.dateRangeFrom} dateRangeTo={this.state.dateRangeTo} @@ -498,7 +509,7 @@ class SearchBarUI extends Component { onSubmit={this.onQueryBarSubmit} indexPatterns={this.props.indexPatterns} isLoading={this.props.isLoading} - prepend={this.props.showFilterBar ? savedQueryManagement : undefined} + prepend={this.props.showFilterBar ? searchBarMenu(!this.useNewHeader) : undefined} showDatePicker={this.props.showDatePicker} dateRangeFrom={this.state.dateRangeFrom} dateRangeTo={this.state.dateRangeTo} From 21b8a2fa772dfd3b7ca718c5d95877f2c9699421 Mon Sep 17 00:00:00 2001 From: Anan Zhuang Date: Thu, 15 Aug 2024 13:05:54 -0700 Subject: [PATCH 11/14] Change the locale dynamically by adding &i18n-locale to URL (#7686) * Change the locale dynamically by adding &i18n-locale to URL The main issue was the inability to dynamically change the locale in OpenSearch Dashboards. Currently we need to update config file and i18nrc.json. This PR allows users to switch to a different locale (e.g., from English to Chinese) by appending or modifying the 'i18n-locale' parameter in the URL. * getAndUpdateLocaleInUrl: If a non-default locale is found, this function reconstructs the URL with the locale parameter in the correct position. * updated the ScopedHistory class, allowing it to detect locale changes and trigger reloads as necessary. * modify the i18nMixin, which sets up the i18n system during server startup, to register all available translation files during server startup, not just the current locale. * update the uiRenderMixin to accept requests for any registered locale and dynamically load and cache translations for requested locales. Signed-off-by: Anan Zhuang * fix PR comments Signed-off-by: Anan Zhuang * fix comments 2 Signed-off-by: Anan Zhuang * fix tests Signed-off-by: Anan Zhuang * Changeset file for PR #7686 created/updated --------- Signed-off-by: Anan Zhuang Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7686.yml | 2 + packages/osd-i18n/src/core/i18n.test.ts | 34 +++- packages/osd-i18n/src/core/i18n.ts | 9 +- .../public/application/scoped_history.test.ts | 60 ++++++ src/core/public/application/scoped_history.ts | 11 ++ src/core/public/locale_helper.test.ts | 50 +++++ src/core/public/locale_helper.ts | 68 +++++++ src/core/public/osd_bootstrap.test.mocks.ts | 28 +-- src/core/public/osd_bootstrap.test.ts | 66 ++++++- src/core/public/osd_bootstrap.ts | 44 +++++ src/dev/jest/config.js | 1 - src/legacy/server/i18n/index.ts | 11 +- src/legacy/ui/ui_render/ui_render_mixin.js | 67 +++++-- .../ui/ui_render/ui_render_mixin.test.js | 175 ++++++++++++++++++ 14 files changed, 586 insertions(+), 40 deletions(-) create mode 100644 changelogs/fragments/7686.yml create mode 100644 src/core/public/locale_helper.test.ts create mode 100644 src/core/public/locale_helper.ts create mode 100644 src/legacy/ui/ui_render/ui_render_mixin.test.js diff --git a/changelogs/fragments/7686.yml b/changelogs/fragments/7686.yml new file mode 100644 index 000000000000..f446ed68764c --- /dev/null +++ b/changelogs/fragments/7686.yml @@ -0,0 +1,2 @@ +feat: +- Change the locale dynamically by adding &i18n-locale to URL ([#7686](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7686)) \ No newline at end of file diff --git a/packages/osd-i18n/src/core/i18n.test.ts b/packages/osd-i18n/src/core/i18n.test.ts index 0ee114c78c95..ebfd546f8561 100644 --- a/packages/osd-i18n/src/core/i18n.test.ts +++ b/packages/osd-i18n/src/core/i18n.test.ts @@ -899,8 +899,17 @@ describe('I18n engine', () => { describe('load', () => { let mockFetch: jest.SpyInstance; + let originalWindow: any; + beforeEach(() => { mockFetch = jest.spyOn(global as any, 'fetch').mockImplementation(); + originalWindow = global.window; + global.window = { ...originalWindow }; + }); + + afterEach(() => { + global.window = originalWindow; + delete (window as any).__i18nWarning; // Clear the warning after each test }); test('fails if server returns >= 300 status code', async () => { @@ -928,7 +937,7 @@ describe('I18n engine', () => { mockFetch.mockResolvedValue({ status: 200, - json: jest.fn().mockResolvedValue(translations), + json: jest.fn().mockResolvedValue({ translations }), }); await expect(i18n.load('some-url')).resolves.toBeUndefined(); @@ -938,5 +947,28 @@ describe('I18n engine', () => { expect(i18n.getTranslation()).toEqual(translations); }); + + test('sets warning on window when present in response', async () => { + const warning = { title: 'Warning', text: 'This is a warning' }; + mockFetch.mockResolvedValue({ + status: 200, + json: jest.fn().mockResolvedValue({ translations: { locale: 'en' }, warning }), + }); + + await i18n.load('some-url'); + + expect((window as any).__i18nWarning).toEqual(warning); + }); + + test('does not set warning on window when not present in response', async () => { + mockFetch.mockResolvedValue({ + status: 200, + json: jest.fn().mockResolvedValue({ translations: { locale: 'en' } }), + }); + + await i18n.load('some-url'); + + expect((window as any).__i18nWarning).toBeUndefined(); + }); }); }); diff --git a/packages/osd-i18n/src/core/i18n.ts b/packages/osd-i18n/src/core/i18n.ts index 3268fae5079f..65da4931ef13 100644 --- a/packages/osd-i18n/src/core/i18n.ts +++ b/packages/osd-i18n/src/core/i18n.ts @@ -261,5 +261,12 @@ export async function load(translationsUrl: string) { throw new Error(`Translations request failed with status code: ${response.status}`); } - init(await response.json()); + const data = await response.json(); + + if (data.warning) { + // Store the warning to be displayed after core system setup + (window as any).__i18nWarning = data.warning; + } + + init(data.translations); } diff --git a/src/core/public/application/scoped_history.test.ts b/src/core/public/application/scoped_history.test.ts index 067c33256bd1..6575a6aa1ab1 100644 --- a/src/core/public/application/scoped_history.test.ts +++ b/src/core/public/application/scoped_history.test.ts @@ -30,8 +30,23 @@ import { ScopedHistory } from './scoped_history'; import { createMemoryHistory } from 'history'; +import { getLocaleInUrl } from '../locale_helper'; +import { i18n } from '@osd/i18n'; + +jest.mock('../locale_helper', () => ({ + getLocaleInUrl: jest.fn(), +})); + +jest.mock('@osd/i18n', () => ({ + i18n: { + getLocale: jest.fn(), + }, +})); describe('ScopedHistory', () => { + beforeEach(() => { + (getLocaleInUrl as jest.Mock).mockReturnValue('en'); + }); describe('construction', () => { it('succeeds if current location matches basePath', () => { const gh = createMemoryHistory(); @@ -358,4 +373,49 @@ describe('ScopedHistory', () => { expect(gh.length).toBe(4); }); }); + + describe('locale handling', () => { + let originalLocation: Location; + + beforeEach(() => { + originalLocation = window.location; + delete (window as any).location; + window.location = { href: 'http://localhost/app/wow', reload: jest.fn() } as any; + (i18n.getLocale as jest.Mock).mockReturnValue('en'); + }); + + afterEach(() => { + window.location = originalLocation; + jest.resetAllMocks(); + }); + + it('reloads the page when locale changes', () => { + const gh = createMemoryHistory(); + gh.push('/app/wow'); + const h = new ScopedHistory(gh, '/app/wow'); + // Use the 'h' variable to trigger the listener + h.push('/new-page'); + + // Mock getLocaleInUrl to return a different locale + (getLocaleInUrl as jest.Mock).mockReturnValue('fr'); + + // Simulate navigation + gh.push('/app/wow/new-page'); + + expect(window.location.reload).toHaveBeenCalled(); + }); + + it('does not reload the page when locale changes', () => { + const gh = createMemoryHistory(); + gh.push('/app/wow'); + + // Mock getLocaleInUrl to return a different locale + (getLocaleInUrl as jest.Mock).mockReturnValue('en'); + + // Simulate navigation + gh.push('/app/wow/new-page'); + + expect(window.location.reload).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/core/public/application/scoped_history.ts b/src/core/public/application/scoped_history.ts index 487093be191f..74e4bb068d3e 100644 --- a/src/core/public/application/scoped_history.ts +++ b/src/core/public/application/scoped_history.ts @@ -39,6 +39,8 @@ import { Href, Action, } from 'history'; +import { i18n } from '@osd/i18n'; +import { getLocaleInUrl } from '../locale_helper'; /** * A wrapper around a `History` instance that is scoped to a particular base path of the history stack. Behaves @@ -307,6 +309,7 @@ export class ScopedHistory * state. Also forwards events to child listeners with the base path stripped from the location. */ private setupHistoryListener() { + const currentLocale = i18n.getLocale() || 'en'; const unlisten = this.parentHistory.listen((location, action) => { // If the user navigates outside the scope of this basePath, tear it down. if (!location.pathname.startsWith(this.basePath)) { @@ -315,6 +318,14 @@ export class ScopedHistory return; } + const localeValue = getLocaleInUrl(window.location.href); + + if (localeValue !== currentLocale) { + // Force a full page reload + window.location.reload(); + return; + } + /** * Track location keys using the same algorithm the browser uses internally. * - On PUSH, remove all items that came after the current location and append the new location. diff --git a/src/core/public/locale_helper.test.ts b/src/core/public/locale_helper.test.ts new file mode 100644 index 000000000000..238dbced3892 --- /dev/null +++ b/src/core/public/locale_helper.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getLocaleInUrl } from './locale_helper'; + +describe('getLocaleInUrl', () => { + beforeEach(() => { + // Clear any warnings before each test + delete (window as any).__localeWarning; + }); + + it('should return the locale from a valid query string', () => { + const url = 'http://localhost:5603/app/home?locale=en-US'; + expect(getLocaleInUrl(url)).toBe('en-US'); + }); + + it('should return the locale from a valid hash query string', () => { + const url = 'http://localhost:5603/app/home#/?locale=fr-FR'; + expect(getLocaleInUrl(url)).toBe('fr-FR'); + }); + + it('should return en for a URL without locale', () => { + const url = 'http://localhost:5603/app/home'; + expect(getLocaleInUrl(url)).toBe('en'); + }); + + it('should return en and set a warning for an invalid locale format in hash', () => { + const url = 'http://localhost:5603/app/home#/&locale=de-DE'; + expect(getLocaleInUrl(url)).toBe('en'); + expect((window as any).__localeWarning).toBeDefined(); + expect((window as any).__localeWarning.title).toBe('Invalid URL Format'); + }); + + it('should return en for an empty locale value', () => { + const url = 'http://localhost:5603/app/home?locale='; + expect(getLocaleInUrl(url)).toBe('en'); + }); + + it('should handle URLs with other query parameters', () => { + const url = 'http://localhost:5603/app/home?param1=value1&locale=ja-JP¶m2=value2'; + expect(getLocaleInUrl(url)).toBe('ja-JP'); + }); + + it('should handle URLs with other hash parameters', () => { + const url = 'http://localhost:5603/app/home#/route?param1=value1&locale=zh-CN¶m2=value2'; + expect(getLocaleInUrl(url)).toBe('zh-CN'); + }); +}); diff --git a/src/core/public/locale_helper.ts b/src/core/public/locale_helper.ts new file mode 100644 index 000000000000..38a734a523b1 --- /dev/null +++ b/src/core/public/locale_helper.ts @@ -0,0 +1,68 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Extracts the locale value from a given URL. + * + * This function looks for the 'locale' parameter in either the main query string + * or in the hash part of the URL. It supports two valid formats: + * 1. As a regular query parameter: "?locale=xx-XX" + * 2. In the hash with a proper query string: "#/?locale=xx-XX" + * + * If an invalid format is detected, it sets a warning message on the window object. + * + * @param url - The URL to extract the locale from + * @returns The locale value if found and valid, or null otherwise + */ +export function getLocaleInUrl(url: string): string | null { + let urlObject: URL; + // Attempt to parse the URL, return null if invalid + try { + urlObject = new URL(url, window.location.origin); + } catch (error) { + setInvalidUrlWarning(); + return null; + } + + let localeValue: string | null = null; + + // Check for locale in the main query string + if (urlObject.searchParams.has('locale')) { + localeValue = urlObject.searchParams.get('locale'); + } + // Check for locale in the hash, but only if it's in proper query string format + else if (urlObject.hash.includes('?')) { + const hashParams = new URLSearchParams(urlObject.hash.split('?')[1]); + if (hashParams.has('locale')) { + localeValue = hashParams.get('locale'); + } + } + + // Check for non standard query format: + if (localeValue === null && url.includes('&locale=')) { + setInvalidUrlWithLocaleWarning(); + return 'en'; + } + + // Return the locale value if found, or 'en' if not found + return localeValue && localeValue.trim() !== '' ? localeValue : 'en'; +} + +function setInvalidUrlWarning(): void { + (window as any).__localeWarning = { + title: 'Invalid URL Format', + text: 'The provided URL is not in a valid format.', + }; +} + +function setInvalidUrlWithLocaleWarning(): void { + (window as any).__localeWarning = { + title: 'Invalid URL Format', + text: + 'The locale parameter is not in a valid URL format. ' + + 'Use either "?locale=xx-XX" in the main URL or "#/?locale=xx-XX" in the hash. ' + + 'For example: "yourapp.com/page?locale=en-US" or "yourapp.com/page#/?locale=en-US".', + }; +} diff --git a/src/core/public/osd_bootstrap.test.mocks.ts b/src/core/public/osd_bootstrap.test.mocks.ts index 77b47e8b895b..87a6ab499731 100644 --- a/src/core/public/osd_bootstrap.test.mocks.ts +++ b/src/core/public/osd_bootstrap.test.mocks.ts @@ -31,18 +31,6 @@ import { applicationServiceMock } from './application/application_service.mock'; import { fatalErrorsServiceMock } from './fatal_errors/fatal_errors_service.mock'; export const fatalErrorMock = fatalErrorsServiceMock.createSetupContract(); -export const coreSystemMock = { - setup: jest.fn().mockResolvedValue({ - fatalErrors: fatalErrorMock, - }), - start: jest.fn().mockResolvedValue({ - application: applicationServiceMock.createInternalStartContract(), - }), -}; -jest.doMock('./core_system', () => ({ - CoreSystem: jest.fn().mockImplementation(() => coreSystemMock), -})); - export const apmSystem = { setup: jest.fn().mockResolvedValue(undefined), start: jest.fn().mockResolvedValue(undefined), @@ -53,9 +41,25 @@ jest.doMock('./apm_system', () => ({ })); export const i18nLoad = jest.fn().mockResolvedValue(undefined); +export const i18nSetLocale = jest.fn(); jest.doMock('@osd/i18n', () => ({ i18n: { ...jest.requireActual('@osd/i18n').i18n, load: i18nLoad, + setLocale: i18nSetLocale, }, })); + +export const coreSystemMock = { + setup: jest.fn().mockResolvedValue({ + fatalErrors: fatalErrorMock, + }), + start: jest.fn().mockResolvedValue({ + application: applicationServiceMock.createInternalStartContract(), + }), +}; +jest.doMock('./core_system', () => ({ + CoreSystem: jest.fn().mockImplementation(() => coreSystemMock), +})); + +export const consoleWarnMock = jest.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/src/core/public/osd_bootstrap.test.ts b/src/core/public/osd_bootstrap.test.ts index e4209b460f86..7630b4441ab6 100644 --- a/src/core/public/osd_bootstrap.test.ts +++ b/src/core/public/osd_bootstrap.test.ts @@ -28,23 +28,46 @@ * under the License. */ -import { apmSystem, fatalErrorMock, i18nLoad } from './osd_bootstrap.test.mocks'; +import { + apmSystem, + fatalErrorMock, + i18nLoad, + i18nSetLocale, + consoleWarnMock, +} from './osd_bootstrap.test.mocks'; import { __osdBootstrap__ } from './'; +import { getLocaleInUrl } from './locale_helper'; + +jest.mock('./locale_helper', () => ({ + getLocaleInUrl: jest.fn(), +})); describe('osd_bootstrap', () => { + let originalWindowLocation: Location; + beforeAll(() => { const metadata = { branding: { darkMode: 'true' }, - i18n: { translationsUrl: 'http://localhost' }, + i18n: { translationsUrl: 'http://localhost/translations/en.json' }, vars: { apmConfig: null }, }; // eslint-disable-next-line no-unsanitized/property - document.body.innerHTML = ` -`; + document.body.innerHTML = ` `; + + originalWindowLocation = window.location; + delete (window as any).location; + window.location = { ...originalWindowLocation, href: 'http://localhost' }; }); beforeEach(() => { jest.clearAllMocks(); + (getLocaleInUrl as jest.Mock).mockReturnValue(null); + }); + + afterAll(() => { + window.location = originalWindowLocation; }); it('does not report a fatal error if apm load fails', async () => { @@ -64,4 +87,39 @@ describe('osd_bootstrap', () => { expect(fatalErrorMock.add).toHaveBeenCalledTimes(1); }); + + it('sets locale from URL if present', async () => { + (getLocaleInUrl as jest.Mock).mockReturnValue('fr'); + window.location.href = 'http://localhost/?locale=fr'; + + await __osdBootstrap__(); + + expect(i18nSetLocale).toHaveBeenCalledWith('fr'); + expect(i18nLoad).toHaveBeenCalledWith('http://localhost/translations/fr.json'); + }); + + it('sets default locale if not present in URL', async () => { + await __osdBootstrap__(); + + expect(i18nSetLocale).toHaveBeenCalledWith('en'); + expect(i18nLoad).toHaveBeenCalledWith('http://localhost/translations/en.json'); + }); + + it('displays locale warning if set', async () => { + (window as any).__localeWarning = { title: 'Locale Warning', text: 'Invalid locale' }; + + await __osdBootstrap__(); + + expect(consoleWarnMock).toHaveBeenCalledWith('Locale Warning: Invalid locale'); + expect((window as any).__localeWarning).toBeUndefined(); + }); + + it('displays i18n warning if set', async () => { + (window as any).__i18nWarning = { title: 'i18n Warning', text: 'Translation issue' }; + + await __osdBootstrap__(); + + expect(consoleWarnMock).toHaveBeenCalledWith('i18n Warning: Translation issue'); + expect((window as any).__i18nWarning).toBeUndefined(); + }); }); diff --git a/src/core/public/osd_bootstrap.ts b/src/core/public/osd_bootstrap.ts index ed64ed0bc2b5..5190fdc37e21 100644 --- a/src/core/public/osd_bootstrap.ts +++ b/src/core/public/osd_bootstrap.ts @@ -31,6 +31,7 @@ import { i18n } from '@osd/i18n'; import { CoreSystem } from './core_system'; import { ApmSystem } from './apm_system'; +import { getLocaleInUrl } from './locale_helper'; /** @internal */ export async function __osdBootstrap__() { @@ -38,6 +39,33 @@ export async function __osdBootstrap__() { document.querySelector('osd-injected-metadata')!.getAttribute('data')! ); + // Extract the locale from the URL if present + const currentLocale = i18n.getLocale(); + const urlLocale = getLocaleInUrl(window.location.href); + + if (urlLocale && urlLocale !== currentLocale) { + // If a locale is specified in the URL, update the i18n settings + // This enables dynamic language switching + // Note: This works in conjunction with server-side changes: + // 1. The server registers all available translation files at startup + // 2. A server route handles requests for specific locale translations + + // Set the locale in the i18n core + // This will affect all subsequent i18n.translate() calls + i18n.setLocale(urlLocale); + + // Modify the translationsUrl to include the new locale + // This ensures that the correct translation file is requested from the server + // The replace function changes the locale in the URL, e.g., + // from '/translations/en.json' to '/translations/zh-CN.json' + injectedMetadata.i18n.translationsUrl = injectedMetadata.i18n.translationsUrl.replace( + /\/([^/]+)\.json$/, + `/${urlLocale}.json` + ); + } else if (!urlLocale) { + i18n.setLocale('en'); + } + const globals: any = typeof window === 'undefined' ? {} : window; const themeTag: string = globals.__osdThemeTag__ || ''; @@ -67,4 +95,20 @@ export async function __osdBootstrap__() { const start = await coreSystem.start(); await apmSystem.start(start); + + // Display the i18n warning if it exists + if ((window as any).__i18nWarning) { + const warning = (window as any).__i18nWarning; + // eslint-disable-next-line no-console + console.warn(`${warning.title}: ${warning.text}`); + delete (window as any).__i18nWarning; + } + + // Display the locale warning if it exists + if ((window as any).__localeWarning) { + const warning = (window as any).__localeWarning; + // eslint-disable-next-line no-console + console.warn(`${warning.title}: ${warning.text}`); + delete (window as any).__localeWarning; + } } diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js index c76cb7bfd343..112249457049 100644 --- a/src/dev/jest/config.js +++ b/src/dev/jest/config.js @@ -122,7 +122,6 @@ const ciGroups = process.argv.reduce((acc, arg) => { return acc; }, []); -console.log('ciGroups', ciGroups); if (ciGroups.length > 0) { console.log(`Requested group${ciGroups.length === 1 ? '' : 's'}: ${ciGroups.join(', ')}`); ciGroups.forEach((id) => { diff --git a/src/legacy/server/i18n/index.ts b/src/legacy/server/i18n/index.ts index e30f9bf7d72b..8a571144cdf5 100644 --- a/src/legacy/server/i18n/index.ts +++ b/src/legacy/server/i18n/index.ts @@ -62,10 +62,11 @@ export async function i18nMixin( }), ]); - const currentTranslationPaths = ([] as string[]) - .concat(...translationPaths) - .filter((translationPath) => basename(translationPath, '.json') === locale); - i18nLoader.registerTranslationFiles(currentTranslationPaths); + // Flatten the array of arrays + const allTranslationPaths = ([] as string[]).concat(...translationPaths); + + // Register all translation files, not just the ones for the current locale + i18nLoader.registerTranslationFiles(allTranslationPaths); const translations = await i18nLoader.getTranslationsByLocale(locale); i18n.init( @@ -75,7 +76,7 @@ export async function i18nMixin( }) ); - const getTranslationsFilePaths = () => currentTranslationPaths; + const getTranslationsFilePaths = () => allTranslationPaths; server.decorate('server', 'getTranslationsFilePaths', getTranslationsFilePaths); diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index 053f5dbdfca2..fbbfbff91f44 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -30,7 +30,7 @@ import { createHash } from 'crypto'; import Boom from '@hapi/boom'; -import { i18n } from '@osd/i18n'; +import { i18n, i18nLoader } from '@osd/i18n'; import * as v7light from '@elastic/eui/dist/eui_theme_light.json'; import * as v7dark from '@elastic/eui/dist/eui_theme_dark.json'; import * as v8light from '@elastic/eui/dist/eui_theme_next_light.json'; @@ -53,32 +53,67 @@ import { getApmConfig } from '../apm'; */ export function uiRenderMixin(osdServer, server, config) { const translationsCache = { translations: null, hash: null }; + const defaultLocale = i18n.getLocale() || 'en'; // Fallback to 'en' if no default locale is set + + // Route handler for serving translation files. + // This handler supports two scenarios: + // 1. Serving translations for the default locale + // 2. Serving translations for other registered locales server.route({ path: '/translations/{locale}.json', method: 'GET', config: { auth: false }, - handler(request, h) { - // OpenSearch Dashboards server loads translations only for a single locale - // that is specified in `i18n.locale` config value. + handler: async (request, h) => { const { locale } = request.params; - if (i18n.getLocale() !== locale.toLowerCase()) { - throw Boom.notFound(`Unknown locale: ${locale}`); - } + const normalizedLocale = locale.toLowerCase(); + const registeredLocales = i18nLoader.getRegisteredLocales().map((l) => l.toLowerCase()); + let warning = null; + + // Function to get or create cached translations + const getCachedTranslations = async (localeKey, getTranslationsFn) => { + if (!translationsCache[localeKey]) { + const translations = await getTranslationsFn(); + translationsCache[localeKey] = { + translations: translations, + hash: createHash('sha1').update(JSON.stringify(translations)).digest('hex'), + }; + } + return translationsCache[localeKey]; + }; + + let cachedTranslations; - // Stringifying thousands of labels and calculating hash on the resulting - // string can be expensive so it makes sense to do it once and cache. - if (translationsCache.translations == null) { - translationsCache.translations = JSON.stringify(i18n.getTranslation()); - translationsCache.hash = createHash('sha1') - .update(translationsCache.translations) - .digest('hex'); + if (normalizedLocale === defaultLocale.toLowerCase()) { + // Default locale + cachedTranslations = await getCachedTranslations(defaultLocale, () => + i18n.getTranslation() + ); + } else if (registeredLocales.includes(normalizedLocale)) { + // Other registered locales + cachedTranslations = await getCachedTranslations(normalizedLocale, () => + i18nLoader.getTranslationsByLocale(locale) + ); + } else { + // Locale not found, fall back to en locale + cachedTranslations = await getCachedTranslations('en', () => + i18nLoader.getTranslationsByLocale('en') + ); + warning = { + title: 'Unsupported Locale', + text: `The requested locale "${locale}" is not supported. Falling back to English.`, + }; } + const response = { + translations: cachedTranslations.translations, + warning, + }; + return h - .response(translationsCache.translations) + .response(response) .header('cache-control', 'must-revalidate') .header('content-type', 'application/json') - .etag(translationsCache.hash); + .etag(cachedTranslations.hash); }, }); diff --git a/src/legacy/ui/ui_render/ui_render_mixin.test.js b/src/legacy/ui/ui_render/ui_render_mixin.test.js new file mode 100644 index 000000000000..81f8a9f9a696 --- /dev/null +++ b/src/legacy/ui/ui_render/ui_render_mixin.test.js @@ -0,0 +1,175 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { uiRenderMixin } from './ui_render_mixin'; + +// Mock dependencies +jest.mock('@osd/i18n', () => ({ + i18n: { + getLocale: jest.fn(), + getTranslation: jest.fn(), + translate: jest.fn((key, { defaultMessage }) => defaultMessage), + }, + i18nLoader: { + getRegisteredLocales: jest.fn(), + getTranslationsByLocale: jest.fn(), + }, +})); + +// Import mocked modules +const { i18n, i18nLoader } = require('@osd/i18n'); + +describe('uiRenderMixin', () => { + let server; + let osdServer; + let config; + let routes; + let decorations; + + beforeEach(() => { + routes = []; + decorations = {}; + server = { + route: jest.fn((route) => routes.push(route)), + decorate: jest.fn((type, name, value) => { + decorations[`${type}.${name}`] = value; + }), + auth: { settings: { default: false } }, + }; + osdServer = { + newPlatform: { + setup: { + core: { + http: { csp: { header: 'test-csp-header' } }, + }, + }, + start: { + core: { + savedObjects: { + getScopedClient: jest.fn(), + }, + uiSettings: { + asScopedToClient: jest.fn(), + }, + }, + }, + __internals: { + rendering: { + render: jest.fn(), + }, + }, + }, + }; + config = { + get: jest.fn(), + }; + + // Reset mocks + jest.clearAllMocks(); + }); + + describe('translations route', () => { + let handler; + let h; + + beforeEach(() => { + uiRenderMixin(osdServer, server, config); + handler = routes.find((route) => route.path === '/translations/{locale}.json').handler; + h = { + response: jest.fn().mockReturnThis(), + header: jest.fn().mockReturnThis(), + etag: jest.fn().mockReturnThis(), + }; + }); + + it('should handle default locale', async () => { + const defaultLocale = 'en'; + const defaultTranslations = { hello: 'Hello' }; + i18n.getLocale.mockReturnValue(defaultLocale); + i18n.getTranslation.mockReturnValue(defaultTranslations); + i18nLoader.getRegisteredLocales.mockReturnValue([defaultLocale]); + + const request = { params: { locale: defaultLocale } }; + await handler(request, h); + + expect(i18n.getTranslation).toHaveBeenCalled(); + expect(h.response).toHaveBeenCalledWith({ + translations: defaultTranslations, + warning: null, + }); + expect(h.header).toHaveBeenCalledWith('cache-control', 'must-revalidate'); + expect(h.header).toHaveBeenCalledWith('content-type', 'application/json'); + expect(h.etag).toHaveBeenCalled(); + }); + + it('should handle non-default registered locale', async () => { + const defaultLocale = 'en'; + const requestedLocale = 'fr'; + const frTranslations = { hello: 'Bonjour' }; + i18n.getLocale.mockReturnValue(defaultLocale); + i18nLoader.getRegisteredLocales.mockReturnValue([defaultLocale, requestedLocale]); + i18nLoader.getTranslationsByLocale.mockResolvedValue(frTranslations); + + const request = { params: { locale: requestedLocale } }; + await handler(request, h); + + expect(i18nLoader.getTranslationsByLocale).toHaveBeenCalledWith(requestedLocale); + expect(h.response).toHaveBeenCalledWith({ + translations: frTranslations, + warning: null, + }); + }); + + it('should fallback to English translations for unknown locale', async () => { + const defaultLocale = 'en'; + const unknownLocale = 'xx'; + const englishTranslations = { hello: 'Hello' }; + i18n.getLocale.mockReturnValue(defaultLocale); + i18nLoader.getRegisteredLocales.mockReturnValue([defaultLocale]); + i18nLoader.getTranslationsByLocale.mockResolvedValue(englishTranslations); + + const request = { params: { locale: unknownLocale } }; + await handler(request, h); + + expect(i18nLoader.getTranslationsByLocale).toHaveBeenCalledWith('en'); + expect(h.response).toHaveBeenCalledWith({ + translations: englishTranslations, + warning: { + title: 'Unsupported Locale', + text: `The requested locale "${unknownLocale}" is not supported. Falling back to English.`, + }, + }); + expect(h.header).toHaveBeenCalledWith('cache-control', 'must-revalidate'); + expect(h.header).toHaveBeenCalledWith('content-type', 'application/json'); + expect(h.etag).toHaveBeenCalled(); + }); + + it('should cache translations', async () => { + const defaultLocale = 'en'; + const defaultTranslations = { hello: 'Hello' }; + i18n.getLocale.mockReturnValue(defaultLocale); + i18n.getTranslation.mockReturnValue(defaultTranslations); + i18nLoader.getRegisteredLocales.mockReturnValue([defaultLocale]); + + const request = { params: { locale: defaultLocale } }; + await handler(request, h); + await handler(request, h); + + expect(i18n.getTranslation).toHaveBeenCalledTimes(1); + }); + + it('should handle errors gracefully', async () => { + const defaultLocale = 'en'; + i18n.getLocale.mockReturnValue(defaultLocale); + i18n.getTranslation.mockImplementation(() => { + throw new Error('Translation error'); + }); + i18nLoader.getRegisteredLocales.mockReturnValue([defaultLocale]); + + const request = { params: { locale: defaultLocale } }; + await expect(handler(request, h)).rejects.toThrow('Translation error'); + }); + }); +}); From 577b3ec1f3cc7f2f4b521ff31a1680af0b129d36 Mon Sep 17 00:00:00 2001 From: Miki Date: Thu, 15 Aug 2024 15:17:17 -0700 Subject: [PATCH 12/14] Allow `screenTitle` to be present when SearchBar is not in Application header (#7721) * Allow `screenTitle` to be present when SearchBar is not Signed-off-by: Miki * Changeset file for PR #7721 created/updated --------- Signed-off-by: Miki Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7721.yml | 2 ++ .../public/top_nav_menu/_index.scss | 6 +++++ .../public/top_nav_menu/top_nav_menu.tsx | 26 +++++++++++++------ 3 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 changelogs/fragments/7721.yml diff --git a/changelogs/fragments/7721.yml b/changelogs/fragments/7721.yml new file mode 100644 index 000000000000..1963c6ea4755 --- /dev/null +++ b/changelogs/fragments/7721.yml @@ -0,0 +1,2 @@ +feat: +- Allow `screenTitle` to be present when SearchBar is not in Application header ([#7721](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7721)) \ No newline at end of file diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index a4de5f473fb0..556b58ff6454 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -67,3 +67,9 @@ text-overflow: ellipsis; } } + +// stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors +.osdTopNavMenuSpread .euiHeaderLinks__list { + width: 100%; + justify-content: space-between; +} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index e8e3489fe285..4abf5a4cd8bc 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -128,11 +128,14 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { ); } - function renderMenu(className: string): ReactElement | null { + function renderMenu(className: string, spreadSections: boolean = false): ReactElement | null { if ((!config || config.length === 0) && (!showDataSourceMenu || !dataSourceMenuConfig)) return null; + + const menuClassName = classNames(className, { osdTopNavMenuSpread: spreadSections }); + return ( - + {renderItems()} {renderDataSourceMenu()} @@ -182,12 +185,19 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { case false: case TopNavMenuItemRenderType.OMITTED: - return ( - <> - - {renderMenu(menuClassName)} - - + return screenTitle ? ( + + + + {screenTitle} + + {renderMenu(menuClassName, true)} + + + ) : ( + + {renderMenu(menuClassName)} + ); // Show the SearchBar in-place From d447c5dbc7914cf22bd9f964f5b5f982264efe42 Mon Sep 17 00:00:00 2001 From: Miki Date: Thu, 15 Aug 2024 16:18:41 -0700 Subject: [PATCH 13/14] Simplify `TopNavControlDescriptionData` to to be followed by links (#7723) * Simplify `TopNavControlDescriptionData` to to be followed by links Signed-off-by: Miki * Changeset file for PR #7723 created/updated --------- Signed-off-by: Miki Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7723.yml | 2 ++ .../public/chrome/ui/header/header_controls_container.scss | 1 + .../navigation/public/top_nav_menu/top_nav_control_data.tsx | 1 + .../navigation/public/top_nav_menu/top_nav_control_item.tsx | 5 +++++ 4 files changed, 9 insertions(+) create mode 100644 changelogs/fragments/7723.yml diff --git a/changelogs/fragments/7723.yml b/changelogs/fragments/7723.yml new file mode 100644 index 000000000000..6f38d9e804be --- /dev/null +++ b/changelogs/fragments/7723.yml @@ -0,0 +1,2 @@ +feat: +- Simplify `TopNavControlDescriptionData` to to be followed by links ([#7723](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7723)) \ No newline at end of file diff --git a/src/core/public/chrome/ui/header/header_controls_container.scss b/src/core/public/chrome/ui/header/header_controls_container.scss index 7d4f2101ab85..bba07ef60fbd 100644 --- a/src/core/public/chrome/ui/header/header_controls_container.scss +++ b/src/core/public/chrome/ui/header/header_controls_container.scss @@ -16,6 +16,7 @@ &.headerBottomControl { padding: $euiSizeM; + max-width: 725px; } &:empty { diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_control_data.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_control_data.tsx index 83fa46e69508..4482eaec723d 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_control_data.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_control_data.tsx @@ -78,6 +78,7 @@ export interface TopNavControlTextData { export interface TopNavControlDescriptionData { description: string; + links?: TopNavControlLinkData | TopNavControlLinkData[]; } export interface TopNavControlComponentData { diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.tsx index ada02dba27bf..4b2d7b948170 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.tsx @@ -46,9 +46,14 @@ export function TopNavControlItem(props: TopNavControlData) { } if ('description' in props) { + const links = props.links && [props.links].flat(); + return ( {props.description} + {links?.map((linkProps) => ( + + ))} ); } From 6877beaadd517207722c261181c1751bfa5bfb40 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 16 Aug 2024 08:37:14 +0800 Subject: [PATCH 14/14] [navigation] Left navigation collective (#7655) * feat: change nav groups Signed-off-by: SuZhou-Joe * Changeset file for PR #7655 created/updated * feat: move visualizations to all use case Signed-off-by: SuZhou-Joe * feat: change the scrollable area Signed-off-by: SuZhou-Joe * feat: register manage workspace category when in a workspace Signed-off-by: SuZhou-Joe * feat: register index patterns to settings and setup Signed-off-by: SuZhou-Joe * feat: show correct icon in top Signed-off-by: SuZhou-Joe * feat: use gap to replace margin Signed-off-by: SuZhou-Joe * feat: make new left nav work in mobile mode Signed-off-by: SuZhou-Joe * fix: bootstrap error Signed-off-by: SuZhou-Joe * fix: unit test error Signed-off-by: SuZhou-Joe * feat: justify content Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * feat: add descriptions to core features Signed-off-by: SuZhou-Joe * feat: add description to index pattern Signed-off-by: SuZhou-Joe * feat: use smaller font size in left nav Signed-off-by: SuZhou-Joe * feat: append navLinks inside second level to custom category if no entry for the nav group Signed-off-by: SuZhou-Joe * feat: some optimize Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: fix unit test Signed-off-by: SuZhou-Joe * feat: filter all use case Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7655.yml | 2 + src/core/public/chrome/chrome_service.tsx | 1 + ...ollapsible_nav_group_enabled.test.tsx.snap | 461 +++++++++--------- .../header/__snapshots__/header.test.tsx.snap | 55 +++ .../header/collapsible_nav_group_enabled.scss | 15 +- .../collapsible_nav_group_enabled.test.tsx | 119 +++-- .../header/collapsible_nav_group_enabled.tsx | 104 ++-- ...collapsible_nav_group_enabled_top.test.tsx | 132 ++--- .../collapsible_nav_group_enabled_top.tsx | 88 ++-- .../public/chrome/ui/header/header.test.tsx | 2 + src/core/public/chrome/ui/header/header.tsx | 4 +- src/core/utils/default_app_categories.ts | 7 + src/core/utils/default_nav_groups.ts | 1 - .../advanced_settings/public/plugin.ts | 3 + .../data_source_management/public/plugin.ts | 49 +- src/plugins/home/public/plugin.ts | 26 +- .../public/plugin.test.ts | 2 +- .../index_pattern_management/public/plugin.ts | 46 +- src/plugins/management/public/plugin.ts | 18 +- .../saved_objects_management/public/plugin.ts | 48 +- src/plugins/visualize/public/plugin.ts | 7 + src/plugins/workspace/public/plugin.test.ts | 10 +- src/plugins/workspace/public/plugin.ts | 15 +- .../public/services/use_case_service.ts | 85 +++- src/plugins/workspace/public/utils.ts | 2 +- 25 files changed, 703 insertions(+), 599 deletions(-) create mode 100644 changelogs/fragments/7655.yml diff --git a/changelogs/fragments/7655.yml b/changelogs/fragments/7655.yml new file mode 100644 index 000000000000..70a921f4e6df --- /dev/null +++ b/changelogs/fragments/7655.yml @@ -0,0 +1,2 @@ +feat: +- [navigation] Left navigation collective ([#7655](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7655)) \ No newline at end of file diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index c56388a9da32..82511b32bc0c 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -385,6 +385,7 @@ export class ChromeService { navGroupsMap$={navGroup.getNavGroupsMap$()} setCurrentNavGroup={navGroup.setCurrentNavGroup} workspaceList$={workspaces.workspaceList$} + currentWorkspace$={workspaces.currentWorkspace$} useUpdatedHeader={this.useUpdatedHeader} /> ), diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap index 2c6835acdaeb..95e501650f08 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap @@ -11,232 +11,232 @@ exports[` should render correctly 1`] = ` class="eui-fullHeight left-navigation-wrapper" >
+ +
+
- -
-
+
-
- - - + Observability +
-
+ +
+
- - -
- + - -
+ +
+
+
-
- - - + Essentials +
-
+ +
+
- - -
- + - -
+ +
- -
+
+

@@ -254,13 +254,8 @@ exports[` should render correctly 2`] = ` class="eui-fullHeight left-navigation-wrapper" >
-
-
+ class="flex-1-container" + />
@@ -283,62 +278,62 @@ exports[` should show all use case by default and class="eui-fullHeight left-navigation-wrapper" >
+ +
+
- -
-
- -
+
+

@@ -356,62 +351,62 @@ exports[` should show all use case when current na class="eui-fullHeight left-navigation-wrapper" >
+ +
+
- -
-
- -
+
+
@@ -424,7 +419,7 @@ exports[` should render correctly 1`] = ` class="euiFlexItem" >