From 7f9bdd739ea4a90e161e1efd512b053872d8b6c2 Mon Sep 17 00:00:00 2001 From: yuboluo Date: Sat, 20 Jul 2024 13:34:43 +0800 Subject: [PATCH 01/10] [Workspace] Refactor the UI of workspace picker (#7045) * Refactor the UI of workspace picker Signed-off-by: yubonluo * Changeset file for PR #7045 created/updated * Optimize the code Signed-off-by: yubonluo * Optimize the code Signed-off-by: yubonluo * Optimize the code Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo * support suggested workspace Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo * delete search and use case menu Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo * delete useless code Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo * add timestamp Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo * optimzie the code Signed-off-by: yubonluo * optimzie the code Signed-off-by: yubonluo * fix unit test error Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo * Replace menuContext with listGroup Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo --------- Signed-off-by: yubonluo Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7045.yml | 2 + src/core/public/chrome/index.ts | 6 +- .../public/chrome/recently_accessed/index.ts | 2 + src/core/public/index.ts | 2 + src/plugins/workspace/common/constants.ts | 2 + .../workspace_menu/workspace_menu.test.tsx | 159 +++++--- .../workspace_menu/workspace_menu.tsx | 373 +++++++++++------- src/plugins/workspace/public/plugin.test.ts | 17 +- src/plugins/workspace/public/plugin.ts | 27 +- .../public/recent_workspace_manager.test.ts | 33 ++ .../public/recent_workspace_manager.ts | 51 +++ 11 files changed, 471 insertions(+), 203 deletions(-) create mode 100644 changelogs/fragments/7045.yml create mode 100644 src/plugins/workspace/public/recent_workspace_manager.test.ts create mode 100644 src/plugins/workspace/public/recent_workspace_manager.ts diff --git a/changelogs/fragments/7045.yml b/changelogs/fragments/7045.yml new file mode 100644 index 000000000000..5e1d9e73addb --- /dev/null +++ b/changelogs/fragments/7045.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace] Refactor the UI of workspace picker ([#7045](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7045)) \ No newline at end of file diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index f49e0e7efe80..02100ce4f63a 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -46,7 +46,11 @@ export { } from './ui/header/header_help_menu'; export { NavType, RightNavigationButton, RightNavigationButtonProps } from './ui'; export { ChromeNavLink, ChromeNavLinks, ChromeNavLinkUpdateableFields } from './nav_links'; -export { ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem } from './recently_accessed'; +export { + ChromeRecentlyAccessed, + ChromeRecentlyAccessedHistoryItem, + PersistedLog, +} from './recently_accessed'; export { ChromeNavControl, ChromeNavControls } from './nav_controls'; export { ChromeDocTitle } from './doc_title'; export { RightNavigationOrder } from './constants'; diff --git a/src/core/public/chrome/recently_accessed/index.ts b/src/core/public/chrome/recently_accessed/index.ts index 8bb2e306b221..9fb34b520470 100644 --- a/src/core/public/chrome/recently_accessed/index.ts +++ b/src/core/public/chrome/recently_accessed/index.ts @@ -33,3 +33,5 @@ export { ChromeRecentlyAccessedHistoryItem, RecentlyAccessedService, } from './recently_accessed_service'; + +export { PersistedLog } from './persisted_log'; diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 59da446d1fe9..8c387361a9ca 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -73,6 +73,7 @@ import { RightNavigationButtonProps, ChromeRegistrationNavLink, ChromeNavGroupUpdater, + PersistedLog, NavGroupItemInMap, fulfillRegistrationLinksToChromeNavLinks, } from './chrome'; @@ -375,6 +376,7 @@ export { RightNavigationButtonProps, ChromeRegistrationNavLink, ChromeNavGroupUpdater, + PersistedLog, NavGroupItemInMap, fulfillRegistrationLinksToChromeNavLinks, }; diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index bdf9aa8ddfc5..9fef78152d8d 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -182,4 +182,6 @@ export const WORKSPACE_USE_CASES = Object.freeze({ }, }); +export const MAX_WORKSPACE_PICKER_NUM = 3; +export const RECENT_WORKSPACES_KEY = 'recentWorkspaces'; export const CURRENT_USER_PLACEHOLDER = '%me%'; diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx index 85ee89482724..d3578498c858 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx @@ -9,18 +9,51 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { WorkspaceMenu } from './workspace_menu'; import { coreMock } from '../../../../../core/public/mocks'; import { CoreStart } from '../../../../../core/public'; +import { BehaviorSubject, of } from 'rxjs'; +import { IntlProvider } from 'react-intl'; +import { recentWorkspaceManager } from '../../recent_workspace_manager'; +import { WORKSPACE_USE_CASES } from '../../../common/constants'; +import * as workspaceUtils from '../utils/workspace'; describe('', () => { let coreStartMock: CoreStart; + const navigateToApp = jest.fn(); + const registeredUseCases$ = new BehaviorSubject([ + WORKSPACE_USE_CASES.observability, + WORKSPACE_USE_CASES['security-analytics'], + WORKSPACE_USE_CASES.analytics, + WORKSPACE_USE_CASES.search, + ]); beforeEach(() => { coreStartMock = coreMock.createStart(); + coreStartMock.application.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: {}, + workspaces: { permissionEnabled: true }, + dashboards: { isDashboardAdmin: true }, + }; + coreStartMock.application = { + ...coreStartMock.application, + navigateToApp, + }; + coreStartMock.workspaces.initialized$.next(true); jest.spyOn(coreStartMock.application, 'getUrlForApp').mockImplementation((appId: string) => { return `https://test.com/app/${appId}`; }); }); + const WorkspaceMenuCreatorComponent = () => { + return ( + + + + ); + }; + afterEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); @@ -28,38 +61,69 @@ describe('', () => { it('should display a list of workspaces in the dropdown', () => { coreStartMock.workspaces.workspaceList$.next([ - { id: 'workspace-1', name: 'workspace 1' }, - { id: 'workspace-2', name: 'workspace 2' }, + { id: 'workspace-1', name: 'workspace 1', features: [] }, + { id: 'workspace-2', name: 'workspace 2', features: [] }, + ]); + + render(); + const selectButton = screen.getByTestId('workspace-select-button'); + fireEvent.click(selectButton); + + expect(screen.getByText(/all workspaces/i)).toBeInTheDocument(); + expect(screen.getByTestId('workspace-menu-item-all-workspace-1')).toBeInTheDocument(); + expect(screen.getByTestId('workspace-menu-item-all-workspace-2')).toBeInTheDocument(); + }); + + it('should display a list of recent workspaces in the dropdown', () => { + jest.spyOn(recentWorkspaceManager, 'getRecentWorkspaces').mockReturnValue([ + { id: 'workspace-1', timestamp: 1234567890 }, + { id: 'workspace-2', timestamp: 1234567899 }, + ]); + + coreStartMock.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1', features: [] }, + { id: 'workspace-2', name: 'workspace 2', features: [] }, ]); - render(); - fireEvent.click(screen.getByText(/select a workspace/i)); + render(); + + const selectButton = screen.getByTestId('workspace-select-button'); + fireEvent.click(selectButton); - expect(screen.getByText(/workspace 1/i)).toBeInTheDocument(); - expect(screen.getByText(/workspace 2/i)).toBeInTheDocument(); + expect(screen.getByText(/recent workspaces/i)).toBeInTheDocument(); + expect(screen.getByTestId('workspace-menu-item-recent-workspace-1')).toBeInTheDocument(); + expect(screen.getByTestId('workspace-menu-item-recent-workspace-2')).toBeInTheDocument(); }); - it('should display current workspace name', () => { - coreStartMock.workspaces.currentWorkspace$.next({ id: 'workspace-1', name: 'workspace 1' }); - render(); - expect(screen.getByText(/workspace 1/i)).toBeInTheDocument(); + it('should display current workspace name and use case name', () => { + coreStartMock.workspaces.currentWorkspace$.next({ + id: 'workspace-1', + name: 'workspace 1', + features: ['use-case-observability'], + }); + render(); + + fireEvent.click(screen.getByTestId('current-workspace-button')); + expect(screen.getByTestId('workspace-menu-current-workspace-name')).toBeInTheDocument(); + expect(screen.getByTestId('workspace-menu-current-use-case')).toBeInTheDocument(); + expect(screen.getByText('Observability')).toBeInTheDocument(); }); it('should close the workspace dropdown list', async () => { - render(); - fireEvent.click(screen.getByText(/select a workspace/i)); + render(); + + fireEvent.click(screen.getByTestId('workspace-select-button')); - expect(screen.getByLabelText(/close workspace dropdown/i)).toBeInTheDocument(); - fireEvent.click(screen.getByLabelText(/close workspace dropdown/i)); + expect(screen.getByText(/all workspaces/i)).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('workspace-select-button')); await waitFor(() => { - expect(screen.queryByLabelText(/close workspace dropdown/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/all workspaces/i)).not.toBeInTheDocument(); }); }); it('should navigate to the workspace', () => { coreStartMock.workspaces.workspaceList$.next([ - { id: 'workspace-1', name: 'workspace 1' }, - { id: 'workspace-2', name: 'workspace 2' }, + { id: 'workspace-1', name: 'workspace 1', features: ['use-case-observability'] }, ]); const originalLocation = window.location; @@ -69,12 +133,12 @@ describe('', () => { }, }); - render(); - fireEvent.click(screen.getByText(/select a workspace/i)); + render(); + fireEvent.click(screen.getByTestId('workspace-select-button')); fireEvent.click(screen.getByText(/workspace 1/i)); expect(window.location.assign).toHaveBeenCalledWith( - 'https://test.com/w/workspace-1/app/workspace_detail' + 'https://test.com/w/workspace-1/app/discover' ); Object.defineProperty(window, 'location', { @@ -82,39 +146,40 @@ describe('', () => { }); }); - it('should navigate to create workspace page', () => { - const originalLocation = window.location; - Object.defineProperty(window, 'location', { - value: { - assign: jest.fn(), - }, + it('should navigate to workspace management page', () => { + coreStartMock.workspaces.currentWorkspace$.next({ + id: 'workspace-1', + name: 'workspace 1', + features: ['use-case-observability'], }); + const navigateToWorkspaceDetail = jest.spyOn(workspaceUtils, 'navigateToWorkspaceDetail'); + render(); - render(); - fireEvent.click(screen.getByText(/select a workspace/i)); - fireEvent.click(screen.getByText(/create workspace/i)); - expect(window.location.assign).toHaveBeenCalledWith('https://test.com/app/workspace_create'); + fireEvent.click(screen.getByTestId('current-workspace-button')); + const button = screen.getByText(/Manage workspace/i); + fireEvent.click(button); + expect(navigateToWorkspaceDetail).toBeCalled(); + }); - Object.defineProperty(window, 'location', { - value: originalLocation, - }); + it('should navigate to workspaces management page', () => { + render(); + fireEvent.click(screen.getByTestId('workspace-select-button')); + fireEvent.click(screen.getByText(/manage workspaces/i)); + expect(coreStartMock.application.navigateToApp).toHaveBeenCalledWith('workspace_list'); }); - it('should navigate to workspace list page', () => { - const originalLocation = window.location; - Object.defineProperty(window, 'location', { - value: { - assign: jest.fn(), - }, - }); + it('should navigate to create workspace page', () => { + render(); + fireEvent.click(screen.getByTestId('workspace-select-button')); + fireEvent.click(screen.getByText(/create workspace/i)); + expect(coreStartMock.application.navigateToApp).toHaveBeenCalledWith('workspace_create'); + }); - render(); - fireEvent.click(screen.getByText(/select a workspace/i)); - fireEvent.click(screen.getByText(/all workspace/i)); - expect(window.location.assign).toHaveBeenCalledWith('https://test.com/app/workspace_list'); + it('should navigate to workspace list page', () => { + render(); - Object.defineProperty(window, 'location', { - value: originalLocation, - }); + fireEvent.click(screen.getByTestId('workspace-select-button')); + fireEvent.click(screen.getByText(/View all/i)); + expect(coreStartMock.application.navigateToApp).toHaveBeenCalledWith('workspace_list'); }); }); diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx index cd6b147b90fe..bda11fb3d113 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -4,61 +4,96 @@ */ import { i18n } from '@osd/i18n'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useObservable } from 'react-use'; import { - EuiSmallButtonIcon, - EuiContextMenu, - EuiFlexGroup, + EuiText, + EuiPanel, + EuiTitle, + EuiAvatar, + EuiButton, + EuiPopover, + EuiToolTip, EuiFlexItem, - EuiIcon, + EuiFlexGroup, EuiListGroup, + EuiButtonIcon, + EuiButtonEmpty, EuiListGroupItem, - EuiPopover, - EuiText, } from '@elastic/eui'; -import type { EuiContextMenuPanelItemDescriptor } from '@elastic/eui'; - +import { BehaviorSubject } from 'rxjs'; import { WORKSPACE_CREATE_APP_ID, WORKSPACE_LIST_APP_ID, + MAX_WORKSPACE_PICKER_NUM, WORKSPACE_DETAIL_APP_ID, } from '../../../common/constants'; -import { cleanWorkspaceId, formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; import { CoreStart, WorkspaceObject } from '../../../../../core/public'; +import { getFirstUseCaseOfFeatureConfigs } from '../../utils'; +import { recentWorkspaceManager } from '../../recent_workspace_manager'; +import { WorkspaceUseCase } from '../../types'; +import { navigateToWorkspaceDetail } from '../utils/workspace'; + +const defaultHeaderName = i18n.translate('workspace.menu.defaultHeaderName', { + defaultMessage: 'Workspaces', +}); + +const allWorkspacesTitle = i18n.translate('workspace.menu.title.allWorkspaces', { + defaultMessage: 'All workspaces', +}); + +const recentWorkspacesTitle = i18n.translate('workspace.menu.title.recentWorkspaces', { + defaultMessage: 'recent workspaces', +}); + +const createWorkspaceButton = i18n.translate('workspace.menu.button.createWorkspace', { + defaultMessage: 'Create workspace', +}); + +const viewAllButton = i18n.translate('workspace.menu.button.viewAll', { + defaultMessage: 'View all', +}); + +const manageWorkspaceButton = i18n.translate('workspace.menu.button.manageWorkspace', { + defaultMessage: 'Manage workspace', +}); + +const manageWorkspacesButton = i18n.translate('workspace.menu.button.manageWorkspaces', { + defaultMessage: 'Manage workspaces', +}); interface Props { coreStart: CoreStart; + registeredUseCases$: BehaviorSubject; } -/** - * Return maximum five workspaces, the current selected workspace - * will be on the top of the list. - */ -function getFilteredWorkspaceList( - workspaceList: WorkspaceObject[], - currentWorkspace: WorkspaceObject | null -): WorkspaceObject[] { - return [ - ...(currentWorkspace ? [currentWorkspace] : []), - ...workspaceList.filter((workspace) => workspace.id !== currentWorkspace?.id), - ].slice(0, 5); -} - -export const WorkspaceMenu = ({ coreStart }: Props) => { +export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { const [isPopoverOpen, setPopover] = useState(false); const currentWorkspace = useObservable(coreStart.workspaces.currentWorkspace$, null); const workspaceList = useObservable(coreStart.workspaces.workspaceList$, []); + const isDashboardAdmin = !!coreStart.application.capabilities.dashboards; + const availableUseCases = useObservable(registeredUseCases$, []); + + const filteredWorkspaceList = useMemo(() => { + return workspaceList.slice(0, MAX_WORKSPACE_PICKER_NUM); + }, [workspaceList]); + + const filteredRecentWorkspaces = useMemo(() => { + return recentWorkspaceManager + .getRecentWorkspaces() + .map((workspace) => workspaceList.find((ws) => ws.id === workspace.id)) + .filter((workspace): workspace is WorkspaceObject => workspace !== undefined) + .slice(0, MAX_WORKSPACE_PICKER_NUM); + }, [workspaceList]); - const defaultHeaderName = i18n.translate( - 'core.ui.primaryNav.workspacePickerMenu.defaultHeaderName', - { - defaultMessage: 'Select a workspace', - } - ); - const filteredWorkspaceList = getFilteredWorkspaceList(workspaceList, currentWorkspace); const currentWorkspaceName = currentWorkspace?.name ?? defaultHeaderName; + const getUseCase = (workspace: WorkspaceObject) => { + const useCaseId = getFirstUseCaseOfFeatureConfigs(workspace?.features!); + return availableUseCases.find((useCase) => useCase.id === useCaseId); + }; + const openPopover = () => { setPopover(!isPopoverOpen); }; @@ -67,113 +102,78 @@ export const WorkspaceMenu = ({ coreStart }: Props) => { setPopover(false); }; - const workspaceToItem = (workspace: WorkspaceObject) => { - const workspaceURL = formatUrlWithWorkspaceId( - coreStart.application.getUrlForApp(WORKSPACE_DETAIL_APP_ID, { - absolute: false, - }), - workspace.id, - coreStart.http.basePath - ); - const name = - currentWorkspace?.name === workspace.name ? ( - - {workspace.name} - - ) : ( - workspace.name - ); - return { - name, - key: workspace.id, - icon: , - onClick: () => { - window.location.assign(workspaceURL); - }, - }; - }; - - const getWorkspaceListItems = () => { - const workspaceListItems: EuiContextMenuPanelItemDescriptor[] = filteredWorkspaceList.map( - workspaceToItem - ); - workspaceListItems.push({ - icon: , - name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.createWorkspace', { - defaultMessage: 'Create workspace', - }), - key: WORKSPACE_CREATE_APP_ID, - onClick: () => { - window.location.assign( - cleanWorkspaceId( - coreStart.application.getUrlForApp(WORKSPACE_CREATE_APP_ID, { - absolute: false, - }) - ) - ); - }, - }); - workspaceListItems.push({ - icon: , - name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.allWorkspace', { - defaultMessage: 'All workspaces', - }), - key: WORKSPACE_LIST_APP_ID, - onClick: () => { - window.location.assign( - cleanWorkspaceId( - coreStart.application.getUrlForApp(WORKSPACE_LIST_APP_ID, { - absolute: false, - }) - ) - ); - }, - }); - return workspaceListItems; - }; + const currentWorkspaceButton = currentWorkspace ? ( + + + + ) : ( + + ); - const currentWorkspaceButton = ( - <> - + const getWorkspaceListGroup = (filterWorkspaceList: WorkspaceObject[], itemType: string) => { + const listItems = filterWorkspaceList.map((workspace: WorkspaceObject) => { + const appId = getUseCase(workspace)?.features[0] ?? WORKSPACE_DETAIL_APP_ID; + const useCaseURL = formatUrlWithWorkspaceId( + coreStart.application.getUrlForApp(appId, { + absolute: false, + }), + workspace.id, + coreStart.http.basePath + ); + return ( + } + label={ + + + {workspace.name} + + + } + onClick={() => { + window.location.assign(useCaseURL); }} /> - - - ); - - const currentWorkspaceTitle = ( - - - {currentWorkspaceName} - - - - - - ); - - const panels = [ - { - id: 0, - title: currentWorkspaceTitle, - items: getWorkspaceListItems(), - }, - ]; + ); + }); + return ( + <> + +

{itemType === 'all' ? allWorkspacesTitle : recentWorkspacesTitle}

+
+ + {listItems} + + + ); + }; return ( { panelPaddingSize="none" anchorPosition="downCenter" > - + + + {currentWorkspace ? ( + <> + + + + + + + {currentWorkspaceName} + + + + + {getUseCase(currentWorkspace)?.title ?? ''} + + + { + navigateToWorkspaceDetail(coreStart, currentWorkspace.id); + }} + > + {manageWorkspaceButton} + + + + ) : ( + <> + + + + + {currentWorkspaceName} + + + { + coreStart.application.navigateToApp(WORKSPACE_LIST_APP_ID); + }} + > + {manageWorkspacesButton} + + + + )} + + + + {getWorkspaceListGroup(filteredRecentWorkspaces, 'recent')} + {getWorkspaceListGroup(filteredWorkspaceList, 'all')} + + + + + { + coreStart.application.navigateToApp(WORKSPACE_LIST_APP_ID); + }} + > + {viewAllButton} + + + {isDashboardAdmin && ( + + { + coreStart.application.navigateToApp(WORKSPACE_CREATE_APP_ID); + }} + > + {createWorkspaceButton} + + + )} + + ); }; diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index d3fff9a3f577..b2ed55c08de6 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -149,15 +149,6 @@ describe('Workspace plugin', () => { windowSpy.mockRestore(); }); - it('#setup register workspace dropdown menu when setup', async () => { - const setupMock = coreMock.createSetup(); - const workspacePlugin = new WorkspacePlugin(); - await workspacePlugin.setup(setupMock, { - management: managementPluginMock.createSetupContract(), - }); - expect(setupMock.chrome.registerCollapsibleNavHeader).toBeCalledTimes(1); - }); - it('#setup should register workspace list with a visible application and register to settingsAndSetup nav group', async () => { const setupMock = coreMock.createSetup(); setupMock.chrome.navGroup.getNavGroupEnabled.mockReturnValue(true); @@ -241,6 +232,14 @@ describe('Workspace plugin', () => { }); }); + it('#start register workspace dropdown menu at left navigation bottom when start', async () => { + const coreStart = coreMock.createStart(); + coreStart.chrome.navGroup.getNavGroupEnabled.mockReturnValue(true); + const workspacePlugin = new WorkspacePlugin(); + workspacePlugin.start(coreStart); + + 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 a2c84554205a..104db7d9b91f 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -43,6 +43,8 @@ import { isAppAccessibleInWorkspace, isNavGroupInFeatureConfigs, } from './utils'; +import { recentWorkspaceManager } from './recent_workspace_manager'; +import { toMountPoint } from '../../opensearch_dashboards_react/public'; import { UseCaseService } from './services/use_case_service'; type WorkspaceAppType = ( @@ -262,6 +264,8 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> } currentAppIdSubscription.unsubscribe(); }); + // Add workspace id to recent workspaces. + recentWorkspaceManager.addRecentWorkspace(workspaceId); })(); } } @@ -319,16 +323,6 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> }, }); - /** - * Register workspace dropdown selector on the top of left navigation menu - */ - core.chrome.registerCollapsibleNavHeader(() => { - if (!this.coreStart) { - return null; - } - return React.createElement(WorkspaceMenu, { coreStart: this.coreStart }); - }); - // workspace list core.application.register({ id: WORKSPACE_LIST_APP_ID, @@ -398,6 +392,19 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> if (!core.chrome.navGroup.getNavGroupEnabled()) { this.addWorkspaceToBreadcrumbs(core); + } else { + /** + * Register workspace dropdown selector on the left navigation bottom + */ + core.chrome.navControls.registerLeftBottom({ + order: 2, + mount: toMountPoint( + React.createElement(WorkspaceMenu, { + coreStart: core, + registeredUseCases$: this.registeredUseCases$, + }) + ), + }); } return {}; diff --git a/src/plugins/workspace/public/recent_workspace_manager.test.ts b/src/plugins/workspace/public/recent_workspace_manager.test.ts new file mode 100644 index 000000000000..16ea6291faef --- /dev/null +++ b/src/plugins/workspace/public/recent_workspace_manager.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { RecentWorkspaceManager } from './recent_workspace_manager'; + +describe('RecentWorkspaceManager', () => { + let recentWorkspaceManager: RecentWorkspaceManager; + + beforeEach(() => { + recentWorkspaceManager = RecentWorkspaceManager.getInstance(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be a singleton', () => { + const anotherInstance = RecentWorkspaceManager.getInstance(); + expect(recentWorkspaceManager).toBe(anotherInstance); + }); + + it('should add and get recent workspaces', () => { + recentWorkspaceManager.addRecentWorkspace('workspace1'); + recentWorkspaceManager.addRecentWorkspace('workspace2'); + + const recentWorkspaces = recentWorkspaceManager.getRecentWorkspaces(); + expect(recentWorkspaces.length).toEqual(2); + expect(recentWorkspaces[0].id).toEqual('workspace2'); + expect(recentWorkspaces[1].id).toEqual('workspace1'); + }); +}); diff --git a/src/plugins/workspace/public/recent_workspace_manager.ts b/src/plugins/workspace/public/recent_workspace_manager.ts new file mode 100644 index 000000000000..2bcadc72e1bd --- /dev/null +++ b/src/plugins/workspace/public/recent_workspace_manager.ts @@ -0,0 +1,51 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PersistedLog } from '../../../core/public'; +import { RECENT_WORKSPACES_KEY } from '../common/constants'; + +export interface WorkspaceEntry { + id: string; + timestamp: number; +} + +export class RecentWorkspaceManager { + private static instance: RecentWorkspaceManager; + private recentWorkspaceLog: PersistedLog; + + private constructor() { + const customIsEqual = (oldItem: WorkspaceEntry, newItem: WorkspaceEntry) => { + return oldItem.id === newItem.id; + }; + this.recentWorkspaceLog = new PersistedLog(RECENT_WORKSPACES_KEY, { + maxLength: 10, + isEqual: customIsEqual, + }); + } + + // Singleton pattern to ensure only one instance is used + public static getInstance(): RecentWorkspaceManager { + if (!RecentWorkspaceManager.instance) { + RecentWorkspaceManager.instance = new RecentWorkspaceManager(); + } + return RecentWorkspaceManager.instance; + } + + public getRecentWorkspaces(): WorkspaceEntry[] { + return this.recentWorkspaceLog.get(); + } + + public addRecentWorkspace(newWorkspace: string): WorkspaceEntry[] { + const newEntry: WorkspaceEntry = { + id: newWorkspace, + timestamp: Date.now(), + }; + this.recentWorkspaceLog.add(newEntry); + return this.getRecentWorkspaces(); + } +} + +// Export the singleton instance +export const recentWorkspaceManager = RecentWorkspaceManager.getInstance(); From 4c619067f2d1f85afd9ad3f67af15d8f42c3e4da Mon Sep 17 00:00:00 2001 From: Shenoy Pratik Date: Sat, 20 Jul 2024 08:06:15 -0700 Subject: [PATCH 02/10] [Navigation] Update dev tools tab css for new left navigation (#7328) * update dev tools tab css Signed-off-by: Shenoy Pratik * Changeset file for PR #7328 created/updated --------- Signed-off-by: Shenoy Pratik Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7328.yml | 2 ++ src/plugins/dev_tools/public/index.scss | 6 ------ 2 files changed, 2 insertions(+), 6 deletions(-) create mode 100644 changelogs/fragments/7328.yml diff --git a/changelogs/fragments/7328.yml b/changelogs/fragments/7328.yml new file mode 100644 index 000000000000..2e2f63275d8b --- /dev/null +++ b/changelogs/fragments/7328.yml @@ -0,0 +1,2 @@ +fix: +- [Navigation] Update dev tools tab css for new left navigation ([#7328](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7328)) \ No newline at end of file diff --git a/src/plugins/dev_tools/public/index.scss b/src/plugins/dev_tools/public/index.scss index 56d7bc9c11c1..554452a8923e 100644 --- a/src/plugins/dev_tools/public/index.scss +++ b/src/plugins/dev_tools/public/index.scss @@ -26,9 +26,3 @@ margin: 7px 8px 0 0; min-width: 400px; } - -.devAppTabs { - display: flex; - flex-flow: row wrap; - justify-content: space-between; -} From 54cbaaa3e086cac4675ab733a91bc2d1abad8f5a Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Sun, 21 Jul 2024 12:01:06 +0800 Subject: [PATCH 03/10] [navigation-next] feat: update category (#7339) (#7340) * feat: update category * Changeset file for PR #7339 created/updated --------- (cherry picked from commit d4d1f7700b29a1b5af211585cf9338b8a2ba2748) Signed-off-by: SuZhou-Joe Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7339.yml | 2 ++ src/core/utils/default_app_categories.ts | 21 +++++++++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 changelogs/fragments/7339.yml diff --git a/changelogs/fragments/7339.yml b/changelogs/fragments/7339.yml new file mode 100644 index 000000000000..23f897df941d --- /dev/null +++ b/changelogs/fragments/7339.yml @@ -0,0 +1,2 @@ +feat: +- [navigation-next] update category ([#7339](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7339)) \ No newline at end of file diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index d22dbaf1b7ac..f274cecf2cda 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -80,19 +80,20 @@ export const DEFAULT_APP_CATEGORIES: Record = Object.freeze }), order: 1000, }, + // TODO remove this default category dashboardAndReport: { id: 'visualizeAndReport', label: i18n.translate('core.ui.visualizeAndReport.label', { defaultMessage: 'Visualize and report', }), - order: 2000, + order: 3000, }, visualizeAndReport: { id: 'visualizeAndReport', label: i18n.translate('core.ui.visualizeAndReport.label', { defaultMessage: 'Visualize and report', }), - order: 2000, + order: 3000, }, analyzeSearch: { id: 'analyzeSearch', @@ -101,12 +102,20 @@ export const DEFAULT_APP_CATEGORIES: Record = Object.freeze }), order: 4000, }, + // TODO remove this default category detect: { - id: 'detect', - label: i18n.translate('core.ui.detect.label', { - defaultMessage: 'Detect', + id: 'configure', + label: i18n.translate('core.ui.configure.label', { + defaultMessage: 'Configure', }), - order: 3000, + order: 2000, + }, + configure: { + id: 'configure', + label: i18n.translate('core.ui.configure.label', { + defaultMessage: 'Configure', + }), + order: 2000, }, manage: { id: 'manage', From 08c2a006fa47b614215cf9364f7161fc55b2b429 Mon Sep 17 00:00:00 2001 From: yuboluo Date: Sun, 21 Jul 2024 14:05:46 +0800 Subject: [PATCH 04/10] [Workspace] Register four get started cards in home page (#7333) * support get start card in home page Signed-off-by: yubonluo * Changeset file for PR #7333 created/updated * fix unit test errors Signed-off-by: yubonluo * optimize the code Signed-off-by: yubonluo --------- Signed-off-by: yubonluo Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7333.yml | 2 + .../card_container/card_container.tsx | 7 +- .../card_container/card_embeddable.test.tsx | 9 +- .../card_container/card_embeddable.tsx | 10 +- .../public/components/page_render.tsx | 25 +- .../public/components/section_input.ts | 2 + .../public/components/section_render.tsx | 27 ++- .../services/content_management/types.ts | 2 + src/plugins/home/public/index.ts | 2 + .../workspace/opensearch_dashboards.json | 4 +- .../components/home_get_start_card/index.ts | 6 + .../use_case_footer.test.tsx | 156 +++++++++++++ .../home_get_start_card/use_case_footer.tsx | 219 ++++++++++++++++++ .../workspace_menu/workspace_menu.test.tsx | 12 - .../workspace_menu/workspace_menu.tsx | 12 +- src/plugins/workspace/public/plugin.test.ts | 22 +- src/plugins/workspace/public/plugin.ts | 52 ++++- 17 files changed, 522 insertions(+), 47 deletions(-) create mode 100644 changelogs/fragments/7333.yml create mode 100644 src/plugins/workspace/public/components/home_get_start_card/index.ts create mode 100644 src/plugins/workspace/public/components/home_get_start_card/use_case_footer.test.tsx create mode 100644 src/plugins/workspace/public/components/home_get_start_card/use_case_footer.tsx diff --git a/changelogs/fragments/7333.yml b/changelogs/fragments/7333.yml new file mode 100644 index 000000000000..09d225d51ca2 --- /dev/null +++ b/changelogs/fragments/7333.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace] Register four get started cards in home page ([#7333](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7333)) \ No newline at end of file diff --git a/src/plugins/content_management/public/components/card_container/card_container.tsx b/src/plugins/content_management/public/components/card_container/card_container.tsx index 518734563607..f3784f1f5fc4 100644 --- a/src/plugins/content_management/public/components/card_container/card_container.tsx +++ b/src/plugins/content_management/public/components/card_container/card_container.tsx @@ -10,7 +10,12 @@ import { CardList } from './card_list'; export const CARD_CONTAINER = 'CARD_CONTAINER'; -export type CardContainerInput = ContainerInput<{ description: string; onClick?: () => void }>; +export type CardContainerInput = ContainerInput<{ + description: string; + onClick?: () => void; + getIcon?: () => React.ReactElement; + getFooter?: () => React.ReactElement; +}>; export class CardContainer extends Container<{}, CardContainerInput> { public readonly type = CARD_CONTAINER; diff --git a/src/plugins/content_management/public/components/card_container/card_embeddable.test.tsx b/src/plugins/content_management/public/components/card_container/card_embeddable.test.tsx index b335bac6d996..a87cd43554ea 100644 --- a/src/plugins/content_management/public/components/card_container/card_embeddable.test.tsx +++ b/src/plugins/content_management/public/components/card_container/card_embeddable.test.tsx @@ -3,10 +3,17 @@ * SPDX-License-Identifier: Apache-2.0 */ +import React from 'react'; import { CardEmbeddable } from './card_embeddable'; test('CardEmbeddable should render a card with the title', () => { - const embeddable = new CardEmbeddable({ id: 'card-id', title: 'card title', description: '' }); + const embeddable = new CardEmbeddable({ + id: 'card-id', + title: 'card title', + description: '', + getIcon: () => <>icon, + getFooter: () => <>footer, + }); const node = document.createElement('div'); embeddable.render(node); diff --git a/src/plugins/content_management/public/components/card_container/card_embeddable.tsx b/src/plugins/content_management/public/components/card_container/card_embeddable.tsx index 844cf13a777c..0e7b6b2c82e5 100644 --- a/src/plugins/content_management/public/components/card_container/card_embeddable.tsx +++ b/src/plugins/content_management/public/components/card_container/card_embeddable.tsx @@ -10,7 +10,12 @@ import { EuiCard } from '@elastic/eui'; import { Embeddable, EmbeddableInput, IContainer } from '../../../../embeddable/public'; export const CARD_EMBEDDABLE = 'card_embeddable'; -export type CardEmbeddableInput = EmbeddableInput & { description: string; onClick?: () => void }; +export type CardEmbeddableInput = EmbeddableInput & { + description: string; + onClick?: () => void; + getIcon: () => React.ReactElement; + getFooter: () => React.ReactElement; +}; export class CardEmbeddable extends Embeddable { public readonly type = CARD_EMBEDDABLE; @@ -27,10 +32,13 @@ export class CardEmbeddable extends Embeddable { this.node = node; ReactDOM.render( , node ); diff --git a/src/plugins/content_management/public/components/page_render.tsx b/src/plugins/content_management/public/components/page_render.tsx index 9a5211ca3a46..90d6033576bb 100644 --- a/src/plugins/content_management/public/components/page_render.tsx +++ b/src/plugins/content_management/public/components/page_render.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { useObservable } from 'react-use'; import { SavedObjectsClientContract } from 'opensearch-dashboards/public'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { Page } from '../services'; import { SectionRender } from './section_render'; import { EmbeddableStart } from '../../../embeddable/public'; @@ -21,16 +22,22 @@ export const PageRender = ({ page, embeddable, savedObjectsClient }: Props) => { const sections = useObservable(page.getSections$()) || []; return ( -
+ {sections.map((section) => ( - + + + ))} -
+ ); }; diff --git a/src/plugins/content_management/public/components/section_input.ts b/src/plugins/content_management/public/components/section_input.ts index 1d37feef8ecc..00bb5b0683c7 100644 --- a/src/plugins/content_management/public/components/section_input.ts +++ b/src/plugins/content_management/public/components/section_input.ts @@ -42,6 +42,8 @@ export const createCardInput = ( title: content.title, description: content.description, onClick: content.onClick, + getIcon: content?.getIcon, + getFooter: content?.getFooter, }, }; } diff --git a/src/plugins/content_management/public/components/section_render.tsx b/src/plugins/content_management/public/components/section_render.tsx index 19f14bdb1d67..d28fbad7296a 100644 --- a/src/plugins/content_management/public/components/section_render.tsx +++ b/src/plugins/content_management/public/components/section_render.tsx @@ -6,9 +6,8 @@ import React, { useState, useEffect, useMemo } from 'react'; import { useObservable } from 'react-use'; import { BehaviorSubject } from 'rxjs'; -import { EuiTitle } from '@elastic/eui'; +import { EuiButtonIcon, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import { SavedObjectsClientContract } from 'opensearch-dashboards/public'; - import { Content, Section } from '../services'; import { EmbeddableInput, EmbeddableRenderer, EmbeddableStart } from '../../../embeddable/public'; import { DashboardContainerInput } from '../../../dashboard/public'; @@ -49,6 +48,10 @@ const DashboardSection = ({ section, embeddable, contents$, savedObjectsClient } }; const CardSection = ({ section, embeddable, contents$ }: Props) => { + const [isCardVisible, setIsCardVisible] = useState(true); + const toggleCardVisibility = () => { + setIsCardVisible(!isCardVisible); + }; const contents = useObservable(contents$); const input = useMemo(() => { return createCardInput(section, contents ?? []); @@ -58,12 +61,24 @@ const CardSection = ({ section, embeddable, contents$ }: Props) => { if (section.kind === 'card' && factory && input) { return ( -
+ -

{section.title}

+

+ + {section.title} +

- -
+ {isCardVisible && ( + <> + + + )} + ); } diff --git a/src/plugins/content_management/public/services/content_management/types.ts b/src/plugins/content_management/public/services/content_management/types.ts index 0a0020ed6254..55da19f26b87 100644 --- a/src/plugins/content_management/public/services/content_management/types.ts +++ b/src/plugins/content_management/public/services/content_management/types.ts @@ -59,6 +59,8 @@ export type Content = title: string; description: string; onClick?: () => void; + getIcon?: () => React.ReactElement; + getFooter?: () => React.ReactElement; }; export type SavedObjectInput = diff --git a/src/plugins/home/public/index.ts b/src/plugins/home/public/index.ts index 58ad10cdf04b..d252a31a0977 100644 --- a/src/plugins/home/public/index.ts +++ b/src/plugins/home/public/index.ts @@ -53,3 +53,5 @@ import { HomePublicPlugin } from './plugin'; export const plugin = (initializerContext: PluginInitializerContext) => new HomePublicPlugin(initializerContext); + +export { HOME_PAGE_ID, HOME_CONTENT_AREAS } from '../common/constants'; diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index 2e9377b3bda9..99a66fb1743a 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -7,6 +7,6 @@ "savedObjects", "opensearchDashboardsReact" ], - "optionalPlugins": ["savedObjectsManagement","management","dataSourceManagement"], - "requiredBundles": ["opensearchDashboardsReact"] + "optionalPlugins": ["savedObjectsManagement","management","dataSourceManagement","contentManagement"], + "requiredBundles": ["opensearchDashboardsReact", "home"] } diff --git a/src/plugins/workspace/public/components/home_get_start_card/index.ts b/src/plugins/workspace/public/components/home_get_start_card/index.ts new file mode 100644 index 000000000000..f78300a492d3 --- /dev/null +++ b/src/plugins/workspace/public/components/home_get_start_card/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { UseCaseFooter } from './use_case_footer'; diff --git a/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.test.tsx b/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.test.tsx new file mode 100644 index 000000000000..8296a5ba8359 --- /dev/null +++ b/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.test.tsx @@ -0,0 +1,156 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { UseCaseFooter as UseCaseFooterComponent, UseCaseFooterProps } from './use_case_footer'; +import { coreMock, httpServiceMock } from '../../../../../core/public/mocks'; +import { IntlProvider } from 'react-intl'; +import { WorkspaceUseCase } from '../../types'; +import { CoreStart } from 'opensearch-dashboards/public'; +import { BehaviorSubject } from 'rxjs'; +import { WORKSPACE_USE_CASES } from '../../../common/constants'; + +describe('UseCaseFooter', () => { + // let coreStartMock: CoreStart; + const navigateToApp = jest.fn(); + const registeredUseCases$ = new BehaviorSubject([ + WORKSPACE_USE_CASES.observability, + WORKSPACE_USE_CASES['security-analytics'], + WORKSPACE_USE_CASES.analytics, + WORKSPACE_USE_CASES.search, + ]); + + const getMockCore = (isDashboardAdmin: boolean = true) => { + const coreStartMock = coreMock.createStart(); + coreStartMock.application.capabilities = { + ...coreStartMock.application.capabilities, + dashboards: { isDashboardAdmin }, + }; + coreStartMock.application = { + ...coreStartMock.application, + navigateToApp, + }; + jest.spyOn(coreStartMock.application, 'getUrlForApp').mockImplementation((appId: string) => { + return `https://test.com/app/${appId}`; + }); + return coreStartMock; + }; + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + const UseCaseFooter = (props: UseCaseFooterProps) => { + return ( + + + + ); + }; + it('renders create workspace button for admin when no workspaces within use case exist', () => { + const { getByTestId } = render( + + ); + + const button = getByTestId('useCase.footer.createWorkspace.button'); + expect(button).toBeInTheDocument(); + fireEvent.click(button); + const createWorkspaceButtonInModal = getByTestId('useCase.footer.modal.create.button'); + expect(createWorkspaceButtonInModal).toHaveAttribute( + 'href', + 'https://test.com/app/workspace_create' + ); + }); + + it('renders create workspace button for non-admin when no workspaces within use case exist', () => { + const { getByTestId } = render( + + ); + + const button = getByTestId('useCase.footer.createWorkspace.button'); + expect(button).toBeInTheDocument(); + fireEvent.click(button); + expect(screen.getByText('Unable to create workspace')).toBeInTheDocument(); + expect(screen.queryByTestId('useCase.footer.modal.create.button')).not.toBeInTheDocument(); + fireEvent.click(getByTestId('useCase.footer.modal.close.button')); + }); + + it('renders open workspace button when one workspace exists', () => { + const core = getMockCore(); + core.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1', features: ['use-case-observability'] }, + ]); + const { getByTestId } = render( + + ); + + const button = getByTestId('useCase.footer.openWorkspace.button'); + expect(button).toBeInTheDocument(); + expect(button).not.toBeDisabled(); + expect(button).toHaveAttribute('href', 'https://test.com/w/workspace-1/app/discover'); + }); + + it('renders select workspace popover when multiple workspaces exist', () => { + const core = getMockCore(); + core.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1', features: ['use-case-observability'] }, + { id: 'workspace-2', name: 'workspace 2', features: ['use-case-observability'] }, + ]); + + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { + assign: jest.fn(), + }, + }); + + render( + + ); + + const button = screen.getByText('Select workspace'); + expect(button).toBeInTheDocument(); + + fireEvent.click(button); + expect(screen.getByText('workspace 1')).toBeInTheDocument(); + expect(screen.getByText('workspace 2')).toBeInTheDocument(); + expect(screen.getByText('Observability Workspaces')).toBeInTheDocument(); + + const inputElement = screen.getByPlaceholderText('Search'); + expect(inputElement).toBeInTheDocument(); + fireEvent.change(inputElement, { target: { value: 'workspace 1' } }); + expect(screen.queryByText('workspace 2')).toBeNull(); + + fireEvent.click(screen.getByText('workspace 1')); + expect(window.location.assign).toHaveBeenCalledWith( + 'https://test.com/w/workspace-1/app/discover' + ); + Object.defineProperty(window, 'location', { + value: originalLocation, + }); + }); +}); diff --git a/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.tsx b/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.tsx new file mode 100644 index 000000000000..fddd542f64d7 --- /dev/null +++ b/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.tsx @@ -0,0 +1,219 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiText, + EuiModal, + EuiTitle, + EuiPanel, + EuiAvatar, + EuiSpacer, + EuiButton, + EuiPopover, + EuiFlexItem, + EuiModalBody, + EuiFlexGroup, + EuiFieldSearch, + EuiModalFooter, + EuiModalHeader, + EuiContextMenu, + EuiModalHeaderTitle, +} from '@elastic/eui'; +import React, { useMemo, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { i18n } from '@osd/i18n'; +import { BehaviorSubject } from 'rxjs'; +import { WORKSPACE_DETAIL_APP_ID } from '../../../common/constants'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { CoreStart, WorkspaceObject } from '../../../../../core/public'; +import { WorkspaceUseCase } from '../../types'; +import { getUseCaseFromFeatureConfig } from '../../utils'; + +export interface UseCaseFooterProps { + useCaseId: string; + useCaseTitle: string; + core: CoreStart; + registeredUseCases$: BehaviorSubject; +} + +export const UseCaseFooter = ({ + useCaseId, + useCaseTitle, + core, + registeredUseCases$, +}: UseCaseFooterProps) => { + const workspaceList = core.workspaces.workspaceList$.getValue(); + const availableUseCases = registeredUseCases$.getValue(); + const basePath = core.http.basePath; + const isDashboardAdmin = core.application.capabilities?.dashboards?.isDashboardAdmin !== false; + const [isPopoverOpen, setPopover] = useState(false); + const [searchValue, setSearchValue] = useState(''); + const [isModalVisible, setIsModalVisible] = useState(false); + const closeModal = () => setIsModalVisible(false); + const showModal = () => setIsModalVisible(!isModalVisible); + const onButtonClick = () => setPopover(!isPopoverOpen); + const closePopover = () => setPopover(false); + + const appId = + availableUseCases?.find((useCase) => useCase.id === useCaseId)?.features[0] ?? + WORKSPACE_DETAIL_APP_ID; + + const filterWorkspaces = useMemo( + () => + workspaceList.filter( + (workspace) => + workspace.features?.map(getUseCaseFromFeatureConfig).filter(Boolean)[0] === useCaseId + ), + [useCaseId, workspaceList] + ); + + const searchWorkspaces = useMemo( + () => + filterWorkspaces + .filter((workspace) => workspace.name.toLowerCase().includes(searchValue.toLowerCase())) + .slice(0, 5), + [filterWorkspaces, searchValue] + ); + + if (filterWorkspaces.length === 0) { + const modalHeaderTitle = i18n.translate('useCase.footer.modal.headerTitle', { + defaultMessage: isDashboardAdmin ? 'No workspaces found' : 'Unable to create workspace', + }); + const modalBodyContent = i18n.translate('useCase.footer.modal.bodyContent', { + defaultMessage: isDashboardAdmin + ? 'There are no available workspaces found. You can create a workspace in the workspace creation page.' + : 'To create a workspace, contact your administrator.', + }); + + return ( + <> + + + + {isModalVisible && ( + + + {modalHeaderTitle} + + + + {modalBodyContent} + + + + + + + {isDashboardAdmin && ( + + + + )} + + + )} + + ); + } + + if (filterWorkspaces.length === 1) { + const useCaseURL = formatUrlWithWorkspaceId( + core.application.getUrlForApp(appId, { absolute: false }), + filterWorkspaces[0].id, + basePath + ); + return ( + + + + ); + } + + const workspaceToItem = (workspace: WorkspaceObject) => { + const useCaseURL = formatUrlWithWorkspaceId( + core.application.getUrlForApp(appId, { absolute: false }), + workspace.id, + basePath + ); + const workspaceName = workspace.name; + + return { + toolTipContent:
{workspaceName}
, + name: ( + + {workspaceName} + + ), + key: workspace.id, + icon: ( + + ), + onClick: () => { + window.location.assign(useCaseURL); + }, + }; + }; + + const button = ( + + + + ); + const panels = [ + { + id: 0, + items: searchWorkspaces.map(workspaceToItem), + }, + ]; + + return ( + + + + + + + + +

{useCaseTitle} Workspaces

+
+
+
+ + setSearchValue(e.target.value)} + fullWidth + /> +
+ +
+ ); +}; diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx index d3578498c858..68ed1c67359f 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx @@ -109,18 +109,6 @@ describe('', () => { expect(screen.getByText('Observability')).toBeInTheDocument(); }); - it('should close the workspace dropdown list', async () => { - render(); - - fireEvent.click(screen.getByTestId('workspace-select-button')); - - expect(screen.getByText(/all workspaces/i)).toBeInTheDocument(); - fireEvent.click(screen.getByTestId('workspace-select-button')); - await waitFor(() => { - expect(screen.queryByText(/all workspaces/i)).not.toBeInTheDocument(); - }); - }); - it('should navigate to the workspace', () => { coreStartMock.workspaces.workspaceList$.next([ { id: 'workspace-1', name: 'workspace 1', features: ['use-case-observability'] }, diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx index bda11fb3d113..77d1cd6e602e 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -44,7 +44,7 @@ const allWorkspacesTitle = i18n.translate('workspace.menu.title.allWorkspaces', }); const recentWorkspacesTitle = i18n.translate('workspace.menu.title.recentWorkspaces', { - defaultMessage: 'recent workspaces', + defaultMessage: 'Recent workspaces', }); const createWorkspaceButton = i18n.translate('workspace.menu.button.createWorkspace', { @@ -158,6 +158,7 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { } onClick={() => { + closePopover(); window.location.assign(useCaseURL); }} /> @@ -221,6 +222,7 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { { + closePopover(); navigateToWorkspaceDetail(coreStart, currentWorkspace.id); }} > @@ -240,6 +242,7 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { { + closePopover(); coreStart.application.navigateToApp(WORKSPACE_LIST_APP_ID); }} > @@ -251,8 +254,9 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { - {getWorkspaceListGroup(filteredRecentWorkspaces, 'recent')} - {getWorkspaceListGroup(filteredWorkspaceList, 'all')} + {filteredRecentWorkspaces.length > 0 && + getWorkspaceListGroup(filteredRecentWorkspaces, 'recent')} + {filteredWorkspaceList.length > 0 && getWorkspaceListGroup(filteredWorkspaceList, 'all')} @@ -263,6 +267,7 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { key={WORKSPACE_LIST_APP_ID} data-test-subj="workspace-menu-view-all-button" onClick={() => { + closePopover(); coreStart.application.navigateToApp(WORKSPACE_LIST_APP_ID); }} > @@ -278,6 +283,7 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { key={WORKSPACE_CREATE_APP_ID} data-test-subj="workspace-menu-create-workspace-button" onClick={() => { + closePopover(); coreStart.application.navigateToApp(WORKSPACE_CREATE_APP_ID); }} > diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index b2ed55c08de6..6ffae95d40c3 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -19,9 +19,13 @@ import { savedObjectsManagementPluginMock } from '../../saved_objects_management import { managementPluginMock } from '../../management/public/mocks'; import { UseCaseService } from './services/use_case_service'; import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; -import { WorkspacePlugin } from './plugin'; +import { WorkspacePlugin, WorkspacePluginStartDeps } from './plugin'; +import { contentManagementPluginMocks } from '../../content_management/public'; describe('Workspace plugin', () => { + const mockDependencies: WorkspacePluginStartDeps = { + contentManagement: contentManagementPluginMocks.createStartContract(), + }; const getSetupMock = () => ({ ...coreMock.createSetup(), chrome: chromeServiceMock.createSetupContract(), @@ -48,7 +52,7 @@ describe('Workspace plugin', () => { const setupMock = getSetupMock(); const coreStart = coreMock.createStart(); await workspacePlugin.setup(setupMock, {}); - workspacePlugin.start(coreStart); + workspacePlugin.start(coreStart, mockDependencies); coreStart.workspaces.currentWorkspaceId$.next('foo'); expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); expect(setupMock.application.register).toBeCalledTimes(4); @@ -182,7 +186,7 @@ describe('Workspace plugin', () => { const breadcrumbs = new BehaviorSubject([{ text: 'dashboards' }]); startMock.chrome.getBreadcrumbs$.mockReturnValue(breadcrumbs); const workspacePlugin = new WorkspacePlugin(); - workspacePlugin.start(startMock); + workspacePlugin.start(startMock, mockDependencies); expect(startMock.chrome.setBreadcrumbs).toBeCalledWith( expect.arrayContaining([ expect.objectContaining({ @@ -208,7 +212,7 @@ describe('Workspace plugin', () => { ]); startMock.chrome.getBreadcrumbs$.mockReturnValue(breadcrumbs); const workspacePlugin = new WorkspacePlugin(); - workspacePlugin.start(startMock); + workspacePlugin.start(startMock, mockDependencies); expect(startMock.chrome.setBreadcrumbs).not.toHaveBeenCalled(); }); @@ -225,7 +229,7 @@ describe('Workspace plugin', () => { jest.spyOn(navGroupUpdater$, 'next'); expect(navGroupUpdater$.next).not.toHaveBeenCalled(); - workspacePlugin.start(coreStart); + workspacePlugin.start(coreStart, mockDependencies); waitFor(() => { expect(navGroupUpdater$.next).toHaveBeenCalled(); @@ -236,7 +240,7 @@ describe('Workspace plugin', () => { const coreStart = coreMock.createStart(); coreStart.chrome.navGroup.getNavGroupEnabled.mockReturnValue(true); const workspacePlugin = new WorkspacePlugin(); - workspacePlugin.start(coreStart); + workspacePlugin.start(coreStart, mockDependencies); expect(coreStart.chrome.navControls.registerLeftBottom).toBeCalledTimes(1); }); @@ -265,7 +269,7 @@ describe('Workspace plugin', () => { const appUpdater$ = setupMock.application.registerAppUpdater.mock.calls[0][0]; - workspacePlugin.start(coreStart); + workspacePlugin.start(coreStart, mockDependencies); const appUpdater = await appUpdater$.pipe(first()).toPromise(); @@ -286,7 +290,7 @@ describe('Workspace plugin', () => { const navGroupUpdater$ = setupMock.chrome.navGroup.registerNavGroupUpdater.mock.calls[0][0]; - workspacePlugin.start(coreStart); + workspacePlugin.start(coreStart, mockDependencies); const navGroupUpdater = await navGroupUpdater$.pipe(first()).toPromise(); @@ -337,7 +341,7 @@ describe('Workspace plugin', () => { const appUpdaterChangeMock = jest.fn(); appUpdater$.subscribe(appUpdaterChangeMock); - workspacePlugin.start(coreStart); + workspacePlugin.start(coreStart, mockDependencies); // Wait for filterNav been executed await new Promise(setImmediate); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 104db7d9b91f..40f475d4a818 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -7,6 +7,7 @@ import { BehaviorSubject, combineLatest, Subscription } from 'rxjs'; import React from 'react'; import { i18n } from '@osd/i18n'; import { map } from 'rxjs/operators'; +import { EuiIcon } from '@elastic/eui'; import { Plugin, CoreStart, @@ -28,6 +29,7 @@ import { WORKSPACE_DETAIL_APP_ID, WORKSPACE_CREATE_APP_ID, WORKSPACE_LIST_APP_ID, + WORKSPACE_USE_CASES, } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { Services, WorkspaceUseCase } from './types'; @@ -46,6 +48,9 @@ import { import { recentWorkspaceManager } from './recent_workspace_manager'; import { toMountPoint } from '../../opensearch_dashboards_react/public'; import { UseCaseService } from './services/use_case_service'; +import { ContentManagementPluginStart } from '../../../plugins/content_management/public'; +import { UseCaseFooter } from './components/home_get_start_card'; +import { HOME_CONTENT_AREAS } from '../../home/public'; type WorkspaceAppType = ( params: AppMountParameters, @@ -59,7 +64,12 @@ interface WorkspacePluginSetupDeps { dataSourceManagement?: DataSourceManagementPluginSetup; } -export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> { +export interface WorkspacePluginStartDeps { + contentManagement: ContentManagementPluginStart; +} + +export class WorkspacePlugin + implements Plugin<{}, {}, WorkspacePluginSetupDeps, WorkspacePluginStartDeps> { private coreStart?: CoreStart; private currentWorkspaceSubscription?: Subscription; private breadcrumbsSubscription?: Subscription; @@ -372,7 +382,41 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> return {}; } - public start(core: CoreStart) { + private registerGetStartedCardToNewHome( + core: CoreStart, + contentManagement: ContentManagementPluginStart + ) { + const useCases = [ + WORKSPACE_USE_CASES.observability, + WORKSPACE_USE_CASES['security-analytics'], + WORKSPACE_USE_CASES.search, + WORKSPACE_USE_CASES.analytics, + ]; + + useCases.forEach((useCase, index) => { + contentManagement.registerContentProvider({ + id: `home_get_start_${useCase.id}`, + getTargetArea: () => HOME_CONTENT_AREAS.GET_STARTED, + getContent: () => ({ + id: useCase.id, + kind: 'card', + order: (index + 1) * 1000, + description: useCase.description, + title: useCase.title, + getIcon: () => React.createElement(EuiIcon, { size: 'xl', type: 'logoOpenSearch' }), + getFooter: () => + React.createElement(UseCaseFooter, { + useCaseId: useCase.id, + useCaseTitle: useCase.title, + core, + registeredUseCases$: this.registeredUseCases$, + }), + }), + }); + }); + } + + public start(core: CoreStart, { contentManagement }: WorkspacePluginStartDeps) { this.coreStart = core; this.currentWorkspaceIdSubscription = this._changeSavedObjectCurrentWorkspace(); @@ -405,8 +449,10 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> }) ), }); - } + // register get started card in new home page + this.registerGetStartedCardToNewHome(core, contentManagement); + } return {}; } From 376ead0ce1ef5224476604e48aa487354fbb0387 Mon Sep 17 00:00:00 2001 From: Viraj Sanghvi Date: Sun, 21 Jul 2024 01:18:20 -0700 Subject: [PATCH 05/10] fix: Fix wrapping of labels in filter by type popover (#7327) * fix: Fix wrapping of labels in filter by type popover Signed-off-by: Viraj Sanghvi * Changeset file for PR #7327 created/updated --------- Signed-off-by: Viraj Sanghvi Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7327.yml | 2 ++ .../data_explorer/public/components/sidebar/index.scss | 4 ++++ .../application/components/sidebar/discover_field_search.tsx | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 changelogs/fragments/7327.yml diff --git a/changelogs/fragments/7327.yml b/changelogs/fragments/7327.yml new file mode 100644 index 000000000000..da4ffa480766 --- /dev/null +++ b/changelogs/fragments/7327.yml @@ -0,0 +1,2 @@ +fix: +- Fix wrapping of labels in filter by type popover ([#7327](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7327)) \ No newline at end of file diff --git a/src/plugins/data_explorer/public/components/sidebar/index.scss b/src/plugins/data_explorer/public/components/sidebar/index.scss index 6d8ad2324bc1..1828568bc361 100644 --- a/src/plugins/data_explorer/public/components/sidebar/index.scss +++ b/src/plugins/data_explorer/public/components/sidebar/index.scss @@ -7,3 +7,7 @@ border-bottom: $euiBorderThin !important; } } + +.dataPanelTypeFilterPopover { + min-width: 300px; +} diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx index acef83191643..b009864411e0 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx @@ -258,7 +258,7 @@ export function DiscoverFieldSearch({ onChange, value, types }: Props) { Date: Mon, 22 Jul 2024 09:57:02 +0800 Subject: [PATCH 06/10] [Navigation-next]Register workspace list card into home page (#7247) * workspace list card on home Signed-off-by: Hailong Cui * fix merge conflicts Signed-off-by: Hailong Cui * add home as requiredBundles Signed-off-by: Hailong Cui * Changeset file for PR #7247 created/updated * fix failed UT Signed-off-by: Hailong Cui * address review comments Signed-off-by: Hailong Cui * update to funtional component Signed-off-by: Hailong Cui * udpate content provider id Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7247.yml | 2 + src/core/types/workspace.ts | 1 + .../workspace_list_card.test.tsx.snap | 121 +++++++++++ .../public/components/service_card/index.ts | 6 + .../service_card/workspace_list_card.test.tsx | 66 ++++++ .../service_card/workspace_list_card.tsx | 192 ++++++++++++++++++ src/plugins/workspace/public/plugin.test.ts | 13 ++ src/plugins/workspace/public/plugin.ts | 24 ++- src/plugins/workspace/public/utils.ts | 2 +- .../workspace/server/workspace_client.ts | 1 + 10 files changed, 426 insertions(+), 2 deletions(-) create mode 100644 changelogs/fragments/7247.yml create mode 100644 src/plugins/workspace/public/components/service_card/__snapshots__/workspace_list_card.test.tsx.snap create mode 100644 src/plugins/workspace/public/components/service_card/index.ts create mode 100644 src/plugins/workspace/public/components/service_card/workspace_list_card.test.tsx create mode 100644 src/plugins/workspace/public/components/service_card/workspace_list_card.tsx diff --git a/changelogs/fragments/7247.yml b/changelogs/fragments/7247.yml new file mode 100644 index 000000000000..535f4c9843b0 --- /dev/null +++ b/changelogs/fragments/7247.yml @@ -0,0 +1,2 @@ +feat: +- Register workspace list card into home page ([#7247](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7247)) \ No newline at end of file diff --git a/src/core/types/workspace.ts b/src/core/types/workspace.ts index d0a0d47b2216..c00d3576d567 100644 --- a/src/core/types/workspace.ts +++ b/src/core/types/workspace.ts @@ -14,6 +14,7 @@ export interface WorkspaceAttribute { icon?: string; reserved?: boolean; uiSettings?: Record; + lastUpdatedTime?: string; } export interface WorkspaceAttributeWithPermission extends WorkspaceAttribute { diff --git a/src/plugins/workspace/public/components/service_card/__snapshots__/workspace_list_card.test.tsx.snap b/src/plugins/workspace/public/components/service_card/__snapshots__/workspace_list_card.test.tsx.snap new file mode 100644 index 000000000000..35970676eb7e --- /dev/null +++ b/src/plugins/workspace/public/components/service_card/__snapshots__/workspace_list_card.test.tsx.snap @@ -0,0 +1,121 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`workspace list card render normally should show workspace list card correctly 1`] = ` +
+
+
+
+

+ Workspaces +

+
+
+ + + +
+
+
+
+ +
+ + +
+
+
+
+
+
+
    +
    + +
    +

    + No Workspaces found +

    + +
    +
    + Workspaces you have recently viewed will appear here. +
    + +
    +
+ +
+
+`; diff --git a/src/plugins/workspace/public/components/service_card/index.ts b/src/plugins/workspace/public/components/service_card/index.ts new file mode 100644 index 000000000000..9bfc561f2561 --- /dev/null +++ b/src/plugins/workspace/public/components/service_card/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceListCard } from './workspace_list_card'; diff --git a/src/plugins/workspace/public/components/service_card/workspace_list_card.test.tsx b/src/plugins/workspace/public/components/service_card/workspace_list_card.test.tsx new file mode 100644 index 000000000000..24d45d42e725 --- /dev/null +++ b/src/plugins/workspace/public/components/service_card/workspace_list_card.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { coreMock } from '../../../../../core/public/mocks'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { WorkspaceListCard } from './workspace_list_card'; +import { recentWorkspaceManager } from '../../recent_workspace_manager'; + +describe('workspace list card render normally', () => { + const coreStart = coreMock.createStart(); + + beforeAll(() => { + const workspaceList = [ + { + id: 'ws-1', + name: 'foo', + lastUpdatedTime: new Date().toISOString(), + }, + { + id: 'ws-2', + name: 'bar', + lastUpdatedTime: new Date().toISOString(), + }, + ]; + coreStart.workspaces.workspaceList$.next(workspaceList); + }); + + it('should show workspace list card correctly', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('should show empty state if no recently viewed workspace', () => { + const { getByTestId, getByText } = render(); + expect(getByTestId('workspace_filter')).toHaveDisplayValue('Recently viewed'); + + // empty statue for recently viewed + expect(getByText('Workspaces you have recently viewed will appear here.')).toBeInTheDocument(); + }); + + it('should show default filter as recently viewed', () => { + recentWorkspaceManager.addRecentWorkspace('foo'); + const { getByTestId, getByText } = render(); + expect(getByTestId('workspace_filter')).toHaveDisplayValue('Recently viewed'); + + waitFor(() => { + expect(getByText('foo')).toBeInTheDocument(); + }); + }); + + it('should show updated filter correctly', () => { + const { getByTestId, getByText } = render(); + expect(getByTestId('workspace_filter')).toHaveDisplayValue('Recently viewed'); + + const filterSelector = getByTestId('workspace_filter'); + fireEvent.change(filterSelector, { target: { value: 'updated' } }); + expect(getByTestId('workspace_filter')).toHaveDisplayValue('Recently updated'); + + // workspace list + expect(getByText('foo')).toBeInTheDocument(); + expect(getByText('bar')).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/workspace/public/components/service_card/workspace_list_card.tsx b/src/plugins/workspace/public/components/service_card/workspace_list_card.tsx new file mode 100644 index 000000000000..12b14325ce11 --- /dev/null +++ b/src/plugins/workspace/public/components/service_card/workspace_list_card.tsx @@ -0,0 +1,192 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect, useMemo } from 'react'; +import { + EuiPanel, + EuiLink, + EuiDescriptionList, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiSelect, + EuiButtonIcon, + EuiSpacer, + EuiListGroup, + EuiText, + EuiTitle, + EuiToolTip, + EuiEmptyPrompt, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import moment from 'moment'; +import { orderBy } from 'lodash'; +import { CoreStart, WorkspaceObject } from '../../../../../core/public'; +import { navigateToWorkspaceDetail } from '../utils/workspace'; + +import { WORKSPACE_CREATE_APP_ID, WORKSPACE_LIST_APP_ID } from '../../../common/constants'; +import { recentWorkspaceManager } from '../../recent_workspace_manager'; + +const WORKSPACE_LIST_CARD_DESCRIPTION = i18n.translate('workspace.list.card.description', { + defaultMessage: + 'Workspaces are dedicated environments for organizing and collaborating on your data, dashboards, and analytics workflows. Each Workspace acts as a self-contained space with its own set of saved objects and access controls.', +}); + +const MAX_ITEM_IN_LIST = 5; + +export interface WorkspaceListCardProps { + core: CoreStart; +} + +export const WorkspaceListCard = (props: WorkspaceListCardProps) => { + const [availableWorkspaces, setAvailableWorkspaces] = useState([]); + const [filter, setFilter] = useState('viewed'); + + useEffect(() => { + const workspaceSub = props.core.workspaces.workspaceList$.subscribe((list) => { + setAvailableWorkspaces(list || []); + }); + return () => { + workspaceSub.unsubscribe(); + }; + }, [props.core]); + + const workspaceList = useMemo(() => { + const recentWorkspaces = recentWorkspaceManager.getRecentWorkspaces() || []; + if (filter === 'viewed') { + return orderBy(recentWorkspaces, ['timestamp'], ['desc']) + .filter((ws) => availableWorkspaces.some((a) => a.id === ws.id)) + .slice(0, MAX_ITEM_IN_LIST) + .map((item) => ({ + id: item.id, + name: availableWorkspaces.find((ws) => ws.id === item.id)?.name!, + time: item.timestamp, + })); + } else if (filter === 'updated') { + return orderBy(availableWorkspaces, ['lastUpdatedTime'], ['desc']) + .slice(0, MAX_ITEM_IN_LIST) + .map((ws) => ({ + id: ws.id, + name: ws.name, + time: ws.lastUpdatedTime, + })); + } + return []; + }, [filter, availableWorkspaces]); + + const handleSwitchWorkspace = (id: string) => { + const { application, http } = props.core; + if (application && http) { + navigateToWorkspaceDetail({ application, http }, id); + } + }; + + const { application } = props.core; + + const isDashboardAdmin = application.capabilities.dashboards?.isDashboardAdmin; + + return ( + + + + +

Workspaces

+
+
+ + + + + + + { + setFilter(e.target.value); + }} + options={[ + { + value: 'viewed', + text: i18n.translate('workspace.list.card.filter.viewed', { + defaultMessage: 'Recently viewed', + }), + }, + { + value: 'updated', + text: i18n.translate('workspace.list.card.filter.updated', { + defaultMessage: 'Recently updated', + }), + }, + ]} + /> + + {isDashboardAdmin && ( + + + { + application.navigateToApp(WORKSPACE_CREATE_APP_ID); + }} + /> + + + )} +
+ + + + {workspaceList && workspaceList.length === 0 ? ( + No Workspaces found

} + body={i18n.translate('workspace.list.card.empty', { + values: { + filter, + }, + defaultMessage: 'Workspaces you have recently {filter} will appear here.', + })} + /> + ) : ( + ({ + title: ( + { + handleSwitchWorkspace(workspace.id); + }} + > + {workspace.name} + + ), + description: ( + + {moment(workspace.time).fromNow()} + + ), + }))} + /> + )} +
+ + { + application.navigateToApp(WORKSPACE_LIST_APP_ID); + }} + > + View all + +
+ ); +}; diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index 6ffae95d40c3..0b683fd9862f 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -30,6 +30,7 @@ describe('Workspace plugin', () => { ...coreMock.createSetup(), chrome: chromeServiceMock.createSetupContract(), }); + beforeEach(() => { WorkspaceClientMock.mockClear(); Object.values(workspaceClientMock).forEach((item) => item.mockClear()); @@ -216,6 +217,18 @@ describe('Workspace plugin', () => { expect(startMock.chrome.setBreadcrumbs).not.toHaveBeenCalled(); }); + it('#start should register workspace list card into new home page', async () => { + const startMock = coreMock.createStart(); + startMock.chrome.navGroup.getNavGroupEnabled.mockReturnValue(true); + const workspacePlugin = new WorkspacePlugin(); + workspacePlugin.start(startMock, mockDependencies); + expect(mockDependencies.contentManagement.registerContentProvider).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'workspace_list_card_home', + }) + ); + }); + it('#start should call navGroupUpdater$.next after currentWorkspace set', async () => { const workspacePlugin = new WorkspacePlugin(); const setupMock = getSetupMock(); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 40f475d4a818..1be221b546ad 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -36,6 +36,7 @@ import { Services, WorkspaceUseCase } from './types'; import { WorkspaceClient } from './workspace_client'; import { SavedObjectsManagementPluginSetup } from '../../../plugins/saved_objects_management/public'; import { ManagementSetup } from '../../../plugins/management/public'; +import { ContentManagementPluginStart } from '../../../plugins/content_management/public'; import { WorkspaceMenu } from './components/workspace_menu/workspace_menu'; import { getWorkspaceColumn } from './components/workspace_column'; import { DataSourceManagementPluginSetup } from '../../../plugins/data_source_management/public'; @@ -48,7 +49,7 @@ import { import { recentWorkspaceManager } from './recent_workspace_manager'; import { toMountPoint } from '../../opensearch_dashboards_react/public'; import { UseCaseService } from './services/use_case_service'; -import { ContentManagementPluginStart } from '../../../plugins/content_management/public'; +import { WorkspaceListCard } from './components/service_card'; import { UseCaseFooter } from './components/home_get_start_card'; import { HOME_CONTENT_AREAS } from '../../home/public'; @@ -450,12 +451,33 @@ export class WorkspacePlugin ), }); + // register workspace list in home page + this.registerWorkspaceListToHome(core, contentManagement); + // register get started card in new home page this.registerGetStartedCardToNewHome(core, contentManagement); } return {}; } + private registerWorkspaceListToHome( + core: CoreStart, + contentManagement: ContentManagementPluginStart + ) { + if (contentManagement) { + contentManagement.registerContentProvider({ + id: 'workspace_list_card_home', + getContent: () => ({ + id: 'workspace_list', + kind: 'custom', + order: 0, + render: () => React.createElement(WorkspaceListCard, { core }), + }), + getTargetArea: () => HOME_CONTENT_AREAS.SERVICE_CARDS, + }); + } + } + public stop() { this.currentWorkspaceSubscription?.unsubscribe(); this.currentWorkspaceIdSubscription?.unsubscribe(); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index 589f1d8159d2..d4bc61638744 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -18,8 +18,8 @@ import { WorkspaceObject, WorkspaceAvailability, } from '../../../core/public'; -import { DEFAULT_SELECTED_FEATURES_IDS } from '../common/constants'; import { WorkspaceUseCase } from './types'; +import { DEFAULT_SELECTED_FEATURES_IDS } from '../common/constants'; const USE_CASE_PREFIX = 'use-case-'; diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index fe88436f38e5..0b7d7c8a57c1 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -77,6 +77,7 @@ export class WorkspaceClient implements IWorkspaceClientImpl { ): WorkspaceAttributeWithPermission { return { ...savedObject.attributes, + lastUpdatedTime: savedObject.updated_at, id: savedObject.id, permissions: savedObject.permissions, }; From d30677d72b58ad0c52fc553630ef1553de7313f9 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Mon, 22 Jul 2024 12:59:17 +0800 Subject: [PATCH 07/10] [navigation-next] fix: redirect to standard index pattern applications while nav group is enabled (#7346) * [navigation-next] fix: redirect to standard index pattern applications while nav group is enabled (#7305) * feat: fix the incorrect jumping logic for Index pattern management Signed-off-by: SuZhou-Joe * Changeset file for PR #7305 created/updated * feat: update Signed-off-by: SuZhou-Joe * feat: update with comment Signed-off-by: SuZhou-Joe * feat: update order and remove reset logic Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update snapshot Signed-off-by: SuZhou-Joe * feat: some category change Signed-off-by: SuZhou-Joe * feat: update category Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> (cherry picked from commit 2c708e320222f7eee43e96247a71529e3f0207b4) Signed-off-by: github-actions[bot] * feat: change the order Signed-off-by: SuZhou-Joe * feat: hide left navigation when workspace enabled Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> Co-authored-by: SuZhou-Joe --- changelogs/fragments/7305.yml | 2 + .../nav_group/nav_group_service.test.ts | 47 ----------- .../chrome/nav_group/nav_group_service.ts | 12 --- ...ollapsible_nav_group_enabled.test.tsx.snap | 7 +- .../header/collapsible_nav_group_enabled.scss | 17 +++- .../collapsible_nav_group_enabled.test.tsx | 26 ++++++ .../header/collapsible_nav_group_enabled.tsx | 80 ++++++++++++------- src/core/public/chrome/ui/header/header.tsx | 1 + src/core/utils/default_app_categories.ts | 9 ++- src/plugins/dashboard/public/plugin.tsx | 4 +- .../data_source_management/public/plugin.ts | 15 ++-- .../public/plugin.test.ts | 36 +++++++++ .../index_pattern_management/public/plugin.ts | 20 +++-- .../management_app/management_app.tsx | 5 +- src/plugins/management/public/plugin.ts | 2 + .../saved_objects_management/public/plugin.ts | 8 ++ 16 files changed, 182 insertions(+), 109 deletions(-) create mode 100644 changelogs/fragments/7305.yml diff --git a/changelogs/fragments/7305.yml b/changelogs/fragments/7305.yml new file mode 100644 index 000000000000..ebb9060e2121 --- /dev/null +++ b/changelogs/fragments/7305.yml @@ -0,0 +1,2 @@ +feat: +- [navigation-next] fix: redirect to standard index pattern applications while nav group is enabled ([#7305](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7305)) \ No newline at end of file diff --git a/src/core/public/chrome/nav_group/nav_group_service.test.ts b/src/core/public/chrome/nav_group/nav_group_service.test.ts index bc18483178fd..90911309ff9a 100644 --- a/src/core/public/chrome/nav_group/nav_group_service.test.ts +++ b/src/core/public/chrome/nav_group/nav_group_service.test.ts @@ -311,53 +311,6 @@ describe('ChromeNavGroupService#start()', () => { expect(sessionStorageMock.getItem(CURRENT_NAV_GROUP_ID)).toBeFalsy(); expect(currentNavGroup).toBeUndefined(); }); - - it('should reset current nav group if app not belongs to any nav group', async () => { - const uiSettings = uiSettingsServiceMock.createSetupContract(); - const navGroupEnabled$ = new Rx.BehaviorSubject(true); - uiSettings.get$.mockImplementation(() => navGroupEnabled$); - - const chromeNavGroupService = new ChromeNavGroupService(); - const chromeNavGroupServiceSetup = chromeNavGroupService.setup({ uiSettings }); - - chromeNavGroupServiceSetup.addNavLinksToGroup( - { - id: 'foo', - title: 'foo title', - description: 'foo description', - }, - [{ id: 'foo-app1' }] - ); - - const chromeNavGroupServiceStart = await chromeNavGroupService.start({ - navLinks: mockedNavLinkService, - application: mockedApplicationService, - }); - - // set an existing nav group id - chromeNavGroupServiceStart.setCurrentNavGroup('foo'); - - expect(sessionStorageMock.getItem(CURRENT_NAV_GROUP_ID)).toEqual('foo'); - - let currentNavGroup = await chromeNavGroupServiceStart - .getCurrentNavGroup$() - .pipe(first()) - .toPromise(); - - expect(currentNavGroup?.id).toEqual('foo'); - - // navigate to app don't belongs to any nav group - mockedApplicationService.navigateToApp('bar-app'); - - currentNavGroup = await chromeNavGroupServiceStart - .getCurrentNavGroup$() - .pipe(first()) - .toPromise(); - - // verify current nav group been reset - expect(currentNavGroup).toBeFalsy(); - expect(sessionStorageMock.getItem(CURRENT_NAV_GROUP_ID)).toBeFalsy(); - }); }); describe('nav group updater', () => { diff --git a/src/core/public/chrome/nav_group/nav_group_service.ts b/src/core/public/chrome/nav_group/nav_group_service.ts index bde7d0d9111a..bdf69b151da9 100644 --- a/src/core/public/chrome/nav_group/nav_group_service.ts +++ b/src/core/public/chrome/nav_group/nav_group_service.ts @@ -212,18 +212,6 @@ export class ChromeNavGroupService { } }; - // erase current nav group when switch app don't belongs to any nav group - application.currentAppId$.subscribe((appId) => { - const navGroupMap = this.navGroupsMap$.getValue(); - const appIdsWithNavGroup = Object.values(navGroupMap).flatMap(({ navLinks: links }) => - links.map(({ id }) => id) - ); - - if (appId && !appIdsWithNavGroup.includes(appId)) { - setCurrentNavGroup(undefined); - } - }); - const currentNavGroupSorted$ = combineLatest([ this.getSortedNavGroupsMap$(), this.currentNavGroup$, 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 61fb739ad6c2..56600b067583 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 @@ -1,5 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[` should hide left navigation when in home page when workspace is enabled 1`] = `
`; + exports[` should render correctly 1`] = `
should render correctly 1`] = ` />
@@ -266,8 +267,7 @@ exports[` should render correctly 2`] = ` class="euiHorizontalRule euiHorizontalRule--full" />
@@ -341,7 +341,6 @@ exports[` should show all use case by default and />
diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.scss b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.scss index 50e822bae295..77626cab7eb7 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.scss +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.scss @@ -2,8 +2,8 @@ border: none !important; .nav-link-item { - padding: $ouiSize / 4 $ouiSize; - border-radius: $ouiSize; + padding: calc($euiSize / 4) $euiSize; + border-radius: $euiSize; box-shadow: none; margin-bottom: 0; margin-top: 0; @@ -39,11 +39,20 @@ } .bottom-container { - padding: 0 $ouiSize; + padding: 0 $euiSize; display: flex; + + &.bottom-container-collapsed { + flex-direction: column; + align-items: center; + + > * { + margin: $euiSizeS 0; + } + } } .nav-controls-padding { - padding: $ouiSize; + padding: $euiSize; } } diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx index b08029553b50..fa4abffac36c 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx @@ -18,6 +18,7 @@ import { httpServiceMock } from '../../../mocks'; import { getLogos } from '../../../../common'; import { ALL_USE_CASE_ID, DEFAULT_NAV_GROUPS } from '../../../../public'; import { CollapsibleNavTopProps } from './collapsible_nav_group_enabled_top'; +import { capabilitiesServiceMock } from '../../../application/capabilities/capabilities_service.mock'; jest.mock('./collapsible_nav_group_enabled_top', () => ({ CollapsibleNavTop: (props: CollapsibleNavTopProps) => ( @@ -166,6 +167,7 @@ describe('', () => { currentNavGroup$.next(undefined); } }, + capabilities: { ...capabilitiesServiceMock.createStartContract().capabilities }, ...props, }; } @@ -223,4 +225,28 @@ describe('', () => { fireEvent.click(getByTestId('back')); expect(getAllByTestId('collapsibleNavAppLink-link-in-analytics').length).toEqual(2); }); + + it('should hide left navigation when in home page when workspace is enabled', async () => { + const props = mockProps({ + navGroupsMap: { + [DEFAULT_NAV_GROUPS.analytics.id]: { + ...DEFAULT_NAV_GROUPS.analytics, + navLinks: [ + { + id: 'link-in-analytics', + title: 'link-in-analytics', + showInAllNavGroup: true, + }, + ], + }, + }, + }); + props.appId$ = new BehaviorSubject('home'); + if (props.capabilities.workspaces) { + (props.capabilities.workspaces as Record) = {}; + (props.capabilities.workspaces as Record).enabled = true; + } + const { container } = render(); + expect(container).toMatchSnapshot(); + }); }); 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 0575dc997fc7..68b031232370 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 @@ -19,7 +19,7 @@ import useObservable from 'react-use/lib/useObservable'; import * as Rx from 'rxjs'; import classNames from 'classnames'; import { ChromeNavControl, ChromeNavLink } from '../..'; -import { NavGroupStatus } from '../../../../types'; +import { AppCategory, NavGroupStatus } from '../../../../types'; import { InternalApplicationStart } from '../../../application/types'; import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; @@ -36,7 +36,7 @@ import { LinkItem, LinkItemType, } from '../../utils'; -import { ALL_USE_CASE_ID } from '../../../../../core/utils'; +import { ALL_USE_CASE_ID, DEFAULT_APP_CATEGORIES } from '../../../../../core/utils'; import { CollapsibleNavTop } from './collapsible_nav_group_enabled_top'; import { HeaderNavControls } from './header_nav_controls'; @@ -58,6 +58,7 @@ export interface CollapsibleNavGroupEnabledProps { navControlsLeftBottom$: Rx.Observable; currentNavGroup$: Rx.Observable; setCurrentNavGroup: ChromeNavGroupServiceStartContract['setCurrentNavGroup']; + capabilities: InternalApplicationStart['capabilities']; } interface NavGroupsProps { @@ -164,6 +165,14 @@ export function NavGroups({ ); } +// Custom category is used for those features not belong to any of use cases in all use case. +// and the custom category should always sit before manage category +const customCategory: AppCategory = { + id: 'custom', + label: i18n.translate('core.ui.customNavList.label', { defaultMessage: 'Custom' }), + order: (DEFAULT_APP_CATEGORIES.manage.order || 0) - 500, +}; + export function CollapsibleNavGroupEnabled({ basePath, id, @@ -176,6 +185,7 @@ export function CollapsibleNavGroupEnabled({ navigateToUrl, logos, setCurrentNavGroup, + capabilities, ...observables }: CollapsibleNavGroupEnabledProps) { const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); @@ -183,29 +193,6 @@ export function CollapsibleNavGroupEnabled({ const navGroupsMap = useObservable(observables.navGroupsMap$, {}); const currentNavGroup = useObservable(observables.currentNavGroup$, undefined); - const onGroupClick = ( - e: React.MouseEvent, - group: NavGroupItemInMap - ) => { - const fulfilledLinks = fulfillRegistrationLinksToChromeNavLinks( - navGroupsMap[group.id]?.navLinks, - navLinks - ); - setCurrentNavGroup(group.id); - - // the `navGroupsMap[group.id]?.navLinks` has already been sorted - const firstLink = fulfilledLinks[0]; - if (firstLink) { - const propsForEui = createEuiListItem({ - link: firstLink, - appId, - dataTestSubj: 'collapsibleNavAppLink', - navigateToApp, - }); - propsForEui.onClick(e); - } - }; - const navLinksForRender: ChromeNavLink[] = useMemo(() => { if (currentNavGroup) { return fulfillRegistrationLinksToChromeNavLinks( @@ -234,7 +221,10 @@ export function CollapsibleNavGroupEnabled({ navLinks .filter((link) => !linkIdsWithUseGroupInfo.includes(link.id)) .forEach((navLink) => { - navLinksForAll.push(navLink); + navLinksForAll.push({ + ...navLink, + category: customCategory, + }); }); // Append all the links registered to all use case @@ -281,6 +271,37 @@ export function CollapsibleNavGroupEnabled({ return 270; }, [isNavOpen]); + // For now, only home page need to hide left navigation + // when workspace is enabled. + // If there are more pages need to hide left navigation in the future + // need to come up with a mechanism to register. + if (capabilities.workspaces.enabled && appId === 'home') { + return null; + } + + const onGroupClick = ( + e: React.MouseEvent, + group: NavGroupItemInMap + ) => { + const fulfilledLinks = fulfillRegistrationLinksToChromeNavLinks( + navGroupsMap[group.id]?.navLinks, + navLinks + ); + setCurrentNavGroup(group.id); + + // the `navGroupsMap[group.id]?.navLinks` has already been sorted + const firstLink = fulfilledLinks[0]; + if (firstLink) { + const propsForEui = createEuiListItem({ + link: firstLink, + appId, + dataTestSubj: 'collapsibleNavAppLink', + navigateToApp, + }); + propsForEui.onClick(e); + } + }; + return (
-
+
) : ( = Object.freeze label: i18n.translate('core.ui.manageNav.label', { defaultMessage: 'Manage', }), - order: 7000, + order: 8000, + }, + manageData: { + id: 'manageData', + label: i18n.translate('core.ui.manageDataNav.label', { + defaultMessage: 'Manage data', + }), + order: 1000, }, }); diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index afa3b6daf281..bbe000f12b79 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -456,14 +456,14 @@ export class DashboardPlugin core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.observability, [ { id: app.id, - order: 300, + order: 400, category: undefined, }, ]); core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS['security-analytics'], [ { id: app.id, - order: 300, + order: 400, category: undefined, }, ]); diff --git a/src/plugins/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts index 59f9cc91d613..df4edf40ddfd 100644 --- a/src/plugins/data_source_management/public/plugin.ts +++ b/src/plugins/data_source_management/public/plugin.ts @@ -146,11 +146,8 @@ export class DataSourceManagementPlugin core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.dataAdministration, [ { id: DSM_APP_ID_FOR_STANDARD_APPLICATION, - category: { - id: DSM_APP_ID_FOR_STANDARD_APPLICATION, - label: PLUGIN_NAME, - order: 200, - }, + category: DEFAULT_APP_CATEGORIES.manageData, + order: 100, }, ]); @@ -186,6 +183,14 @@ export class DataSourceManagementPlugin }, ]); + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.all, [ + { + id: DSM_APP_ID_FOR_STANDARD_APPLICATION, + category: DEFAULT_APP_CATEGORIES.manage, + order: 100, + }, + ]); + const registerAuthenticationMethod = (authMethod: AuthenticationMethod) => { if (this.started) { throw new Error( diff --git a/src/plugins/index_pattern_management/public/plugin.test.ts b/src/plugins/index_pattern_management/public/plugin.test.ts index e207af770af3..a0525acae2dc 100644 --- a/src/plugins/index_pattern_management/public/plugin.test.ts +++ b/src/plugins/index_pattern_management/public/plugin.test.ts @@ -7,6 +7,11 @@ import { coreMock } from '../../../core/public/mocks'; import { IndexPatternManagementPlugin } from './plugin'; import { urlForwardingPluginMock } from '../../url_forwarding/public/mocks'; import { managementPluginMock } from '../../management/public/mocks'; +import { + ManagementApp, + ManagementAppMountParams, + RegisterManagementAppArgs, +} from 'src/plugins/management/public'; describe('DiscoverPlugin', () => { it('setup successfully', () => { @@ -22,4 +27,35 @@ describe('DiscoverPlugin', () => { expect(setupMock.application.register).toBeCalledTimes(1); expect(setupMock.chrome.navGroup.addNavLinksToGroup).toBeCalledTimes(5); }); + + it('when new navigation is enabled, should navigate to standard IPM app', async () => { + const setupMock = coreMock.createSetup(); + const startMock = coreMock.createStart(); + setupMock.getStartServices.mockResolvedValue([startMock, {}, {}]); + const initializerContext = coreMock.createPluginInitializerContext(); + const pluginInstance = new IndexPatternManagementPlugin(initializerContext); + const managementMock = managementPluginMock.createSetupContract(); + let applicationRegistration = {} as Omit; + managementMock.sections.section.opensearchDashboards.registerApp = ( + app: Omit + ) => { + applicationRegistration = app; + return {} as ManagementApp; + }; + + setupMock.chrome.navGroup.getNavGroupEnabled.mockReturnValue(true); + startMock.application.getUrlForApp.mockReturnValue('/app/indexPatterns'); + + pluginInstance.setup(setupMock, { + urlForwarding: urlForwardingPluginMock.createSetupContract(), + management: managementMock, + }); + + await applicationRegistration.mount({} as ManagementAppMountParams); + + expect(startMock.application.getUrlForApp).toBeCalledWith('indexPatterns'); + expect(startMock.application.navigateToUrl).toBeCalledWith( + 'http://localhost/app/indexPatterns' + ); + }); }); diff --git a/src/plugins/index_pattern_management/public/plugin.ts b/src/plugins/index_pattern_management/public/plugin.ts index 7ee82dbcc3b0..ef462374129e 100644 --- a/src/plugins/index_pattern_management/public/plugin.ts +++ b/src/plugins/index_pattern_management/public/plugin.ts @@ -111,6 +111,17 @@ export class IndexPatternManagementPlugin title: sectionsHeader, order: 0, mount: async (params) => { + if (core.chrome.navGroup.getNavGroupEnabled()) { + const [coreStart] = await core.getStartServices(); + const urlForStandardIPMApp = new URL( + coreStart.application.getUrlForApp(IPM_APP_ID), + window.location.href + ); + const targetUrl = new URL(window.location.href); + targetUrl.pathname = urlForStandardIPMApp.pathname; + coreStart.application.navigateToUrl(targetUrl.toString()); + return () => {}; + } const { mountManagementSection } = await import('./management_app'); return mountManagementSection( @@ -178,14 +189,11 @@ export class IndexPatternManagementPlugin }, ]); - core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.dataAdministration, [ + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.all, [ { id: IPM_APP_ID, - category: { - id: IPM_APP_ID, - label: sectionsHeader, - order: 100, - }, + category: DEFAULT_APP_CATEGORIES.manage, + order: 200, }, ]); diff --git a/src/plugins/management/public/components/management_app/management_app.tsx b/src/plugins/management/public/components/management_app/management_app.tsx index b2109ceb08ca..c30243563b01 100644 --- a/src/plugins/management/public/components/management_app/management_app.tsx +++ b/src/plugins/management/public/components/management_app/management_app.tsx @@ -51,6 +51,7 @@ export interface ManagementAppDependencies { sections: SectionsServiceStart; opensearchDashboardsVersion: string; setBreadcrumbs: (newBreadcrumbs: ChromeBreadcrumb[]) => void; + hideInAppNavigation?: boolean; } export const ManagementApp = ({ dependencies, history }: ManagementAppProps) => { @@ -89,7 +90,9 @@ export const ManagementApp = ({ dependencies, history }: ManagementAppProps) => return ( - + {dependencies.hideInAppNavigation ? null : ( + + )} Date: Mon, 22 Jul 2024 14:10:27 +0800 Subject: [PATCH 08/10] fix: data source selector in dev tools tab moved to left (#7347) * fix: style issue that data source selector in dev tools tab moved to left Signed-off-by: Yulong Ruan * Changeset file for PR #7347 created/updated --------- Signed-off-by: Yulong Ruan Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7347.yml | 2 ++ src/plugins/dev_tools/public/application.tsx | 1 + src/plugins/dev_tools/public/index.scss | 8 +++++++- 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 changelogs/fragments/7347.yml diff --git a/changelogs/fragments/7347.yml b/changelogs/fragments/7347.yml new file mode 100644 index 000000000000..075f9d718446 --- /dev/null +++ b/changelogs/fragments/7347.yml @@ -0,0 +1,2 @@ +fix: +- Data source selector in dev tools tab moved to left ([#7347](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7347)) \ No newline at end of file diff --git a/src/plugins/dev_tools/public/application.tsx b/src/plugins/dev_tools/public/application.tsx index b1ccd290e59a..6c4cae6e18cf 100644 --- a/src/plugins/dev_tools/public/application.tsx +++ b/src/plugins/dev_tools/public/application.tsx @@ -124,6 +124,7 @@ function DevToolsWrapper({ onSelectedDataSource={onChange} disabled={!dataSourceEnabled} fullWidth={false} + compressed />
); diff --git a/src/plugins/dev_tools/public/index.scss b/src/plugins/dev_tools/public/index.scss index 554452a8923e..69754c302ff9 100644 --- a/src/plugins/dev_tools/public/index.scss +++ b/src/plugins/dev_tools/public/index.scss @@ -23,6 +23,12 @@ } .devAppDataSourceSelector { - margin: 7px 8px 0 0; + margin: 4px 8px 0 0; min-width: 400px; + margin-left: auto; +} + +.devAppTabs { + display: flex; + flex-flow: row wrap; } From e64de155546d584b92c868c38c6084d1d04b281e Mon Sep 17 00:00:00 2001 From: Tianyu Gao Date: Mon, 22 Jul 2024 14:29:22 +0800 Subject: [PATCH 09/10] [HomePage] Add home page static list card (#7351) * feat: add home static list card Signed-off-by: tygao * Changeset file for PR #7351 created/updated * update link property Signed-off-by: tygao * add i18n and description Signed-off-by: tygao --------- Signed-off-by: tygao Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7351.yml | 2 + .../home_list_card.test.tsx.snap | 51 +++++++++ .../components/home_list_card.test.tsx | 27 +++++ .../application/components/home_list_card.tsx | 102 ++++++++++++++++++ .../home/public/application/home_render.tsx | 36 ++++++- 5 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 changelogs/fragments/7351.yml create mode 100644 src/plugins/home/public/application/components/__snapshots__/home_list_card.test.tsx.snap create mode 100644 src/plugins/home/public/application/components/home_list_card.test.tsx create mode 100644 src/plugins/home/public/application/components/home_list_card.tsx diff --git a/changelogs/fragments/7351.yml b/changelogs/fragments/7351.yml new file mode 100644 index 000000000000..e8cbdc921c78 --- /dev/null +++ b/changelogs/fragments/7351.yml @@ -0,0 +1,2 @@ +feat: +- Add home page static list card ([#7351](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7351)) \ No newline at end of file diff --git a/src/plugins/home/public/application/components/__snapshots__/home_list_card.test.tsx.snap b/src/plugins/home/public/application/components/__snapshots__/home_list_card.test.tsx.snap new file mode 100644 index 000000000000..892d6a8dc225 --- /dev/null +++ b/src/plugins/home/public/application/components/__snapshots__/home_list_card.test.tsx.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render static content normally 1`] = ` + +
+
+

+ What's New +

+
+
+
+ + Quickstart guide + + + (opens in a new tab or window) + + +
+
+ Get started in minutes with OpenSearch Dashboards +
+
+
+
+ +`; diff --git a/src/plugins/home/public/application/components/home_list_card.test.tsx b/src/plugins/home/public/application/components/home_list_card.test.tsx new file mode 100644 index 000000000000..3e0a646e7279 --- /dev/null +++ b/src/plugins/home/public/application/components/home_list_card.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { HomeListCard } from './home_list_card'; + +describe('', () => { + it('should render static content normally', async () => { + const mockConfig = { + title: `What's New`, + list: [ + { + label: 'Quickstart guide', + href: 'https://opensearch.org/docs/latest/dashboards/quickstart/', + target: '_blank', + description: 'Get started in minutes with OpenSearch Dashboards', + }, + ], + }; + const { baseElement } = render(); + expect(baseElement).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/home/public/application/components/home_list_card.tsx b/src/plugins/home/public/application/components/home_list_card.tsx new file mode 100644 index 000000000000..c905ca3272cc --- /dev/null +++ b/src/plugins/home/public/application/components/home_list_card.tsx @@ -0,0 +1,102 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiDescriptionList, + EuiText, + EuiLink, + EuiTitle, + EuiPanel, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +export const LEARN_OPENSEARCH_CONFIG = { + title: i18n.translate('homepage.card.learnOpenSearch.title', { + defaultMessage: 'Learn Opensearch', + }), + list: [ + { + label: 'Quickstart guide', + href: 'https://opensearch.org/docs/latest/dashboards/quickstart/', + description: 'Get started in minutes with OpenSearch Dashboards', + }, + { + label: 'Building data visualizations', + href: 'https://opensearch.org/docs/latest/dashboards/visualize/viz-index/', + description: 'Design interactive charts and graphs to unlock insights form your data.', + }, + { + label: 'Creating dashboards', + href: 'https://opensearch.org/docs/latest/dashboards/dashboard/index/', + description: 'Build interactive dashboards to explore and analyze your data', + }, + ], + allLink: 'https://opensearch.org/docs/latest/', +}; + +export const WHATS_NEW_CONFIG = { + title: i18n.translate('homepage.card.whatsNew.title', { + defaultMessage: `What's New`, + }), + list: [ + { + label: 'Quickstart guide', + href: 'https://opensearch.org/docs/latest/dashboards/quickstart/', + description: 'Get started in minutes with OpenSearch Dashboards', + }, + ], +}; + +interface Config { + title: string; + list: Array<{ + label: string; + href: string; + description: string; + }>; + allLink?: string; +} + +export const HomeListCard = ({ config }: { config: Config }) => { + return ( + <> + + +

{config.title}

+
+ + {config.list.length > 0 && ( + + {config.list.map((item) => ( + <> + + + {item.label} + + + {item.description} + + ))} + + )} + + {config.allLink ? ( + <> + + + + View all + + + + ) : null} +
+ + ); +}; diff --git a/src/plugins/home/public/application/home_render.tsx b/src/plugins/home/public/application/home_render.tsx index 49d5bd1c9043..757475dd7f45 100644 --- a/src/plugins/home/public/application/home_render.tsx +++ b/src/plugins/home/public/application/home_render.tsx @@ -9,7 +9,12 @@ import { ContentManagementPluginSetup, ContentManagementPluginStart, } from '../../../../plugins/content_management/public'; -import { HOME_PAGE_ID, SECTIONS } from '../../common/constants'; +import { HOME_PAGE_ID, SECTIONS, HOME_CONTENT_AREAS } from '../../common/constants'; +import { + WHATS_NEW_CONFIG, + LEARN_OPENSEARCH_CONFIG, + HomeListCard, +} from './components/home_list_card'; export const setupHome = (contentManagement: ContentManagementPluginSetup) => { contentManagement.registerPage({ @@ -38,4 +43,31 @@ export const setupHome = (contentManagement: ContentManagementPluginSetup) => { }); }; -export const initHome = (contentManagement: ContentManagementPluginStart, core: CoreStart) => {}; +export const initHome = (contentManagement: ContentManagementPluginStart, core: CoreStart) => { + contentManagement.registerContentProvider({ + id: 'whats_new_cards', + getContent: () => ({ + id: 'whats_new', + kind: 'custom', + order: 3, + render: () => + React.createElement(HomeListCard, { + config: WHATS_NEW_CONFIG, + }), + }), + getTargetArea: () => HOME_CONTENT_AREAS.SERVICE_CARDS, + }); + contentManagement.registerContentProvider({ + id: 'learn_opensearch_new_cards', + getContent: () => ({ + id: 'learn_opensearch', + kind: 'custom', + order: 4, + render: () => + React.createElement(HomeListCard, { + config: LEARN_OPENSEARCH_CONFIG, + }), + }), + getTargetArea: () => HOME_CONTENT_AREAS.SERVICE_CARDS, + }); +}; From fcfddfa1d3c61dfebf321fb82d8c9f9b55dd323d Mon Sep 17 00:00:00 2001 From: Kawika Avilla Date: Mon, 22 Jul 2024 13:12:29 +0000 Subject: [PATCH 10/10] ppl def working Signed-off-by: Kawika Avilla --- changelogs/fragments/7289.yml | 2 + package.json | 2 +- .../search/search_source/search_source.ts | 7 +- .../create_dataset_navigator.tsx | 16 + .../dataset_navigator/dataset_navigator.tsx | 385 ++++++++++++++++++ .../ui/dataset_navigator/fetch_datasources.ts | 18 + .../dataset_navigator/fetch_index_patterns.ts | 22 + .../ui/dataset_navigator/fetch_indices.ts | 46 +++ .../public/ui/dataset_navigator/index.tsx | 23 ++ .../ui/query_editor/_dataset_navigator.scss | 12 + .../data/public/ui/query_editor/_index.scss | 1 + .../public/ui/query_editor/_query_editor.scss | 6 + .../public/ui/query_editor/query_editor.tsx | 74 +--- .../ui/query_editor/query_editor_top_row.tsx | 6 +- .../ui/search_bar/create_search_bar.tsx | 19 +- .../data/public/ui/search_bar/search_bar.tsx | 6 +- .../data/public/ui/settings/settings.ts | 9 + src/plugins/data/public/ui/types.ts | 9 +- src/plugins/data/public/ui/ui_service.ts | 23 +- .../public/components/sidebar/index.tsx | 43 +- .../utils/state_management/metadata_slice.ts | 21 +- .../public/utils/state_management/store.ts | 2 +- .../opensearch_dashboards.json | 2 +- .../components/connections_bar.tsx | 93 ----- .../components/index.ts | 6 - .../public/data_source_connection/index.ts | 7 - .../data_source_connection/services/index.ts | 6 - .../utils/create_extension.tsx | 34 -- .../data_source_connection/utils/index.ts | 6 - .../query_enhancements/public/plugin.tsx | 17 +- .../public/search/ppl_search_interceptor.ts | 4 +- .../query_enhancements/public/services.ts | 11 - .../services/connections_service.ts | 4 +- .../public/services/index.ts | 13 + .../routes/data_source_connection/routes.ts | 5 +- .../query_enhancements/server/types.ts | 2 +- 36 files changed, 662 insertions(+), 300 deletions(-) create mode 100644 changelogs/fragments/7289.yml create mode 100644 src/plugins/data/public/ui/dataset_navigator/create_dataset_navigator.tsx create mode 100644 src/plugins/data/public/ui/dataset_navigator/dataset_navigator.tsx create mode 100644 src/plugins/data/public/ui/dataset_navigator/fetch_datasources.ts create mode 100644 src/plugins/data/public/ui/dataset_navigator/fetch_index_patterns.ts create mode 100644 src/plugins/data/public/ui/dataset_navigator/fetch_indices.ts create mode 100644 src/plugins/data/public/ui/dataset_navigator/index.tsx create mode 100644 src/plugins/data/public/ui/query_editor/_dataset_navigator.scss delete mode 100644 src/plugins/query_enhancements/public/data_source_connection/components/connections_bar.tsx delete mode 100644 src/plugins/query_enhancements/public/data_source_connection/components/index.ts delete mode 100644 src/plugins/query_enhancements/public/data_source_connection/index.ts delete mode 100644 src/plugins/query_enhancements/public/data_source_connection/services/index.ts delete mode 100644 src/plugins/query_enhancements/public/data_source_connection/utils/create_extension.tsx delete mode 100644 src/plugins/query_enhancements/public/data_source_connection/utils/index.ts delete mode 100644 src/plugins/query_enhancements/public/services.ts rename src/plugins/query_enhancements/public/{data_source_connection => }/services/connections_service.ts (95%) create mode 100644 src/plugins/query_enhancements/public/services/index.ts diff --git a/changelogs/fragments/7289.yml b/changelogs/fragments/7289.yml new file mode 100644 index 000000000000..b181433dbeca --- /dev/null +++ b/changelogs/fragments/7289.yml @@ -0,0 +1,2 @@ +feat: +- Add DataSet dropdown with index patterns and indices ([#7289](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7289)) \ No newline at end of file diff --git a/package.json b/package.json index f0600caadc20..13c78afffe0c 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "start": "scripts/use_node scripts/opensearch_dashboards --dev", "start:docker": "scripts/use_node scripts/opensearch_dashboards --dev --opensearch.hosts=$OPENSEARCH_HOSTS --opensearch.ignoreVersionMismatch=true --server.host=$SERVER_HOST", "start:security": "scripts/use_node scripts/opensearch_dashboards --dev --security", - "start:enhancements": "scripts/use_node scripts/opensearch_dashboards --dev --uiSettings.overrides['query:enhancements:enabled']=true", + "start:enhancements": "scripts/use_node scripts/opensearch_dashboards --dev --uiSettings.overrides['query:enhancements:enabled']=true --uiSettings.overrides['home:useNewHomePage']=true", "debug": "scripts/use_node --nolazy --inspect scripts/opensearch_dashboards --dev", "debug-break": "scripts/use_node --nolazy --inspect-brk scripts/opensearch_dashboards --dev", "lint": "yarn run lint:es && yarn run lint:style", diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index d9518e6a6cab..2c553fdbed97 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -324,7 +324,12 @@ export class SearchSource { const dataFrame = createDataFrame({ name: searchRequest.index.title || searchRequest.index, fields: [], - ...(rawQueryString && { meta: { queryConfig: parseRawQueryString(rawQueryString) } }), + ...(rawQueryString && { + meta: { + queryConfig: parseRawQueryString(rawQueryString), + ...(searchRequest.dataSourceId && { dataSource: searchRequest.dataSourceId }), + }, + }), }); await this.setDataFrame(dataFrame); return this.getDataFrame(); diff --git a/src/plugins/data/public/ui/dataset_navigator/create_dataset_navigator.tsx b/src/plugins/data/public/ui/dataset_navigator/create_dataset_navigator.tsx new file mode 100644 index 000000000000..e20c3bf3f540 --- /dev/null +++ b/src/plugins/data/public/ui/dataset_navigator/create_dataset_navigator.tsx @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { SavedObjectsClientContract } from 'src/core/public'; +import { DataSetNavigator, DataSetNavigatorProps } from './'; + +// Updated function signature to include additional dependencies +export function createDataSetNavigator(savedObjectsClient: SavedObjectsClientContract) { + // Return a function that takes props, omitting the dependencies from the props type + return (props: Omit) => ( + + ); +} diff --git a/src/plugins/data/public/ui/dataset_navigator/dataset_navigator.tsx b/src/plugins/data/public/ui/dataset_navigator/dataset_navigator.tsx new file mode 100644 index 000000000000..4e7b2601bdf9 --- /dev/null +++ b/src/plugins/data/public/ui/dataset_navigator/dataset_navigator.tsx @@ -0,0 +1,385 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiContextMenu, + EuiContextMenuPanelItemDescriptor, + EuiForm, + EuiFormRow, + EuiLoadingSpinner, + EuiPopover, + EuiSelect, +} from '@elastic/eui'; +import { SavedObjectsClientContract } from 'opensearch-dashboards/public'; +import _ from 'lodash'; +import { i18n } from '@osd/i18n'; +import { fetchDataSources } from './fetch_datasources'; +import { fetchIndices } from './fetch_indices'; +import { getIndexPatterns, getQueryService, getSearchService, getUiService } from '../../services'; +import { fetchIndexPatterns } from './fetch_index_patterns'; +import { IIndexPattern } from '../..'; + +export interface DataSetNavigatorProps { + dataSetId: string | undefined; + savedObjectsClient?: SavedObjectsClientContract; + onSelectDataSet: (dataSet: SimpleDataSet) => void; +} + +export interface SimpleObject { + id: string; + title: string; + dataSourceRef?: SimpleDataSource; +} + +export interface SimpleDataSource { + id: string; + name: string; + indices?: SimpleObject[]; + type: 'data-source' | 'external-source'; +} + +export interface SimpleDataSet extends SimpleObject { + fields?: any[]; + timeFieldName?: string; + timeFields?: any[]; + type: 'index-pattern' | 'temporary'; +} + +interface DataSetNavigatorState { + isLoading: boolean; + isOpen: boolean; + indexPatterns: IIndexPattern[]; + dataSources: SimpleDataSource[]; + externalDataSources: SimpleDataSource[]; + searchValue?: string; + selectedDataSource?: SimpleDataSource; + selectedObjects: SimpleObject[]; + selectedObject?: SimpleDataSet; + selectedDataSet?: SimpleDataSet; +} + +// eslint-disable-next-line import/no-default-export +export default class DataSetNavigator extends Component { + private isMounted: boolean = false; + state: DataSetNavigatorState; + private searchService = getSearchService(); + private queryService = getQueryService(); + private uiService = getUiService(); + private indexPatternsService = getIndexPatterns(); + + constructor(props: DataSetNavigatorProps) { + super(props); + this.state = { + isLoading: true, + isOpen: false, + indexPatterns: [], + dataSources: [], + externalDataSources: [], + searchValue: undefined, + selectedDataSource: undefined, + selectedObjects: [], + selectedObject: undefined, + selectedDataSet: undefined, + }; + } + + async componentDidMount() { + this.isMounted = true; + const indexPatterns = await fetchIndexPatterns(this.props.savedObjectsClient!, ''); // TODO: add search + const dataSources = await fetchDataSources(this.props.savedObjectsClient!); + const selectedDataSet = + !this.state.selectedDataSet && this.props.dataSetId + ? indexPatterns.find((pattern) => pattern.id === this.props.dataSetId) + : undefined; + // TODO: do i need to get datasource ref too? + this.setState({ + indexPatterns, + dataSources, + selectedDataSet, + }); + } + + componentWillUnmount() { + this.isMounted = false; + } + + getQueryStringInitialValue = (dataSet: SimpleDataSet) => { + const language = this.uiService.Settings.getUserQueryLanguage(); + const input = this.uiService.Settings.getQueryEnhancements(language)?.searchBar + ?.queryStringInput?.initialValue; + + if (!dataSet || !input) + return { + query: '', + language, + }; + + return { + query: input.replace('', dataSet.title), + language, + }; + }; + + setSelectedDataSource = async (dataSource: SimpleDataSource) => { + if (!this.isMounted || !dataSource) return; + this.setState({ + isLoading: false, + selectedDataSource: dataSource, + }); + }; + + setSelectedObjects = async (dataSource: SimpleDataSource) => { + if (!this.isMounted || !dataSource) return; + const indices: any[] = await fetchIndices(this.searchService, dataSource.id); + const indicesWithDataSource = indices.map((indexName) => ({ + id: indexName, + title: indexName, + dataSourceRef: { + id: dataSource.id, + name: dataSource.name, + type: 'data-source', + }, + })); + this.setState({ + selectedObjects: indicesWithDataSource, + }); + }; + + setSelectedObject = async (object: SimpleObject) => { + if (!this.isMounted || !object) return; + this.setState({ isLoading: true }); + const fields = await this.indexPatternsService.getFieldsForWildcard({ + pattern: object.title, + dataSourceId: object.dataSourceRef?.id, + }); + const timeFields = fields.filter((field: any) => field.type === 'date'); + this.setState({ + selectedObject: { + ...object, + fields, + timeFields, + ...(timeFields[0]?.name ? { timeFieldName: timeFields[0].name } : {}), + }, + isLoading: false, + }); + }; + + setSelectedObjectTimeField = async (object: SimpleObject, timeFieldName: string | undefined) => { + if (!this.isMounted) return; + const newObject = { + ...object, + timeFieldName: timeFieldName ?? undefined, + }; + this.setState({ + selectedObject: newObject, + }); + }; + + setSelectedDataSet = async (dataSet: SimpleDataSet) => { + if (dataSet.type === 'temporary') { + const fieldsMap = dataSet.fields?.reduce((acc: any, field: any) => { + acc[field.name] = field; + return acc; + }); + const temporaryIndexPattern = await this.indexPatternsService.create( + { + id: dataSet.id, + title: dataSet.title, + dataSourceRef: { + id: dataSet.dataSourceRef?.id!, + name: dataSet.dataSourceRef?.name!, + type: dataSet.dataSourceRef?.type!, + }, + timeFieldName: dataSet.timeFieldName, + }, + true + ); + this.indexPatternsService.saveToCache(temporaryIndexPattern.title, temporaryIndexPattern); + } + this.searchService.df.clear(); + this.props.onSelectDataSet(dataSet); + this.queryService.queryString.setQuery(this.getQueryStringInitialValue(dataSet)); + this.setState({ selectedDataSet: dataSet }); + this.closePopover(); + }; + + closePopover = () => { + this.setState({ isOpen: false }); + }; + + render() { + const indexPatternsLabel = i18n.translate('data.query.dataSetNavigator.indexPatternsName', { + defaultMessage: 'Index patterns', + }); + const indicesLabel = i18n.translate('data.query.dataSetNavigator.indicesName', { + defaultMessage: 'Indexes', + }); + + const loadingSpinner = ; + + return ( + this.setState({ isOpen: !this.state.isOpen })} + > + {`${ + this.state.selectedDataSet?.dataSourceRef + ? `${this.state.selectedDataSet.dataSourceRef.name}::` + : '' + }${ + this.state.selectedDataSet?.title ?? + i18n.translate('data.query.dataSetNavigator.selectDataSet', { + defaultMessage: 'Select data set', + }) + }`} + + } + isOpen={this.state.isOpen} + closePopover={this.closePopover} + panelPaddingSize="none" + anchorPosition="downLeft" + > + ({ + name: dataSource.name, + panel: 2, + onClick: async () => { + await this.setSelectedDataSource(dataSource); + }, + })), + ], + }, + { + id: 1, + title: indexPatternsLabel, + items: this.state.indexPatterns.flatMap((indexPattern, indexNum, arr) => [ + { + name: indexPattern.title, + onClick: async () => { + await this.setSelectedDataSet({ + id: indexPattern.id ?? indexPattern.title, + title: indexPattern.title, + fields: indexPattern.fields, + timeFieldName: indexPattern.timeFieldName, + type: 'index-pattern', + }); + }, + }, + ...(indexNum < arr.length - 1 ? [{ isSeparator: true }] : []), + ]) as EuiContextMenuPanelItemDescriptor[], + }, + { + id: 2, + title: this.state.selectedDataSource?.name, + items: [ + { + name: indicesLabel, + panel: 3, + onClick: async () => { + await this.setSelectedObjects(this.state.selectedDataSource!); + }, + }, + { + name: 'Connected data sources', + disabled: true, + }, + ] as EuiContextMenuPanelItemDescriptor[], + }, + { + id: 3, + title: indicesLabel, + items: this.state.selectedObjects.flatMap( + (object: SimpleObject, indexNum: number, arr: any[]) => [ + { + name: object.title, + panel: 4, + onClick: async () => { + await this.setSelectedObject(object); + }, + }, + ...(indexNum < arr.length - 1 ? [{ isSeparator: true }] : []), + ] + ) as EuiContextMenuPanelItemDescriptor[], + }, + { + id: 4, + title: this.state.selectedObject?.title, + content: + this.state.isLoading && !this.state.selectedObject ? ( + loadingSpinner + ) : ( + + + 0 + ? [ + ...this.state.selectedObject!.timeFields.map((field: any) => ({ + value: field.name, + text: field.name, + })), + ] + : []), + { value: 'no-time-filter', text: "I don't want to use a time filter" }, + ]} + onChange={(event) => { + this.setSelectedObjectTimeField( + this.state.selectedObject!, + event.target.value !== 'no-time-filter' ? event.target.value : undefined + ); + }} + aria-label="Select a date field" + /> + + { + await this.setSelectedDataSet({ + ...this.state.selectedObject!, + type: 'temporary', + }); + }} + > + Select + + + ), + }, + ]} + /> + + ); + } +} diff --git a/src/plugins/data/public/ui/dataset_navigator/fetch_datasources.ts b/src/plugins/data/public/ui/dataset_navigator/fetch_datasources.ts new file mode 100644 index 000000000000..3b44733d8666 --- /dev/null +++ b/src/plugins/data/public/ui/dataset_navigator/fetch_datasources.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsClientContract } from 'opensearch-dashboards/public'; + +export const fetchDataSources = async (client: SavedObjectsClientContract) => { + const resp = await client.find({ + type: 'data-source', + perPage: 10000, + }); + return resp.savedObjects.map((savedObject) => ({ + id: savedObject.id, + name: savedObject.attributes.title, + type: 'data-source', + })); +}; diff --git a/src/plugins/data/public/ui/dataset_navigator/fetch_index_patterns.ts b/src/plugins/data/public/ui/dataset_navigator/fetch_index_patterns.ts new file mode 100644 index 000000000000..589b5f2b7e48 --- /dev/null +++ b/src/plugins/data/public/ui/dataset_navigator/fetch_index_patterns.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsClientContract } from 'opensearch-dashboards/public'; +import { IIndexPattern } from '../..'; + +export const fetchIndexPatterns = async (client: SavedObjectsClientContract, search: string) => { + const resp = await client.find({ + type: 'index-pattern', + fields: ['title'], + search: `${search}*`, + searchFields: ['title'], + perPage: 100, + }); + return resp.savedObjects.map((savedObject) => ({ + id: savedObject.id, + title: savedObject.attributes.title, + dataSourceId: savedObject.references[0]?.id, + })); +}; diff --git a/src/plugins/data/public/ui/dataset_navigator/fetch_indices.ts b/src/plugins/data/public/ui/dataset_navigator/fetch_indices.ts new file mode 100644 index 000000000000..35b1eb4a83f2 --- /dev/null +++ b/src/plugins/data/public/ui/dataset_navigator/fetch_indices.ts @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { map } from 'rxjs/operators'; +import { ISearchStart } from '../../search'; + +export const fetchIndices = async (search: ISearchStart, dataSourceId?: string) => { + const buildSearchRequest = () => { + const request = { + params: { + ignoreUnavailable: true, + expand_wildcards: 'all', + index: '*', + body: { + size: 0, // no hits + aggs: { + indices: { + terms: { + field: '_index', + size: 100, + }, + }, + }, + }, + }, + dataSourceId, + }; + + return request; + }; + + const searchResponseToArray = (response: any) => { + const { rawResponse } = response; + return rawResponse.aggregations + ? rawResponse.aggregations.indices.buckets.map((bucket: { key: any }) => bucket.key) + : []; + }; + + return search + .getDefaultSearchInterceptor() + .search(buildSearchRequest()) + .pipe(map(searchResponseToArray)) + .toPromise(); +}; diff --git a/src/plugins/data/public/ui/dataset_navigator/index.tsx b/src/plugins/data/public/ui/dataset_navigator/index.tsx new file mode 100644 index 000000000000..e8ce9554d5a3 --- /dev/null +++ b/src/plugins/data/public/ui/dataset_navigator/index.tsx @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import type { DataSetNavigatorProps } from './dataset_navigator'; + +const Fallback = () =>
; + +const LazyDataSetNavigator = React.lazy(() => import('./dataset_navigator')); +export const DataSetNavigator = (props: DataSetNavigatorProps) => ( + }> + + +); + +export * from './create_dataset_navigator'; +export type { DataSetNavigatorProps } from './dataset_navigator'; + +export { fetchDataSources } from './fetch_datasources'; +export { fetchIndexPatterns } from './fetch_index_patterns'; +export { fetchIndices } from './fetch_indices'; diff --git a/src/plugins/data/public/ui/query_editor/_dataset_navigator.scss b/src/plugins/data/public/ui/query_editor/_dataset_navigator.scss new file mode 100644 index 000000000000..ab85456df1b0 --- /dev/null +++ b/src/plugins/data/public/ui/query_editor/_dataset_navigator.scss @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +.dataSetNavigator { + width: 300px; + border-bottom: $euiBorderThin !important; +} + +.dataSetNavigatorFormWrapper { + padding: $euiSizeS; +} diff --git a/src/plugins/data/public/ui/query_editor/_index.scss b/src/plugins/data/public/ui/query_editor/_index.scss index 64fb0056cb71..f51d45b8245c 100644 --- a/src/plugins/data/public/ui/query_editor/_index.scss +++ b/src/plugins/data/public/ui/query_editor/_index.scss @@ -1,2 +1,3 @@ @import "./language_selector"; +@import "./dataset_navigator"; @import "./query_editor"; diff --git a/src/plugins/data/public/ui/query_editor/_query_editor.scss b/src/plugins/data/public/ui/query_editor/_query_editor.scss index 8fc81308b533..ac411b38ab88 100644 --- a/src/plugins/data/public/ui/query_editor/_query_editor.scss +++ b/src/plugins/data/public/ui/query_editor/_query_editor.scss @@ -86,6 +86,12 @@ } } +.osdQueryEditor__dataSetNavigatorWrapper { + :first-child { + border-bottom: $euiBorderThin !important; + } +} + @include euiBreakpoint("xs", "s") { .osdQueryEditor--withDatePicker { > :first-child { diff --git a/src/plugins/data/public/ui/query_editor/query_editor.tsx b/src/plugins/data/public/ui/query_editor/query_editor.tsx index 69d332c65c04..903287382d2d 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor.tsx @@ -41,9 +41,7 @@ export interface QueryEditorProps { dataSource?: DataSource; query: Query; container?: HTMLDivElement; - dataSourceContainerRef?: React.RefCallback; - containerRef?: React.RefCallback; - languageSelectorContainerRef?: React.RefCallback; + dataSetContainerRef?: React.RefCallback; settings: Settings; disableAutoFocus?: boolean; screenTitle?: string; @@ -56,7 +54,7 @@ export interface QueryEditorProps { onChange?: (query: Query, dateRange?: TimeRange) => void; onChangeQueryEditorFocus?: (isFocused: boolean) => void; onSubmit?: (query: Query, dateRange?: TimeRange) => void; - getQueryStringInitialValue?: (language: string) => string; + getQueryStringInitialValue?: (language: string, dataSetName?: string) => string; dataTestSubj?: string; size?: SuggestionsListSize; className?: string; @@ -78,6 +76,7 @@ interface State { isSuggestionsVisible: boolean; index: number | null; suggestions: QuerySuggestion[]; + selectedDataSet: any; indexPatterns: IIndexPattern[]; isCollapsed: boolean; timeStamp: IFieldType | null; @@ -107,6 +106,7 @@ export default class QueryEditorUI extends Component { index: null, suggestions: [], indexPatterns: [], + selectedDataSet: null, isCollapsed: false, // default to expand mode timeStamp: null, lineCount: undefined, @@ -117,7 +117,6 @@ export default class QueryEditorUI extends Component { private persistedLog: PersistedLog | undefined; private abortController?: AbortController; private services = this.props.opensearchDashboards.services; - private componentIsUnmounting = false; private headerRef: RefObject = createRef(); private bannerRef: RefObject = createRef(); private extensionMap = this.props.settings?.getQueryEditorExtensionMap(); @@ -246,10 +245,6 @@ export default class QueryEditorUI extends Component { : undefined; this.onChange(newQuery, dateRange); this.onSubmit(newQuery, dateRange); - this.setState({ - isDataSourcesVisible: enhancement?.searchBar?.showDataSourcesSelector ?? true, - isDataSetsVisible: enhancement?.searchBar?.showDataSetsSelector ?? true, - }); }; private initPersistedLog = () => { @@ -259,20 +254,6 @@ export default class QueryEditorUI extends Component { : getQueryLog(uiSettings, storage, appName, this.props.query.language); }; - private initDataSourcesVisibility = () => { - if (this.componentIsUnmounting) return; - - return this.props.settings.getQueryEnhancements(this.props.query.language)?.searchBar - ?.showDataSourcesSelector; - }; - - private initDataSetsVisibility = () => { - if (this.componentIsUnmounting) return; - - return this.props.settings.getQueryEnhancements(this.props.query.language)?.searchBar - ?.showDataSetsSelector; - }; - public onMouseEnterSuggestion = (index: number) => { this.setState({ index }); }; @@ -287,10 +268,6 @@ export default class QueryEditorUI extends Component { this.initPersistedLog(); // this.fetchIndexPatterns().then(this.updateSuggestions); - this.setState({ - isDataSourcesVisible: this.initDataSourcesVisibility() || true, - isDataSetsVisible: this.initDataSetsVisibility() || true, - }); } public componentDidUpdate(prevProps: Props) { @@ -304,7 +281,6 @@ export default class QueryEditorUI extends Component { public componentWillUnmount() { if (this.abortController) this.abortController.abort(); - this.componentIsUnmounting = true; } handleOnFocus = () => { @@ -373,6 +349,15 @@ export default class QueryEditorUI extends Component { const useQueryEditor = this.props.query.language !== 'kuery' && this.props.query.language !== 'lucene'; + const languageSelector = ( + + ); + return (
@@ -385,17 +370,9 @@ export default class QueryEditorUI extends Component { isCollapsed={!this.state.isCollapsed} /> - {this.state.isDataSourcesVisible && ( - -
- - )} - - {this.state.isDataSetsVisible && ( - -
- - )} + +
+ {(this.state.isCollapsed || !useQueryEditor) && ( @@ -434,14 +411,7 @@ export default class QueryEditorUI extends Component { )} {!useQueryEditor && ( -
- -
+
{languageSelector}
)}
@@ -491,15 +461,7 @@ export default class QueryEditorUI extends Component { } > - - - + {languageSelector} {this.state.lineCount} {this.state.lineCount === 1 ? 'line' : 'lines'} diff --git a/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx b/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx index 8304fdc252ee..7c2cc98d9299 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx @@ -38,8 +38,7 @@ const QueryEditor = withOpenSearchDashboards(QueryEditorUI); // @internal export interface QueryEditorTopRowProps { query?: Query; - dataSourceContainerRef?: React.RefCallback; - containerRef?: React.RefCallback; + dataSetContainerRef?: React.RefCallback; settings?: Settings; onSubmit: (payload: { dateRange: TimeRange; query?: Query }) => void; onChange: (payload: { dateRange: TimeRange; query?: Query }) => void; @@ -223,8 +222,7 @@ export default function QueryEditorTopRow(props: QueryEditorTopRowProps) { dataSource={props.dataSource} prepend={props.prepend} query={parsedQuery} - dataSourceContainerRef={props.dataSourceContainerRef} - containerRef={props.containerRef} + dataSetContainerRef={props.dataSetContainerRef} settings={props.settings!} screenTitle={props.screenTitle} onChange={onQueryChange} diff --git a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx index 244f4296216c..7fe38f548048 100644 --- a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx @@ -48,8 +48,7 @@ interface StatefulSearchBarDeps { data: Omit; storage: IStorageWrapper; settings: Settings; - setDataSourceContainerRef: (ref: HTMLDivElement | null) => void; - setContainerRef: (ref: HTMLDivElement | null) => void; + setDataSetContainerRef: (ref: HTMLDivElement | null) => void; } export type StatefulSearchBarProps = SearchBarOwnProps & { @@ -139,8 +138,7 @@ export function createSearchBar({ storage, data, settings, - setDataSourceContainerRef, - setContainerRef, + setDataSetContainerRef, }: StatefulSearchBarDeps) { // App name should come from the core application service. // Until it's available, we'll ask the user to provide it for the pre-wired component. @@ -176,15 +174,9 @@ export function createSearchBar({ notifications: core.notifications, }); - const dataSourceContainerRef = useCallback((node) => { + const dataSetContainerRef = useCallback((node) => { if (node) { - setDataSourceContainerRef(node); - } - }, []); - - const containerRef = useCallback((node) => { - if (node) { - setContainerRef(node); + setDataSetContainerRef(node); } }, []); @@ -228,8 +220,7 @@ export function createSearchBar({ filters={filters} query={query} settings={settings} - dataSourceContainerRef={dataSourceContainerRef} - containerRef={containerRef} + dataSetContainerRef={dataSetContainerRef} onFiltersUpdated={defaultFiltersUpdated(data.query)} onRefreshChange={defaultOnRefreshChange(data.query)} savedQuery={savedQuery} diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index 11914f134443..0855a0fc6a4f 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -80,8 +80,7 @@ export interface SearchBarOwnProps { // Query bar - should be in SearchBarInjectedDeps query?: Query; settings?: Settings; - dataSourceContainerRef?: React.RefCallback; - containerRef?: React.RefCallback; + dataSetContainerRef?: React.RefCallback; // Show when user has privileges to save showSaveQuery?: boolean; savedQuery?: SavedQuery; @@ -491,8 +490,7 @@ class SearchBarUI extends Component { queryEditor = ( ; SearchBar: React.ComponentType; SuggestionsComponent: React.ComponentType; + /** + * @experimental - Subject to change + */ Settings: Settings; - dataSourceContainer$: Observable; - container$: Observable; + DataSetNavigator: React.ComponentType; + dataSetContainer$: Observable; + onSelectDataSet?: () => void | undefined; } diff --git a/src/plugins/data/public/ui/ui_service.ts b/src/plugins/data/public/ui/ui_service.ts index 1e0e6be8b78c..a55fe1ccf847 100644 --- a/src/plugins/data/public/ui/ui_service.ts +++ b/src/plugins/data/public/ui/ui_service.ts @@ -9,6 +9,7 @@ import { IStorageWrapper } from '../../../opensearch_dashboards_utils/public'; import { ConfigSchema } from '../../config'; import { DataPublicPluginStart } from '../types'; import { createIndexPatternSelect } from './index_pattern_select'; +import { createDataSetNavigator } from './dataset_navigator/create_dataset_navigator'; import { QueryEditorExtensionConfig } from './query_editor'; import { createSearchBar } from './search_bar/create_search_bar'; import { createSettings } from './settings'; @@ -29,9 +30,8 @@ export class UiService implements Plugin { enhancementsConfig: ConfigSchema['enhancements']; private queryEnhancements: Map = new Map(); private queryEditorExtensionMap: Record = {}; - private dataSourceContainer$ = new BehaviorSubject(null); - private container$ = new BehaviorSubject(null); - + private dataSetContainer$ = new BehaviorSubject(null); + private onSelectDataSet?: () => void; constructor(initializerContext: PluginInitializerContext) { const { enhancements } = initializerContext.config.get(); @@ -62,12 +62,12 @@ export class UiService implements Plugin { queryEditorExtensionMap: this.queryEditorExtensionMap, }); - const setDataSourceContainerRef = (ref: HTMLDivElement | null) => { - this.dataSourceContainer$.next(ref); + const setDataSetContainerRef = (ref: HTMLDivElement | null) => { + this.dataSetContainer$.next(ref); }; - const setContainerRef = (ref: HTMLDivElement | null) => { - this.container$.next(ref); + const setOnDataSetSelect = (onSelectDataSet: () => void) => { + this.onSelectDataSet = onSelectDataSet; }; const SearchBar = createSearchBar({ @@ -75,17 +75,18 @@ export class UiService implements Plugin { data: dataServices, storage, settings: Settings, - setDataSourceContainerRef, - setContainerRef, + setDataSetContainerRef, + setOnDataSetSelect, }); return { IndexPatternSelect: createIndexPatternSelect(core.savedObjects.client), + DataSetNavigator: createDataSetNavigator(core.savedObjects.client), SearchBar, SuggestionsComponent, Settings, - dataSourceContainer$: this.dataSourceContainer$, - container$: this.container$, + dataSetContainer$: this.dataSetContainer$, + onSelectDataSet: this.onSelectDataSet, }; } diff --git a/src/plugins/data_explorer/public/components/sidebar/index.tsx b/src/plugins/data_explorer/public/components/sidebar/index.tsx index eea1860dc950..9e7ed63b2009 100644 --- a/src/plugins/data_explorer/public/components/sidebar/index.tsx +++ b/src/plugins/data_explorer/public/components/sidebar/index.tsx @@ -30,6 +30,8 @@ export const Sidebar: FC = ({ children }) => { }, } = useOpenSearchDashboards(); + const { DataSetNavigator } = ui; + useEffect(() => { const subscriptions = ui.Settings.getEnabledQueryEnhancementsUpdated$().subscribe( (enabledQueryEnhancements) => { @@ -48,17 +50,17 @@ export const Sidebar: FC = ({ children }) => { useEffect(() => { if (!isEnhancementsEnabled) return; - const subscriptions = ui.container$.subscribe((container) => { - if (container === null) return; + const subscriptions = ui.dataSetContainer$.subscribe((dataSetContainer) => { + if (dataSetContainer === null) return; if (containerRef.current) { - setContainerRef(container); + setContainerRef(dataSetContainer); } }); return () => { subscriptions.unsubscribe(); }; - }, [ui.container$, containerRef, setContainerRef, isEnhancementsEnabled]); + }, [ui.dataSetContainer$, containerRef, setContainerRef, isEnhancementsEnabled]); useEffect(() => { let isMounted = true; @@ -130,23 +132,17 @@ export const Sidebar: FC = ({ children }) => { [toasts] ); + const handleDataSetSelection = useCallback( + (dataSet: any) => { + dispatch(setIndexPattern(dataSet.id!)); + }, + [dispatch] + ); + const memorizedReload = useCallback(() => { dataSources.dataSourceService.reload(); }, [dataSources.dataSourceService]); - const dataSourceSelector = ( - - ); - return ( { containerRef.current = node; }} > - {dataSourceSelector} + )} {!isEnhancementsEnabled && ( @@ -171,7 +167,16 @@ export const Sidebar: FC = ({ children }) => { color="transparent" className="deSidebar_dataSource" > - {dataSourceSelector} + )} diff --git a/src/plugins/data_explorer/public/utils/state_management/metadata_slice.ts b/src/plugins/data_explorer/public/utils/state_management/metadata_slice.ts index e9fe84713120..234951359952 100644 --- a/src/plugins/data_explorer/public/utils/state_management/metadata_slice.ts +++ b/src/plugins/data_explorer/public/utils/state_management/metadata_slice.ts @@ -6,10 +6,26 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { DataExplorerServices } from '../../types'; +interface DataSourceMeta { + ref?: string; // MDS ID + dsName?: string; // flint datasource +} + +export interface DataSet { + id: string; // index pattern ID, index name, or flintdatasource.database.table + dataSource?: DataSourceMeta; + meta?: { + timestampField: string; + mapping?: any; + }; + type?: 'dataset' | 'temporary'; +} + export interface MetadataState { indexPattern?: string; originatingApp?: string; view?: string; + dataSet?: DataSet; } const initialState: MetadataState = {}; @@ -40,6 +56,9 @@ export const slice = createSlice({ setIndexPattern: (state, action: PayloadAction) => { state.indexPattern = action.payload; }, + setDataSet: (state, action: PayloadAction) => { + state.dataSet = action.payload; + }, setOriginatingApp: (state, action: PayloadAction) => { state.originatingApp = action.payload; }, @@ -53,4 +72,4 @@ export const slice = createSlice({ }); export const { reducer } = slice; -export const { setIndexPattern, setOriginatingApp, setView, setState } = slice.actions; +export const { setIndexPattern, setDataSet, setOriginatingApp, setView, setState } = slice.actions; diff --git a/src/plugins/data_explorer/public/utils/state_management/store.ts b/src/plugins/data_explorer/public/utils/state_management/store.ts index daf0b3d7e369..9d320de4b54b 100644 --- a/src/plugins/data_explorer/public/utils/state_management/store.ts +++ b/src/plugins/data_explorer/public/utils/state_management/store.ts @@ -116,4 +116,4 @@ export type RenderState = Omit; // Remaining state after export type Store = ReturnType; export type AppDispatch = Store['dispatch']; -export { MetadataState, setIndexPattern, setOriginatingApp } from './metadata_slice'; +export { MetadataState, setIndexPattern, setDataSet, setOriginatingApp } from './metadata_slice'; diff --git a/src/plugins/query_enhancements/opensearch_dashboards.json b/src/plugins/query_enhancements/opensearch_dashboards.json index b09494aab0ca..69d8fd3bd667 100644 --- a/src/plugins/query_enhancements/opensearch_dashboards.json +++ b/src/plugins/query_enhancements/opensearch_dashboards.json @@ -3,7 +3,7 @@ "version": "opensearchDashboards", "server": true, "ui": true, - "requiredPlugins": ["data", "opensearchDashboardsReact", "opensearchDashboardsUtils", "dataSourceManagement", "savedObjects", "uiActions"], + "requiredPlugins": ["data", "opensearchDashboardsReact", "opensearchDashboardsUtils", "savedObjects", "uiActions"], "optionalPlugins": ["dataSource"] } diff --git a/src/plugins/query_enhancements/public/data_source_connection/components/connections_bar.tsx b/src/plugins/query_enhancements/public/data_source_connection/components/connections_bar.tsx deleted file mode 100644 index d7590b278220..000000000000 --- a/src/plugins/query_enhancements/public/data_source_connection/components/connections_bar.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useEffect, useRef, useState } from 'react'; -import { EuiPortal } from '@elastic/eui'; -import { distinctUntilChanged } from 'rxjs/operators'; -import { ToastsSetup } from 'opensearch-dashboards/public'; -import { DataPublicPluginStart, QueryEditorExtensionDependencies } from '../../../../data/public'; -import { DataSourceSelector } from '../../../../data_source_management/public'; -import { ConnectionsService } from '../services'; - -interface ConnectionsProps { - dependencies: QueryEditorExtensionDependencies; - toasts: ToastsSetup; - connectionsService: ConnectionsService; -} - -export const ConnectionsBar: React.FC = ({ connectionsService, toasts }) => { - const [isDataSourceEnabled, setIsDataSourceEnabled] = useState(false); - const [uiService, setUiService] = useState(undefined); - const containerRef = useRef(null); - - useEffect(() => { - const uiServiceSubscription = connectionsService.getUiService().subscribe(setUiService); - const dataSourceEnabledSubscription = connectionsService - .getIsDataSourceEnabled$() - .subscribe(setIsDataSourceEnabled); - - return () => { - uiServiceSubscription.unsubscribe(); - dataSourceEnabledSubscription.unsubscribe(); - }; - }, [connectionsService]); - - useEffect(() => { - if (!uiService || !isDataSourceEnabled || !containerRef.current) return; - const subscriptions = uiService.dataSourceContainer$.subscribe((container) => { - if (container && containerRef.current) { - container.append(containerRef.current); - } - }); - - return () => subscriptions.unsubscribe(); - }, [uiService, isDataSourceEnabled]); - - useEffect(() => { - const selectedConnectionSubscription = connectionsService - .getSelectedConnection$() - .pipe(distinctUntilChanged()) - .subscribe((connection) => { - if (connection) { - // Assuming setSelectedConnection$ is meant to update some state or perform an action outside this component - connectionsService.setSelectedConnection$(connection); - } - }); - - return () => selectedConnectionSubscription.unsubscribe(); - }, [connectionsService]); - - const handleSelectedConnection = (id: string | undefined) => { - if (!id) { - connectionsService.setSelectedConnection$(undefined); - return; - } - connectionsService.getConnectionById(id).subscribe((connection) => { - connectionsService.setSelectedConnection$(connection); - }); - }; - - return ( - { - containerRef.current = node; - }} - > -
- - handleSelectedConnection(dataSource[0]?.id || undefined) - } - /> -
-
- ); -}; diff --git a/src/plugins/query_enhancements/public/data_source_connection/components/index.ts b/src/plugins/query_enhancements/public/data_source_connection/components/index.ts deleted file mode 100644 index 1ee969a1d079..000000000000 --- a/src/plugins/query_enhancements/public/data_source_connection/components/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export { ConnectionsBar } from './connections_bar'; diff --git a/src/plugins/query_enhancements/public/data_source_connection/index.ts b/src/plugins/query_enhancements/public/data_source_connection/index.ts deleted file mode 100644 index e334163d91d4..000000000000 --- a/src/plugins/query_enhancements/public/data_source_connection/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export { createDataSourceConnectionExtension } from './utils'; -export * from './services'; diff --git a/src/plugins/query_enhancements/public/data_source_connection/services/index.ts b/src/plugins/query_enhancements/public/data_source_connection/services/index.ts deleted file mode 100644 index 08eeda5a7aa1..000000000000 --- a/src/plugins/query_enhancements/public/data_source_connection/services/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export { ConnectionsService } from './connections_service'; diff --git a/src/plugins/query_enhancements/public/data_source_connection/utils/create_extension.tsx b/src/plugins/query_enhancements/public/data_source_connection/utils/create_extension.tsx deleted file mode 100644 index e5822c4b378e..000000000000 --- a/src/plugins/query_enhancements/public/data_source_connection/utils/create_extension.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { ToastsSetup } from 'opensearch-dashboards/public'; -import { QueryEditorExtensionConfig } from '../../../../data/public'; -import { ConfigSchema } from '../../../common/config'; -import { ConnectionsBar } from '../components'; -import { ConnectionsService } from '../services'; - -export const createDataSourceConnectionExtension = ( - connectionsService: ConnectionsService, - toasts: ToastsSetup, - config: ConfigSchema -): QueryEditorExtensionConfig => { - return { - id: 'data-source-connection', - order: 2000, - isEnabled$: (dependencies) => { - return connectionsService.getIsDataSourceEnabled$(); - }, - getComponent: (dependencies) => { - return ( - - ); - }, - }; -}; diff --git a/src/plugins/query_enhancements/public/data_source_connection/utils/index.ts b/src/plugins/query_enhancements/public/data_source_connection/utils/index.ts deleted file mode 100644 index 9eccc9e6f35a..000000000000 --- a/src/plugins/query_enhancements/public/data_source_connection/utils/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export * from './create_extension'; diff --git a/src/plugins/query_enhancements/public/plugin.tsx b/src/plugins/query_enhancements/public/plugin.tsx index 0ea557db8ce2..9baec694f1a9 100644 --- a/src/plugins/query_enhancements/public/plugin.tsx +++ b/src/plugins/query_enhancements/public/plugin.tsx @@ -7,10 +7,9 @@ import moment from 'moment'; import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '../../../core/public'; import { IStorageWrapper, Storage } from '../../opensearch_dashboards_utils/public'; import { ConfigSchema } from '../common/config'; -import { ConnectionsService, createDataSourceConnectionExtension } from './data_source_connection'; +import { ConnectionsService, setData, setStorage } from './services'; import { createQueryAssistExtension } from './query_assist'; import { PPLSearchInterceptor, SQLAsyncSearchInterceptor, SQLSearchInterceptor } from './search'; -import { setData, setStorage } from './services'; import { QueryEnhancementsPluginSetup, QueryEnhancementsPluginSetupDependencies, @@ -89,7 +88,7 @@ export class QueryEnhancementsPlugin initialTo: moment().add(2, 'days').toISOString(), }, showFilterBar: false, - showDataSetsSelector: false, + showDataSetsSelector: true, showDataSourcesSelector: true, }, fields: { @@ -109,7 +108,7 @@ export class QueryEnhancementsPlugin searchBar: { showDatePicker: false, showFilterBar: false, - showDataSetsSelector: false, + showDataSetsSelector: true, showDataSourcesSelector: true, queryStringInput: { initialValue: 'SELECT * FROM ' }, }, @@ -155,16 +154,6 @@ export class QueryEnhancementsPlugin }, }); - data.__enhance({ - ui: { - queryEditorExtension: createDataSourceConnectionExtension( - this.connectionsService, - core.notifications.toasts, - this.config - ), - }, - }); - return {}; } diff --git a/src/plugins/query_enhancements/public/search/ppl_search_interceptor.ts b/src/plugins/query_enhancements/public/search/ppl_search_interceptor.ts index bca9961fea3b..0d1e5292b5df 100644 --- a/src/plugins/query_enhancements/public/search/ppl_search_interceptor.ts +++ b/src/plugins/query_enhancements/public/search/ppl_search_interceptor.ts @@ -34,7 +34,7 @@ import { fetchDataFrame, } from '../../common'; import { QueryEnhancementsPluginStartDependencies } from '../types'; -import { ConnectionsService } from '../data_source_connection'; +import { ConnectionsService } from '../services'; export class PPLSearchInterceptor extends SearchInterceptor { protected queryService!: DataPublicPluginStart['query']; @@ -166,7 +166,7 @@ export class PPLSearchInterceptor extends SearchInterceptor { queryConfig: { ...dataFrame.meta.queryConfig, ...(this.connectionsService.getSelectedConnection() && { - dataSourceId: this.connectionsService.getSelectedConnection()?.id, + dataSourceId: this.connectionsService.getSelectedConnection()?.dataSource.id, }), }, }; diff --git a/src/plugins/query_enhancements/public/services.ts b/src/plugins/query_enhancements/public/services.ts deleted file mode 100644 index d11233be2dca..000000000000 --- a/src/plugins/query_enhancements/public/services.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { createGetterSetter } from '../../opensearch_dashboards_utils/common'; -import { IStorageWrapper } from '../../opensearch_dashboards_utils/public'; -import { DataPublicPluginStart } from '../../data/public'; - -export const [getStorage, setStorage] = createGetterSetter('storage'); -export const [getData, setData] = createGetterSetter('data'); diff --git a/src/plugins/query_enhancements/public/data_source_connection/services/connections_service.ts b/src/plugins/query_enhancements/public/services/connections_service.ts similarity index 95% rename from src/plugins/query_enhancements/public/data_source_connection/services/connections_service.ts rename to src/plugins/query_enhancements/public/services/connections_service.ts index 6afec4b51a99..97a59c2cd94a 100644 --- a/src/plugins/query_enhancements/public/data_source_connection/services/connections_service.ts +++ b/src/plugins/query_enhancements/public/services/connections_service.ts @@ -6,8 +6,8 @@ import { BehaviorSubject, Observable, from } from 'rxjs'; import { DataPublicPluginStart } from 'src/plugins/data/public'; import { CoreStart } from 'opensearch-dashboards/public'; -import { API } from '../../../common'; -import { Connection, ConnectionsServiceDeps } from '../../types'; +import { API } from '../../common'; +import { Connection, ConnectionsServiceDeps } from '../types'; export class ConnectionsService { protected http!: ConnectionsServiceDeps['http']; diff --git a/src/plugins/query_enhancements/public/services/index.ts b/src/plugins/query_enhancements/public/services/index.ts new file mode 100644 index 000000000000..bb0284408faa --- /dev/null +++ b/src/plugins/query_enhancements/public/services/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createGetterSetter } from '../../../opensearch_dashboards_utils/common'; +import { IStorageWrapper } from '../../../opensearch_dashboards_utils/public'; +import { DataPublicPluginStart } from '../../../data/public'; + +export const [getStorage, setStorage] = createGetterSetter('storage'); +export const [getData, setData] = createGetterSetter('data'); + +export { ConnectionsService } from './connections_service'; diff --git a/src/plugins/query_enhancements/server/routes/data_source_connection/routes.ts b/src/plugins/query_enhancements/server/routes/data_source_connection/routes.ts index f4fe42779dae..162cc7e8f103 100644 --- a/src/plugins/query_enhancements/server/routes/data_source_connection/routes.ts +++ b/src/plugins/query_enhancements/server/routes/data_source_connection/routes.ts @@ -5,7 +5,6 @@ import { schema } from '@osd/config-schema'; import { IRouter } from 'opensearch-dashboards/server'; -import { DataSourceAttributes } from '../../../../data_source/common/data_sources'; import { API } from '../../../common'; export function registerDataSourceConnectionsRoutes(router: IRouter) { @@ -18,7 +17,7 @@ export function registerDataSourceConnectionsRoutes(router: IRouter) { }, async (context, request, response) => { const fields = ['id', 'title', 'auth.type']; - const resp = await context.core.savedObjects.client.find({ + const resp = await context.core.savedObjects.client.find({ type: 'data-source', fields, perPage: 10000, @@ -38,7 +37,7 @@ export function registerDataSourceConnectionsRoutes(router: IRouter) { }, }, async (context, request, response) => { - const resp = await context.core.savedObjects.client.get( + const resp = await context.core.savedObjects.client.get( 'data-source', request.params.dataSourceId ); diff --git a/src/plugins/query_enhancements/server/types.ts b/src/plugins/query_enhancements/server/types.ts index 2ab716b98928..dd264568386d 100644 --- a/src/plugins/query_enhancements/server/types.ts +++ b/src/plugins/query_enhancements/server/types.ts @@ -4,7 +4,7 @@ */ import { PluginSetup } from 'src/plugins/data/server'; -import { DataSourcePluginSetup } from '../../data_source/server'; +import { DataSourcePluginSetup } from 'src/plugins/data_source/server'; import { Logger } from '../../../core/server'; import { ConfigSchema } from '../common/config';