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/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/changelogs/fragments/7651.yml b/changelogs/fragments/7651.yml new file mode 100644 index 000000000000..451ec7d8c444 --- /dev/null +++ b/changelogs/fragments/7651.yml @@ -0,0 +1,2 @@ +feat: +- [contentManagement] allow to update section input after page rendered ([#7651](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7651)) \ No newline at end of file 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/changelogs/fragments/7656.yml b/changelogs/fragments/7656.yml new file mode 100644 index 000000000000..0447cdf6e893 --- /dev/null +++ b/changelogs/fragments/7656.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace]Add name and description characters limitation ([#7656](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7656)) \ No newline at end of file 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/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/changelogs/fragments/7691.yml b/changelogs/fragments/7691.yml new file mode 100644 index 000000000000..e42b7eb3ce88 --- /dev/null +++ b/changelogs/fragments/7691.yml @@ -0,0 +1,2 @@ +feat: +- Allow customizing `restrictWidth` and `paddingSize` of `TableListView` ([#7691](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7691)) \ No newline at end of file diff --git a/changelogs/fragments/7697.yml b/changelogs/fragments/7697.yml new file mode 100644 index 000000000000..df490d5ed68a --- /dev/null +++ b/changelogs/fragments/7697.yml @@ -0,0 +1,2 @@ +feat: +- Integrate new page header for workspace pages ([#7697](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7697)) \ No newline at end of file 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/changelogs/fragments/7702.yml b/changelogs/fragments/7702.yml new file mode 100644 index 000000000000..fe4b48169a40 --- /dev/null +++ b/changelogs/fragments/7702.yml @@ -0,0 +1,2 @@ +feat: +- Refractor the homepage assets list section ([#7702](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7702)) \ No newline at end of file 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/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/docs/_sidebar.md b/docs/_sidebar.md index e5136a6df4c5..792c35e3949b 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -30,6 +30,7 @@ - public - application - [Hooks](../src/plugins/console/public/application/hooks/README.md) + - [Content_management](../src/plugins/content_management/README.md) - [Csp_handler](../src/plugins/csp_handler/README.md) - [Dashboard](../src/plugins/dashboard/README.md) - [Data](../src/plugins/data/README.md) @@ -164,6 +165,7 @@ - [Opensearch dashboards.release notes 2.13.0](../release-notes/opensearch-dashboards.release-notes-2.13.0.md) - [Opensearch dashboards.release notes 2.14.0](../release-notes/opensearch-dashboards.release-notes-2.14.0.md) - [Opensearch dashboards.release notes 2.15.0](../release-notes/opensearch-dashboards.release-notes-2.15.0.md) + - [Opensearch dashboards.release notes 2.16.0](../release-notes/opensearch-dashboards.release-notes-2.16.0.md) - [Opensearch dashboards.release notes 2.2.0](../release-notes/opensearch-dashboards.release-notes-2.2.0.md) - [Opensearch dashboards.release notes 2.2.1](../release-notes/opensearch-dashboards.release-notes-2.2.1.md) - [Opensearch dashboards.release notes 2.3.0](../release-notes/opensearch-dashboards.release-notes-2.3.0.md) 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-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/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/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/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..82511b32bc0c 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,8 @@ export class ChromeService { navGroupsMap$={navGroup.getNavGroupsMap$()} setCurrentNavGroup={navGroup.setCurrentNavGroup} workspaceList$={workspaces.workspaceList$} + currentWorkspace$={workspaces.currentWorkspace$} + useUpdatedHeader={this.useUpdatedHeader} /> ), @@ -328,6 +396,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 +457,7 @@ export class ChromeService { public stop() { this.navLinks.stop(); this.navGroup.stop(); + this.updatedHeaderSubscription?.unsubscribe(); this.stop$.next(); } } @@ -465,6 +538,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__/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" > - - - - -
- -
-
- -
- -
- +
+ +
+ +
+ + +
+ +
+ -
- -
- - -
+ + - + -
-
- -
- -
-
- -
+
+ +
+ + - + +
+
+
+ +
+ +
+ + + + + + +`; + +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": 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, - "toRespond": 0, - "values": Array [ - undefined, - "", - ], }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } + > + + + + } + 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 +22286,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" > - ), + CollapsibleNavTop: () => , })); const mockBasePath = httpServiceMock.createSetupContract({ basePath: '/test' }).basePath; @@ -99,6 +94,28 @@ describe('', () => { }); }); +const defaultNavGroupMap = { + [ALL_USE_CASE_ID]: { + ...DEFAULT_NAV_GROUPS[ALL_USE_CASE_ID], + navLinks: [ + { + id: 'link-in-all', + title: 'link-in-all', + }, + ], + }, + [DEFAULT_NAV_GROUPS.observability.id]: { + ...DEFAULT_NAV_GROUPS.observability, + navLinks: [ + { + id: 'link-in-observability', + title: 'link-in-observability', + showInAllNavGroup: true, + }, + ], + }, +}; + describe('', () => { function mockProps( props?: Partial & { @@ -107,28 +124,9 @@ describe('', () => { navLinks?: ChromeNavLink[]; } ): CollapsibleNavGroupEnabledProps { - const navGroupsMap$ = new BehaviorSubject>({ - [ALL_USE_CASE_ID]: { - ...DEFAULT_NAV_GROUPS[ALL_USE_CASE_ID], - navLinks: [ - { - id: 'link-in-all', - title: 'link-in-all', - }, - ], - }, - [DEFAULT_NAV_GROUPS.observability.id]: { - ...DEFAULT_NAV_GROUPS.observability, - navLinks: [ - { - id: 'link-in-observability', - title: 'link-in-observability', - showInAllNavGroup: true, - }, - ], - }, - ...props?.navGroupsMap, - }); + const navGroupsMap$ = new BehaviorSubject>( + props?.navGroupsMap || defaultNavGroupMap + ); const currentNavGroup$ = new BehaviorSubject( props?.currentNavGroupId ? navGroupsMap$.getValue()[props.currentNavGroupId] : undefined ); @@ -138,6 +136,7 @@ describe('', () => { id: 'collapsibe-nav', isLocked: false, isNavOpen: false, + currentWorkspace$: new BehaviorSubject({ id: 'test', name: 'test' }), navLinks$: new BehaviorSubject([ { id: 'link-in-all', @@ -187,6 +186,7 @@ describe('', () => { const props = mockProps({ isNavOpen: true, navGroupsMap: { + ...defaultNavGroupMap, [DEFAULT_NAV_GROUPS.essentials.id]: { ...DEFAULT_NAV_GROUPS.essentials, navLinks: [ @@ -208,7 +208,12 @@ describe('', () => { }); it('should render correctly when only one visible use case is provided', () => { - const props = mockProps(); + const props = mockProps({ + navGroupsMap: { + [DEFAULT_NAV_GROUPS.observability.id]: + defaultNavGroupMap[DEFAULT_NAV_GROUPS.observability.id], + }, + }); const { getAllByTestId } = render(); expect(getAllByTestId('collapsibleNavAppLink-link-in-observability').length).toEqual(1); }); @@ -216,6 +221,7 @@ describe('', () => { it('should show all use case by default and able to click see all', async () => { const props = mockProps({ navGroupsMap: { + ...defaultNavGroupMap, [DEFAULT_NAV_GROUPS.essentials.id]: { ...DEFAULT_NAV_GROUPS.essentials, navLinks: [ @@ -228,20 +234,19 @@ describe('', () => { }, }, }); - const { container, getAllByTestId, getByTestId } = render( + const { container, getAllByTestId } = render( ); fireEvent.click(getAllByTestId('collapsibleNavAppLink-link-in-essentials')[1]); expect(getAllByTestId('collapsibleNavAppLink-link-in-essentials').length).toEqual(1); expect(container).toMatchSnapshot(); - fireEvent.click(getByTestId('back')); - expect(getAllByTestId('collapsibleNavAppLink-link-in-essentials').length).toEqual(2); }); it('should show all use case when current nav group is `all`', async () => { const props = mockProps({ currentNavGroupId: ALL_USE_CASE_ID, navGroupsMap: { + ...defaultNavGroupMap, [DEFAULT_NAV_GROUPS.essentials.id]: { ...DEFAULT_NAV_GROUPS.essentials, navLinks: [ @@ -254,20 +259,19 @@ describe('', () => { }, }, }); - const { container, getAllByTestId, getByTestId } = render( + const { container, getAllByTestId } = render( ); fireEvent.click(getAllByTestId('collapsibleNavAppLink-link-in-essentials')[1]); expect(getAllByTestId('collapsibleNavAppLink-link-in-essentials').length).toEqual(1); expect(container).toMatchSnapshot(); - fireEvent.click(getByTestId('back')); - expect(getAllByTestId('collapsibleNavAppLink-link-in-essentials').length).toEqual(2); }); it('should not show group if the nav link is hidden', async () => { const props = mockProps({ currentNavGroupId: ALL_USE_CASE_ID, navGroupsMap: { + ...defaultNavGroupMap, [DEFAULT_NAV_GROUPS.essentials.id]: { ...DEFAULT_NAV_GROUPS.essentials, navLinks: [ @@ -295,4 +299,47 @@ describe('', () => { ); expect(queryAllByTestId('collapsibleNavAppLink-link-in-all').length).toEqual(1); }); + + it('should show links with custom category if the nav link is inside second level but no entry in all use case', async () => { + const props = mockProps({ + currentNavGroupId: ALL_USE_CASE_ID, + navGroupsMap: { + ...defaultNavGroupMap, + [DEFAULT_NAV_GROUPS.essentials.id]: { + ...DEFAULT_NAV_GROUPS.essentials, + navLinks: [ + { + id: 'link-in-essentials', + title: 'link-in-essentials', + }, + { + id: 'link-in-all', + title: 'link-in-all', + }, + ], + }, + }, + navLinks: [ + { + id: 'link-in-essentials', + title: 'link-in-essentials', + baseUrl: '', + href: '', + }, + { + id: 'link-in-all', + title: 'link-in-all', + baseUrl: '', + href: '', + }, + ], + }); + const { queryAllByTestId, getByText, getByTestId } = render( + + ); + // Should render custom category + expect(getByText('Custom')).toBeInTheDocument(); + expect(getByTestId('collapsibleNavAppLink-link-in-essentials')).toBeInTheDocument(); + expect(queryAllByTestId('collapsibleNavAppLink-link-in-all').length).toEqual(1); + }); }); diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx index ab24309551f4..af67974ecb9d 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx @@ -18,6 +18,7 @@ import React, { useMemo } from 'react'; import useObservable from 'react-use/lib/useObservable'; import * as Rx from 'rxjs'; import classNames from 'classnames'; +import { WorkspacesStart } from 'src/core/public/workspace'; import { ChromeNavControl, ChromeNavLink } from '../..'; import { AppCategory, NavGroupStatus } from '../../../../types'; import { InternalApplicationStart } from '../../../application/types'; @@ -59,6 +60,7 @@ export interface CollapsibleNavGroupEnabledProps { currentNavGroup$: Rx.Observable; setCurrentNavGroup: ChromeNavGroupServiceStartContract['setCurrentNavGroup']; capabilities: InternalApplicationStart['capabilities']; + currentWorkspace$: WorkspacesStart['currentWorkspace$']; } interface NavGroupsProps { @@ -106,7 +108,7 @@ export function NavGroups({ return { id: `${link.id}-${link.title}`, - name: {link.title}, + name: {link.title}, onClick: euiListItem.onClick, href: euiListItem.href, emphasize: euiListItem.isActive, @@ -190,7 +192,7 @@ export function NavGroups({ .filter((item): item is EuiSideNavItemType<{}> => !!item); return ( - + {suffix} ); @@ -224,10 +226,20 @@ export function CollapsibleNavGroupEnabled({ capabilities, ...observables }: CollapsibleNavGroupEnabledProps) { - const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); + const allNavLinks = useObservable(observables.navLinks$, []); + const navLinks = allNavLinks.filter((link) => !link.hidden); + const homeLink = useMemo(() => allNavLinks.find((item) => item.id === 'home'), [allNavLinks]); const appId = useObservable(observables.appId$, ''); const navGroupsMap = useObservable(observables.navGroupsMap$, {}); const currentNavGroup = useObservable(observables.currentNavGroup$, undefined); + const firstVisibleNavLinkOfAllUseCase = useMemo( + () => + fulfillRegistrationLinksToChromeNavLinks( + navGroupsMap[ALL_USE_CASE_ID]?.navLinks || [], + navLinks + )[0], + [navGroupsMap, navLinks] + ); const visibleUseCases = useMemo( () => @@ -300,6 +312,22 @@ export function CollapsibleNavGroupEnabled({ order: Number.MAX_SAFE_INTEGER, category: categoryInfo, }); + } else { + /** + * Find if there are any links inside a use case but without a `see all` entry. + * If so, append these features into custom category as a fallback + */ + fulfillRegistrationLinksToChromeNavLinks(group.navLinks, navLinks) + // Filter out links that already exists in all use case + .filter( + (navLink) => !navLinksForAll.find((navLinkInAll) => navLinkInAll.id === navLink.id) + ) + .forEach((navLink) => { + navLinksForAll.push({ + ...navLink, + category: customCategory, + }); + }); } }); @@ -353,48 +381,62 @@ export function CollapsibleNavGroupEnabled({ closeButtonPosition="outside" hideCloseButton paddingSize="none" + ownFocus={false} >
-
+ {!isNavOpen ? null : ( - {!isNavOpen ? null : ( - <> - setCurrentNavGroup(undefined)} - currentNavGroup={currentNavGroup} - shouldShrinkNavigation={!isNavOpen} - onClickShrink={closeNav} - visibleUseCases={visibleUseCases} - /> - { - if (navItem.title === titleForSeeAll && navItem.category?.id) { - const navGroup = navGroupsMap[navItem.category.id]; - onGroupClick(event, navGroup); - } - }} - appId={appId} - /> - - )} + -
+ )} + {!isNavOpen ? null : ( + + { + if (navItem.title === titleForSeeAll && navItem.category?.id) { + const navGroup = navGroupsMap[navItem.category.id]; + onGroupClick(event, navGroup); + } + }} + appId={appId} + /> + + )} + { + // This element is used to push icons to the bottom of left navigation when collapsed + !isNavOpen ?
: null + }
', () => { title: '', ...navLink, }); - const mockedNavLinks = [ - getMockedNavLink({ - id: 'home', - title: 'home link', - }), - getMockedNavLink({ - id: 'subLink', - title: 'subLink', - parentNavLinkId: 'pure', - }), - getMockedNavLink({ - id: 'link-in-category', - title: 'link-in-category', - category: { - id: 'category-1', - label: 'category-1', - }, - }), - getMockedNavLink({ - id: 'link-in-category-2', - title: 'link-in-category-2', - category: { - id: 'category-1', - label: 'category-1', - }, - }), - getMockedNavLink({ - id: 'sub-link-in-category', - title: 'sub-link-in-category', - parentNavLinkId: 'link-in-category', - category: { - id: 'category-1', - label: 'category-1', - }, - }), - ]; const getMockedProps = () => { return { - navLinks: mockedNavLinks, + homeLink: getMockedNavLink({ id: 'home', title: 'Home', href: '/' }), navigateToApp: jest.fn(), logos: getLogos({}, mockBasePath.serverBasePath), shouldShrinkNavigation: false, visibleUseCases: [], + currentWorkspace$: new BehaviorSubject(null), + setCurrentNavGroup: jest.fn(), }; }; - it('should render home icon', async () => { - const { findByTestId } = render(); - await findByTestId('collapsibleNavHome'); - }); - it('should render back icon', async () => { - const { findByTestId, findByText } = render( - { + const props = { + ...getMockedProps(), + currentWorkspace$: new BehaviorSubject({ id: 'foo', name: 'foo' }), + visibleUseCases: [ + { id: 'navGroupFoo', title: 'navGroupFoo', description: 'navGroupFoo', navLinks: [], - }} - /> - ); + }, + { + id: 'navGroupBar', + title: 'navGroupBar', + description: 'navGroupBar', + navLinks: [], + }, + ], + currentNavGroup: { + id: 'navGroupFoo', + title: 'navGroupFoo', + description: 'navGroupFoo', + navLinks: [], + }, + firstVisibleNavLinkOfAllUseCase: getMockedNavLink({ + id: 'firstVisibleNavLinkOfAllUseCase', + }), + }; + const { findByTestId, findByText, getByTestId } = render(); await findByTestId('collapsibleNavBackButton'); await findByText('Back'); + fireEvent.click(getByTestId('collapsibleNavBackButton')); + expect(props.navigateToApp).toBeCalledWith('firstVisibleNavLinkOfAllUseCase'); + expect(props.setCurrentNavGroup).toBeCalledWith(ALL_USE_CASE_ID); }); - it('should render back home icon', async () => { - const { findByTestId, findByText } = render( - - ); - await findByTestId('collapsibleNavBackButton'); - await findByText('Home'); + it('should render home icon when not in a workspace', async () => { + const { findByTestId } = render(); + await findByTestId('collapsibleNavHome'); }); - it('should render expand icon', async () => { + it('should render expand icon when collapsed', async () => { const { findByTestId } = render( ); diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx index 396d78a1024a..23e2f7e6108c 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx @@ -4,7 +4,8 @@ */ import React, { useMemo } from 'react'; -import { Logos } from 'opensearch-dashboards/public'; +import useObservable from 'react-use/lib/useObservable'; +import { Logos, WorkspacesStart } from 'opensearch-dashboards/public'; import { EuiButtonEmpty, EuiButtonIcon, @@ -12,64 +13,53 @@ import { EuiFlexItem, EuiIcon, EuiSpacer, + EuiText, } from '@elastic/eui'; import { InternalApplicationStart } from 'src/core/public/application'; import { i18n } from '@osd/i18n'; import { createEuiListItem } from './nav_link'; -import { NavGroupItemInMap } from '../../nav_group'; +import { ChromeNavGroupServiceStartContract, NavGroupItemInMap } from '../../nav_group'; import { ChromeNavLink } from '../../nav_links'; import { ALL_USE_CASE_ID } from '../../../../../core/utils'; export interface CollapsibleNavTopProps { - navLinks: ChromeNavLink[]; + homeLink?: ChromeNavLink; + firstVisibleNavLinkOfAllUseCase?: ChromeNavLink; currentNavGroup?: NavGroupItemInMap; navigateToApp: InternalApplicationStart['navigateToApp']; logos: Logos; - onClickBack?: () => void; onClickShrink?: () => void; shouldShrinkNavigation: boolean; visibleUseCases: NavGroupItemInMap[]; + currentWorkspace$: WorkspacesStart['currentWorkspace$']; + setCurrentNavGroup: ChromeNavGroupServiceStartContract['setCurrentNavGroup']; } export const CollapsibleNavTop = ({ - navLinks, currentNavGroup, navigateToApp, logos, - onClickBack, onClickShrink, shouldShrinkNavigation, visibleUseCases, + currentWorkspace$, + setCurrentNavGroup, + homeLink, + firstVisibleNavLinkOfAllUseCase, }: CollapsibleNavTopProps) => { - const homeLink = useMemo(() => navLinks.find((link) => link.id === 'home'), [navLinks]); + const currentWorkspace = useObservable(currentWorkspace$); - const isOutsideWorkspace = useMemo( - () => !visibleUseCases.find((useCase) => useCase.id === currentNavGroup?.id), - [currentNavGroup, visibleUseCases] - ); - - const shouldShowBackButton = useMemo(() => { - if (!currentNavGroup || currentNavGroup.id === ALL_USE_CASE_ID || shouldShrinkNavigation) { - return false; - } - - // It means user is in a specific type of workspace - if (visibleUseCases.length <= 1) { - return false; - } - - if (isOutsideWorkspace) { - return true; - } - - return visibleUseCases.length > 1; - }, [visibleUseCases, currentNavGroup, shouldShrinkNavigation, isOutsideWorkspace]); - - const shouldShowHomeLink = useMemo(() => { - if (!homeLink || shouldShrinkNavigation) return false; + /** + * We can ensure that left nav is inside second level once all the following conditions are met: + * 1. Inside a workspace + * 2. The use case type of current workspace is all use case + * 3. current nav group is not all use case + */ + const isInsideSecondLevelOfAllWorkspace = + visibleUseCases.length > 1 && !!currentWorkspace && currentNavGroup?.id !== ALL_USE_CASE_ID; - return !shouldShowBackButton; - }, [shouldShowBackButton, homeLink, shouldShrinkNavigation]); + const shouldShowBackButton = !shouldShrinkNavigation && isInsideSecondLevelOfAllWorkspace; + const shouldShowHomeLink = !shouldShrinkNavigation && !shouldShowBackButton; const homeLinkProps = useMemo(() => { if (homeLink) { @@ -90,9 +80,8 @@ export const CollapsibleNavTop = ({ }, [homeLink, navigateToApp]); return ( -
- - +
+ {shouldShowHomeLink ? ( @@ -104,17 +93,18 @@ export const CollapsibleNavTop = ({ { + if (firstVisibleNavLinkOfAllUseCase) { + navigateToApp(firstVisibleNavLinkOfAllUseCase.id); + } + setCurrentNavGroup(ALL_USE_CASE_ID); + }} data-test-subj="collapsibleNavBackButton" > - {isOutsideWorkspace - ? i18n.translate('core.ui.primaryNav.homeButtonLabel', { - defaultMessage: 'Home', - }) - : i18n.translate('core.ui.primaryNav.backButtonLabel', { - defaultMessage: 'Back', - })} + {i18n.translate('core.ui.primaryNav.backButtonLabel', { + defaultMessage: 'Back', + })} ) : null} @@ -129,7 +119,16 @@ export const CollapsibleNavTop = ({ /> - + {currentNavGroup?.title && ( + <> + + +
+ {currentNavGroup?.title} +
+
+ + )}
); }; diff --git a/src/core/public/chrome/ui/header/header.scss b/src/core/public/chrome/ui/header/header.scss new file mode 100644 index 000000000000..334bd76fbc4b --- /dev/null +++ b/src/core/public/chrome/ui/header/header.scss @@ -0,0 +1,61 @@ +/* +* Copyright OpenSearch Contributors +* SPDX-License-Identifier: Apache-2.0 +*/ + +.newTopNavHeader { + z-index: 1000; + padding-left: $euiSizeXS; + box-shadow: none; + border-bottom: none; + background: none; + + &.primaryHeader { + height: auto; + padding-top: $euiSizeM; + } + + &.primaryApplicationHeader { + height: auto; + padding-top: $euiSizeM - $euiSizeXS; + } + + &:has(.headerDescriptionControl, .headerBottomControl) { + height: auto; + } +} + +.primaryApplicationHeader { + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + .euiHeaderSection > .euiHeaderSectionItem { + align-items: stretch; + } + + .headerAppActionMenuSection { + flex-grow: 1; + } + + .headerAppActionMenu { + width: 100%; + } +} + +.newTopNavApplicationTitle { + padding: 0 $euiSizeM; +} + +.newAppTopNavExpander { + position: fixed; + left: 0; + top: $euiSizeL - $euiSizeXS; +} + +.newPageTopNavExpander { + position: fixed; + left: 0; + top: $euiSizeL - $euiSizeXS; +} + +.stretchedActionMenu { + width: 100%; +} diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index 1cfcc84acee5..9cc8652c3e41 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -28,16 +28,18 @@ * under the License. */ +import { EuiHeaderSectionItemButton } from '@elastic/eui'; import React from 'react'; import { act } from 'react-dom/test-utils'; import { BehaviorSubject } from 'rxjs'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; import { httpServiceMock } from '../../../http/http_service.mock'; import { applicationServiceMock, chromeServiceMock } from '../../../mocks'; -import { Header } from './header'; -import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; import { ISidecarConfig, SIDECAR_DOCKED_MODE } from '../../../overlays'; -import { EuiHeaderSectionItemButton } from '@elastic/eui'; +import { WorkspaceObject } from 'src/core/public/workspace'; +import { HeaderVariant } from '../../constants'; +import { Header } from './header'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', @@ -56,6 +58,7 @@ function mockProps() { breadcrumbsEnricher$: new BehaviorSubject(undefined), homeHref: '/', isVisible$: new BehaviorSubject(true), + headerVariant$: new BehaviorSubject(undefined), opensearchDashboardsDocLink: '/docs', navLinks$: new BehaviorSubject([]), customNavLink$: new BehaviorSubject(undefined), @@ -85,6 +88,8 @@ function mockProps() { navControlsLeftBottom$: new BehaviorSubject([]), setCurrentNavGroup: jest.fn(() => {}), workspaceList$: new BehaviorSubject([]), + currentWorkspace$: new BehaviorSubject(null), + useUpdatedHeader: false, }; } @@ -219,4 +224,54 @@ describe('Header', () => { component.find(EuiHeaderSectionItemButton).first().simulate('click'); expect(component).toMatchSnapshot(); }); + + it('renders page header with application title', () => { + const branding = { + useExpandedHeader: false, + }; + const useUpdatedHeader = true; + const breadcrumbs$ = new BehaviorSubject([{ text: 'test' }, { text: 'testTitle' }]); + const props = { + ...mockProps(), + breadcrumbs$, + branding, + useUpdatedHeader, + }; + const component = mountWithIntl(
); + expect(component.find('[data-test-subj="headerApplicationTitle"]').exists()).toBeTruthy(); + expect(component.find('[data-test-subj="breadcrumb first"]').exists()).toBeTruthy(); + expect(component.find('[data-test-subj="headerBadgeControl"]').exists()).toBeTruthy(); + expect(component.find('HeaderBadge').exists()).toBeTruthy(); + expect(component.find('[data-test-subj="headerLeftControl"]').exists()).toBeTruthy(); + expect(component.find('HeaderNavControls').exists()).toBeTruthy(); + expect(component.find('[data-test-subj="headerCenterControl"]').exists()).toBeTruthy(); + expect(component.find('[data-test-subj="headerRightControl"]').exists()).toBeTruthy(); + expect(component.find('HeaderActionMenu').exists()).toBeTruthy(); + expect(component.find('[data-test-subj="headerDescriptionControl"]').exists()).toBeTruthy(); + expect(component.find('[data-test-subj="headerBottomControl"]').exists()).toBeTruthy(); + expect(component).toMatchSnapshot(); + }); + + it('renders application header without title and breadcrumbs', () => { + const branding = { + useExpandedHeader: false, + }; + const useUpdatedHeader = true; + const headerVariant$ = new BehaviorSubject(HeaderVariant.APPLICATION); + const breadcrumbs$ = new BehaviorSubject([{ text: 'test' }, { text: 'testTitle' }]); + const props = { + ...mockProps(), + breadcrumbs$, + branding, + useUpdatedHeader, + headerVariant$, + }; + const component = mountWithIntl(
); + expect(component.find('[data-test-subj="headerApplicationTitle"]').exists()).toBeFalsy(); + expect(component.find('[data-test-subj="breadcrumb first"]').exists()).toBeFalsy(); + expect(component.find('HeaderActionMenu').exists()).toBeTruthy(); + expect(component.find('RecentItems').exists()).toBeTruthy(); + expect(component.find('[data-test-subj="headerRightControl"]').exists()).toBeTruthy(); + expect(component).toMatchSnapshot(); + }); }); diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 9c4ae18d4a39..4dfe57ecd053 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -27,7 +27,6 @@ * specific language governing permissions and limitations * under the License. */ - import { EuiHeader, EuiHeaderProps, @@ -37,6 +36,7 @@ import { EuiHideFor, EuiIcon, EuiShowFor, + EuiText, htmlIdGenerator, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; @@ -51,29 +51,32 @@ import { ChromeNavControl, ChromeNavLink, ChromeRecentlyAccessedHistoryItem, + HeaderVariant, } from '../..'; +import type { Logos } from '../../../../common/types'; +import { WorkspaceObject, WorkspacesStart } from '../../../../public/workspace'; import { InternalApplicationStart } from '../../../application/types'; import { HttpStart } from '../../../http'; +import { getOsdSidecarPaddingStyle, ISidecarConfig } from '../../../overlays'; import { - ChromeHelpExtension, ChromeBranding, ChromeBreadcrumbEnricher, + ChromeHelpExtension, } from '../../chrome_service'; +import { ChromeNavGroupServiceStartContract, NavGroupItemInMap } from '../../nav_group'; import { OnIsLockedUpdate } from './'; import { CollapsibleNav } from './collapsible_nav'; +import { CollapsibleNavGroupEnabled } from './collapsible_nav_group_enabled'; +import './header.scss'; +import { HeaderActionMenu } from './header_action_menu'; import { HeaderBadge } from './header_badge'; import { HeaderBreadcrumbs } from './header_breadcrumbs'; +import { HeaderControlsContainer } from './header_controls_container'; import { HeaderHelpMenu } from './header_help_menu'; -import { HomeLoader } from './home_loader'; -import { HeaderNavControls } from './header_nav_controls'; -import { HeaderActionMenu } from './header_action_menu'; import { HeaderLogo } from './header_logo'; -import type { Logos } from '../../../../common/types'; -import { ISidecarConfig, getOsdSidecarPaddingStyle } from '../../../overlays'; -import { CollapsibleNavGroupEnabled } from './collapsible_nav_group_enabled'; -import { ChromeNavGroupServiceStartContract, NavGroupItemInMap } from '../../nav_group'; +import { HeaderNavControls } from './header_nav_controls'; +import { HomeLoader } from './home_loader'; import { RecentItems } from './recent_items'; -import { WorkspaceObject } from '../../../../public/workspace'; export interface HeaderProps { opensearchDashboardsVersion: string; @@ -86,6 +89,7 @@ export interface HeaderProps { customNavLink$: Observable; homeHref: string; isVisible$: Observable; + headerVariant$: Observable; opensearchDashboardsDocLink: string; navLinks$: Observable; recentlyAccessed$: Observable; @@ -111,6 +115,8 @@ export interface HeaderProps { navGroupsMap$: Observable>; setCurrentNavGroup: ChromeNavGroupServiceStartContract['setCurrentNavGroup']; workspaceList$: Observable; + currentWorkspace$: WorkspacesStart['currentWorkspace$']; + useUpdatedHeader?: boolean; } export function Header({ @@ -126,13 +132,16 @@ export function Header({ collapsibleNavHeaderRender, navGroupEnabled, setCurrentNavGroup, + useUpdatedHeader, ...observables }: HeaderProps) { const isVisible = useObservable(observables.isVisible$, false); + const headerVariant = useObservable(observables.headerVariant$, HeaderVariant.PAGE); const isLocked = useObservable(observables.isLocked$, false); const appId = useObservable(application.currentAppId$, ''); const [isNavOpen, setIsNavOpen] = useState(false); const sidecarConfig = useObservable(observables.sidecarConfig$, undefined); + const breadcrumbs = useObservable(observables.breadcrumbs$, []); /** * This is a workaround on 2.16 to hide the navigation items within left navigation @@ -153,145 +162,316 @@ export function Header({ const navId = htmlIdGenerator()(); const className = classnames('hide-for-sharing', 'headerGlobalNav'); const { useExpandedHeader = true } = branding; + const useApplicationHeader = headerVariant === HeaderVariant.APPLICATION; const expandedHeaderColorScheme: EuiHeaderProps['theme'] = 'dark'; + const renderLegacyExpandedHeader = () => ( + , + ], + borders: 'none', + }, + { + items: [ + + + , + ], + borders: 'none', + }, + { + items: [ + + + , + , + ], + borders: 'none', + }, + ]} + /> + ); + + const renderBreadcrumbs = () => ( + + ); + + const renderNavToggle = () => ( + setIsNavOpen(!isNavOpen)} + aria-expanded={isNavOpen} + aria-pressed={isNavOpen} + aria-controls={navId} + ref={toggleCollapsibleNavRef} + className={ + useUpdatedHeader + ? useApplicationHeader + ? 'newAppTopNavExpander' + : 'newPageTopNavExpander' + : undefined + } + > + + + ); + + const renderLeftControls = () => ( + <> + {useUpdatedHeader && ( + + + + )} + + {/* Nav controls left */} + + + + + ); + + const renderCenterControls = () => ( + <> + {useUpdatedHeader && ( + + + + )} + + {useUpdatedHeader && ( + + + + )} + + + + + + ); + + const renderRightControls = () => ( + <> + {useUpdatedHeader && ( + + + + )} + + {useUpdatedHeader && ( + + + + )} + + + + + + ); + + const renderActionMenu = () => ( + + + + ); + + const renderBadge = () => ( + <> + {useUpdatedHeader && ( + + + + )} + + {/* Nav controls badge */} + + + + + ); + + const renderHelp = () => ( + + + + ); + + const renderRecentItems = () => ( + + + + ); + + const renderLegacyHeader = () => ( + + + {shouldHideExpandIcon ? null : ( + + {renderNavToggle()} + + )} + + {renderLeftControls()} + + {/* Home loader left */} + + + + + + {renderBreadcrumbs()} + {renderBadge()} + + + {renderActionMenu()} + {renderCenterControls()} + {renderRightControls()} + {renderHelp()} + + + ); + + const renderPageHeader = () => ( +
+ + {shouldHideExpandIcon || isNavOpen ? null : renderNavToggle()} + + {renderRecentItems()} + + {renderBreadcrumbs()} + + + {/* Secondary header */} + + + + {breadcrumbs &&

{breadcrumbs[breadcrumbs.length - 1]?.text}

}
+
+ + {renderBadge()} + {renderLeftControls()} +
+ + + {renderCenterControls()} + {renderActionMenu()} + {renderRightControls()} + +
+ + + + + + + + +
+ ); + + const renderApplicationHeader = () => ( +
+ + {shouldHideExpandIcon || isNavOpen ? null : renderNavToggle()} + + {renderRecentItems()} + {renderActionMenu()} + + {renderRightControls()} + +
+ ); + + const renderHeader = () => { + return useApplicationHeader ? renderApplicationHeader() : renderPageHeader(); + }; + return ( <>
- {useExpandedHeader && ( - , - ], - borders: 'none', - }, - { - items: [ - - - , - ], - borders: 'none', - }, - { - items: [ - - - , - , - ], - borders: 'none', - }, - ]} - /> - )} - - - - {shouldHideExpandIcon ? null : ( - - setIsNavOpen(!isNavOpen)} - aria-expanded={isNavOpen} - aria-pressed={isNavOpen} - aria-controls={navId} - ref={toggleCollapsibleNavRef} - > - - - - )} - - - - - - - - - {/* Only display recent items when navGroup is enabled */} - {navGroupEnabled && ( - - - - )} - - - - - - - - - - - - - - - - - - - - - - - - - - + {!useUpdatedHeader && useExpandedHeader && renderLegacyExpandedHeader()} + {useUpdatedHeader ? renderHeader() : renderLegacyHeader()}
{navGroupEnabled ? ( @@ -318,6 +498,7 @@ export function Header({ currentNavGroup$={observables.currentNavGroup$} setCurrentNavGroup={setCurrentNavGroup} capabilities={application.capabilities} + currentWorkspace$={observables.currentWorkspace$} /> ) : ( { await refresh(); expect(component.html()).toMatchInlineSnapshot( - `"
FOO
"` + `"
FOO
"` ); }); @@ -92,7 +92,7 @@ describe('HeaderActionMenu', () => { await refresh(); expect(component.html()).toMatchInlineSnapshot( - `"
FOO
"` + `"
FOO
"` ); act(() => { @@ -101,7 +101,7 @@ describe('HeaderActionMenu', () => { await refresh(); expect(component.html()).toMatchInlineSnapshot( - `"
"` + `"
"` ); }); @@ -114,7 +114,7 @@ describe('HeaderActionMenu', () => { await refresh(); expect(component.html()).toMatchInlineSnapshot( - `"
FOO
"` + `"
FOO
"` ); act(() => { @@ -123,7 +123,7 @@ describe('HeaderActionMenu', () => { await refresh(); expect(component.html()).toMatchInlineSnapshot( - `"
BAR
"` + `"
BAR
"` ); }); diff --git a/src/core/public/chrome/ui/header/header_action_menu.tsx b/src/core/public/chrome/ui/header/header_action_menu.tsx index f45a2ac39e1e..92ae9ffcd030 100644 --- a/src/core/public/chrome/ui/header/header_action_menu.tsx +++ b/src/core/public/chrome/ui/header/header_action_menu.tsx @@ -71,5 +71,7 @@ export const HeaderActionMenu: FC = ({ actionMenu$ }) => } }, [mounter]); - return
; + return ( +
+ ); }; diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.scss b/src/core/public/chrome/ui/header/header_breadcrumbs.scss new file mode 100644 index 000000000000..b3f09e9e8a93 --- /dev/null +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.scss @@ -0,0 +1,35 @@ +/* +* Copyright OpenSearch Contributors +* SPDX-License-Identifier: Apache-2.0 +*/ + +.headerBreadcrumbs { + margin-left: 0; + color: $euiColorPrimary; + + .euiBreadcrumbWall { + background-image: none; // Removes any background image + } + + .euiBreadcrumbWrapper { + padding-left: 0; + padding-right: 0; + + &.euiBreadcrumbWrapper--last::after, + &:not(.euiBreadcrumbWrapper--last)::after { + content: "/"; + background-color: transparent; + padding: 0 $euiSizeS; + color: $euiColorMediumShade; + } + + &.euiBreadcrumbWrapper--last::before, + &:not(.euiBreadcrumbWrapper--last)::before { + display: none; + } + } + + .euiBreadcrumb { + color: inherit !important; + } +} diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx b/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx index ec82658efa21..6cc25b392ce3 100644 --- a/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx @@ -32,8 +32,8 @@ import { mount } from 'enzyme'; import React from 'react'; import { act } from 'react-dom/test-utils'; import { BehaviorSubject } from 'rxjs'; -import { HeaderBreadcrumbs } from './header_breadcrumbs'; import { ChromeBreadcrumb } from '../../chrome_service'; +import { HeaderBreadcrumbs } from './header_breadcrumbs'; describe('HeaderBreadcrumbs', () => { it('renders updates to the breadcrumbs$ observable', () => { @@ -56,6 +56,30 @@ describe('HeaderBreadcrumbs', () => { expect(wrapper.find('.euiBreadcrumb')).toMatchSnapshot(); }); + it('renders updates to the breadcrumbs$ observable with updated header', () => { + const breadcrumbs$ = new BehaviorSubject([{ text: 'First' }]); + const wrapper = mount( + + ); + expect(wrapper.find('.euiBreadcrumb')).toMatchSnapshot(); + expect(wrapper.find('.headerBreadcrumbs').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="breadcrumb first"]').exists()).toBeFalsy(); + + act(() => breadcrumbs$.next([{ text: 'First' }, { text: 'Second' }])); + wrapper.update(); + expect(wrapper.find('.euiBreadcrumb')).toMatchSnapshot(); + expect(wrapper.find('[data-test-subj="breadcrumb first"]').exists()).toBeTruthy(); + + act(() => breadcrumbs$.next([])); + wrapper.update(); + expect(wrapper.find('.euiBreadcrumb')).toMatchSnapshot(); + }); + it('prepend current nav group into existing breadcrumbs when nav group is enabled', () => { const breadcrumbs$ = new BehaviorSubject([{ text: 'First' }]); const breadcrumbsEnricher$ = new BehaviorSubject((crumbs: ChromeBreadcrumb[]) => [ diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx index 25f337f56d20..cac19ae160af 100644 --- a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx @@ -34,14 +34,21 @@ import React, { useEffect, useState } from 'react'; import useObservable from 'react-use/lib/useObservable'; import { Observable } from 'rxjs'; import { ChromeBreadcrumb, ChromeBreadcrumbEnricher } from '../../chrome_service'; +import './header_breadcrumbs.scss'; interface Props { appTitle$: Observable; breadcrumbs$: Observable; breadcrumbsEnricher$: Observable; + useUpdatedHeader?: boolean; } -export function HeaderBreadcrumbs({ appTitle$, breadcrumbs$, breadcrumbsEnricher$ }: Props) { +export function HeaderBreadcrumbs({ + appTitle$, + breadcrumbs$, + breadcrumbsEnricher$, + useUpdatedHeader, +}: Props) { const appTitle = useObservable(appTitle$, 'OpenSearch Dashboards'); const breadcrumbs = useObservable(breadcrumbs$, []); const [breadcrumbEnricher, setBreadcrumbEnricher] = useState< @@ -75,5 +82,15 @@ export function HeaderBreadcrumbs({ appTitle$, breadcrumbs$, breadcrumbsEnricher ), })); - return ; + const remainingCrumbs = useUpdatedHeader ? crumbs.slice(0, -1) : crumbs; + const className = useUpdatedHeader ? 'headerBreadcrumbs' : ''; + + return ( + + ); } diff --git a/src/core/public/chrome/ui/header/header_controls_container.scss b/src/core/public/chrome/ui/header/header_controls_container.scss new file mode 100644 index 000000000000..bba07ef60fbd --- /dev/null +++ b/src/core/public/chrome/ui/header/header_controls_container.scss @@ -0,0 +1,30 @@ +/* +* Copyright OpenSearch Contributors +* SPDX-License-Identifier: Apache-2.0 +*/ + +.headerControl { + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + & > .euiHeaderSectionItem { + margin: 0 $euiSizeXS; + } + + &.headerDescriptionControl { + padding: 0 $euiSizeM; + color: $euiTextSubduedColor; + } + + &.headerBottomControl { + padding: $euiSizeM; + max-width: 725px; + } + + &:empty { + display: none; + } + + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + .euiButton { + min-width: auto; + } +} diff --git a/src/core/public/chrome/ui/header/header_controls_container.test.tsx b/src/core/public/chrome/ui/header/header_controls_container.test.tsx new file mode 100644 index 000000000000..189d84f54ae9 --- /dev/null +++ b/src/core/public/chrome/ui/header/header_controls_container.test.tsx @@ -0,0 +1,131 @@ +/* + * 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 { mount, ReactWrapper } from 'enzyme'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { BehaviorSubject } from 'rxjs'; +import { MountPoint, UnmountCallback } from '../../../types'; +import { HeaderControlsContainer } from './header_controls_container'; + +type MockedUnmount = jest.MockedFunction; + +describe('HeaderControlsContainer', () => { + let component: ReactWrapper; + let controlMount$: BehaviorSubject; + let unmounts: Record; + + beforeEach(() => { + controlMount$ = new BehaviorSubject(undefined); + unmounts = {}; + }); + + const refresh = () => { + new Promise(async (resolve) => { + if (component) { + act(() => { + component.update(); + }); + } + setImmediate(() => resolve(component)); // flushes any pending promises + }); + }; + + const createMountPoint = (id: string, content: string = id): MountPoint => ( + root + ): MockedUnmount => { + const container = document.createElement('DIV'); + // eslint-disable-next-line no-unsanitized/property + container.innerHTML = content; + root.appendChild(container); + const unmount = jest.fn(() => container.remove()); + unmounts[id] = unmount; + return unmount; + }; + + it('mounts the current value of the provided observable', async () => { + component = mount(); + + act(() => { + controlMount$.next(createMountPoint('FOO')); + }); + await refresh(); + + expect(component.html()).toMatchInlineSnapshot( + `"
FOO
"` + ); + }); + + it('clears the content of the component when emitting undefined', async () => { + component = mount(); + + act(() => { + controlMount$.next(createMountPoint('FOO')); + }); + await refresh(); + + expect(component.html()).toMatchInlineSnapshot( + `"
FOO
"` + ); + + act(() => { + controlMount$.next(undefined); + }); + await refresh(); + + expect(component.html()).toMatchInlineSnapshot( + `"
"` + ); + }); + + it('updates the dom when a new mount point is emitted', async () => { + component = mount(); + + act(() => { + controlMount$.next(createMountPoint('FOO')); + }); + await refresh(); + + expect(component.html()).toMatchInlineSnapshot( + `"
FOO
"` + ); + + act(() => { + controlMount$.next(createMountPoint('BAR')); + }); + await refresh(); + + expect(component.html()).toMatchInlineSnapshot( + `"
BAR
"` + ); + }); + + it('calls the previous mount point `unmount` when mounting a new mount point', async () => { + component = mount(); + + act(() => { + controlMount$.next(createMountPoint('FOO')); + }); + await refresh(); + + expect(Object.keys(unmounts)).toEqual(['FOO']); + expect(unmounts.FOO).not.toHaveBeenCalled(); + + act(() => { + controlMount$.next(createMountPoint('BAR')); + }); + await refresh(); + + expect(Object.keys(unmounts)).toEqual(['FOO', 'BAR']); + expect(unmounts.FOO).toHaveBeenCalledTimes(1); + expect(unmounts.BAR).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/public/chrome/ui/header/header_controls_container.tsx b/src/core/public/chrome/ui/header/header_controls_container.tsx new file mode 100644 index 000000000000..a2f22db83e78 --- /dev/null +++ b/src/core/public/chrome/ui/header/header_controls_container.tsx @@ -0,0 +1,95 @@ +/* + * 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, { FC, useRef, useLayoutEffect, useState } from 'react'; +import { Observable } from 'rxjs'; +import classNames from 'classnames'; +import { MountPoint, UnmountCallback } from '../../../types'; +import './header_controls_container.scss'; + +interface HeaderControlsContainerProps { + controls$: Observable; + className?: string; + 'data-test-subj'?: string; +} + +export const HeaderControlsContainer: FC = ({ + controls$, + className, + 'data-test-subj': testSubject, +}) => { + // useObservable relies on useState under the hood. The signature is type SetStateAction = S | ((prevState: S) => S); + // As we got a Observable here, React's setState setter assume he's getting a `(prevState: S) => S` signature, + // therefore executing the mount method, causing everything to crash. + // piping the observable before calling `useObservable` causes the effect to always having a new reference, as + // the piped observable is a new instance on every render, causing infinite loops. + // this is why we use `useLayoutEffect` manually here. + const [mounter, setMounter] = useState<{ mount: MountPoint | undefined }>({ mount: undefined }); + useLayoutEffect(() => { + const s = controls$.subscribe((value) => { + setMounter({ mount: value }); + }); + return () => s.unsubscribe(); + }, [controls$]); + + const elementRef = useRef(null); + const unmountRef = useRef(null); + + useLayoutEffect(() => { + if (unmountRef.current) { + unmountRef.current(); + unmountRef.current = null; + } + + if (mounter.mount && elementRef.current) { + try { + unmountRef.current = mounter.mount(elementRef.current); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + } + }, [mounter]); + + const containerClassName = classNames( + 'euiHeaderSection', + 'euiHeaderSection--dontGrow', + 'headerControl', + className + ); + + return ( +
+ ); +}; diff --git a/src/core/public/chrome/ui/header/header_help_menu.tsx b/src/core/public/chrome/ui/header/header_help_menu.tsx index 467b72398982..e527f19fb7d3 100644 --- a/src/core/public/chrome/ui/header/header_help_menu.tsx +++ b/src/core/public/chrome/ui/header/header_help_menu.tsx @@ -45,6 +45,7 @@ import { EuiTitle, EuiToolTip, EuiHorizontalRule, + EuiButtonIcon, } from '@elastic/eui'; import { ExclusiveUnion } from '@elastic/eui'; @@ -124,6 +125,7 @@ interface Props { useDefaultContent?: boolean; opensearchDashboardsDocLink: string; surveyLink?: string; + useUpdatedAppearance?: boolean; } interface State { @@ -201,6 +203,7 @@ class HeaderHelpMenuUI extends Component { useDefaultContent, opensearchDashboardsDocLink, surveyLink, + useUpdatedAppearance, } = this.props; const { helpExtension, helpSupportUrl } = this.state; @@ -321,7 +324,20 @@ class HeaderHelpMenuUI extends Component { ); } - const button = ( + const button = useUpdatedAppearance ? ( + + ) : ( { return ( ({ createRecentNavLink: jest.fn().mockImplementation(() => { @@ -23,6 +23,7 @@ const defaultMockProps = { recentlyAccessed$: new BehaviorSubject([]), navLinks$: new BehaviorSubject([]), basePath: httpServiceMock.createStartContract().basePath, + renderBreadcrumbs: <>, }; const setup = (props: Props) => { return render(); @@ -70,11 +71,13 @@ describe('Recent items', () => { }, ]); const navigateToUrl = jest.fn(); + const renderBreadcrumbs = <>; setup({ ...defaultMockProps, workspaceList$, recentlyAccessed$, navigateToUrl, + renderBreadcrumbs, }); const button = screen.getByTestId('recentItemsSectionButton'); fireEvent.click(button); diff --git a/src/core/public/chrome/ui/header/recent_items.tsx b/src/core/public/chrome/ui/header/recent_items.tsx index eef9b86fcebe..6cd7b5c9706b 100644 --- a/src/core/public/chrome/ui/header/recent_items.tsx +++ b/src/core/public/chrome/ui/header/recent_items.tsx @@ -13,13 +13,16 @@ import { EuiTitle, EuiIcon, EuiText, + EuiSpacer, + EuiHeaderSectionItemButtonProps, } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; -import { ChromeRecentlyAccessedHistoryItem } from '../..'; +import { ChromeRecentlyAccessedHistoryItem, HeaderVariant } from '../..'; import { WorkspaceObject } from '../../../workspace'; import { createRecentNavLink } from './nav_link'; import { HttpStart } from '../../../http'; import { ChromeNavLink } from '../../../'; +import './recent_items.scss'; export interface Props { recentlyAccessed$: Rx.Observable; @@ -27,6 +30,9 @@ export interface Props { navigateToUrl: (url: string) => Promise; basePath: HttpStart['basePath']; navLinks$: Rx.Observable; + headerVariant?: HeaderVariant; + renderBreadcrumbs: React.JSX.Element; + buttonSize?: EuiHeaderSectionItemButtonProps['size']; } export const RecentItems = ({ @@ -35,6 +41,9 @@ export const RecentItems = ({ navigateToUrl, navLinks$, basePath, + headerVariant, + renderBreadcrumbs, + buttonSize = 's', }: Props) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -43,7 +52,7 @@ export const RecentItems = ({ const navLinks = useObservable(navLinks$, []).filter((link) => !link.hidden); const items = useMemo(() => { - // Only display five most latest items + // Only display five most recent items return recentlyAccessedItems.slice(0, 5).map((item) => { return { link: createRecentNavLink(item, navLinks, basePath, navigateToUrl).href, @@ -60,6 +69,8 @@ export const RecentItems = ({ setIsPopoverOpen(false); }; + const appendBreadcrumbs = Boolean(headerVariant === HeaderVariant.APPLICATION); + return ( !prev); }} data-test-subj="recentItemsSectionButton" + size={buttonSize} + className="headerRecentItemsButton" > {/* TODO: replace this icon once there is a new icon added to OUI https://github.com/opensearch-project/OpenSearch-Dashboards/issues/7354 */} @@ -82,6 +95,13 @@ export const RecentItems = ({ initialFocus={false} panelPaddingSize="s" > + {appendBreadcrumbs ? ( + <> + {renderBreadcrumbs} + + + ) : null} +

Recents

diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 731cf461824e..7f450eedae1a 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -29,10 +29,15 @@ */ import { pick } from '@osd/std'; -import { CoreId } from '../server'; -import { PackageInfo, EnvironmentMode } from '../server/types'; import { CoreSetup, CoreStart } from '.'; +import { CoreId } from '../server'; +import { EnvironmentMode, PackageInfo } from '../server/types'; +import { ApplicationService } from './application'; +import type { InternalApplicationSetup, InternalApplicationStart } from './application/types'; import { ChromeService } from './chrome'; +import { ContextService } from './context'; +import { CoreApp } from './core_app'; +import { DocLinksService } from './doc_links'; import { FatalErrorsService, FatalErrorsSetup } from './fatal_errors'; import { HttpService } from './http'; import { I18nService } from './i18n'; @@ -42,18 +47,13 @@ import { InjectedMetadataSetup, InjectedMetadataStart, } from './injected_metadata'; +import { IntegrationsService } from './integrations'; import { NotificationsService } from './notifications'; import { OverlayService } from './overlays'; import { PluginsService } from './plugins'; -import { UiSettingsService } from './ui_settings'; -import { ApplicationService } from './application'; -import { DocLinksService } from './doc_links'; import { RenderingService } from './rendering'; import { SavedObjectsService } from './saved_objects'; -import { ContextService } from './context'; -import { IntegrationsService } from './integrations'; -import { CoreApp } from './core_app'; -import type { InternalApplicationSetup, InternalApplicationStart } from './application/types'; +import { UiSettingsService } from './ui_settings'; import { WorkspacesService } from './workspace'; interface Params { @@ -272,7 +272,11 @@ export class CoreSystem { await this.plugins.start(core); - const { useExpandedHeader = true } = injectedMetadata.getBranding() ?? {}; + let { useExpandedHeader = true } = injectedMetadata.getBranding() ?? {}; + if (uiSettings.get('home:useNewHomePage')) { + useExpandedHeader = false; + this.rootDomElement.classList.add('headerIsDense'); + } // ensure the rootDomElement is empty this.rootDomElement.textContent = ''; diff --git a/src/core/public/index.ts b/src/core/public/index.ts index aa1f104faec0..979e618fb505 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -77,6 +77,7 @@ import { NavGroupItemInMap, fulfillRegistrationLinksToChromeNavLinks, createRecentNavLink, + HeaderVariant, LinkItemType, getSortedNavLinks, } from './chrome'; @@ -383,6 +384,7 @@ export { NavGroupItemInMap, fulfillRegistrationLinksToChromeNavLinks, createRecentNavLink, + HeaderVariant, LinkItemType, getSortedNavLinks, }; 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/mocks.ts b/src/core/public/mocks.ts index 05c3b7d18d1b..d2c7f86f8216 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -31,24 +31,26 @@ import { createMemoryHistory } from 'history'; // Only import types from '.' to avoid triggering default Jest mocks. -import { CoreContext, PluginInitializerContext, AppMountParameters } from '.'; +import { AppMountParameters, CoreContext, PluginInitializerContext } from '.'; // Import values from their individual modules instead. import { ScopedHistory } from './application'; import { applicationServiceMock } from './application/application_service.mock'; import { chromeServiceMock } from './chrome/chrome_service.mock'; +import { contextServiceMock } from './context/context_service.mock'; import { docLinksServiceMock } from './doc_links/doc_links_service.mock'; import { fatalErrorsServiceMock } from './fatal_errors/fatal_errors_service.mock'; import { httpServiceMock } from './http/http_service.mock'; import { i18nServiceMock } from './i18n/i18n_service.mock'; +import { injectedMetadataServiceMock } from './injected_metadata/injected_metadata_service.mock'; import { notificationServiceMock } from './notifications/notifications_service.mock'; import { overlayServiceMock } from './overlays/overlay_service.mock'; -import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; -import { contextServiceMock } from './context/context_service.mock'; -import { injectedMetadataServiceMock } from './injected_metadata/injected_metadata_service.mock'; +import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { workspacesServiceMock } from './workspace/workspaces_service.mock'; +export { applicationServiceMock } from './application/application_service.mock'; +export { scopedHistoryMock } from './application/scoped_history.mock'; export { chromeServiceMock } from './chrome/chrome_service.mock'; export { docLinksServiceMock } from './doc_links/doc_links_service.mock'; export { fatalErrorsServiceMock } from './fatal_errors/fatal_errors_service.mock'; @@ -57,10 +59,8 @@ export { i18nServiceMock } from './i18n/i18n_service.mock'; export { injectedMetadataServiceMock } from './injected_metadata/injected_metadata_service.mock'; export { notificationServiceMock } from './notifications/notifications_service.mock'; export { overlayServiceMock } from './overlays/overlay_service.mock'; -export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; export { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; -export { scopedHistoryMock } from './application/scoped_history.mock'; -export { applicationServiceMock } from './application/application_service.mock'; +export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; export { workspacesServiceMock } from './workspace/workspaces_service.mock'; function createCoreSetupMock({ @@ -185,6 +185,12 @@ function createAppMountParametersMock(appBasePath = '') { history, onAppLeave: jest.fn(), setHeaderActionMenu: jest.fn(), + setHeaderLeftControls: jest.fn(), + setHeaderCenterControls: jest.fn(), + setHeaderRightControls: jest.fn(), + setHeaderBadgeControls: jest.fn(), + setHeaderDescriptionControls: jest.fn(), + setHeaderBottomControls: jest.fn(), }; return params; 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/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 6ef454abab46..7088be99c017 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -154,6 +154,12 @@ export function createPluginStartContext< navigateToApp: deps.application.navigateToApp, navigateToUrl: deps.application.navigateToUrl, getUrlForApp: deps.application.getUrlForApp, + setAppLeftControls: deps.application.setAppLeftControls, + setAppCenterControls: deps.application.setAppCenterControls, + setAppRightControls: deps.application.setAppRightControls, + setAppBadgeControls: deps.application.setAppBadgeControls, + setAppDescriptionControls: deps.application.setAppDescriptionControls, + setAppBottomControls: deps.application.setAppBottomControls, registerMountContext: (contextName, provider) => deps.application.registerMountContext(plugin.opaqueId, contextName, provider), }, diff --git a/src/core/public/ui_settings/ui_settings_service.mock.ts b/src/core/public/ui_settings/ui_settings_service.mock.ts index 8458c86d6774..de9477ed0e08 100644 --- a/src/core/public/ui_settings/ui_settings_service.mock.ts +++ b/src/core/public/ui_settings/ui_settings_service.mock.ts @@ -67,6 +67,8 @@ const createMock = () => { }; mocked.setup.mockReturnValue(createSetupContractMock()); + // UiSettings.start returns the client that is returned by setup + mocked.start.mockReturnValue(createSetupContractMock()); return mocked; }; diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index e68e1667e223..412336817f15 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -130,4 +130,11 @@ export const DEFAULT_APP_CATEGORIES: Record = Object.freeze }), order: 1000, }, + manageWorkspace: { + id: 'manageWorkspace', + label: i18n.translate('core.ui.manageWorkspaceNav.label', { + defaultMessage: 'Manage workspace', + }), + order: 8000, + }, }); diff --git a/src/core/utils/default_nav_groups.ts b/src/core/utils/default_nav_groups.ts index 5565bce3c0f5..64fea126ff3e 100644 --- a/src/core/utils/default_nav_groups.ts +++ b/src/core/utils/default_nav_groups.ts @@ -40,7 +40,6 @@ const defaultNavGroups = { defaultMessage: 'This is a use case contains all the features.', }), order: 3000, - type: NavGroupType.SYSTEM, }, observability: { id: 'observability', 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'); + }); + }); +}); diff --git a/src/plugins/advanced_settings/public/header_user_theme_menu.tsx b/src/plugins/advanced_settings/public/header_user_theme_menu.tsx index 0513016eaa9c..fc297aaa74bc 100644 --- a/src/plugins/advanced_settings/public/header_user_theme_menu.tsx +++ b/src/plugins/advanced_settings/public/header_user_theme_menu.tsx @@ -8,7 +8,6 @@ import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; import { EuiSmallButton, - EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiCompressedFormRow, @@ -18,18 +17,15 @@ import { EuiPopover, EuiPopoverTitle, EuiCompressedSelect, - EuiSpacer, EuiToolTip, + EuiButtonIcon, } from '@elastic/eui'; import { CoreStart } from 'opensearch-dashboards/public'; import { useOpenSearchDashboards, useUiSetting$ } from '../../opensearch_dashboards_react/public'; export const HeaderUserThemeMenu = () => { const { - services: { - http: { basePath }, - uiSettings, - }, + services: { uiSettings }, } = useOpenSearchDashboards(); // TODO: move to central location? const themeOptions = [ @@ -74,6 +70,8 @@ export const HeaderUserThemeMenu = () => { const defaultTheme = allSettings['theme:version'].value; const defaultScreenMode = allSettings['theme:darkMode'].value; + const legacyAppearance = !uiSettings.get('home:useNewHomePage'); + const onButtonClick = () => { setPopover(!isPopoverOpen); }; @@ -111,6 +109,31 @@ export const HeaderUserThemeMenu = () => { setPopover(false); }; + const innerButton = legacyAppearance ? ( + + ) : ( +
@@ -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/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts index 0288249ae6b2..4caf0fe755d1 100644 --- a/src/plugins/data_source_management/public/plugin.ts +++ b/src/plugins/data_source_management/public/plugin.ts @@ -4,6 +4,7 @@ */ import React from 'react'; +import { i18n } from '@osd/i18n'; import { DataSourcePluginSetup } from 'src/plugins/data_source/public'; import { AppMountParameters, @@ -93,6 +94,9 @@ export interface DataSourceManagementPluginStart { getAuthenticationMethodRegistry: () => IAuthenticationMethodRegistry; } +/** + * The id is used in src/plugins/workspace/public/plugin.ts and please change that accordingly if you change the id here. + */ export const DSM_APP_ID = 'dataSources'; export class DataSourceManagementPlugin @@ -145,6 +149,8 @@ export class DataSourceManagementPlugin /** * The data sources features in observability has the same name as `DSM_APP_ID` * Add a suffix to avoid duplication + * + * The id is used in src/plugins/workspace/public/plugin.ts and please change that accordingly if you change the id here. */ const DSM_APP_ID_FOR_STANDARD_APPLICATION = `${DSM_APP_ID}_core`; @@ -153,6 +159,9 @@ export class DataSourceManagementPlugin id: DSM_APP_ID_FOR_STANDARD_APPLICATION, title: PLUGIN_NAME, order: 100, + description: i18n.translate('data_source_management.description', { + defaultMessage: 'Create and manage data source connections.', + }), mount: async (params: AppMountParameters) => { const { mountManagementSection } = await import('./management_app'); const [coreStart] = await core.getStartServices(); @@ -181,46 +190,6 @@ export class DataSourceManagementPlugin }, ]); - core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.observability, [ - { - id: DSM_APP_ID_FOR_STANDARD_APPLICATION, - category: DEFAULT_APP_CATEGORIES.manage, - order: 100, - }, - ]); - - core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.search, [ - { - id: DSM_APP_ID_FOR_STANDARD_APPLICATION, - category: DEFAULT_APP_CATEGORIES.manage, - order: 100, - }, - ]); - - core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS['security-analytics'], [ - { - id: DSM_APP_ID_FOR_STANDARD_APPLICATION, - category: DEFAULT_APP_CATEGORIES.manage, - order: 100, - }, - ]); - - core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.essentials, [ - { - id: DSM_APP_ID_FOR_STANDARD_APPLICATION, - category: DEFAULT_APP_CATEGORIES.manage, - order: 100, - }, - ]); - - core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.all, [ - { - id: DSM_APP_ID_FOR_STANDARD_APPLICATION, - category: DEFAULT_APP_CATEGORIES.manage, - order: 100, - }, - ]); - // when the feature flag is disabled, we don't need to register any of the mds components if (!this.featureFlagStatus) { return undefined; 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/home/public/plugin.ts b/src/plugins/home/public/plugin.ts index 672f390b6416..fe1099a8e635 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -64,7 +64,7 @@ import { PLUGIN_ID, HOME_APP_BASE_PATH, IMPORT_SAMPLE_DATA_APP_ID } from '../com import { DataSourcePluginStart } from '../../data_source/public'; import { workWithDataSection } from './application/components/homepage/sections/work_with_data'; import { learnBasicsSection } from './application/components/homepage/sections/learn_basics'; -import { DEFAULT_NAV_GROUPS, DEFAULT_APP_CATEGORIES } from '../../../core/public'; +import { DEFAULT_NAV_GROUPS } from '../../../core/public'; import { ContentManagementPluginSetup, ContentManagementPluginStart, @@ -179,9 +179,7 @@ export class HomePublicPlugin title: i18n.translate('home.tutorialDirectory.featureCatalogueTitle', { defaultMessage: 'Add sample data', }), - navLinkStatus: core.chrome.navGroup.getNavGroupEnabled() - ? AppNavLinkStatus.visible - : AppNavLinkStatus.hidden, + navLinkStatus: AppNavLinkStatus.hidden, mount: async (params: AppMountParameters) => { const [coreStart] = await core.getStartServices(); setCommonService(); @@ -196,26 +194,6 @@ export class HomePublicPlugin }); urlForwarding.forwardApp('home', 'home'); - const configurationInfoForImportSampleData = { - id: IMPORT_SAMPLE_DATA_APP_ID, - title: i18n.translate('home.nav.sampleData.label', { - defaultMessage: 'Sample data', - }), - order: 400, - category: DEFAULT_APP_CATEGORIES.manage, - }; - - // Register sample data to all of the use cases in 2.16 - [ - DEFAULT_NAV_GROUPS.all, - DEFAULT_NAV_GROUPS.essentials, - DEFAULT_NAV_GROUPS['security-analytics'], - DEFAULT_NAV_GROUPS.observability, - DEFAULT_NAV_GROUPS.search, - ].forEach((navGroup) => - core.chrome.navGroup.addNavLinksToGroup(navGroup, [configurationInfoForImportSampleData]) - ); - const featureCatalogue = { ...this.featuresCatalogueRegistry.setup() }; featureCatalogue.register({ 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.test.ts b/src/plugins/index_pattern_management/public/plugin.test.ts index a0525acae2dc..ec9a6137ffcf 100644 --- a/src/plugins/index_pattern_management/public/plugin.test.ts +++ b/src/plugins/index_pattern_management/public/plugin.test.ts @@ -25,7 +25,7 @@ describe('DiscoverPlugin', () => { }) ).not.toThrow(); expect(setupMock.application.register).toBeCalledTimes(1); - expect(setupMock.chrome.navGroup.addNavLinksToGroup).toBeCalledTimes(5); + expect(setupMock.chrome.navGroup.addNavLinksToGroup).toBeCalledTimes(1); }); it('when new navigation is enabled, should navigate to standard IPM app', async () => { diff --git a/src/plugins/index_pattern_management/public/plugin.ts b/src/plugins/index_pattern_management/public/plugin.ts index 3e98374b3c80..d74cdaffe97e 100644 --- a/src/plugins/index_pattern_management/public/plugin.ts +++ b/src/plugins/index_pattern_management/public/plugin.ts @@ -46,8 +46,9 @@ import { } from './service'; import { ManagementSetup } from '../../management/public'; -import { DEFAULT_NAV_GROUPS, AppStatus, DEFAULT_APP_CATEGORIES } from '../../../core/public'; +import { AppStatus, DEFAULT_NAV_GROUPS } 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; } @@ -68,6 +70,9 @@ const sectionsHeader = i18n.translate('indexPatternManagement.indexPattern.secti defaultMessage: 'Index patterns', }); +/** + * The id is used in src/plugins/workspace/public/plugin.ts and please change that accordingly if you change the id here. + */ const IPM_APP_ID = 'indexPatterns'; export class IndexPatternManagementPlugin @@ -137,6 +142,9 @@ export class IndexPatternManagementPlugin core.application.register({ id: IPM_APP_ID, title: sectionsHeader, + description: i18n.translate('indexPatternManagement.indexPattern.description', { + defaultMessage: 'Manage index patterns to retrieve data from OpenSearch.', + }), status: core.chrome.navGroup.getNavGroupEnabled() ? AppStatus.accessible : AppStatus.inaccessible, @@ -159,43 +167,11 @@ export class IndexPatternManagementPlugin }, }); - core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.essentials, [ - { - id: IPM_APP_ID, - category: DEFAULT_APP_CATEGORIES.manage, - order: 200, - }, - ]); - - core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.observability, [ - { - id: IPM_APP_ID, - category: DEFAULT_APP_CATEGORIES.manage, - order: 200, - }, - ]); - - core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.search, [ - { - id: IPM_APP_ID, - category: DEFAULT_APP_CATEGORIES.manage, - order: 200, - }, - ]); - - core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS['security-analytics'], [ - { - id: IPM_APP_ID, - category: DEFAULT_APP_CATEGORIES.manage, - order: 200, - }, - ]); - - core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.all, [ + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.settingsAndSetup, [ { id: IPM_APP_ID, - category: DEFAULT_APP_CATEGORIES.manage, - order: 200, + title: sectionsHeader, + order: 400, }, ]); 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/management/opensearch_dashboards.json b/src/plugins/management/opensearch_dashboards.json index cf7d7d781f16..eb9a67c09296 100644 --- a/src/plugins/management/opensearch_dashboards.json +++ b/src/plugins/management/opensearch_dashboards.json @@ -4,5 +4,6 @@ "server": true, "ui": true, "optionalPlugins": ["home", "managementOverview"], - "requiredBundles": ["opensearchDashboardsReact", "opensearchDashboardsUtils"] + "requiredBundles": ["opensearchDashboardsReact", "opensearchDashboardsUtils"], + "requiredPlugins": ["navigation"] } diff --git a/src/plugins/management/public/components/feature_cards/__snapshots__/feature_cards.test.tsx.snap b/src/plugins/management/public/components/feature_cards/__snapshots__/feature_cards.test.tsx.snap index 0718fb98bf4f..c0963f0462aa 100644 --- a/src/plugins/management/public/components/feature_cards/__snapshots__/feature_cards.test.tsx.snap +++ b/src/plugins/management/public/components/feature_cards/__snapshots__/feature_cards.test.tsx.snap @@ -2,22 +2,6 @@ exports[` render with complex navLinks 1`] = `
-
-
-
-

-

-
-
null; + const navLinks: ChromeNavLink[] = [ { id: '1', @@ -85,7 +91,13 @@ const navLinks: ChromeNavLink[] = [ describe('', () => { it('render with empty navLinks', () => { const { container } = render( - + ); expect(container).toMatchSnapshot(); }); @@ -93,10 +105,11 @@ describe('', () => { it('render with complex navLinks', () => { const { container, getAllByTestId } = render( ); expect(container).toMatchSnapshot(); @@ -107,10 +120,11 @@ describe('', () => { const mockedNavigateToApp = jest.fn(); const { getByTestId } = render( ); fireEvent.click(getByTestId('landingPageFeature_1')); diff --git a/src/plugins/management/public/components/feature_cards/feature_cards.tsx b/src/plugins/management/public/components/feature_cards/feature_cards.tsx index 57d7e128c928..7c52e6a05fda 100644 --- a/src/plugins/management/public/components/feature_cards/feature_cards.tsx +++ b/src/plugins/management/public/components/feature_cards/feature_cards.tsx @@ -3,42 +3,32 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { i18n } from '@osd/i18n'; import { EuiCard, - EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiPageContent, - EuiPageHeader, - EuiPageHeaderSection, - EuiPanel, EuiSpacer, EuiTitle, } from '@elastic/eui'; import { AppCategory, ChromeNavLink, CoreStart } from 'opensearch-dashboards/public'; import React, { useMemo } from 'react'; +import { NavigationPublicPluginStart } from '../../../../../../src/plugins/navigation/public'; export interface FeatureCardsProps { - pageTitle: string; + pageDescription: string; navLinks: ChromeNavLink[]; navigateToApp: CoreStart['application']['navigateToApp']; - getStartedCards: Array<{ - id: string; - title: string; - description: string; - }>; + setAppDescriptionControls: CoreStart['application']['setAppDescriptionControls']; + navigationUI: NavigationPublicPluginStart['ui']; } -const getStartedTitle = i18n.translate('management.gettingStarted.label', { - defaultMessage: 'Get started', -}); - export const FeatureCards = ({ navLinks, navigateToApp, - pageTitle, - getStartedCards, + setAppDescriptionControls, + pageDescription, + navigationUI: { HeaderControl }, }: FeatureCardsProps) => { const itemsPerRow = 4; const groupedCardForDisplay = useMemo(() => { @@ -65,41 +55,14 @@ export const FeatureCards = ({ } return ( <> - - - - -

{pageTitle}

-
-
-
- {getStartedCards.length ? ( - <> - - -

{getStartedTitle}

-
- - {getStartedCards.map((card) => { - return ( - - - navigateToApp(card.id)} - titleSize="xs" - /> - - - ); - })} - - - ) : null} -
+ {groupedCardForDisplay.map((group) => (
diff --git a/src/plugins/management/public/landing_page_application.test.tsx b/src/plugins/management/public/landing_page_application.test.tsx index 13d14ff34609..e8c6f994fff9 100644 --- a/src/plugins/management/public/landing_page_application.test.tsx +++ b/src/plugins/management/public/landing_page_application.test.tsx @@ -4,6 +4,8 @@ */ import { renderApp } from './landing_page_application'; +import { navigationPluginMock } from '../../navigation/public/mocks'; +import { coreMock } from '../../../../src/core/public/mocks'; describe('Landing page application', () => { it('renders and unmount without crashing', () => { @@ -13,6 +15,9 @@ describe('Landing page application', () => { props: { navigateToApp: jest.fn(), navLinks: [], + navigationUI: navigationPluginMock.createStartContract().ui, + setAppDescriptionControls: coreMock.createStart().application.setAppDescriptionControls, + pageDescription: '', }, }); diff --git a/src/plugins/management/public/plugin.ts b/src/plugins/management/public/plugin.ts index ef91e776a962..7b219bffad9e 100644 --- a/src/plugins/management/public/plugin.ts +++ b/src/plugins/management/public/plugin.ts @@ -46,7 +46,6 @@ import { AppNavLinkStatus, DEFAULT_NAV_GROUPS, WorkspaceAvailability, - ChromeNavLink, } from '../../../core/public'; import { MANAGEMENT_APP_ID } from '../common/contants'; @@ -62,13 +61,17 @@ import { LinkItemType, getSortedNavLinks, } from '../../../core/public'; +import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public'; interface ManagementSetupDependencies { home?: HomePublicPluginSetup; managementOverview?: ManagementOverViewPluginSetup; } - -export class ManagementPlugin implements Plugin { +interface ManagementStartDependencies { + navigation: NavigationPublicPluginStart; +} +export class ManagementPlugin + implements Plugin { private readonly managementSections = new ManagementSectionsService(); private readonly appUpdater = new BehaviorSubject(() => ({})); @@ -81,7 +84,10 @@ export class ManagementPlugin implements Plugin, + { home, managementOverview }: ManagementSetupDependencies + ) { const opensearchDashboardsVersion = this.initializerContext.env.packageInfo.version; core.application.register({ @@ -111,15 +117,44 @@ export class ManagementPlugin implements Plugin { const { renderApp } = await import('./landing_page_application'); - const [coreStart] = await core.getStartServices(); + const [coreStart, { navigation }] = await core.getStartServices(); const navLinks = ( await getNavLinksByNavGroupId(DEFAULT_NAV_GROUPS.settingsAndSetup.id) ).filter((navLink) => navLink.id !== settingsLandingPageId); + coreStart.chrome.setBreadcrumbs([ + { + text: settingsLandingPageTitle, + }, + ]); return renderApp({ mountElement: params.element, props: { navigateToApp: coreStart.application.navigateToApp, + setAppDescriptionControls: coreStart.application.setAppDescriptionControls, navLinks, - pageTitle: settingsLandingPageTitle, - getStartedCards: [], + pageDescription: settingsLandingPageDescription, + navigationUI: navigation.ui, }, }); }, @@ -205,7 +246,7 @@ export class ManagementPlugin implements Plugin { const { renderApp } = await import('./landing_page_application'); - const [coreStart] = await core.getStartServices(); + const [coreStart, { navigation }] = await core.getStartServices(); const navLinks = ( await getNavLinksByNavGroupId(DEFAULT_NAV_GROUPS.dataAdministration.id) ).filter((navLink) => navLink.id !== dataAdministrationLandingPageId); + coreStart.chrome.setBreadcrumbs([ + { + text: dataAdministrationPageTitle, + }, + ]); + return renderApp({ mountElement: params.element, props: { navigateToApp: coreStart.application.navigateToApp, navLinks, - pageTitle: dataAdministrationPageTitle, - getStartedCards: [], + pageDescription: dataAdministrationPageDescription, + navigationUI: navigation.ui, + setAppDescriptionControls: coreStart.application.setAppDescriptionControls, }, }); }, 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..556b58ff6454 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,3 +1,75 @@ +@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; + } +} + +// 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/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..4482eaec723d --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_control_data.tsx @@ -0,0 +1,94 @@ +/* + * 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; + links?: TopNavControlLinkData | TopNavControlLinkData[]; +} + +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..4b2d7b948170 --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.tsx @@ -0,0 +1,155 @@ +/* + * 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) { + const links = props.links && [props.links].flat(); + + return ( + + {props.description} + {links?.map((linkProps) => ( + + ))} + + ); + } + + 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..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 @@ -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,18 +115,27 @@ 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 { + 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()} @@ -127,17 +148,91 @@ 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 screenTitle ? ( + + + + {screenTitle} + + {renderMenu(menuClassName, true)} + + + ) : ( + + {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 +241,14 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { {renderSearchBar()} ); - } else { - return ( - <> - {renderMenu(menuClassName)} - {renderSearchBar()} - - ); } + + return ( + <> + {renderMenu(menuClassName)} + {renderSearchBar()} + + ); } return renderLayout(); @@ -167,4 +262,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/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx b/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx index a472461e0e3c..3994bd3e4f7b 100644 --- a/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx @@ -46,6 +46,7 @@ import { EuiCallOut, EuiBasicTableColumn, EuiText, + EuiPageProps, } from '@elastic/eui'; import { HttpFetchError, ToastsStart } from 'opensearch-dashboards/public'; import { toMountPoint } from '../util'; @@ -81,6 +82,8 @@ export interface TableListViewProps { * If the table is not empty, this component renders its own h1 element using the same id. */ headingId?: string; + restrictWidth?: boolean; + paddingSize?: EuiPageProps['paddingSize']; } export interface TableListViewState { @@ -559,7 +562,8 @@ class TableListView extends React.Component
- +
- -
+
+
+
+ this is my foo workspace description +
+
+
+
+
+
+
+
+

+ Use case +

+

+

+
+
+
+

+ Owner +

+

+ user1 +    + +

+
+
+
+
+

+ Last updated +

+

+ Invalid date +

+
+
+
+
+

+ ID +

+

+ foo_id + + + +

+
+
+
+
+

+ Workspace overview +

+

+ + Overview + + +

+
+
+
+
+
@@ -52,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 +

+
+
+ +
+
- - About - +
+ +
+
-

- this is my foo workspace description -

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

+ Description - + + optional + +

+
+
+ Describe the workspace. +
+
+
+
+
+
+ +
+
+ +
+ + 164 characters left. + +
+
+
+
+
+
+
+
+
+

+ 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 20f739273182..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 @@ -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,168 @@ 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(screen.queryByText('foo')).not.toBeNull(); - expect(document.querySelector('#overview')).toHaveClass('euiTab-isSelected'); + expect(document.querySelector('#details')).toHaveClass('euiTab-isSelected'); + expect(screen.queryByTestId('workspaceTabs')).not.toBeNull(); + }); + + 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 collaborators tab will workspace update page with permission', async () => { + 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'); + }); + + 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(); }); }); 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 348414eba46d..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,26 +3,37 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { EuiPage, + EuiText, + EuiSpacer, + EuiFlexItem, EuiPageBody, + EuiFlexGroup, EuiPageHeader, - EuiFlexItem, + EuiPageContent, + EuiSmallButton, + EuiConfirmModal, EuiTabbedContent, - EuiFlexGroup, - EuiPanel, } from '@elastic/eui'; - -import { useObservable } from 'react-use'; 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; @@ -30,76 +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 pageTitle = ( - - {currentWorkspace?.name} - - ); + 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 294851502567..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'); }, }, ]; @@ -36,15 +78,104 @@ export const WorkspaceDetailApp = (props: WorkspaceDetailProps) => { text: currentWorkspace.name, }); breadcrumbs.push({ - text: i18n.translate('workspace.detail.breadcrumb', { defaultMessage: 'Workspace Detail' }), + text: i18n.translate('workspace.detail.title', { + defaultMessage: '{name} settings', + values: { + name: currentWorkspace.name, + }, + }), }); } 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/fields/workspace_description_field.test.tsx b/src/plugins/workspace/public/components/workspace_form/fields/workspace_description_field.test.tsx new file mode 100644 index 000000000000..e8795bef7a76 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/fields/workspace_description_field.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { MAX_WORKSPACE_DESCRIPTION_LENGTH } from '../../../../common/constants'; +import { WorkspaceDescriptionField } from './workspace_description_field'; + +describe('', () => { + it('should call onChange when the new value', () => { + const onChangeMock = jest.fn(); + const value = 'test'; + + render(); + + const textarea = screen.getByPlaceholderText('Describe the workspace'); + fireEvent.change(textarea, { target: { value: 'new value' } }); + + expect(onChangeMock).toHaveBeenCalledWith('new value'); + + fireEvent.change(textarea, { + target: { value: 'a'.repeat(MAX_WORKSPACE_DESCRIPTION_LENGTH + 1) }, + }); + + expect(onChangeMock).toHaveBeenCalledWith('a'.repeat(MAX_WORKSPACE_DESCRIPTION_LENGTH + 1)); + }); + + it('should render the correct number of characters left when value larger than MAX_WORKSPACE_DESCRIPTION_LENGTH', () => { + render( + + ); + + const helpText = screen.getByText(new RegExp(`-1.+characters left\.`)); + expect(helpText).toBeInTheDocument(); + }); + + it('should render the correct number of characters left when value is empty', () => { + render(); + + const helpText = screen.getByText( + new RegExp(`${MAX_WORKSPACE_DESCRIPTION_LENGTH}.+characters left\.`) + ); + expect(helpText).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/fields/workspace_description_field.tsx b/src/plugins/workspace/public/components/workspace_form/fields/workspace_description_field.tsx new file mode 100644 index 000000000000..599445cd25d2 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/fields/workspace_description_field.tsx @@ -0,0 +1,66 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiCompressedFormRow, EuiCompressedTextArea, EuiTextColor } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import React, { useCallback } from 'react'; + +import { MAX_WORKSPACE_DESCRIPTION_LENGTH } from '../../../../common/constants'; + +export interface WorkspaceDescriptionFieldProps { + value?: string; + onChange: (newValue: string) => void; + error?: string; + readOnly?: boolean; +} + +export const WorkspaceDescriptionField = ({ + value, + error, + readOnly, + onChange, +}: WorkspaceDescriptionFieldProps) => { + const handleChange = useCallback( + (e) => { + onChange(e.currentTarget.value); + }, + [onChange] + ); + const leftCharacters = MAX_WORKSPACE_DESCRIPTION_LENGTH - (value?.length ?? 0); + const charactersOverflow = leftCharacters < 0; + + return ( + + Description - optional + + } + isInvalid={!!error || charactersOverflow} + error={error} + helpText={ + + {i18n.translate('workspace.form.description.charactersLeft', { + defaultMessage: '{leftCharacters} characters left.', + values: { + leftCharacters, + }, + })} + + } + > + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/fields/workspace_name_field.test.tsx b/src/plugins/workspace/public/components/workspace_form/fields/workspace_name_field.test.tsx new file mode 100644 index 000000000000..50779124f3f8 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/fields/workspace_name_field.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { MAX_WORKSPACE_NAME_LENGTH } from '../../../../common/constants'; +import { WorkspaceNameField } from './workspace_name_field'; + +describe('', () => { + it('should call onChange when the new value', () => { + const onChangeMock = jest.fn(); + const value = 'test'; + + render(); + + const input = screen.getByPlaceholderText('Enter a name'); + fireEvent.change(input, { target: { value: 'new value' } }); + + expect(onChangeMock).toHaveBeenCalledWith('new value'); + + fireEvent.change(input, { target: { value: 'a'.repeat(MAX_WORKSPACE_NAME_LENGTH + 1) } }); + + expect(onChangeMock).toHaveBeenCalledWith('a'.repeat(MAX_WORKSPACE_NAME_LENGTH + 1)); + }); + + it('should render the correct number of characters left when value greater than MAX_WORKSPACE_NAME_LENGTH', () => { + render( + + ); + + const helpText = screen.getByText(new RegExp(`-1.+characters left\.`)); + expect(helpText).toBeInTheDocument(); + }); + + it('should render the correct number of characters left when value is empty', () => { + render(); + + const helpText = screen.getByText( + new RegExp(`${MAX_WORKSPACE_NAME_LENGTH}.+characters left\.`) + ); + expect(helpText).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/fields/workspace_name_field.tsx b/src/plugins/workspace/public/components/workspace_form/fields/workspace_name_field.tsx new file mode 100644 index 000000000000..e8ddf6b4e3ea --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/fields/workspace_name_field.tsx @@ -0,0 +1,70 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiCompressedFieldText, EuiCompressedFormRow, EuiTextColor } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import React, { useCallback } from 'react'; + +import { MAX_WORKSPACE_NAME_LENGTH } from '../../../../common/constants'; + +export interface WorkspaceNameFieldProps { + value?: string; + onChange: (newValue: string) => void; + error?: string; + readOnly?: boolean; +} + +export const WorkspaceNameField = ({ + value, + error, + readOnly, + onChange, +}: WorkspaceNameFieldProps) => { + const handleChange = useCallback( + (e) => { + onChange(e.currentTarget.value); + }, + [onChange] + ); + const leftCharacters = MAX_WORKSPACE_NAME_LENGTH - (value?.length ?? 0); + const charactersOverflow = leftCharacters < 0; + + return ( + + + {i18n.translate('workspace.form.name.charactersLeft', { + defaultMessage: '{leftCharacters} characters left.', + values: { + leftCharacters, + }, + })} + +
+ {i18n.translate('workspace.form.workspaceDetails.name.helpText', { + defaultMessage: + 'Use a unique name for the workspace. Valid characters are a-z, A-Z, 0-9, (), [], _ (underscore), - (hyphen) and (space).', + })} + + } + isInvalid={!!error || charactersOverflow} + error={error} + > + +
+ ); +}; 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 e0b01454fa0f..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 @@ -4,12 +4,7 @@ */ import { useCallback, useState, FormEventHandler, useRef, useMemo } from 'react'; -import { - htmlIdGenerator, - EuiFieldTextProps, - EuiTextAreaProps, - EuiColorPickerProps, -} from '@elastic/eui'; +import { htmlIdGenerator, EuiColorPickerProps } from '@elastic/eui'; import { useApplications } from '../../hooks'; import { @@ -38,10 +33,11 @@ export const useWorkspaceForm = ({ permissionEnabled, }: WorkspaceFormProps) => { const applications = useApplications(application); - const [name, setName] = useState(defaultValues?.name); + const [name, setName] = useState(defaultValues?.name ?? ''); 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) ); @@ -124,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, @@ -133,31 +129,37 @@ export const useWorkspaceForm = ({ [onSubmit, permissionEnabled] ); - const handleNameInputChange = useCallback['onChange']>((e) => { - setName(e.target.value); - }, []); - - const handleDescriptionChange = useCallback['onChange']>((e) => { - setDescription(e.target.value); - }, []); - const handleColorChange = useCallback['onChange']>((text) => { 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, handleColorChange, handleUseCaseChange, - handleNameInputChange, setPermissionSettings, setSelectedDataSources, - handleDescriptionChange, }; }; 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_create_action_panel.test.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_create_action_panel.test.tsx new file mode 100644 index 000000000000..6f1dbc58bf9e --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_create_action_panel.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { applicationServiceMock } from '../../../../../core/public/mocks'; +import { + MAX_WORKSPACE_DESCRIPTION_LENGTH, + MAX_WORKSPACE_NAME_LENGTH, +} from '../../../common/constants'; +import { WorkspaceCreateActionPanel } from './workspace_create_action_panel'; + +const mockApplication = applicationServiceMock.createStartContract(); + +describe('WorkspaceCreateActionPanel', () => { + const formId = 'workspaceForm'; + const formData = { + name: 'Test Workspace', + description: 'This is a test workspace', + }; + + it('should disable the "Create Workspace" button when name exceeds the maximum length', () => { + const longName = 'a'.repeat(MAX_WORKSPACE_NAME_LENGTH + 1); + render( + + ); + const createButton = screen.getByText('Create workspace'); + expect(createButton.closest('button')).toBeDisabled(); + }); + + it('should disable the "Create Workspace" button when description exceeds the maximum length', () => { + const longDescription = 'a'.repeat(MAX_WORKSPACE_DESCRIPTION_LENGTH + 1); + render( + + ); + const createButton = screen.getByText('Create workspace'); + expect(createButton.closest('button')).toBeDisabled(); + }); + + it('should enable the "Create Workspace" button when name and description are within the maximum length', () => { + render( + + ); + const createButton = screen.getByText('Create workspace'); + expect(createButton.closest('button')).not.toBeDisabled(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_create_action_panel.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_create_action_panel.tsx index a7824ffac428..0b914c0a7658 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_create_action_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_create_action_panel.tsx @@ -6,21 +6,31 @@ import { EuiSmallButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import React, { useState, useCallback } from 'react'; -import { ApplicationStart } from 'opensearch-dashboards/public'; +import type { ApplicationStart } from 'opensearch-dashboards/public'; +import type { WorkspaceFormData } from './types'; import { WorkspaceCancelModal } from './workspace_cancel_modal'; +import { + MAX_WORKSPACE_DESCRIPTION_LENGTH, + MAX_WORKSPACE_NAME_LENGTH, +} from '../../../common/constants'; interface WorkspaceCreateActionPanelProps { formId: string; + formData: Partial>; application: ApplicationStart; } export const WorkspaceCreateActionPanel = ({ formId, + formData, application, }: WorkspaceCreateActionPanelProps) => { const [isCancelModalVisible, setIsCancelModalVisible] = useState(false); const closeCancelModal = useCallback(() => setIsCancelModalVisible(false), []); const showCancelModal = useCallback(() => setIsCancelModalVisible(true), []); + const createButtonDisabled = + (formData.name?.length ?? 0) > MAX_WORKSPACE_NAME_LENGTH || + (formData.description?.length ?? 0) > MAX_WORKSPACE_DESCRIPTION_LENGTH; return ( <> @@ -41,6 +51,7 @@ export const WorkspaceCreateActionPanel = ({ type="submit" form={formId} data-test-subj="workspaceForm-bottomBar-createButton" + disabled={createButtonDisabled} > {i18n.translate('workspace.form.bottomBar.createWorkspace', { defaultMessage: 'Create workspace', 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 b4b42743b410..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, + handleResetForm, handleFormSubmit, - handleColorChange, - handleUseCaseChange, setPermissionSettings, - handleNameInputChange, - setSelectedDataSources, - handleDescriptionChange, - } = 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_enter_details_panel.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_enter_details_panel.tsx index 88915665c8cb..fc4a669426b5 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_enter_details_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_enter_details_panel.tsx @@ -15,6 +15,8 @@ import { i18n } from '@osd/i18n'; import React from 'react'; import { EuiColorPickerOutput } from '@elastic/eui/src/components/color_picker/color_picker'; import { WorkspaceFormErrors } from './types'; +import { WorkspaceNameField } from './fields/workspace_name_field'; +import { WorkspaceDescriptionField } from './fields/workspace_description_field'; export interface EnterDetailsPanelProps { formErrors: WorkspaceFormErrors; @@ -22,8 +24,8 @@ export interface EnterDetailsPanelProps { description?: string; color?: string; readOnly: boolean; - handleNameInputChange: React.ChangeEventHandler; - handleDescriptionChange: React.ChangeEventHandler; + onNameChange: (newValue: string) => void; + onDescriptionChange: (newValue: string) => void; handleColorChange: (text: string, output: EuiColorPickerOutput) => void; } @@ -33,69 +35,35 @@ export const EnterDetailsPanel = ({ description, color, readOnly, - handleNameInputChange, - handleDescriptionChange, + onNameChange, + onDescriptionChange, handleColorChange, }: EnterDetailsPanelProps) => { return ( <> - - - - - Description - optional - - } - > - <> - - {i18n.translate('workspace.form.workspaceDetails.description.introduction', { - defaultMessage: - 'Help others understand the purpose of this workspace by providing an overview of the workspace you’re creating.', - })} - - - - + /> +
- {i18n.translate('workspace.form.workspaceDetails.color.helpText', { - defaultMessage: 'Accent color for your workspace', + {i18n.translate('workspace.form.workspaceDetails.color.description', { + defaultMessage: 'Select a background color for the icon representing this workspace.', })} 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 70d2a34d7585..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, @@ -39,13 +37,13 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { formErrors, numberOfErrors, numberOfChanges, + setName, + setDescription, handleFormSubmit, handleColorChange, handleUseCaseChange, - handleNameInputChange, setPermissionSettings, setSelectedDataSources, - handleDescriptionChange, } = useWorkspaceForm(props); const disabledUserOrGroupInputIdsRef = useRef( @@ -73,9 +71,9 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { description={formData.description} color={formData.color} readOnly={!!defaultValues?.reserved} - handleNameInputChange={handleNameInputChange} - handleDescriptionChange={handleDescriptionChange} handleColorChange={handleColorChange} + onNameChange={setName} + onDescriptionChange={setDescription} /> @@ -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_list/__snapshots__/index.test.tsx.snap b/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap index 32283810c139..86989c4bbf25 100644 --- a/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap +++ b/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap @@ -3,275 +3,732 @@ exports[`WorkspaceList should render title and table normally 1`] = `
-
-
+ + + Create workspace + + + +
+
-
-

- 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 && ( { 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 ( { const mockDependencies: WorkspacePluginStartDeps = { contentManagement: contentManagementPluginMocks.createStartContract(), }; - const getSetupMock = () => ({ - ...coreMock.createSetup(), - chrome: chromeServiceMock.createSetupContract(), - }); + const getSetupMock = () => coreMock.createSetup(); beforeEach(() => { WorkspaceClientMock.mockClear(); @@ -96,7 +93,7 @@ describe('Workspace plugin', () => { expect(setupMock.application.register).toBeCalledTimes(4); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId'); - expect(setupMock.getStartServices).toBeCalledTimes(1); + expect(setupMock.getStartServices).toBeCalledTimes(2); await waitFor( () => { expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_FATAL_ERROR_APP_ID, { @@ -129,6 +126,7 @@ describe('Workspace plugin', () => { }); const setupMock = getSetupMock(); const applicationStartMock = applicationServiceMock.createStartContract(); + const chromeStartMock = chromeServiceMock.createStartContract(); let currentAppIdSubscriber: Subscriber | undefined; setupMock.getStartServices.mockImplementation(() => { return Promise.resolve([ @@ -139,6 +137,7 @@ describe('Workspace plugin', () => { currentAppIdSubscriber = subscriber; }), }, + chrome: chromeStartMock, }, {}, {}, @@ -283,6 +282,7 @@ describe('Workspace plugin', () => { expect(coreStart.chrome.navControls.registerLeftBottom).toBeCalledTimes(1); }); + it('#start should not update systematic use case features after currentWorkspace set', async () => { const registeredUseCases$ = new BehaviorSubject([ { diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 264ce1f12c35..0086b9bedaae 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -53,6 +53,7 @@ import { UseCaseService } from './services/use_case_service'; import { WorkspaceListCard } from './components/service_card'; import { UseCaseFooter } from './components/home_get_start_card'; import { HOME_CONTENT_AREAS } from '../../home/public'; +import { NavigationPublicPluginStart } from '../../../plugins/navigation/public'; type WorkspaceAppType = ( params: AppMountParameters, @@ -68,6 +69,7 @@ interface WorkspacePluginSetupDeps { export interface WorkspacePluginStartDeps { contentManagement: ContentManagementPluginStart; + navigation: NavigationPublicPluginStart; } export class WorkspacePlugin @@ -150,7 +152,7 @@ export class WorkspacePlugin /** * The following logic determines whether a navigation group should be hidden or not based on the workspace's feature configurations. * It checks the following conditions: - * 1. The navigation group is not a system-level group (system groups are always visible except all use case). + * 1. The navigation group is not a system-level group. * 2. The current workspace has feature configurations set up. * 3. The current workspace's use case is not "All use case". * 4. The current navigation group is not included in the feature configurations of the workspace. @@ -158,7 +160,7 @@ export class WorkspacePlugin * If all these conditions are true, it means that the navigation group should be hidden. */ if ( - (navGroup.type !== NavGroupType.SYSTEM || navGroup.id === ALL_USE_CASE_ID) && + navGroup.type !== NavGroupType.SYSTEM && currentWorkspace.features && getFirstUseCaseOfFeatureConfigs(currentWorkspace.features) !== ALL_USE_CASE_ID && !isNavGroupInFeatureConfigs(navGroup.id, currentWorkspace.features) @@ -235,12 +237,19 @@ export class WorkspacePlugin } public async setup( - core: CoreSetup, + core: CoreSetup, { savedObjectsManagement, management, dataSourceManagement }: WorkspacePluginSetupDeps ) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); await workspaceClient.init(); core.workspaces.setClient(workspaceClient); + + this.useCase.setup({ + chrome: core.chrome, + getStartServices: core.getStartServices, + workspaces: core.workspaces, + }); + core.application.registerAppUpdater(this.appUpdater$); this.unregisterNavGroupUpdater = core.chrome.navGroup.registerNavGroupUpdater( this.navGroupUpdater$ @@ -296,11 +305,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, { @@ -360,6 +371,9 @@ export class WorkspacePlugin navLinkStatus: core.chrome.navGroup.getNavGroupEnabled() ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden, + description: i18n.translate('workspace.workspaceList.description', { + defaultMessage: 'Organize collaborative projects in use-case-specific workspaces.', + }), async mount(params: AppMountParameters) { const { renderListApp } = await import('./application'); return mountWorkspaceApp(params, renderListApp); @@ -432,7 +446,7 @@ export class WorkspacePlugin }); } - public start(core: CoreStart, { contentManagement }: WorkspacePluginStartDeps) { + public start(core: CoreStart, { contentManagement, navigation }: WorkspacePluginStartDeps) { this.coreStart = core; this.currentWorkspaceIdSubscription = this._changeSavedObjectCurrentWorkspace(); @@ -504,5 +518,6 @@ export class WorkspacePlugin this.unregisterNavGroupUpdater?.(); this.registeredUseCasesUpdaterSubscription?.unsubscribe(); this.workspaceAndUseCasesCombineSubscription?.unsubscribe(); + this.useCase.stop(); } } diff --git a/src/plugins/workspace/public/services/use_case_service.ts b/src/plugins/workspace/public/services/use_case_service.ts index eed33795a49b..ec0e973b6dac 100644 --- a/src/plugins/workspace/public/services/use_case_service.ts +++ b/src/plugins/workspace/public/services/use_case_service.ts @@ -3,16 +3,91 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Observable } from 'rxjs'; +import { combineLatest, Observable, Subscription } from 'rxjs'; import { distinctUntilChanged, map } from 'rxjs/operators'; -import { ChromeStart, PublicAppInfo } from '../../../../core/public'; +import { + ChromeStart, + CoreSetup, + DEFAULT_APP_CATEGORIES, + PublicAppInfo, + WorkspacesSetup, +} from '../../../../core/public'; import { WORKSPACE_USE_CASES } from '../../common/constants'; -import { convertNavGroupToWorkspaceUseCase, isEqualWorkspaceUseCase } from '../utils'; +import { + convertNavGroupToWorkspaceUseCase, + getFirstUseCaseOfFeatureConfigs, + isEqualWorkspaceUseCase, +} from '../utils'; + +export interface UseCaseServiceSetupDeps { + chrome: CoreSetup['chrome']; + workspaces: WorkspacesSetup; + getStartServices: CoreSetup['getStartServices']; +} export class UseCaseService { + private workspaceAndManageWorkspaceCategorySubscription?: Subscription; constructor() {} + /** + * Add nav links belong to `manage workspace` to all of the use cases. + * @param coreSetup + * @param currentWorkspace + */ + private async registerManageWorkspaceCategory(setupDeps: UseCaseServiceSetupDeps) { + const [coreStart] = await setupDeps.getStartServices(); + this.workspaceAndManageWorkspaceCategorySubscription?.unsubscribe(); + this.workspaceAndManageWorkspaceCategorySubscription = combineLatest([ + setupDeps.workspaces.currentWorkspace$, + coreStart.chrome.navGroup.getNavGroupsMap$(), + ]) + .pipe( + map(([currentWorkspace, navGroupMap]) => { + const currentUseCase = getFirstUseCaseOfFeatureConfigs(currentWorkspace?.features || []); + if (!currentUseCase) { + return undefined; + } + + return navGroupMap[currentUseCase]; + }) + ) + .pipe( + distinctUntilChanged((navGroupInfo, anotherNavGroup) => { + return navGroupInfo?.id === anotherNavGroup?.id; + }) + ) + .subscribe((navGroupInfo) => { + if (navGroupInfo) { + setupDeps.chrome.navGroup.addNavLinksToGroup(navGroupInfo, [ + { + id: 'dataSources_core', + category: DEFAULT_APP_CATEGORIES.manageWorkspace, + order: 100, + }, + { + id: 'indexPatterns', + category: DEFAULT_APP_CATEGORIES.manageWorkspace, + order: 200, + }, + { + id: 'objects', + category: DEFAULT_APP_CATEGORIES.manageWorkspace, + order: 300, + }, + ]); + } + }); + } + + setup({ chrome, workspaces, getStartServices }: UseCaseServiceSetupDeps) { + this.registerManageWorkspaceCategory({ + chrome, + workspaces, + getStartServices, + }); + } + start({ chrome, workspaceConfigurableApps$, @@ -70,4 +145,8 @@ export class UseCaseService { }, }; } + + stop() { + this.workspaceAndManageWorkspaceCategorySubscription?.unsubscribe(); + } } 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 { diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts index 545b4b3bfa5d..4e0146b39ad6 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -380,8 +380,17 @@ describe('workspace utils: getDataSourcesList', () => { 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..d57cacde7684 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 { @@ -249,7 +256,7 @@ export const convertNavGroupToWorkspaceUseCase = ({ title, description, features: navLinks.map((item) => item.id), - systematic: type === NavGroupType.SYSTEM, + systematic: type === NavGroupType.SYSTEM || id === ALL_USE_CASE_ID, order, }); @@ -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 71c7cf666bfa..05b3578076cd 100644 --- a/src/plugins/workspace/public/workspace_client.ts +++ b/src/plugins/workspace/public/workspace_client.ts @@ -233,6 +233,9 @@ export class WorkspaceClient implements IWorkspaceClient { 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(); } diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index 4676a743f9d7..71269e871d0e 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -5,7 +5,11 @@ import { schema } from '@osd/config-schema'; import { CoreSetup, Logger, PrincipalType, ACL } from '../../../../core/server'; -import { WorkspacePermissionMode } from '../../common/constants'; +import { + WorkspacePermissionMode, + MAX_WORKSPACE_NAME_LENGTH, + MAX_WORKSPACE_DESCRIPTION_LENGTH, +} from '../../common/constants'; import { IWorkspaceClientImpl, WorkspaceAttributeWithPermission } from '../types'; import { SavedObjectsPermissionControlContract } from '../permission_control/client'; import { registerDuplicateRoute } from './duplicate'; @@ -39,7 +43,7 @@ const settingsSchema = schema.object({ }); const workspaceOptionalAttributesSchema = { - description: schema.maybe(schema.string()), + description: schema.maybe(schema.string({ maxLength: MAX_WORKSPACE_DESCRIPTION_LENGTH })), features: schema.maybe(schema.arrayOf(schema.string())), color: schema.maybe( schema.string({ @@ -56,6 +60,7 @@ const workspaceOptionalAttributesSchema = { }; const workspaceNameSchema = schema.string({ + maxLength: MAX_WORKSPACE_NAME_LENGTH, validate(value) { if (!value || value.trim().length === 0) { return "can't be empty or blank."; diff --git a/test/interpreter_functional/plugins/osd_tp_run_pipeline/package.json b/test/interpreter_functional/plugins/osd_tp_run_pipeline/package.json index 4cf64fe2e768..f7f34b3bf070 100644 --- a/test/interpreter_functional/plugins/osd_tp_run_pipeline/package.json +++ b/test/interpreter_functional/plugins/osd_tp_run_pipeline/package.json @@ -12,7 +12,7 @@ "build": "../../../../scripts/use_node ../../../../scripts/remove.js './target' && tsc" }, "devDependencies": { - "@elastic/eui": "npm:@opensearch-project/oui@1.8.1", + "@elastic/eui": "npm:@opensearch-project/oui@1.9.0", "@osd/plugin-helpers": "1.0.0", "react": "^16.14.0", "react-dom": "^16.12.0", diff --git a/test/plugin_functional/plugins/osd_sample_panel_action/package.json b/test/plugin_functional/plugins/osd_sample_panel_action/package.json index c69b3cbb739d..acffe3bf4dc7 100644 --- a/test/plugin_functional/plugins/osd_sample_panel_action/package.json +++ b/test/plugin_functional/plugins/osd_sample_panel_action/package.json @@ -12,7 +12,7 @@ "build": "../../../../scripts/use_node ../../../../scripts/remove.js './target' && tsc" }, "devDependencies": { - "@elastic/eui": "npm:@opensearch-project/oui@1.8.1", + "@elastic/eui": "npm:@opensearch-project/oui@1.9.0", "react": "^16.14.0", "typescript": "4.0.2" } diff --git a/test/plugin_functional/plugins/osd_tp_custom_visualizations/package.json b/test/plugin_functional/plugins/osd_tp_custom_visualizations/package.json index 357b60b739a6..7e5deeb8955c 100644 --- a/test/plugin_functional/plugins/osd_tp_custom_visualizations/package.json +++ b/test/plugin_functional/plugins/osd_tp_custom_visualizations/package.json @@ -12,7 +12,7 @@ "build": "../../../../scripts/use_node ../../../../scripts/remove.js './target' && tsc" }, "devDependencies": { - "@elastic/eui": "npm:@opensearch-project/oui@1.8.1", + "@elastic/eui": "npm:@opensearch-project/oui@1.9.0", "@osd/plugin-helpers": "1.0.0", "react": "^16.14.0", "typescript": "4.0.2" diff --git a/yarn.lock b/yarn.lock index 0d304cad7211..6f0fd034493a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1407,10 +1407,10 @@ resolved "https://registry.yarnpkg.com/@elastic/eslint-plugin-eui/-/eslint-plugin-eui-0.0.2.tgz#56b9ef03984a05cc213772ae3713ea8ef47b0314" integrity sha512-IoxURM5zraoQ7C8f+mJb9HYSENiZGgRVcG4tLQxE61yHNNRDXtGDWTZh8N1KIHcsqN1CEPETjuzBXkJYF/fDiQ== -"@elastic/eui@npm:@opensearch-project/oui@1.8.1": - version "1.8.1" - resolved "https://registry.yarnpkg.com/@opensearch-project/oui/-/oui-1.8.1.tgz#474e76429420c07d9f6058569d3482f2402dc067" - integrity sha512-1RN3SHm8YHOh3ZhiMjQIOWdop2SFuYp7gbnbASZ0X90V2aCyViUt22aKY3Z4lUfDW27D0BaNbNF/q2edp2TK9A== +"@elastic/eui@npm:@opensearch-project/oui@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@opensearch-project/oui/-/oui-1.9.0.tgz#c31ceffaf2a5e6dc9b51bc44eb8029f1dfd74a94" + integrity sha512-iHgS8HA5u2YdDI6IcesoO/wzeFdEiQwm51scGIJPvk+nxMyU2eX1s3SHs0xJd9gpNc2VSY97CgF6yK6tT52eUA== dependencies: "@types/chroma-js" "^2.4.0" "@types/react-beautiful-dnd" "^13.1.3"