From 0831c99576694960c82a29f57bce585e403d3718 Mon Sep 17 00:00:00 2001 From: Qxisylolo Date: Tue, 1 Oct 2024 17:26:06 +0800 Subject: [PATCH 01/31] [workspace]feat/add workspace selector to left nav bar. (#8364) * feat/add_workspace_selector Signed-off-by: Qxisylolo * Add_workspace_selector_to_left_nav, adjust style Signed-off-by: Qxisylolo * Add_workspace_selector_to_left_nav, adjust styles Signed-off-by: Qxisylolo * Add_workspace_selector_to_left_nav, keep the origin width of nav bar Signed-off-by: Qxisylolo * Add_workspace_selector_to_left_nav, keep the origin width of nav bar, and adjust width Signed-off-by: Qxisylolo * fix/the_UI_of_recent_assets, resolve comments Signed-off-by: Qxisylolo * fix/the_UI_of_recent_assets, add tests Signed-off-by: Qxisylolo * fix/the_UI_of_recent_assets, set scss Signed-off-by: Qxisylolo * fix/the_UI_of_recent_assets, resolve conflicts Signed-off-by: Qxisylolo * fix/the_UI_of_recent_assets, delete workspace list Signed-off-by: Qxisylolo * feat: show nav group when not in a workspace Signed-off-by: SuZhou-Joe * feat: format scss file Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * Update src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx Co-authored-by: Yulong Ruan Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe --------- Signed-off-by: Qxisylolo Signed-off-by: SuZhou-Joe Co-authored-by: SuZhou-Joe Co-authored-by: Yulong Ruan --- .../header/collapsible_nav_group_enabled.tsx | 15 +- .../collapsible_nav_group_enabled_top.tsx | 24 +- .../workspace_menu/workspace_menu.scss | 3 + .../workspace_menu/workspace_menu.test.tsx | 28 +-- .../workspace_menu/workspace_menu.tsx | 201 +++++++-------- .../workspace_picker_content.tsx | 183 +++++++++----- .../workspace_selector.test.tsx | 231 ++++++++++++++++++ .../workspace_selector/workspace_selector.tsx | 206 ++++++++++++++++ src/plugins/workspace/public/plugin.test.ts | 16 +- src/plugins/workspace/public/plugin.ts | 22 +- 10 files changed, 722 insertions(+), 207 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_menu/workspace_menu.scss create mode 100644 src/plugins/workspace/public/components/workspace_selector/workspace_selector.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_selector/workspace_selector.tsx 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 c1c9d518a219..4387eb6c1769 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 @@ -8,7 +8,6 @@ import { EuiFlyout, EuiPanel, EuiHorizontalRule, - EuiSpacer, EuiHideFor, EuiFlyoutProps, EuiShowFor, @@ -79,6 +78,7 @@ export function CollapsibleNavGroupEnabled({ id, isNavOpen, storage = window.localStorage, + currentWorkspace$, closeNav, navigateToApp, navigateToUrl, @@ -94,7 +94,6 @@ export function CollapsibleNavGroupEnabled({ const appId = useObservable(observables.appId$, ''); const navGroupsMap = useObservable(observables.navGroupsMap$, {}); const currentNavGroup = useObservable(observables.currentNavGroup$, undefined); - const visibleUseCases = useMemo(() => getVisibleUseCases(navGroupsMap), [navGroupsMap]); const currentNavGroupId = useMemo(() => { @@ -115,9 +114,6 @@ export function CollapsibleNavGroupEnabled({ ? !currentNavGroupId : currentNavGroupId === ALL_USE_CASE_ID; - const shouldShowCollapsedNavHeaderContent = - isNavOpen && !!collapsibleNavHeaderRender && !currentNavGroupId; - const navLinksForRender: ChromeNavLink[] = useMemo(() => { const getSystemNavGroups = () => { const result: ChromeNavLink[] = []; @@ -300,6 +296,7 @@ export function CollapsibleNavGroupEnabled({ )} @@ -320,12 +317,6 @@ export function CollapsibleNavGroupEnabled({ hasShadow={false} className="eui-yScroll flex-1-container" > - {shouldShowCollapsedNavHeaderContent && collapsibleNavHeaderRender ? ( - <> - {collapsibleNavHeaderRender()} - - - ) : null} JSX.Element | null; homeLink?: ChromeNavLink; navGroupsMap: Record; currentNavGroup?: NavGroupItemInMap; @@ -39,6 +40,7 @@ export interface CollapsibleNavTopProps { } export const CollapsibleNavTop = ({ + collapsibleNavHeaderRender, currentNavGroup, navigateToApp, logos, @@ -52,7 +54,6 @@ export const CollapsibleNavTop = ({ navLinks, }: CollapsibleNavTopProps) => { const currentWorkspace = useObservable(currentWorkspace$); - const firstVisibleNavLinkInFirstVisibleUseCase = useMemo( () => fulfillRegistrationLinksToChromeNavLinks( @@ -148,12 +149,19 @@ export const CollapsibleNavTop = ({ /> - {currentNavGroup?.title && ( - <> - - {currentNavGroup?.title} - - )} + { + // Nav groups with type are system(global) nav group and we should show title for those nav groups + (currentNavGroup?.type || collapsibleNavHeaderRender) && ( + <> + + {currentNavGroup?.type ? ( + {currentNavGroup?.title} + ) : ( + collapsibleNavHeaderRender?.() + )} + + ) + } ); }; diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.scss b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.scss new file mode 100644 index 000000000000..7fe456168b68 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.scss @@ -0,0 +1,3 @@ +.workspaceMenuHeader { + padding: $ouiSizeL; +} 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 a0afcf3eb8bb..71a7632d6f08 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 @@ -5,7 +5,7 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; - +import moment from 'moment'; import { WorkspaceMenu } from './workspace_menu'; import { coreMock } from '../../../../../core/public/mocks'; import { CoreStart, DEFAULT_NAV_GROUPS } from '../../../../../core/public'; @@ -64,16 +64,14 @@ describe('', () => { 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(); + expect(screen.getByTestId('workspace-menu-item-workspace-1')).toBeInTheDocument(); + expect(screen.getByTestId('workspace-menu-item-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 }, - ]); + it('should display viewed xx ago for recent workspaces', () => { + jest + .spyOn(recentWorkspaceManager, 'getRecentWorkspaces') + .mockReturnValue([{ id: 'workspace-1', timestamp: 1234567890 }]); coreStartMock.workspaces.workspaceList$.next([ { id: 'workspace-1', name: 'workspace 1', features: [] }, @@ -85,9 +83,7 @@ describe('', () => { const selectButton = screen.getByTestId('workspace-select-button'); fireEvent.click(selectButton); - 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(); + expect(screen.getByText(`viewed ${moment(1234567890).fromNow()}`)).toBeInTheDocument(); }); it('should be able to display empty state when the workspace list is empty', () => { @@ -98,7 +94,7 @@ describe('', () => { expect(screen.getByText(/no workspace available/i)).toBeInTheDocument(); }); - it('should be able to perform search and filter and the results will be shown in both all and recent section', () => { + it('should be able to perform search and filter and the results will be shown', () => { coreStartMock.workspaces.workspaceList$.next([ { id: 'workspace-1', name: 'workspace 1', features: [] }, { id: 'test-2', name: 'test 2', features: [] }, @@ -113,8 +109,8 @@ describe('', () => { const searchInput = screen.getByRole('searchbox'); fireEvent.change(searchInput, { target: { value: 'works' } }); - expect(screen.getByTestId('workspace-menu-item-recent-workspace-1')).toBeInTheDocument(); - expect(screen.getByTestId('workspace-menu-item-recent-workspace-1')).toBeInTheDocument(); + expect(screen.getByTestId('workspace-menu-item-workspace-1')).toBeInTheDocument(); + expect(screen.queryByText('workspace-menu-item-workspace-1')).not.toBeInTheDocument(); }); it('should be able to display empty state when seach is not found', () => { @@ -145,7 +141,7 @@ describe('', () => { fireEvent.click(screen.getByTestId('workspace-select-button')); expect(screen.getByTestId('workspace-menu-current-workspace-name')).toBeInTheDocument(); - expect(screen.getByTestId('workspace-menu-current-use-case')).toBeInTheDocument(); + expect(screen.getByTestId('workspace-menu-current-workspace-use-case')).toBeInTheDocument(); expect(screen.getByTestId('current-workspace-icon-wsObservability')).toBeInTheDocument(); expect(screen.getByText('Observability')).toBeInTheDocument(); }); 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 64c8fb583802..a68aea576ae1 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -2,18 +2,17 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ - import { i18n } from '@osd/i18n'; import React, { useState } from 'react'; import { useObservable } from 'react-use'; import { EuiText, EuiPanel, - EuiButton, EuiPopover, EuiButtonIcon, EuiFlexItem, EuiIcon, + EuiSpacer, EuiFlexGroup, EuiHorizontalRule, EuiButtonEmpty, @@ -26,6 +25,7 @@ import { getFirstUseCaseOfFeatureConfigs } from '../../utils'; import { WorkspaceUseCase } from '../../types'; import { validateWorkspaceColor } from '../../../common/utils'; import { WorkspacePickerContent } from '../workspace_picker_content/workspace_picker_content'; +import './workspace_menu.scss'; const defaultHeaderName = i18n.translate('workspace.menu.defaultHeaderName', { defaultMessage: 'Workspaces', @@ -93,106 +93,115 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { button={currentWorkspaceButton} isOpen={isPopoverOpen} closePopover={closePopover} - panelPaddingSize="s" anchorPosition="downCenter" + panelPaddingSize="s" repositionOnScroll={true} > - - - {currentWorkspace ? ( - <> - - - - - {currentWorkspaceName} - + + + {currentWorkspace ? ( + <> + + + + + +

{currentWorkspaceName}

+
+
+ + + {getUseCase(currentWorkspace)?.title ?? ''} + + + + ) : ( + <> + + + + + {currentWorkspaceName} + + + )} +
+
+ + + setPopover(false)} + isInTwoLines={false} + /> + + + {isDashboardAdmin && ( + + + + + + { + closePopover(); + coreStart.application.navigateToApp(WORKSPACE_LIST_APP_ID); + }} > - {getUseCase(currentWorkspace)?.title ?? ''} -
-
- - ) : ( - <> - - + {manageWorkspacesButton} + - - {currentWorkspaceName} - - - )} -
-
- - - setPopover(false)} - /> - - - {isDashboardAdmin && ( - - - - - { - closePopover(); - coreStart.application.navigateToApp(WORKSPACE_LIST_APP_ID); - }} - > - {manageWorkspacesButton} - - - - { - closePopover(); - coreStart.application.navigateToApp(WORKSPACE_CREATE_APP_ID); - }} - > - {createWorkspaceButton} - - - - - )} + + { + closePopover(); + coreStart.application.navigateToApp(WORKSPACE_CREATE_APP_ID); + }} + > + {createWorkspaceButton} + + + + + )} + ); }; diff --git a/src/plugins/workspace/public/components/workspace_picker_content/workspace_picker_content.tsx b/src/plugins/workspace/public/components/workspace_picker_content/workspace_picker_content.tsx index 72fdd67b7367..389ffb04ea0d 100644 --- a/src/plugins/workspace/public/components/workspace_picker_content/workspace_picker_content.tsx +++ b/src/plugins/workspace/public/components/workspace_picker_content/workspace_picker_content.tsx @@ -5,8 +5,9 @@ import { i18n } from '@osd/i18n'; import React, { useMemo, useState } from 'react'; import { useObservable } from 'react-use'; +import moment from 'moment'; import { - EuiTitle, + EuiHorizontalRule, EuiIcon, EuiPanel, EuiSpacer, @@ -15,6 +16,8 @@ import { EuiListGroup, EuiListGroupItem, EuiEmptyPrompt, + EuiFlexItem, + EuiFlexGroup, } from '@elastic/eui'; import { BehaviorSubject } from 'rxjs'; @@ -24,65 +27,80 @@ import { WorkspaceUseCase } from '../../types'; import { validateWorkspaceColor } from '../../../common/utils'; import { getFirstUseCaseOfFeatureConfigs, getUseCaseUrl } from '../../utils'; -const allWorkspacesTitle = i18n.translate('workspace.menu.title.allWorkspaces', { - defaultMessage: 'All workspaces', -}); +function sortBy(getkey: (item: T) => number | undefined) { + return (a: T, b: T): number => { + const aValue = getkey(a); + const bValue = getkey(b); + + if (aValue === undefined) return 1; + if (bValue === undefined) return -1; + + return bValue - aValue; + }; +} const searchFieldPlaceholder = i18n.translate('workspace.menu.search.placeholder', { defaultMessage: 'Search workspace name', }); -const recentWorkspacesTitle = i18n.translate('workspace.menu.title.recentWorkspaces', { - defaultMessage: 'Recent workspaces', -}); - const getValidWorkspaceColor = (color?: string) => validateWorkspaceColor(color) ? color : undefined; +interface UpdatedWorkspaceObject extends WorkspaceObject { + accessTimeStamp?: number; + accessTime?: string; +} interface Props { coreStart: CoreStart; registeredUseCases$: BehaviorSubject; onClickWorkspace?: () => void; + isInTwoLines?: boolean; } export const WorkspacePickerContent = ({ coreStart, registeredUseCases$, onClickWorkspace, + isInTwoLines, }: Props) => { - const workspaceList = useObservable(coreStart.workspaces.workspaceList$, []); const isDashboardAdmin = coreStart.application.capabilities?.dashboards?.isDashboardAdmin; const availableUseCases = useObservable(registeredUseCases$, []); const [search, setSearch] = useState(''); + const workspaceList = useObservable(coreStart.workspaces.workspaceList$, []); - const recentWorkspaces = useMemo(() => { - return recentWorkspaceManager - .getRecentWorkspaces() - .map((workspace) => workspaceList.find((ws) => ws.id === workspace.id)) - .filter((workspace): workspace is WorkspaceObject => workspace !== undefined); + const updatedRecentWorkspaceList: UpdatedWorkspaceObject[] = useMemo(() => { + const recentWorkspaces = recentWorkspaceManager.getRecentWorkspaces(); + const updatedList = workspaceList.map((workspace) => { + const recentWorkspace = recentWorkspaces.find((recent) => recent.id === workspace.id); + + if (recentWorkspace) { + return { + ...workspace, + accessTimeStamp: recentWorkspace.timestamp, + accessTime: `viewed ${moment(recentWorkspace.timestamp).fromNow()}`, + }; + } + return workspace as UpdatedWorkspaceObject; + }); + + return updatedList.sort(sortBy((workspace) => workspace.accessTimeStamp)); }, [workspaceList]); - const queryFromList = ({ list, query }: { list: WorkspaceObject[]; query: string }) => { + const queryFromList = ({ list, query }: { list: UpdatedWorkspaceObject[]; query: string }) => { if (!list || list.length === 0) { return []; } - if (query && query.trim() !== '') { const normalizedQuery = query.toLowerCase(); const result = list.filter((item) => item.name.toLowerCase().includes(normalizedQuery)); return result; } - return list; }; const queriedWorkspace = useMemo(() => { - return queryFromList({ list: workspaceList, query: search }); - }, [workspaceList, search]); - - const queriedRecentWorkspace = useMemo(() => { - return queryFromList({ list: recentWorkspaces, query: search }); - }, [recentWorkspaces, search]); + return queryFromList({ list: updatedRecentWorkspaceList, query: search }); + }, [updatedRecentWorkspaceList, search]); const getUseCase = (workspace: WorkspaceObject) => { if (!workspace.features) { @@ -125,42 +143,91 @@ export const WorkspacePickerContent = ({ ); }; - const getWorkspaceListGroup = (filterWorkspaceList: WorkspaceObject[], itemType: string) => { - const listItems = filterWorkspaceList.map((workspace: WorkspaceObject) => { + const getWorkspaceLists = (filterWorkspaceList: UpdatedWorkspaceObject[]) => { + const listItems = filterWorkspaceList.map((workspace: UpdatedWorkspaceObject) => { const useCase = getUseCase(workspace); const useCaseURL = getUseCaseUrl(useCase, workspace, coreStart.application, coreStart.http); return ( - - } - label={workspace.name} - onClick={() => { - onClickWorkspace?.(); - window.location.assign(useCaseURL); - }} - /> + <> + + + } + label={ + !isInTwoLines ? ( + + + + {workspace.name} + + + + + + + {useCase?.title} + + + + + {workspace.accessTime} + + + + + + ) : ( + + + + {workspace.name} + + + + + {useCase?.title} + + + + + {workspace.accessTime} + + + + ) + } + onClick={() => { + onClickWorkspace?.(); + window.location.assign(useCaseURL); + }} + /> + ); }); + return listItems; + }; + + const getWorkspaceListGroup = (filterWorkspaceList: UpdatedWorkspaceObject[]) => { + const listItems = getWorkspaceLists(filterWorkspaceList); return ( <> - -

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

-
- - - {listItems} - - + {listItems} ); }; @@ -174,18 +241,18 @@ export const WorkspacePickerContent = ({ onChange={(e) => setSearch(e.target.value)} placeholder={searchFieldPlaceholder} /> - + + diff --git a/src/plugins/workspace/public/components/workspace_selector/workspace_selector.test.tsx b/src/plugins/workspace/public/components/workspace_selector/workspace_selector.test.tsx new file mode 100644 index 000000000000..5b8b05a6506a --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_selector/workspace_selector.test.tsx @@ -0,0 +1,231 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import moment from 'moment'; +import { WorkspaceSelector } from './workspace_selector'; +import { coreMock } from '../../../../../core/public/mocks'; +import { CoreStart, DEFAULT_NAV_GROUPS, WorkspaceObject } from '../../../../../core/public'; +import { BehaviorSubject } from 'rxjs'; +import { IntlProvider } from 'react-intl'; +import { recentWorkspaceManager } from '../../recent_workspace_manager'; +jest.mock('@osd/i18n', () => ({ + i18n: { + translate: (id: string, { defaultMessage }: { defaultMessage: string }) => defaultMessage, + }, +})); +describe('', () => { + let coreStartMock: CoreStart; + const navigateToApp = jest.fn(); + const registeredUseCases$ = new BehaviorSubject([ + { ...DEFAULT_NAV_GROUPS.observability, features: [{ id: 'discover', title: 'Discover' }] }, + ]); + + beforeEach(() => { + coreStartMock = coreMock.createStart(); + coreStartMock.application.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: {}, + workspaces: { permissionEnabled: true }, + dashboards: { isDashboardAdmin: true }, + }; + + coreStartMock.application = { + ...coreStartMock.application, + navigateToApp, + }; + + const mockCurrentWorkspace = [{ id: 'workspace-1', name: 'workspace 1' }]; + coreStartMock.workspaces.currentWorkspace$ = new BehaviorSubject( + mockCurrentWorkspace + ); + + jest.spyOn(coreStartMock.application, 'getUrlForApp').mockImplementation((appId: string) => { + return `https://test.com/app/${appId}`; + }); + }); + + const WorkspaceSelectorCreatorComponent = () => { + return ( + + + + ); + }; + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('should display the current workspace name', () => { + render(); + expect(screen.getByTestId('workspace-selector-current-title')).toBeInTheDocument(); + expect(screen.getByTestId('workspace-selector-current-name')).toBeInTheDocument(); + }); + it('should display a list of workspaces in the dropdown', () => { + jest + .spyOn(recentWorkspaceManager, 'getRecentWorkspaces') + .mockReturnValue([{ id: 'workspace-1', timestamp: 1234567890 }]); + + coreStartMock.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1', features: [] }, + { id: 'workspace-2', name: 'workspace 2', features: [] }, + ]); + + render(); + const selectButton = screen.getByTestId('workspace-selector-button'); + fireEvent.click(selectButton); + + expect(screen.getByTestId('workspace-menu-item-workspace-1')).toBeInTheDocument(); + expect(screen.getByTestId('workspace-menu-item-workspace-2')).toBeInTheDocument(); + }); + + it('should display viewed xx ago for recent workspaces', () => { + jest + .spyOn(recentWorkspaceManager, 'getRecentWorkspaces') + .mockReturnValue([{ id: 'workspace-1', timestamp: 1234567890 }]); + + coreStartMock.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1', features: [] }, + { id: 'workspace-2', name: 'workspace 2', features: [] }, + ]); + + render(); + + const selectButton = screen.getByTestId('workspace-selector-button'); + fireEvent.click(selectButton); + + expect(screen.getByText(`viewed ${moment(1234567890).fromNow()}`)).toBeInTheDocument(); + }); + + it('should be able to display empty state when the workspace list is empty', () => { + coreStartMock.workspaces.workspaceList$.next([]); + render(); + const selectButton = screen.getByTestId('workspace-selector-button'); + fireEvent.click(selectButton); + expect(screen.getByText(/no workspace available/i)).toBeInTheDocument(); + }); + + it('should be able to perform search and filter and the results will be shown', () => { + coreStartMock.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1', features: [] }, + { id: 'test-2', name: 'test 2', features: [] }, + ]); + jest + .spyOn(recentWorkspaceManager, 'getRecentWorkspaces') + .mockReturnValue([{ id: 'workspace-1', timestamp: 1234567890 }]); + render(); + + const selectButton = screen.getByTestId('workspace-selector-button'); + fireEvent.click(selectButton); + + const searchInput = screen.getByRole('searchbox'); + fireEvent.change(searchInput, { target: { value: 'works' } }); + expect(screen.getByTestId('workspace-menu-item-workspace-1')).toBeInTheDocument(); + expect(screen.queryByText('workspace-menu-item-workspace-1')).not.toBeInTheDocument(); + }); + + it('should be able to display empty state when seach is not found', () => { + coreStartMock.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1', features: [] }, + { id: 'test-2', name: 'test 2', features: [] }, + ]); + jest + .spyOn(recentWorkspaceManager, 'getRecentWorkspaces') + .mockReturnValue([{ id: 'workspace-1', timestamp: 1234567890 }]); + render(); + + const selectButton = screen.getByTestId('workspace-selector-button'); + fireEvent.click(selectButton); + + const searchInput = screen.getByRole('searchbox'); + fireEvent.change(searchInput, { target: { value: 'noitems' } }); + expect(screen.getByText(/no workspace available/i)).toBeInTheDocument(); + }); + + it('should navigate to the first feature of workspace use case', () => { + coreStartMock.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1', features: ['use-case-observability'] }, + ]); + + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { + assign: jest.fn(), + }, + }); + + render(); + fireEvent.click(screen.getByTestId('workspace-selector-button')); + fireEvent.click(screen.getByText(/workspace 1/i)); + + expect(window.location.assign).toHaveBeenCalledWith( + 'https://test.com/w/workspace-1/app/discover' + ); + + Object.defineProperty(window, 'location', { + value: originalLocation, + }); + }); + + it('should navigate to the workspace detail page when use case is all', () => { + coreStartMock.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1', features: ['use-case-all'] }, + ]); + + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { + assign: jest.fn(), + }, + }); + + render(); + fireEvent.click(screen.getByTestId('workspace-selector-button')); + fireEvent.click(screen.getByText(/workspace 1/i)); + + expect(window.location.assign).toHaveBeenCalledWith( + 'https://test.com/w/workspace-1/app/workspace_detail' + ); + + Object.defineProperty(window, 'location', { + value: originalLocation, + }); + }); + + it('should navigate to create workspace page', () => { + render(); + fireEvent.click(screen.getByTestId('workspace-selector-button')); + fireEvent.click(screen.getByText(/create workspace/i)); + expect(coreStartMock.application.navigateToApp).toHaveBeenCalledWith('workspace_create'); + }); + + it('should navigate to workspace list page', () => { + render(); + + fireEvent.click(screen.getByTestId('workspace-selector-button')); + fireEvent.click(screen.getByText(/manage/i)); + expect(coreStartMock.application.navigateToApp).toHaveBeenCalledWith('workspace_list'); + }); + + it('should hide create workspace button for non dashboard admin', () => { + coreStartMock.application.capabilities = { + ...coreStartMock.application.capabilities, + dashboards: { + ...coreStartMock.application.capabilities.dashboards, + isDashboardAdmin: false, + }, + }; + render(); + + fireEvent.click(screen.getByTestId('workspace-selector-button')); + expect(screen.queryByText(/manage/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/create workspaces/i)).toBeNull(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_selector/workspace_selector.tsx b/src/plugins/workspace/public/components/workspace_selector/workspace_selector.tsx new file mode 100644 index 000000000000..ca6431da1e1d --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_selector/workspace_selector.tsx @@ -0,0 +1,206 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { useObservable } from 'react-use'; +import { i18n } from '@osd/i18n'; +import { + EuiButton, + EuiIcon, + EuiPopover, + EuiPanel, + EuiHorizontalRule, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiButtonEmpty, +} from '@elastic/eui'; +import { BehaviorSubject } from 'rxjs'; +import { WORKSPACE_CREATE_APP_ID, WORKSPACE_LIST_APP_ID } from '../../../common/constants'; +import { CoreStart, WorkspaceObject } from '../../../../../core/public'; +import { getFirstUseCaseOfFeatureConfigs } from '../../utils'; +import { WorkspaceUseCase } from '../../types'; +import { validateWorkspaceColor } from '../../../common/utils'; +import { WorkspacePickerContent } from '../workspace_picker_content/workspace_picker_content'; + +const createWorkspaceButton = i18n.translate('workspace.menu.button.createWorkspace', { + defaultMessage: 'Create workspace', +}); + +const manageWorkspacesButton = i18n.translate('workspace.menu.button.manageWorkspaces', { + defaultMessage: 'Manage', +}); + +const getValidWorkspaceColor = (color?: string) => + validateWorkspaceColor(color) ? color : undefined; + +interface Props { + coreStart: CoreStart; + registeredUseCases$: BehaviorSubject; +} + +export const WorkspaceSelector = ({ coreStart, registeredUseCases$ }: Props) => { + const [isPopoverOpen, setPopover] = useState(false); + const currentWorkspace = useObservable(coreStart.workspaces.currentWorkspace$, null); + const availableUseCases = useObservable(registeredUseCases$, []); + const isDashboardAdmin = coreStart.application.capabilities?.dashboards?.isDashboardAdmin; + + const getUseCase = (workspace: WorkspaceObject) => { + if (!workspace.features) { + return; + } + const useCaseId = getFirstUseCaseOfFeatureConfigs(workspace.features); + return availableUseCases.find((useCase) => useCase.id === useCaseId); + }; + + const onButtonClick = () => { + setPopover(!isPopoverOpen); + }; + + const closePopover = () => { + setPopover(false); + }; + const button = currentWorkspace ? ( + + + + {i18n.translate('workspace.left.nav.selector.label', { + defaultMessage: 'WORKSPACE', + })} + + + + + + + + + + + + + +

{currentWorkspace.name}

+
+
+ + + + {i18n.translate('workspace.left.nav.selector.title', { + defaultMessage: getUseCase(currentWorkspace)?.title || '', + })} + + + +
+
+
+
+ + + +
+
+
+ ) : ( + Select a Workspace + ); + + return ( + + + + + setPopover(false)} + isInTwoLines={false} + /> + + + + {isDashboardAdmin && ( + + + + + + { + closePopover(); + coreStart.application.navigateToApp(WORKSPACE_LIST_APP_ID); + }} + > + {manageWorkspacesButton} + + + + + { + closePopover(); + 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 0bbf636f6397..2fba6a457322 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -221,7 +221,20 @@ describe('Workspace plugin', () => { ); }); - it('#setup should register registerCollapsibleNavHeader when new left nav is turned on', async () => { + it('#setup should register registerCollapsibleNavHeader when enter a workspace', async () => { + const windowSpy = jest.spyOn(window, 'window', 'get'); + windowSpy.mockImplementation( + () => + ({ + location: { + href: 'http://localhost/w/workspaceId/app', + }, + } as any) + ); + workspaceClientMock.enterWorkspace.mockResolvedValueOnce({ + success: true, + error: 'error', + }); const setupMock = coreMock.createSetup(); let collapsibleNavHeaderImplementation = () => null; setupMock.chrome.navGroup.getNavGroupEnabled.mockReturnValue(true); @@ -234,6 +247,7 @@ describe('Workspace plugin', () => { const startMock = coreMock.createStart(); await workspacePlugin.start(startMock, getMockDependencies()); expect(collapsibleNavHeaderImplementation()).not.toEqual(null); + windowSpy.mockRestore(); }); it('#setup should register workspace essential use case when new home is disabled', async () => { diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 42a0d154cecb..ff6ce2c75898 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -60,6 +60,7 @@ import { UseCaseService } from './services/use_case_service'; import { WorkspaceListCard } from './components/service_card'; import { NavigationPublicPluginStart } from '../../../plugins/navigation/public'; import { WorkspacePickerContent } from './components/workspace_picker_content/workspace_picker_content'; +import { WorkspaceSelector } from './components/workspace_selector/workspace_selector'; import { HOME_CONTENT_AREAS } from '../../../plugins/content_management/public'; import { registerEssentialOverviewContent, @@ -122,7 +123,6 @@ export class WorkspacePlugin */ private filterNavLinks = (core: CoreStart) => { const currentWorkspace$ = core.workspaces.currentWorkspace$; - this.workspaceAndUseCasesCombineSubscription?.unsubscribe(); this.workspaceAndUseCasesCombineSubscription = combineLatest([ currentWorkspace$, @@ -536,25 +536,15 @@ export class WorkspacePlugin }, ]); - if (core.chrome.navGroup.getNavGroupEnabled()) { - /** - * Show workspace picker content when outside of workspace and not in any nav group - */ + if (workspaceId) { core.chrome.registerCollapsibleNavHeader(() => { if (!this.coreStart) { return null; } - return React.createElement(EuiPanel, { - hasShadow: false, - hasBorder: false, - paddingSize: 's', - children: [ - React.createElement(WorkspacePickerContent, { - key: 'workspacePickerContent', - coreStart: this.coreStart, - registeredUseCases$: this.registeredUseCases$, - }), - ], + return React.createElement(WorkspaceSelector, { + key: 'workspaceSelector', + coreStart: this.coreStart, + registeredUseCases$: this.registeredUseCases$, }); }); } From a7f3e9d3f2b6e9c4cf935ac305ad564a02122116 Mon Sep 17 00:00:00 2001 From: Sean Li Date: Tue, 1 Oct 2024 04:55:27 -0700 Subject: [PATCH 02/31] [Discover] Display Cache Time and Clear Cache Button (#8214) * initial commit for cache time and clearing cache Signed-off-by: Sean Li * Changeset file for PR #8214 created/updated * updating UI to address some comments Signed-off-by: Sean Li * addressing comments, adding tests Signed-off-by: Sean Li * removing dynamic default message, following i18n best practice Signed-off-by: Sean Li --------- Signed-off-by: Sean Li Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8214.yml | 2 + src/plugins/data/common/storage/storage.ts | 4 + .../dataset_service/dataset_service.test.ts | 71 +++++++++------ .../dataset_service/dataset_service.ts | 13 +++ .../ui/dataset_selector/dataset_explorer.tsx | 91 ++++++++++++++----- 5 files changed, 130 insertions(+), 51 deletions(-) create mode 100644 changelogs/fragments/8214.yml diff --git a/changelogs/fragments/8214.yml b/changelogs/fragments/8214.yml new file mode 100644 index 000000000000..ce9d1ee6ca32 --- /dev/null +++ b/changelogs/fragments/8214.yml @@ -0,0 +1,2 @@ +feat: +- Add last updated time and cache refresh button to Discover Advanced Dataset Selector ([#8214](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8214)) \ No newline at end of file diff --git a/src/plugins/data/common/storage/storage.ts b/src/plugins/data/common/storage/storage.ts index 8ec5f6a13b8d..752e0b29c92e 100644 --- a/src/plugins/data/common/storage/storage.ts +++ b/src/plugins/data/common/storage/storage.ts @@ -61,6 +61,10 @@ export class DataStorage { if (ourKey != null) ours.push(ourKey); }); } + + clear(): void { + this.engine.clear(); + } } export function createStorage(deps: { engine: IStorageEngine; prefix: string }) { diff --git a/src/plugins/data/public/query/query_string/dataset_service/dataset_service.test.ts b/src/plugins/data/public/query/query_string/dataset_service/dataset_service.test.ts index f72fd0ea5b46..02b9eb0759fc 100644 --- a/src/plugins/data/public/query/query_string/dataset_service/dataset_service.test.ts +++ b/src/plugins/data/public/query/query_string/dataset_service/dataset_service.test.ts @@ -23,17 +23,26 @@ describe('DatasetService', () => { service = new DatasetService(uiSettings, sessionStorage); }); - test('registerType and getType', () => { - const mockType = { - id: 'test-type', - title: 'Test Type', - meta: { icon: { type: 'test' } }, - toDataset: jest.fn(), - fetch: jest.fn(), - fetchFields: jest.fn(), - supportedLanguages: jest.fn(), - }; + const mockResult = { + id: 'test-structure', + title: 'Test Structure', + type: 'test-type', + children: [{ id: 'child1', title: 'Child 1', type: 'test-type' }], + }; + + const mockPath: DataStructure[] = [{ id: 'root', title: 'Root', type: 'root' }]; + + const mockType = { + id: 'test-type', + title: 'Test Type', + meta: { icon: { type: 'test' } }, + toDataset: jest.fn(), + fetch: jest.fn().mockResolvedValue(mockResult), + fetchFields: jest.fn(), + supportedLanguages: jest.fn(), + }; + test('registerType and getType', () => { service.registerType(mockType); expect(service.getType('test-type')).toBe(mockType); }); @@ -52,25 +61,9 @@ describe('DatasetService', () => { }); test('fetchOptions caches and returns data structures', async () => { - const mockType = { - id: 'test-type', - title: 'Test Type', - meta: { icon: { type: 'test' } }, - toDataset: jest.fn(), - fetch: jest.fn().mockResolvedValue({ - id: 'test-structure', - title: 'Test Structure', - type: 'test-type', - children: [{ id: 'child1', title: 'Child 1', type: 'test-type' }], - }), - fetchFields: jest.fn(), - supportedLanguages: jest.fn(), - }; - service.registerType(mockType); - const path: DataStructure[] = [{ id: 'root', title: 'Root', type: 'root' }]; - const result = await service.fetchOptions(mockDataPluginServices, path, 'test-type'); + const result = await service.fetchOptions(mockDataPluginServices, mockPath, 'test-type'); expect(result).toEqual({ id: 'test-structure', @@ -79,8 +72,30 @@ describe('DatasetService', () => { children: [{ id: 'child1', title: 'Child 1', type: 'test-type' }], }); - const cachedResult = await service.fetchOptions(mockDataPluginServices, path, 'test-type'); + const cachedResult = await service.fetchOptions(mockDataPluginServices, mockPath, 'test-type'); expect(cachedResult).toEqual(result); expect(mockType.fetch).toHaveBeenCalledTimes(2); }); + + test('clear cache', async () => { + service.registerType(mockType); + + await service.fetchOptions(mockDataPluginServices, mockPath, 'test-type'); + expect(sessionStorage.keys().length === 1); + + service.clearCache(); + expect(sessionStorage.keys().length === 0); + }); + + test('caching object correctly sets last cache time', async () => { + service.registerType(mockType); + + const time = Date.now(); + + Date.now = jest.fn(() => time); + + await service.fetchOptions(mockDataPluginServices, mockPath, 'test-type'); + + expect(service.getLastCacheTime()).toEqual(time); + }); }); diff --git a/src/plugins/data/public/query/query_string/dataset_service/dataset_service.ts b/src/plugins/data/public/query/query_string/dataset_service/dataset_service.ts index 2f9a0884442f..36faead38308 100644 --- a/src/plugins/data/public/query/query_string/dataset_service/dataset_service.ts +++ b/src/plugins/data/public/query/query_string/dataset_service/dataset_service.ts @@ -138,6 +138,7 @@ export class DatasetService { } private cacheDataStructure(dataType: string, dataStructure: DataStructure) { + this.setLastCacheTime(Date.now()); const cachedDataStructure: CachedDataStructure = { id: dataStructure.id, title: dataStructure.title, @@ -164,6 +165,18 @@ export class DatasetService { }); } + public clearCache(): void { + this.sessionStorage.clear(); + } + + public getLastCacheTime(): number | undefined { + return Number(this.sessionStorage.get('lastCacheTime')) || undefined; + } + + private setLastCacheTime(time: number): void { + this.sessionStorage.set('lastCacheTime', time); + } + private async fetchDefaultDataset(): Promise { const defaultIndexPatternId = this.uiSettings.get('defaultIndex'); if (!defaultIndexPatternId) { diff --git a/src/plugins/data/public/ui/dataset_selector/dataset_explorer.tsx b/src/plugins/data/public/ui/dataset_selector/dataset_explorer.tsx index f393884bc599..dbcf7fe5acfc 100644 --- a/src/plugins/data/public/ui/dataset_selector/dataset_explorer.tsx +++ b/src/plugins/data/public/ui/dataset_selector/dataset_explorer.tsx @@ -7,6 +7,8 @@ import React, { useState } from 'react'; import { EuiButton, EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, EuiIcon, EuiLink, EuiModalBody, @@ -19,6 +21,7 @@ import { EuiToolTip, } from '@elastic/eui'; import { FormattedMessage } from '@osd/i18n/react'; +import moment from 'moment'; import { BaseDataset, DATA_STRUCTURE_META_TYPES, DataStructure } from '../../../common'; import { QueryStringContract } from '../../query'; import { IDataPluginServices } from '../../types'; @@ -38,6 +41,7 @@ export const DatasetExplorer = ({ onNext: (dataset: BaseDataset) => void; onCancel: () => void; }) => { + const uiSettings = services.uiSettings; const [explorerDataset, setExplorerDataset] = useState(undefined); const [loading, setLoading] = useState(false); @@ -68,31 +72,72 @@ export const DatasetExplorer = ({ return ( <> - -

- -

- -

- - + + + +

- -

- - +

+ +

+ + + + +

+
+
+ + {queryString.getDatasetService().getLastCacheTime() && ( + + + + + + + + + { + queryString.getDatasetService().clearCache(); + onCancel(); + }} + size="xs" + iconSide="left" + iconType="refresh" + iconGap="s" + flush="both" + > + + + + + + )} +
Date: Wed, 2 Oct 2024 02:07:19 +0800 Subject: [PATCH 03/31] add Hailong-am as maintainer (#8415) * feat: add Hailong-am as maintainer Signed-off-by: SuZhou-Joe * Changeset file for PR #8415 created/updated * Changeset file for PR #8415 created/updated --------- Signed-off-by: SuZhou-Joe Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- MAINTAINERS.md | 1 + changelogs/fragments/8415.yml | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 changelogs/fragments/8415.yml diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 9f964d82a66c..c75a1404b7b9 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -30,6 +30,7 @@ This document contains a list of maintainers in this repo. See [opensearch-proje | Sean Li | [sejli](https://github.com/sejli) | Amazon | | Joshua Li | [joshuali925](https://github.com/joshuali925) | Amazon | | Huy Nguyen | [huyaboo](https://github.com/huyaboo) | Amazon | +| Hailong Cui | [Hailong-am](https://github.com/Hailong-am) | Amazon | ## Emeritus diff --git a/changelogs/fragments/8415.yml b/changelogs/fragments/8415.yml new file mode 100644 index 000000000000..261821962a13 --- /dev/null +++ b/changelogs/fragments/8415.yml @@ -0,0 +1,2 @@ +doc: +- Add Hailong-am as maintainer ([#8415](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8415)) \ No newline at end of file From 0f126da2c8e7bdbf8b8b915e197a4a0aa90ed1ee Mon Sep 17 00:00:00 2001 From: Miki Date: Tue, 1 Oct 2024 11:32:28 -0700 Subject: [PATCH 04/31] Fix unprefixed and duplicate i18n identifiers in workspace plugin (#8410) Signed-off-by: Miki --- .../home_get_start_card/use_case_card_title.tsx | 6 +++--- .../components/workspace_column/workspace_column.tsx | 2 +- .../workspace/public/components/workspace_creator_app.tsx | 2 +- .../components/workspace_detail/workspace_detail.tsx | 8 ++++---- .../workspace/public/components/workspace_detail_app.tsx | 2 +- .../workspace_fatal_error/workspace_fatal_error.tsx | 6 +++--- .../workspace_form/fields/workspace_name_field.tsx | 2 +- .../workspace/public/components/workspace_form/utils.ts | 4 ++-- .../workspace_form/workspace_form_error_callout.tsx | 6 +++--- .../workspace_form/workspace_permission_setting_input.tsx | 6 +++--- .../workspace_form/workspace_permission_setting_panel.tsx | 6 +++--- src/plugins/workspace/server/permission_control/client.ts | 2 +- .../workspace_saved_objects_client_wrapper.ts | 6 +++--- 13 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/plugins/workspace/public/components/home_get_start_card/use_case_card_title.tsx b/src/plugins/workspace/public/components/home_get_start_card/use_case_card_title.tsx index 95400ee336e7..379039700dbc 100644 --- a/src/plugins/workspace/public/components/home_get_start_card/use_case_card_title.tsx +++ b/src/plugins/workspace/public/components/home_get_start_card/use_case_card_title.tsx @@ -51,8 +51,8 @@ export const UseCaseCardTitle = ({ filterWorkspaces, useCase, core }: UseCaseCar const iconButton = ( - {i18n.translate('workspace.getStartCard.popover.title.', { + {i18n.translate('workspace.getStartCard.popover.title', { defaultMessage: 'SELECT WORKSPACE', })} diff --git a/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx b/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx index 4391f8d2c858..035fa3b6ebd4 100644 --- a/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx +++ b/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx @@ -35,7 +35,7 @@ export function getWorkspaceColumn( euiColumn: { align: 'left', field: 'workspaces', - name: i18n.translate('savedObjectsManagement.objectsTable.table.columnWorkspacesName', { + name: i18n.translate('workspace.objectsTable.table.columnWorkspacesName', { defaultMessage: 'Workspace', }), render: (workspaces: string[]) => { diff --git a/src/plugins/workspace/public/components/workspace_creator_app.tsx b/src/plugins/workspace/public/components/workspace_creator_app.tsx index 007d0d7cb151..5a4671c4fd05 100644 --- a/src/plugins/workspace/public/components/workspace_creator_app.tsx +++ b/src/plugins/workspace/public/components/workspace_creator_app.tsx @@ -20,7 +20,7 @@ export const WorkspaceCreatorApp = (props: WorkspaceCreatorProps) => { */ useEffect(() => { const homeBreadcrumb = { - text: i18n.translate('core.breadcrumbs.homeTitle', { defaultMessage: 'Home' }), + text: i18n.translate('workspace.breadcrumbs.homeTitle', { defaultMessage: 'Home' }), onClick: () => { application?.navigateToApp('home'); }, diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx index 674b1e31ed47..e027c7d5f586 100644 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx @@ -369,7 +369,7 @@ export const WorkspaceDetail = (props: WorkspaceDetailPropsWithFormSubmitting) = {modalVisible && ( setModalVisible(false)} @@ -379,14 +379,14 @@ export const WorkspaceDetail = (props: WorkspaceDetailPropsWithFormSubmitting) = history.push(`?tab=${tabId}`); setSelectedTabId(tabId); }} - cancelButtonText={i18n.translate('workspace.form.cancelButtonText', { + cancelButtonText={i18n.translate('workspace.detail.cancelButtonText', { defaultMessage: 'Cancel', })} - confirmButtonText={i18n.translate('workspace.form.confirmButtonText', { + confirmButtonText={i18n.translate('workspace.detail.confirmButtonText', { defaultMessage: 'Confirm', })} > - {i18n.translate('workspace.form.cancelModal.body', { + {i18n.translate('workspace.detail.cancelModal.body', { defaultMessage: 'Any unsaved changes will be lost.', })} diff --git a/src/plugins/workspace/public/components/workspace_detail_app.tsx b/src/plugins/workspace/public/components/workspace_detail_app.tsx index f0867b8d59e7..66ea4f3f8b9c 100644 --- a/src/plugins/workspace/public/components/workspace_detail_app.tsx +++ b/src/plugins/workspace/public/components/workspace_detail_app.tsx @@ -99,7 +99,7 @@ export const WorkspaceDetailApp = (props: WorkspaceDetailPropsWithOnAppLeave) => } if (!currentWorkspace) { notifications?.toasts.addDanger({ - title: i18n.translate('Cannot find current workspace', { + title: i18n.translate('workspace.detail.notFoundError', { defaultMessage: 'Cannot update workspace', }), }); diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx index a75e8bc45976..3a6e3fae9cd7 100644 --- a/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx +++ b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx @@ -35,7 +35,7 @@ export function WorkspaceFatalError(props: { error?: string }) { title={

@@ -43,7 +43,7 @@ export function WorkspaceFatalError(props: { error?: string }) { body={

@@ -51,7 +51,7 @@ export function WorkspaceFatalError(props: { error?: string }) { actions={[ , diff --git a/src/plugins/workspace/public/components/workspace_form/fields/workspace_name_field.tsx b/src/plugins/workspace/public/components/workspace_form/fields/workspace_name_field.tsx index 768cb4b41c27..eae05968d8e8 100644 --- a/src/plugins/workspace/public/components/workspace_form/fields/workspace_name_field.tsx +++ b/src/plugins/workspace/public/components/workspace_form/fields/workspace_name_field.tsx @@ -53,7 +53,7 @@ export const WorkspaceNameField = ({ {showDescription && ( <>
- {i18n.translate('workspace.form.workspaceDetails.name.helpText', { + {i18n.translate('workspace.form.workspaceDetails.name.helpTextLong', { defaultMessage: 'Use a unique name for the workspace. Valid characters are a-z, A-Z, 0-9, (), [], _ (underscore), - (hyphen) and (space).', })} diff --git a/src/plugins/workspace/public/components/workspace_form/utils.ts b/src/plugins/workspace/public/components/workspace_form/utils.ts index f6497315a262..5bbc1125fbeb 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.ts @@ -331,7 +331,7 @@ export const validateWorkspaceForm = ( if (!features || !features.some((featureConfig) => isUseCaseFeatureConfig(featureConfig))) { formErrors.features = { code: WorkspaceFormErrorCode.UseCaseMissing, - message: i18n.translate('workspace.form.features.empty', { + message: i18n.translate('workspace.form.features.emptyUseCase', { defaultMessage: 'Use case is required. Select a use case.', }), }; @@ -339,7 +339,7 @@ export const validateWorkspaceForm = ( if (color && !validateWorkspaceColor(color)) { formErrors.color = { code: WorkspaceFormErrorCode.InvalidColor, - message: i18n.translate('workspace.form.features.empty', { + message: i18n.translate('workspace.form.features.invalidColor', { defaultMessage: 'Color is invalid. Enter a valid color.', }), }; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form_error_callout.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form_error_callout.tsx index 1d90235ed82c..2c32fde1d100 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form_error_callout.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form_error_callout.tsx @@ -31,11 +31,11 @@ const getSuggestionFromErrorCode = (error: WorkspaceFormError) => { defaultMessage: 'Enter a user group.', }); case WorkspaceFormErrorCode.DuplicateUserIdPermissionSetting: - return i18n.translate('workspace.form.errorCallout.duplicatePermission', { + return i18n.translate('workspace.form.errorCallout.duplicateUserPermission', { defaultMessage: 'Enter a unique user.', }); case WorkspaceFormErrorCode.DuplicateUserGroupPermissionSetting: - return i18n.translate('workspace.form.errorCallout.duplicatePermission', { + return i18n.translate('workspace.form.errorCallout.duplicateGroupPermission', { defaultMessage: 'Enter a unique user group.', }); case WorkspaceFormErrorCode.PermissionSettingOwnerMissing: @@ -112,7 +112,7 @@ export const WorkspaceFormErrorCallout = ({ errors }: WorkspaceFormErrorCalloutP )} {errors.color && ( - {i18n.translate('workspaceForm.permissionSetting.typeLabel', { + {i18n.translate('workspace.form.permissionSetting.typeLabel', { defaultMessage: 'Type', })} - {i18n.translate('workspaceForm.permissionSetting.collaboratorLabel', { + {i18n.translate('workspace.form.permissionSetting.collaboratorLabel', { defaultMessage: 'Collaborator', })} - {i18n.translate('workspaceForm.permissionSetting.accessLevelLabel', { + {i18n.translate('workspace.form.permissionSetting.accessLevelLabel', { defaultMessage: 'Access level', })} diff --git a/src/plugins/workspace/server/permission_control/client.ts b/src/plugins/workspace/server/permission_control/client.ts index 3d052c3fe2f6..c6e147f3609c 100644 --- a/src/plugins/workspace/server/permission_control/client.ts +++ b/src/plugins/workspace/server/permission_control/client.ts @@ -184,7 +184,7 @@ export class SavedObjectsPermissionControl { if (!savedObjectsGet.length) { return { success: false, - error: i18n.translate('savedObjects.permission.notFound', { + error: i18n.translate('workspace.savedObjects.permission.notFound', { defaultMessage: 'Can not find target saved objects.', }), }; diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts index 091644d46efc..876f333be95e 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -48,7 +48,7 @@ const generateWorkspacePermissionError = () => const generateSavedObjectsPermissionError = () => SavedObjectsErrorHelpers.decorateForbiddenError( new Error( - i18n.translate('saved_objects.permission.invalidate', { + i18n.translate('workspace.saved_objects.permission.invalidate', { defaultMessage: 'Invalid saved objects permission', }) ) @@ -57,7 +57,7 @@ const generateSavedObjectsPermissionError = () => const generateDataSourcePermissionError = () => SavedObjectsErrorHelpers.decorateForbiddenError( new Error( - i18n.translate('saved_objects.data_source.invalidate', { + i18n.translate('workspace.saved_objects.data_source.invalidate', { defaultMessage: 'Invalid data source permission, please associate it to current workspace', }) ) @@ -66,7 +66,7 @@ const generateDataSourcePermissionError = () => const generateOSDAdminPermissionError = () => SavedObjectsErrorHelpers.decorateForbiddenError( new Error( - i18n.translate('dashboard.admin.permission.invalidate', { + i18n.translate('workspace.admin.permission.invalidate', { defaultMessage: 'Invalid permission, please contact OSD admin', }) ) From 2e7e235fe6047a12720463d4a0e4f7408d3c8532 Mon Sep 17 00:00:00 2001 From: Miki Date: Tue, 1 Oct 2024 11:49:44 -0700 Subject: [PATCH 05/31] Fix duplicate i18n identifiers in visualize plugin (#8407) * Fix duplicate i18n identifiers in visualize plugin Signed-off-by: Miki * Changeset file for PR #8407 created/updated --------- Signed-off-by: Miki Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8407.yml | 2 ++ .../public/application/components/visualize_listing.tsx | 4 ++-- .../public/application/components/visualize_top_nav.tsx | 2 +- .../public/application/utils/get_top_nav_config.tsx | 8 ++++---- 4 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 changelogs/fragments/8407.yml diff --git a/changelogs/fragments/8407.yml b/changelogs/fragments/8407.yml new file mode 100644 index 000000000000..27029ebbe418 --- /dev/null +++ b/changelogs/fragments/8407.yml @@ -0,0 +1,2 @@ +fix: +- Fix duplicate i18n identifiers in visualize plugin ([#8407](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8407)) \ No newline at end of file diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx index 17ddd2c7bfe5..b904bdfe1b00 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.tsx +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -89,7 +89,7 @@ export const VisualizeListing = () => { if (showUpdatedUx) { chrome.setBreadcrumbs([ { - text: i18n.translate('visualize.visualizeListingBreadcrumbsTitle', { + text: i18n.translate('visualize.listingBreadcrumbsTitle', { defaultMessage: 'Visualizations', }), }, @@ -97,7 +97,7 @@ export const VisualizeListing = () => { } else { chrome.setBreadcrumbs([ { - text: i18n.translate('visualize.visualizeListingBreadcrumbsTitle', { + text: i18n.translate('visualize.legacy.listingBreadcrumbsTitle', { defaultMessage: 'Visualize', }), }, diff --git a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx index f1c47016d048..e2bc639e79f0 100644 --- a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx +++ b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx @@ -261,7 +261,7 @@ const TopNav = ({ indexPatterns={indexPatterns} screenTitle={ vis.title || - i18n.translate('discover.savedSearch.newTitle', { + i18n.translate('visualize.savedSearch.newTitle', { defaultMessage: 'New visualization', }) } diff --git a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx index cffd251b3eb4..78d1dbb07a30 100644 --- a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx @@ -106,7 +106,7 @@ export const getLegacyTopNavConfig = ( { id: 'inspector', label: i18n.translate('visualize.topNavMenu.openInspectorButtonLabel', { - defaultMessage: 'inspect', + defaultMessage: 'Inspect', }), description: i18n.translate('visualize.topNavMenu.openInspectorButtonAriaLabel', { defaultMessage: 'Open Inspector for visualization', @@ -127,7 +127,7 @@ export const getLegacyTopNavConfig = ( { id: 'share', label: i18n.translate('visualize.topNavMenu.shareVisualizationButtonLabel', { - defaultMessage: 'share', + defaultMessage: 'Share', }), description: i18n.translate('visualize.topNavMenu.shareVisualizationButtonAriaLabel', { defaultMessage: 'Share Visualization', @@ -168,10 +168,10 @@ export const getLegacyTopNavConfig = ( label: savedVis?.id && originatingApp ? i18n.translate('visualize.topNavMenu.saveVisualizationAsButtonLabel', { - defaultMessage: 'save as', + defaultMessage: 'Save as', }) : i18n.translate('visualize.topNavMenu.saveVisualizationButtonLabel', { - defaultMessage: 'save', + defaultMessage: 'Save', }), emphasize: (savedVis && !savedVis.id) || !originatingApp, description: i18n.translate('visualize.topNavMenu.saveVisualizationButtonAriaLabel', { From daa59deb5e243191a62926ec19a40fb92af9bf4c Mon Sep 17 00:00:00 2001 From: Suchit Sahoo <38322563+LDrago27@users.noreply.github.com> Date: Tue, 1 Oct 2024 16:10:15 -0700 Subject: [PATCH 06/31] Add Ui Metric for Discover 2.0 (#8345) Signed-off-by: Suchit Sahoo --- .../index_pattern_select.test.tsx | 2 +- .../data_explorer/opensearch_dashboards.json | 3 +- .../public/components/app_container.tsx | 15 +++++++- src/plugins/data_explorer/public/plugin.ts | 4 +- src/plugins/data_explorer/public/services.ts | 10 +++++ src/plugins/data_explorer/public/types.ts | 2 + .../public/ui_metric/constants.ts | 8 ++++ .../data_explorer/public/ui_metric/index.ts | 7 ++++ .../public/ui_metric/report_ui_metric.ts | 23 ++++++++++++ .../discover/opensearch_dashboards.json | 3 +- .../view_components/utils/use_search.ts | 9 ++++- .../public/opensearch_dashboards_services.ts | 5 +++ src/plugins/discover/public/plugin.ts | 4 ++ .../discover/public/ui_metric/constants.ts | 8 ++++ .../discover/public/ui_metric/index.ts | 7 ++++ .../public/ui_metric/report_ui_metric.ts | 37 +++++++++++++++++++ 16 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 src/plugins/data_explorer/public/services.ts create mode 100644 src/plugins/data_explorer/public/ui_metric/constants.ts create mode 100644 src/plugins/data_explorer/public/ui_metric/index.ts create mode 100644 src/plugins/data_explorer/public/ui_metric/report_ui_metric.ts create mode 100644 src/plugins/discover/public/ui_metric/constants.ts create mode 100644 src/plugins/discover/public/ui_metric/index.ts create mode 100644 src/plugins/discover/public/ui_metric/report_ui_metric.ts diff --git a/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.test.tsx b/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.test.tsx index 9186aa361a0e..dcf008fcf2d7 100644 --- a/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.test.tsx +++ b/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.test.tsx @@ -49,7 +49,7 @@ describe('IndexPatternSelect', () => { bulkGetMock.mockResolvedValue({ savedObjects: [{ attributes: { title: 'test1' } }] }); compInstance.debouncedFetch(''); - await new Promise((resolve) => setTimeout(resolve, 300)); + await new Promise((resolve) => setTimeout(resolve, 600)); await nextTick(); expect(bulkGetMock).toBeCalledWith([{ id: 'testDataSourceId', type: 'data-source' }]); }); diff --git a/src/plugins/data_explorer/opensearch_dashboards.json b/src/plugins/data_explorer/opensearch_dashboards.json index 23db353b2cc8..bed97d60d323 100644 --- a/src/plugins/data_explorer/opensearch_dashboards.json +++ b/src/plugins/data_explorer/opensearch_dashboards.json @@ -8,7 +8,8 @@ "data", "navigation", "embeddable", - "expressions" + "expressions", + "usageCollection" ], "optionalPlugins": [], "requiredBundles": [ diff --git a/src/plugins/data_explorer/public/components/app_container.tsx b/src/plugins/data_explorer/public/components/app_container.tsx index 9326c4097f0f..d555316dd21d 100644 --- a/src/plugins/data_explorer/public/components/app_container.tsx +++ b/src/plugins/data_explorer/public/components/app_container.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { memo, useRef } from 'react'; +import React, { memo, useEffect, useRef } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -23,6 +23,13 @@ import './app_container.scss'; import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; import { IDataPluginServices } from '../../../data/public'; import { QUERY_ENHANCEMENT_ENABLED_SETTING } from './constants'; +import { + DISCOVER_LOAD_EVENT, + NEW_DISCOVER_LOAD_EVENT, + NEW_DISCOVER_OPT_IN, + NEW_DISCOVER_OPT_OUT, + trackUiMetric, +} from '../ui_metric'; export const AppContainer = React.memo( ({ view, params }: { view?: View; params: AppMountParameters }) => { @@ -35,10 +42,16 @@ export const AppContainer = React.memo( const topLinkRef = useRef(null); const datePickerRef = useRef(null); + if (!view) { return ; } + trackUiMetric(DISCOVER_LOAD_EVENT); + if (isEnhancementsEnabled) { + trackUiMetric(NEW_DISCOVER_LOAD_EVENT); + } + const { Canvas, Panel, Context } = view; const MemoizedPanel = memo(Panel); diff --git a/src/plugins/data_explorer/public/plugin.ts b/src/plugins/data_explorer/public/plugin.ts index d2c8da53a697..28e4237d6773 100644 --- a/src/plugins/data_explorer/public/plugin.ts +++ b/src/plugins/data_explorer/public/plugin.ts @@ -31,6 +31,7 @@ import { } from '../../opensearch_dashboards_utils/public'; import { getPreloadedStore } from './utils/state_management'; import { opensearchFilters } from '../../data/public'; +import { setUsageCollector } from './services'; export class DataExplorerPlugin implements @@ -47,10 +48,11 @@ export class DataExplorerPlugin public setup( core: CoreSetup, - { data }: DataExplorerPluginSetupDependencies + { data, usageCollection }: DataExplorerPluginSetupDependencies ): DataExplorerPluginSetup { const viewService = this.viewService; + setUsageCollector(usageCollection); const { appMounted, appUnMounted, stop: stopUrlTracker } = createOsdUrlTracker({ baseUrl: core.http.basePath.prepend(`/app/${PLUGIN_ID}`), defaultSubUrl: '#/', diff --git a/src/plugins/data_explorer/public/services.ts b/src/plugins/data_explorer/public/services.ts new file mode 100644 index 000000000000..13c6e57b6646 --- /dev/null +++ b/src/plugins/data_explorer/public/services.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { createGetterSetter } from '../../opensearch_dashboards_utils/public'; +import { UsageCollectionSetup } from '../../usage_collection/public'; + +export const [getUsageCollector, setUsageCollector] = createGetterSetter( + 'UsageCollector' +); diff --git a/src/plugins/data_explorer/public/types.ts b/src/plugins/data_explorer/public/types.ts index 5f677fb46cfd..d63396155b55 100644 --- a/src/plugins/data_explorer/public/types.ts +++ b/src/plugins/data_explorer/public/types.ts @@ -10,6 +10,7 @@ import { ViewServiceStart, ViewServiceSetup } from './services/view_service'; import { IOsdUrlStateStorage } from '../../opensearch_dashboards_utils/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../data/public'; import { Store } from './utils/state_management'; +import { UsageCollectionSetup } from '../../usage_collection/public'; export type DataExplorerPluginSetup = ViewServiceSetup; @@ -18,6 +19,7 @@ export interface DataExplorerPluginStart {} export interface DataExplorerPluginSetupDependencies { data: DataPublicPluginSetup; + usageCollection: UsageCollectionSetup; } export interface DataExplorerPluginStartDependencies { diff --git a/src/plugins/data_explorer/public/ui_metric/constants.ts b/src/plugins/data_explorer/public/ui_metric/constants.ts new file mode 100644 index 000000000000..769dbe12e954 --- /dev/null +++ b/src/plugins/data_explorer/public/ui_metric/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const NEW_DISCOVER_LOAD_EVENT = 'new_discover_load_count'; +export const DISCOVER_LOAD_EVENT = 'discover_load_count'; +export const NEW_DISCOVER_APP_NAME = 'New_Discover'; diff --git a/src/plugins/data_explorer/public/ui_metric/index.ts b/src/plugins/data_explorer/public/ui_metric/index.ts new file mode 100644 index 000000000000..eceec571b67d --- /dev/null +++ b/src/plugins/data_explorer/public/ui_metric/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './constants'; +export * from './report_ui_metric'; diff --git a/src/plugins/data_explorer/public/ui_metric/report_ui_metric.ts b/src/plugins/data_explorer/public/ui_metric/report_ui_metric.ts new file mode 100644 index 000000000000..1d53ec4e3336 --- /dev/null +++ b/src/plugins/data_explorer/public/ui_metric/report_ui_metric.ts @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { UiStatsMetricType } from 'packages/osd-analytics/target/types'; +import { METRIC_TYPE } from '../../../usage_collection/public'; +import { NEW_DISCOVER_APP_NAME } from './constants'; +import { getUsageCollector } from '../services'; + +export const trackUiMetric = ( + eventName: string, + appName: string = NEW_DISCOVER_APP_NAME, + metricType: UiStatsMetricType = METRIC_TYPE.COUNT +) => { + try { + const usageCollector = getUsageCollector(); + usageCollector.reportUiStats(appName, metricType, eventName); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } +}; diff --git a/src/plugins/discover/opensearch_dashboards.json b/src/plugins/discover/opensearch_dashboards.json index bcbfc2096731..c49528d2e227 100644 --- a/src/plugins/discover/opensearch_dashboards.json +++ b/src/plugins/discover/opensearch_dashboards.json @@ -13,7 +13,8 @@ "urlForwarding", "navigation", "uiActions", - "visualizations" + "visualizations", + "usageCollection" ], "optionalPlugins": ["home", "share"], "requiredBundles": [ diff --git a/src/plugins/discover/public/application/view_components/utils/use_search.ts b/src/plugins/discover/public/application/view_components/utils/use_search.ts index 88300cc570fa..34264540c5ec 100644 --- a/src/plugins/discover/public/application/view_components/utils/use_search.ts +++ b/src/plugins/discover/public/application/view_components/utils/use_search.ts @@ -8,7 +8,7 @@ import { BehaviorSubject, Subject, merge } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; import { i18n } from '@osd/i18n'; import { useEffect } from 'react'; -import { cloneDeep } from 'lodash'; +import { cloneDeep, isEqual } from 'lodash'; import { useLocation } from 'react-router-dom'; import { RequestAdapter } from '../../../../../inspector/public'; import { DiscoverViewServices } from '../../../build_services'; @@ -33,6 +33,7 @@ import { } from '../../../opensearch_dashboards_services'; import { SEARCH_ON_PAGE_LOAD_SETTING } from '../../../../common'; import { syncQueryStateWithUrl } from '../../../../../data/public'; +import { trackQueryMetric } from '../../../ui_metric'; export enum ResultStatus { UNINITIALIZED = 'uninitialized', @@ -184,6 +185,12 @@ export const useSearch = (services: DiscoverViewServices) => { inspectorRequest.json(body); }); + // Track the dataset type and language used + const query = searchSource.getField('query'); + if (query && query.dataset?.type && query.language) { + trackQueryMetric(query); + } + // Execute the search const fetchResp = await searchSource.fetch({ abortSignal: fetchStateRef.current.abortController.signal, diff --git a/src/plugins/discover/public/opensearch_dashboards_services.ts b/src/plugins/discover/public/opensearch_dashboards_services.ts index 85d8ba7976cc..25e948ec6d6f 100644 --- a/src/plugins/discover/public/opensearch_dashboards_services.ts +++ b/src/plugins/discover/public/opensearch_dashboards_services.ts @@ -37,6 +37,7 @@ import { createGetterSetter } from '../../opensearch_dashboards_utils/public'; import { search } from '../../data/public'; import { DocViewsRegistry } from './application/doc_views/doc_views_registry'; import { DocViewsLinksRegistry } from './application/doc_views_links/doc_views_links_registry'; +import { UsageCollectionSetup } from '../../usage_collection/public'; let services: DiscoverServices | null = null; let uiActions: UiActionsStart; @@ -67,6 +68,10 @@ export const [getDocViewsLinksRegistry, setDocViewsLinksRegistry] = createGetter DocViewsLinksRegistry >('DocViewsLinksRegistry'); +export const [getUsageCollector, setUsageCollector] = createGetterSetter( + 'UsageCollector' +); + /** * Makes sure discover and context are using one instance of history. */ diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index 3a083f05b805..2e1f76aea6ad 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -51,6 +51,7 @@ import { setScopedHistory, syncHistoryLocations, getServices, + setUsageCollector, } from './opensearch_dashboards_services'; import { createSavedSearchesLoader } from './saved_searches'; import { buildServices } from './build_services'; @@ -75,6 +76,7 @@ declare module '../../share/public' { [DISCOVER_APP_URL_GENERATOR]: UrlGeneratorState; } } +import { UsageCollectionSetup } from '../../usage_collection/public'; /** * @public @@ -128,6 +130,7 @@ export interface DiscoverSetupPlugins { visualizations: VisualizationsSetup; data: DataPublicPluginSetup; dataExplorer: DataExplorerPluginSetup; + usageCollection: UsageCollectionSetup; } /** @@ -174,6 +177,7 @@ export class DiscoverPlugin ); } + setUsageCollector(plugins.usageCollection); this.docViewsRegistry = new DocViewsRegistry(); setDocViewsRegistry(this.docViewsRegistry); this.docViewsRegistry.addDocView({ diff --git a/src/plugins/discover/public/ui_metric/constants.ts b/src/plugins/discover/public/ui_metric/constants.ts new file mode 100644 index 000000000000..e6d4fc20ae71 --- /dev/null +++ b/src/plugins/discover/public/ui_metric/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const DATASET_METRIC_SUFFIX = 'dataset_queries_count'; +export const LANGUAGE_METRIC_SUFFIX = 'language_queries_count'; +export const NEW_DISCOVER_APP_NAME = 'New_Discover'; diff --git a/src/plugins/discover/public/ui_metric/index.ts b/src/plugins/discover/public/ui_metric/index.ts new file mode 100644 index 000000000000..eceec571b67d --- /dev/null +++ b/src/plugins/discover/public/ui_metric/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './constants'; +export * from './report_ui_metric'; diff --git a/src/plugins/discover/public/ui_metric/report_ui_metric.ts b/src/plugins/discover/public/ui_metric/report_ui_metric.ts new file mode 100644 index 000000000000..9768de74d3df --- /dev/null +++ b/src/plugins/discover/public/ui_metric/report_ui_metric.ts @@ -0,0 +1,37 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { UiStatsMetricType } from 'packages/osd-analytics/target/types'; +import { METRIC_TYPE } from '../../../usage_collection/public'; +import { DATASET_METRIC_SUFFIX, LANGUAGE_METRIC_SUFFIX, NEW_DISCOVER_APP_NAME } from './constants'; +import { getUsageCollector } from '../opensearch_dashboards_services'; +import { Query } from '../../../data/public'; + +export const getDatasetTypeMetricEventName = (datasource: string) => { + return `${datasource}_${DATASET_METRIC_SUFFIX}`; +}; + +export const getLanguageMetricEventName = (language: string) => { + return `${language}_${LANGUAGE_METRIC_SUFFIX}`; +}; + +export const trackUiMetric = ( + eventName: string, + appName: string = NEW_DISCOVER_APP_NAME, + metricType: UiStatsMetricType = METRIC_TYPE.COUNT +) => { + try { + const usageCollector = getUsageCollector(); + usageCollector.reportUiStats(appName, metricType, eventName); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } +}; + +export const trackQueryMetric = (query: Query) => { + trackUiMetric(getDatasetTypeMetricEventName(query.dataset?.type!)); + trackUiMetric(getLanguageMetricEventName(query.language)); +}; From b6eb1a042244a1fcecb45ecd30f3dc9c8151da38 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Wed, 2 Oct 2024 09:08:51 +0800 Subject: [PATCH 07/31] [workspace]feat: validate workspace when find objects (#8268) * feat: validate workspace when find objects Signed-off-by: SuZhou-Joe * Changeset file for PR #8268 created/updated * fix: type error Signed-off-by: SuZhou-Joe * feat: add unit test Signed-off-by: SuZhou-Joe * feat: address some comments Signed-off-by: SuZhou-Joe * feat: optimize performance Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8268.yml | 2 + src/plugins/workspace/server/plugin.ts | 2 +- .../workspace/server/routes/duplicate.ts | 1 - .../workspace/server/routes/index.test.ts | 8 ++- src/plugins/workspace/server/routes/index.ts | 5 -- .../workspace_id_consumer_wrapper.test.ts | 8 +++ ...space_saved_objects_client_wrapper.test.ts | 23 +++---- .../workspace_id_consumer_wrapper.test.ts | 68 ++++++++++++++++++- .../workspace_id_consumer_wrapper.ts | 60 +++++++++++++--- src/plugins/workspace/server/types.ts | 1 - .../workspace/server/workspace_client.mock.ts | 20 +++--- 11 files changed, 154 insertions(+), 44 deletions(-) create mode 100644 changelogs/fragments/8268.yml diff --git a/changelogs/fragments/8268.yml b/changelogs/fragments/8268.yml new file mode 100644 index 000000000000..dc7bc64538a8 --- /dev/null +++ b/changelogs/fragments/8268.yml @@ -0,0 +1,2 @@ +feat: +- Validate if the workspaces param is valid or not when consume it in the wrapper. ([#8268](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8268)) \ No newline at end of file diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 7b0702b41f71..42ca77e922e4 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -200,7 +200,7 @@ export class WorkspacePlugin implements Plugin>; const mockDynamicConfigService = dynamicConfigServiceMock.createInternalStartContract(); @@ -19,15 +20,18 @@ const mockDynamicConfigService = dynamicConfigServiceMock.createInternalStartCon describe(`Workspace routes`, () => { let server: SetupServerReturn['server']; let httpSetup: SetupServerReturn['httpSetup']; + let mockedWorkspaceClient: IWorkspaceClientImpl; beforeEach(async () => { ({ server, httpSetup } = await setupServer()); const router = httpSetup.createRouter(''); + mockedWorkspaceClient = workspaceClientMock.create(); + registerRoutes({ router, - client: workspaceClientMock, + client: mockedWorkspaceClient, logger: loggingSystemMock.create().get(), maxImportExportSize: Number.MAX_SAFE_INTEGER, isPermissionControlEnabled: false, @@ -51,7 +55,7 @@ describe(`Workspace routes`, () => { }) .expect(200); expect(result.body).toEqual({ id: expect.any(String) }); - expect(workspaceClientMock.create).toHaveBeenCalledWith( + expect(mockedWorkspaceClient.create).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ name: 'Observability', diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index d7be4fb8b846..599bc1e82f59 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -141,7 +141,6 @@ export function registerRoutes({ const result = await client.list( { request: req, - logger, }, req.body ); @@ -180,7 +179,6 @@ export function registerRoutes({ const result = await client.get( { request: req, - logger, }, id ); @@ -225,7 +223,6 @@ export function registerRoutes({ const result = await client.create( { request: req, - logger, }, createPayload ); @@ -252,7 +249,6 @@ export function registerRoutes({ const result = await client.update( { request: req, - logger, }, id, { @@ -280,7 +276,6 @@ export function registerRoutes({ const result = await client.delete( { request: req, - logger, }, id ); diff --git a/src/plugins/workspace/server/saved_objects/integration_tests/workspace_id_consumer_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_id_consumer_wrapper.test.ts index 1586a7dfa9b2..c8212d9cc6b1 100644 --- a/src/plugins/workspace/server/saved_objects/integration_tests/workspace_id_consumer_wrapper.test.ts +++ b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_id_consumer_wrapper.test.ts @@ -277,6 +277,14 @@ describe('workspace_id_consumer integration test', () => { ); }); + it('should return error when find with a not existing workspace', async () => { + const findResult = await osdTestServer.request + .get(root, `/w/not_exist_workspace_id/api/saved_objects/_find?type=${dashboard.type}`) + .expect(400); + + expect(findResult.body.message).toEqual('Invalid workspaces'); + }); + it('import within workspace', async () => { await clearFooAndBar(); diff --git a/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts index b5ab4210f1ba..82c943545aca 100644 --- a/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts +++ b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts @@ -243,19 +243,14 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { describe('find', () => { it('should return empty result if user not permitted', async () => { - const result = await notPermittedSavedObjectedClient.find({ - type: 'dashboard', - workspaces: ['workspace-1'], - perPage: 999, - page: 1, - }); - - expect(result).toEqual({ - saved_objects: [], - total: 0, - page: 1, - per_page: 999, - }); + await expect( + notPermittedSavedObjectedClient.find({ + type: 'dashboard', + workspaces: ['workspace-1'], + perPage: 999, + page: 1, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Invalid workspaces]`); }); it('should return consistent inner workspace data when user permitted', async () => { @@ -758,7 +753,7 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { { id: deleteWorkspaceId, permissions: { - library_read: { users: ['foo'] }, + read: { users: ['foo'] }, library_write: { users: ['foo'] }, }, } diff --git a/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.test.ts index 2db8d146822f..570d701d7c63 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.test.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.test.ts @@ -7,10 +7,12 @@ import { updateWorkspaceState } from '../../../../core/server/utils'; import { SavedObject } from '../../../../core/public'; import { httpServerMock, savedObjectsClientMock, coreMock } from '../../../../core/server/mocks'; import { WorkspaceIdConsumerWrapper } from './workspace_id_consumer_wrapper'; +import { workspaceClientMock } from '../workspace_client.mock'; describe('WorkspaceIdConsumerWrapper', () => { const requestHandlerContext = coreMock.createRequestHandlerContext(); - const wrapperInstance = new WorkspaceIdConsumerWrapper(); + const mockedWorkspaceClient = workspaceClientMock.create(); + const wrapperInstance = new WorkspaceIdConsumerWrapper(mockedWorkspaceClient); const mockedClient = savedObjectsClientMock.create(); const workspaceEnabledMockRequest = httpServerMock.createOpenSearchDashboardsRequest(); updateWorkspaceState(workspaceEnabledMockRequest, { @@ -103,6 +105,29 @@ describe('WorkspaceIdConsumerWrapper', () => { describe('find', () => { beforeEach(() => { mockedClient.find.mockClear(); + mockedWorkspaceClient.get.mockImplementation((requestContext, id) => { + if (id === 'foo') { + return { + success: true, + }; + } + + return { + success: false, + }; + }); + mockedWorkspaceClient.list.mockResolvedValue({ + success: true, + result: { + workspaces: [ + { + id: 'foo', + }, + ], + }, + }); + mockedWorkspaceClient.get.mockClear(); + mockedWorkspaceClient.list.mockClear(); }); it(`Should add workspaces parameters when find`, async () => { @@ -113,10 +138,48 @@ describe('WorkspaceIdConsumerWrapper', () => { type: 'dashboard', workspaces: ['foo'], }); + expect(mockedWorkspaceClient.get).toBeCalledTimes(1); + expect(mockedWorkspaceClient.list).toBeCalledTimes(0); }); it(`Should pass a empty workspace array`, async () => { - const workspaceIdConsumerWrapper = new WorkspaceIdConsumerWrapper(); + const workspaceIdConsumerWrapper = new WorkspaceIdConsumerWrapper(mockedWorkspaceClient); + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + updateWorkspaceState(mockRequest, {}); + const mockedWrapperClient = workspaceIdConsumerWrapper.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: mockRequest, + }); + await mockedWrapperClient.find({ + type: ['dashboard', 'visualization'], + }); + expect(mockedClient.find).toBeCalledWith({ + type: ['dashboard', 'visualization'], + }); + }); + + it(`Should throw error when passing in invalid workspaces`, async () => { + const workspaceIdConsumerWrapper = new WorkspaceIdConsumerWrapper(mockedWorkspaceClient); + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + updateWorkspaceState(mockRequest, {}); + const mockedWrapperClient = workspaceIdConsumerWrapper.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: mockRequest, + }); + expect( + mockedWrapperClient.find({ + type: ['dashboard', 'visualization'], + workspaces: ['foo', 'not-exist'], + }) + ).rejects.toMatchInlineSnapshot(`[Error: Invalid workspaces]`); + expect(mockedWorkspaceClient.get).toBeCalledTimes(0); + expect(mockedWorkspaceClient.list).toBeCalledTimes(1); + }); + + it(`Should not throw error when passing in '*'`, async () => { + const workspaceIdConsumerWrapper = new WorkspaceIdConsumerWrapper(mockedWorkspaceClient); const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); updateWorkspaceState(mockRequest, {}); const mockedWrapperClient = workspaceIdConsumerWrapper.wrapperFactory({ @@ -126,6 +189,7 @@ describe('WorkspaceIdConsumerWrapper', () => { }); await mockedWrapperClient.find({ type: ['dashboard', 'visualization'], + workspaces: ['*'], }); expect(mockedClient.find).toBeCalledWith({ type: ['dashboard', 'visualization'], diff --git a/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.ts index 1871a3a9b9f1..90820c835d47 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.ts @@ -2,6 +2,7 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ +import { i18n } from '@osd/i18n'; import { getWorkspaceState } from '../../../../core/server/utils'; import { @@ -12,8 +13,9 @@ import { SavedObjectsCheckConflictsObject, OpenSearchDashboardsRequest, SavedObjectsFindOptions, - SavedObject, + SavedObjectsErrorHelpers, } from '../../../../core/server'; +import { IWorkspaceClientImpl } from '../types'; const UI_SETTINGS_SAVED_OBJECTS_TYPE = 'config'; @@ -74,15 +76,55 @@ export class WorkspaceIdConsumerWrapper { this.formatWorkspaceIdParams(wrapperOptions.request, options) ), delete: wrapperOptions.client.delete, - find: (options: SavedObjectsFindOptions) => { - return wrapperOptions.client.find( - // Based on https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/src/core/server/ui_settings/create_or_upgrade_saved_config/get_upgradeable_config.ts#L49 - // we need to make sure the find call for upgrade config should be able to find all the global configs as it was before. - // It is a workaround for 2.17, should be optimized in the upcoming 2.18 release. + find: async (options: SavedObjectsFindOptions) => { + // Based on https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/src/core/server/ui_settings/create_or_upgrade_saved_config/get_upgradeable_config.ts#L49 + // we need to make sure the find call for upgrade config should be able to find all the global configs as it was before. + // It is a workaround for 2.17, should be optimized in the upcoming 2.18 release. + const finalOptions = this.isConfigType(options.type as string) && options.sortField === 'buildNum' ? options - : this.formatWorkspaceIdParams(wrapperOptions.request, options) - ); + : this.formatWorkspaceIdParams(wrapperOptions.request, options); + if (finalOptions.workspaces?.length) { + let isAllTargetWorkspaceExisting = false; + // If only has one workspace, we should use get to optimize performance + if (finalOptions.workspaces.length === 1) { + const workspaceGet = await this.workspaceClient.get( + { request: wrapperOptions.request }, + finalOptions.workspaces[0] + ); + if (workspaceGet.success) { + isAllTargetWorkspaceExisting = true; + } + } else { + const workspaceList = await this.workspaceClient.list( + { + request: wrapperOptions.request, + }, + { + perPage: 9999, + } + ); + if (workspaceList.success) { + const workspaceIdsSet = new Set( + workspaceList.result.workspaces.map((workspace) => workspace.id) + ); + isAllTargetWorkspaceExisting = finalOptions.workspaces.every((targetWorkspace) => + workspaceIdsSet.has(targetWorkspace) + ); + } + } + + if (!isAllTargetWorkspaceExisting) { + throw SavedObjectsErrorHelpers.decorateBadRequestError( + new Error( + i18n.translate('workspace.id_consumer.invalid', { + defaultMessage: 'Invalid workspaces', + }) + ) + ); + } + } + return wrapperOptions.client.find(finalOptions); }, bulkGet: wrapperOptions.client.bulkGet, get: wrapperOptions.client.get, @@ -94,5 +136,5 @@ export class WorkspaceIdConsumerWrapper { }; }; - constructor() {} + constructor(private readonly workspaceClient: IWorkspaceClientImpl) {} } diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index ed056bedf4e3..d9b998cc3344 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -32,7 +32,6 @@ export interface WorkspaceFindOptions { export interface IRequestDetail { request: OpenSearchDashboardsRequest; - logger: Logger; } export interface IWorkspaceClientImpl { diff --git a/src/plugins/workspace/server/workspace_client.mock.ts b/src/plugins/workspace/server/workspace_client.mock.ts index b6dce61cedcf..c11a6245e792 100644 --- a/src/plugins/workspace/server/workspace_client.mock.ts +++ b/src/plugins/workspace/server/workspace_client.mock.ts @@ -4,13 +4,15 @@ */ export const workspaceClientMock = { - setup: jest.fn(), - setSavedObjects: jest.fn(), - setUiSettings: jest.fn(), - create: jest.fn().mockResolvedValue({ id: 'mock-workspace-id' }), - list: jest.fn(), - get: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - destroy: jest.fn(), + create: () => ({ + setup: jest.fn(), + setSavedObjects: jest.fn(), + setUiSettings: jest.fn(), + create: jest.fn().mockResolvedValue({ id: 'mock-workspace-id' }), + list: jest.fn(), + get: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + destroy: jest.fn(), + }), }; From 8dc3611298f3aebac7f3fe95aa0ac74c507f8332 Mon Sep 17 00:00:00 2001 From: Miki Date: Tue, 1 Oct 2024 18:53:12 -0700 Subject: [PATCH 08/31] Fix duplicate i18n identifier in savedObjectsManagement plugin (#8405) Signed-off-by: Miki --- .../components/__snapshots__/table.test.tsx.snap | 8 ++++---- .../objects_table/components/flyout.tsx | 2 +- .../management_section/objects_table/components/table.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap index 20c945c5ee4e..ac3e7baa5220 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap @@ -84,7 +84,7 @@ exports[`Table prevents saved objects from being deleted 1`] = ` checked={true} label={ { isLegacyFile={isLegacyFile} updateSelection={(newValues: ImportMode) => this.changeImportMode(newValues)} optionLabel={i18n.translate( - 'savedObjectsManagement.objectsTable.importModeControl.importOptionsTitle', + 'savedObjectsManagement.objectsTable.importModeControl.conflictManagementTitle', { defaultMessage: 'Conflict management' } )} useUpdatedUX={this.props.useUpdatedUX} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx index 5f7ee1235378..c71774244f59 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -487,7 +487,7 @@ export class Table extends PureComponent { label={ Date: Tue, 1 Oct 2024 18:53:35 -0700 Subject: [PATCH 09/31] Fix dynamic uses of i18n in indexPatternManagement plugin (#8398) * Fix dynamic uses of i18n in indexPatternManagement plugin Signed-off-by: Miki * Changeset file for PR #8398 created/updated --------- Signed-off-by: Miki Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8398.yml | 2 ++ .../public/components/breadcrumbs.ts | 10 +++++++--- .../index_pattern_table.tsx | 18 +++++++++--------- 3 files changed, 18 insertions(+), 12 deletions(-) create mode 100644 changelogs/fragments/8398.yml diff --git a/changelogs/fragments/8398.yml b/changelogs/fragments/8398.yml new file mode 100644 index 000000000000..649798050b3e --- /dev/null +++ b/changelogs/fragments/8398.yml @@ -0,0 +1,2 @@ +fix: +- Fix dynamic uses of i18n in indexPatternManagement plugin ([#8398](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8398)) \ No newline at end of file diff --git a/src/plugins/index_pattern_management/public/components/breadcrumbs.ts b/src/plugins/index_pattern_management/public/components/breadcrumbs.ts index 4811f41a4c0a..69034a221573 100644 --- a/src/plugins/index_pattern_management/public/components/breadcrumbs.ts +++ b/src/plugins/index_pattern_management/public/components/breadcrumbs.ts @@ -34,9 +34,13 @@ import { IndexPattern } from '../../../data/public'; export function getListBreadcrumbs(currentWorkspaceName?: string) { return [ { - text: i18n.translate('indexPatternManagement.indexPatterns.listBreadcrumb', { - defaultMessage: currentWorkspaceName ? 'Workspace index patterns' : 'Index patterns', - }), + text: currentWorkspaceName + ? i18n.translate('indexPatternManagement.inWorkspace.indexPatterns.listBreadcrumb', { + defaultMessage: 'Workspace index patterns', + }) + : i18n.translate('indexPatternManagement.indexPatterns.listBreadcrumb', { + defaultMessage: 'Index patterns', + }), href: `/`, }, ]; diff --git a/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx b/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx index eaffaf4feacf..6cda80c2e464 100644 --- a/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx +++ b/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx @@ -245,21 +245,21 @@ export const IndexPatternTable = ({ canSave, history }: Props) => { ); })(); - const description = i18n.translate( - 'indexPatternManagement.indexPatternTable.indexPatternExplanation', - currentWorkspace - ? { + const description = currentWorkspace + ? i18n.translate( + 'indexPatternManagement.indexPatternTable.indexPatternExplanationWithWorkspace', + { defaultMessage: 'Create and manage the index patterns that help you retrieve your data from OpenSearch for {name} workspace.', values: { name: currentWorkspace.name, }, } - : { - defaultMessage: - 'Create and manage the index patterns that help you retrieve your data from OpenSearch.', - } - ); + ) + : i18n.translate('indexPatternManagement.indexPatternTable.indexPatternExplanation', { + defaultMessage: + 'Create and manage the index patterns that help you retrieve your data from OpenSearch.', + }); const pageTitleAndDescription = useUpdatedUX ? ( Date: Wed, 2 Oct 2024 10:45:53 +0800 Subject: [PATCH 10/31] [navigation] feat: Render dev tools inside a modal (#7938) * feat: render the content inside a modal Signed-off-by: SuZhou-Joe * Changeset file for PR #7938 created/updated * feat: use memory router when opened in modal Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update buttons Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: optimize layout Signed-off-by: SuZhou-Joe * feat: optimize layout Signed-off-by: SuZhou-Joe * fix: vertical scrollbar issue Signed-off-by: SuZhou-Joe * feat: update test Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update order Signed-off-by: SuZhou-Joe * fix: update snapshot Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7938.yml | 2 + .../__snapshots__/top_nav_menu.test.tsx.snap | 76 ++++++++ .../components/top_nav_menu.test.tsx | 37 ++++ .../application/components/top_nav_menu.tsx | 61 ++++++- .../application/containers/editor/editor.tsx | 10 +- .../containers/main/get_top_nav.ts | 115 ------------ .../containers/main/get_top_nav.tsx | 166 ++++++++++++++++++ .../application/containers/main/main.tsx | 47 +++-- src/plugins/console/public/styles/_app.scss | 4 + src/plugins/dev_tools/public/application.tsx | 104 ++++++++--- .../dev_tools/public/dev_tools_icon.scss | 32 ++++ .../dev_tools/public/dev_tools_icon.test.tsx | 49 +++++- .../dev_tools/public/dev_tools_icon.tsx | 143 +++++++++++++-- src/plugins/dev_tools/public/plugin.ts | 5 + 14 files changed, 669 insertions(+), 182 deletions(-) create mode 100644 changelogs/fragments/7938.yml create mode 100644 src/plugins/console/public/application/components/__snapshots__/top_nav_menu.test.tsx.snap create mode 100644 src/plugins/console/public/application/components/top_nav_menu.test.tsx delete mode 100644 src/plugins/console/public/application/containers/main/get_top_nav.ts create mode 100644 src/plugins/console/public/application/containers/main/get_top_nav.tsx create mode 100644 src/plugins/dev_tools/public/dev_tools_icon.scss diff --git a/changelogs/fragments/7938.yml b/changelogs/fragments/7938.yml new file mode 100644 index 000000000000..12162f9a219b --- /dev/null +++ b/changelogs/fragments/7938.yml @@ -0,0 +1,2 @@ +feat: +- Change dev tools to a modal ([#7938](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7938)) \ No newline at end of file diff --git a/src/plugins/console/public/application/components/__snapshots__/top_nav_menu.test.tsx.snap b/src/plugins/console/public/application/components/__snapshots__/top_nav_menu.test.tsx.snap new file mode 100644 index 000000000000..e297409a5060 --- /dev/null +++ b/src/plugins/console/public/application/components/__snapshots__/top_nav_menu.test.tsx.snap @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TopNavMenu Component should render correctly when not use updatedUX 1`] = ` +
+
+ + +
+
+`; + +exports[`TopNavMenu Component should render correctly when use updatedUX 1`] = ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/src/plugins/console/public/application/components/top_nav_menu.test.tsx b/src/plugins/console/public/application/components/top_nav_menu.test.tsx new file mode 100644 index 000000000000..7cd1d761c82f --- /dev/null +++ b/src/plugins/console/public/application/components/top_nav_menu.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { MenuItemPosition, TopNavMenu, TopNavMenuItem } from './top_nav_menu'; +import { render } from '@testing-library/react'; + +describe('TopNavMenu Component', () => { + const mockedItems: TopNavMenuItem[] = [ + { + id: 'foo', + label: 'foo', + description: 'foo', + onClick: jest.fn(), + testId: 'foo', + position: MenuItemPosition.LEFT, + }, + { + id: 'bar', + label: 'bar', + description: 'bar', + onClick: jest.fn(), + testId: 'bar', + position: MenuItemPosition.RIGHT, + }, + ]; + it('should render correctly when not use updatedUX', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it('should render correctly when use updatedUX', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/console/public/application/components/top_nav_menu.tsx b/src/plugins/console/public/application/components/top_nav_menu.tsx index bd99098ec21a..90a3c34535ec 100644 --- a/src/plugins/console/public/application/components/top_nav_menu.tsx +++ b/src/plugins/console/public/application/components/top_nav_menu.tsx @@ -29,7 +29,18 @@ */ import React, { FunctionComponent } from 'react'; -import { EuiTabs, EuiTab } from '@elastic/eui'; +import { EuiTabs, EuiTab, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; + +interface CommonProps { + disabled: boolean; + onClick: () => void; + ['data-test-subj']: string; +} + +export enum MenuItemPosition { + LEFT = 'left', + RIGHT = 'right', +} export interface TopNavMenuItem { id: string; @@ -37,14 +48,60 @@ export interface TopNavMenuItem { description: string; onClick: () => void; testId: string; + render?: (commonProps: CommonProps) => React.JSX.Element; + position: MenuItemPosition; } interface Props { disabled?: boolean; items: TopNavMenuItem[]; + useUpdatedUX?: boolean; + rightContainerChildren?: React.ReactNode; } -export const TopNavMenu: FunctionComponent = ({ items, disabled }) => { +export const TopNavMenu: FunctionComponent = ({ + items, + disabled, + useUpdatedUX, + rightContainerChildren, +}) => { + if (useUpdatedUX) { + const leftMenus = items.filter((item) => item.position === MenuItemPosition.LEFT); + const rightMenus = items.filter((item) => item.position === MenuItemPosition.RIGHT); + const renderMenus = (item: TopNavMenuItem, idx: number) => { + const commonProps: CommonProps = { + disabled: !!disabled, + onClick: item.onClick, + ['data-test-subj']: item.testId, + }; + + return ( + + {item.render?.(commonProps) || null} + + ); + }; + return ( + <> + + + + + {leftMenus.map((item, index) => renderMenus(item, index))} + + + + + {rightContainerChildren} + {rightMenus.map((item, index) => renderMenus(item, index))} + + + + + + ); + } + return ( {items.map((item, idx) => { diff --git a/src/plugins/console/public/application/containers/editor/editor.tsx b/src/plugins/console/public/application/containers/editor/editor.tsx index 4d5723b123d4..5ed67118e419 100644 --- a/src/plugins/console/public/application/containers/editor/editor.tsx +++ b/src/plugins/console/public/application/containers/editor/editor.tsx @@ -49,9 +49,10 @@ const PANEL_MIN_WIDTH = '100px'; interface Props { loading: boolean; dataSourceId?: string; + useUpdatedUX?: boolean; } -export const Editor = memo(({ loading, dataSourceId }: Props) => { +export const Editor = memo(({ loading, dataSourceId, useUpdatedUX }: Props) => { const { services: { storage }, } = useServicesContext(); @@ -101,7 +102,12 @@ export const Editor = memo(({ loading, dataSourceId }: Props) => { )} {loading ? : } diff --git a/src/plugins/console/public/application/containers/main/get_top_nav.ts b/src/plugins/console/public/application/containers/main/get_top_nav.ts deleted file mode 100644 index cd21321993bb..000000000000 --- a/src/plugins/console/public/application/containers/main/get_top_nav.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Any modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { i18n } from '@osd/i18n'; - -interface Props { - onClickHistory: () => void; - onClickSettings: () => void; - onClickHelp: () => void; - onClickExport: () => void; - onClickImport: () => void; -} - -export function getTopNavConfig({ - onClickHistory, - onClickSettings, - onClickHelp, - onClickExport, - onClickImport, -}: Props) { - return [ - { - id: 'history', - label: i18n.translate('console.topNav.historyTabLabel', { - defaultMessage: 'History', - }), - description: i18n.translate('console.topNav.historyTabDescription', { - defaultMessage: 'History', - }), - onClick: () => { - onClickHistory(); - }, - testId: 'consoleHistoryButton', - }, - { - id: 'settings', - label: i18n.translate('console.topNav.settingsTabLabel', { - defaultMessage: 'Settings', - }), - description: i18n.translate('console.topNav.settingsTabDescription', { - defaultMessage: 'Settings', - }), - onClick: () => { - onClickSettings(); - }, - testId: 'consoleSettingsButton', - }, - { - id: 'help', - label: i18n.translate('console.topNav.helpTabLabel', { - defaultMessage: 'Help', - }), - description: i18n.translate('console.topNav.helpTabDescription', { - defaultMessage: 'Help', - }), - onClick: () => { - onClickHelp(); - }, - testId: 'consoleHelpButton', - }, - { - id: 'export', - label: i18n.translate('console.topNav.exportTabLabel', { - defaultMessage: 'Export', - }), - description: i18n.translate('console.topNav.exportTabDescription', { - defaultMessage: 'Export', - }), - onClick: () => { - onClickExport(); - }, - testId: 'consoleExportButton', - }, - { - id: 'import', - label: i18n.translate('console.topNav.importTabLabel', { - defaultMessage: 'Import', - }), - description: i18n.translate('console.topNav.importTabDescription', { - defaultMessage: 'Import', - }), - onClick: () => { - onClickImport(); - }, - testId: 'consoleImportButton', - }, - ]; -} diff --git a/src/plugins/console/public/application/containers/main/get_top_nav.tsx b/src/plugins/console/public/application/containers/main/get_top_nav.tsx new file mode 100644 index 000000000000..d9da29f1de3c --- /dev/null +++ b/src/plugins/console/public/application/containers/main/get_top_nav.tsx @@ -0,0 +1,166 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { EuiSmallButton, EuiSmallButtonEmpty, EuiSmallButtonIcon, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { TopNavMenuItem } from '../../components'; +import { MenuItemPosition } from '../../components/top_nav_menu'; + +interface Props { + onClickHistory: () => void; + onClickSettings: () => void; + onClickHelp: () => void; + onClickExport: () => void; + onClickImport: () => void; + useUpdatedUX?: boolean; +} + +export function getTopNavConfig({ + onClickHistory, + onClickSettings, + onClickHelp, + onClickExport, + onClickImport, + useUpdatedUX, +}: Props): TopNavMenuItem[] { + const helpItem: TopNavMenuItem = { + id: 'help', + label: i18n.translate('console.topNav.helpTabLabel', { + defaultMessage: 'Help', + }), + description: i18n.translate('console.topNav.helpTabDescription', { + defaultMessage: 'Help', + }), + onClick: () => { + onClickHelp(); + }, + testId: 'consoleHelpButton', + render: (commonProps) => ( + + + + ), + position: MenuItemPosition.RIGHT, + }; + const settingsItem = { + id: 'settings', + label: i18n.translate('console.topNav.settingsTabLabel', { + defaultMessage: 'Settings', + }), + description: i18n.translate('console.topNav.settingsTabDescription', { + defaultMessage: 'Settings', + }), + onClick: () => { + onClickSettings(); + }, + testId: 'consoleSettingsButton', + render: (commonProps) => ( + + + + ), + position: MenuItemPosition.RIGHT, + }; + return [ + { + id: 'history', + label: i18n.translate('console.topNav.historyTabLabel', { + defaultMessage: 'History', + }), + description: i18n.translate('console.topNav.historyTabDescription', { + defaultMessage: 'History', + }), + onClick: () => { + onClickHistory(); + }, + testId: 'consoleHistoryButton', + render: (commonProps) => ( + + {i18n.translate('console.topNav.historyTabLabel', { + defaultMessage: 'History', + })} + + ), + position: MenuItemPosition.LEFT, + }, + ...(useUpdatedUX ? [helpItem, settingsItem] : [settingsItem, helpItem]), + { + id: 'export', + label: i18n.translate('console.topNav.exportTabLabel', { + defaultMessage: 'Export', + }), + description: i18n.translate('console.topNav.exportTabDescription', { + defaultMessage: 'Export', + }), + onClick: () => { + onClickExport(); + }, + testId: 'consoleExportButton', + render: (commonProps) => ( + + {i18n.translate('console.topNav.exportTabLabel', { + defaultMessage: 'Export', + })} + + ), + position: MenuItemPosition.RIGHT, + }, + { + id: 'import', + label: i18n.translate('console.topNav.importTabLabel', { + defaultMessage: 'Import', + }), + description: i18n.translate('console.topNav.importTabDescription', { + defaultMessage: 'Import', + }), + onClick: () => { + onClickImport(); + }, + testId: 'consoleImportButton', + render: (commonProps) => ( + + {i18n.translate('console.topNav.importButtonLabel', { + defaultMessage: 'Import query', + })} + + ), + position: MenuItemPosition.RIGHT, + }, + ]; +} diff --git a/src/plugins/console/public/application/containers/main/main.tsx b/src/plugins/console/public/application/containers/main/main.tsx index bbe5bd9856eb..071229495294 100644 --- a/src/plugins/console/public/application/containers/main/main.tsx +++ b/src/plugins/console/public/application/containers/main/main.tsx @@ -57,7 +57,7 @@ interface MainProps { export function Main({ dataSourceId }: MainProps) { const { - services: { storage, objectStorageClient }, + services: { storage, objectStorageClient, uiSettings }, } = useServicesContext(); const { ready: editorsReady } = useEditorReadContext(); @@ -98,10 +98,31 @@ export function Main({ dataSourceId }: MainProps) { const lastDatum = requestData?.[requestData.length - 1] ?? requestError; + const useUpdatedUX = uiSettings.get('home:useNewHomePage'); + + const networkRequestStatusBarContent = ( + + + + ); + return (
setShowHistory(!showingHistory), onClickSettings: () => setShowSettings(true), onClickHelp: () => setShowHelp(!showHelp), onClickExport: () => onExport(), onClickImport: () => setShowImportFlyout(!showImportFlyout), })} + rightContainerChildren={networkRequestStatusBarContent} /> - - - + {useUpdatedUX ? null : networkRequestStatusBarContent} {showingHistory ? {renderConsoleHistory()} : null} - + diff --git a/src/plugins/console/public/styles/_app.scss b/src/plugins/console/public/styles/_app.scss index 161d1f200afd..706a4b8cf025 100644 --- a/src/plugins/console/public/styles/_app.scss +++ b/src/plugins/console/public/styles/_app.scss @@ -11,6 +11,10 @@ .consoleContainer { padding: $euiSizeS; + + &.useUpdatedUX-true { + padding: $euiSizeS 0; + } } .conApp { diff --git a/src/plugins/dev_tools/public/application.tsx b/src/plugins/dev_tools/public/application.tsx index 38d444665852..e57f11bee617 100644 --- a/src/plugins/dev_tools/public/application.tsx +++ b/src/plugins/dev_tools/public/application.tsx @@ -39,6 +39,7 @@ import { ApplicationStart, ChromeStart, CoreStart, + MountPoint, NotificationsStart, SavedObjectsStart, ScopedHistory, @@ -56,6 +57,8 @@ interface DevToolsWrapperProps { notifications: NotificationsStart; dataSourceEnabled: boolean; dataSourceManagement?: DataSourceManagementPluginSetup; + useUpdatedUX?: boolean; + setMenuMountPoint?: (menuMount: MountPoint | undefined) => void; } interface MountedDevToolDescriptor { @@ -69,10 +72,13 @@ function DevToolsWrapper({ activeDevTool, updateRoute, savedObjects, - notifications: { toasts }, + notifications, dataSourceEnabled, dataSourceManagement, + useUpdatedUX, + setMenuMountPoint, }: DevToolsWrapperProps) { + const { toasts } = notifications; const mountedTool = useRef(null); const [isLoading, setIsLoading] = React.useState(true); @@ -115,6 +121,21 @@ function DevToolsWrapper({ }; const renderDataSourceSelector = () => { + if (useUpdatedUX) { + const DataSourceMenu = dataSourceManagement!.ui.getDataSourceMenu(); + return ( + + ); + } const DataSourceSelector = dataSourceManagement!.ui.DataSourceSelector; return (
@@ -218,26 +239,33 @@ function setBreadcrumbs(chrome: ChromeStart) { ]); } -export function renderApp( - { application, chrome, docLinks, savedObjects, notifications }: CoreStart, - element: HTMLElement, - history: ScopedHistory, - devTools: readonly DevToolApp[], - { dataSourceManagement, dataSource }: DevToolsSetupDependencies +export function MainApp( + props: { + devTools: readonly DevToolApp[]; + RouterComponent?: React.ComponentClass; + } & Pick< + DevToolsWrapperProps, + | 'savedObjects' + | 'notifications' + | 'dataSourceEnabled' + | 'dataSourceManagement' + | 'useUpdatedUX' + | 'setMenuMountPoint' + > ) { - const dataSourceEnabled = !!dataSource; - if (redirectOnMissingCapabilities(application)) { - return () => {}; - } - - addHelpMenuToAppChrome(chrome, docLinks); - setBadge(application, chrome); - setBreadcrumbs(chrome); - setTitle(chrome); - - ReactDOM.render( + const { + devTools, + savedObjects, + notifications, + dataSourceEnabled, + dataSourceManagement, + useUpdatedUX, + setMenuMountPoint, + RouterComponent = Router, + } = props; + return ( - + {devTools // Only create routes for devtools that are not disabled @@ -247,15 +275,17 @@ export function renderApp( key={devTool.id} path={`/${devTool.id}`} exact={!devTool.enableRouting} - render={(props) => ( + render={(routeProps) => ( )} /> @@ -264,8 +294,36 @@ export function renderApp( - - , + + + ); +} + +export function renderApp( + { application, chrome, docLinks, savedObjects, notifications }: CoreStart, + element: HTMLElement, + history: ScopedHistory, + devTools: readonly DevToolApp[], + { dataSourceManagement, dataSource }: DevToolsSetupDependencies +) { + const dataSourceEnabled = !!dataSource; + if (redirectOnMissingCapabilities(application)) { + return () => {}; + } + + addHelpMenuToAppChrome(chrome, docLinks); + setBadge(application, chrome); + setBreadcrumbs(chrome); + setTitle(chrome); + + ReactDOM.render( + , element ); diff --git a/src/plugins/dev_tools/public/dev_tools_icon.scss b/src/plugins/dev_tools/public/dev_tools_icon.scss new file mode 100644 index 000000000000..69e6e524a86d --- /dev/null +++ b/src/plugins/dev_tools/public/dev_tools_icon.scss @@ -0,0 +1,32 @@ +.devToolsOverlayMask { + padding-bottom: 0; + + .devApp__container { + overflow-y: auto; + flex-shrink: 1; + } + + // Make the parents of devApp__container as overflow hidden + // so that devApp__container can be scrollable + .devAppWrapper, + .devApp { + overflow: hidden; + } + + .devToolsCloseButton { + align-self: flex-end; + } +} + +.devToolsModalContent { + padding-left: $euiSize; + padding-right: $euiSize; + margin-top: 0; +} + +// When there are multiple maskOverlays in the page and once a focused overlay get unmounted +// it will remove the hasOverlay class in the root element, making the whole page scrollable. +// This class is used for keep the root element unscrollable when the dev tools model is mount. +.noScrollByDevTools { + overflow: hidden; +} diff --git a/src/plugins/dev_tools/public/dev_tools_icon.test.tsx b/src/plugins/dev_tools/public/dev_tools_icon.test.tsx index 3a3eef74b11f..b465ec3c8b07 100644 --- a/src/plugins/dev_tools/public/dev_tools_icon.test.tsx +++ b/src/plugins/dev_tools/public/dev_tools_icon.test.tsx @@ -7,13 +7,52 @@ import React from 'react'; import { fireEvent, render } from '@testing-library/react'; import { DevToolsIcon } from './dev_tools_icon'; import { coreMock } from '../../../core/public/mocks'; +import { urlForwardingPluginMock } from 'src/plugins/url_forwarding/public/mocks'; + +const createDepsMock = () => { + return { + urlForwarding: urlForwardingPluginMock.createSetupContract(), + }; +}; + +jest.mock('./application', () => ({ + MainApp: () =>
, +})); describe('', () => { - it('should call chrome.navGroup.setCurrentNavGroup and application.navigateToApp methods from core service when click', () => { + it('should call chrome.navGroup.setCurrentNavGroup and application.navigateToApp methods from core service when click', async () => { const coreStartMock = coreMock.createStart(); - const { container } = render(); - const component = container.children[0].children[0]; - fireEvent.click(component); - expect(coreStartMock.application.navigateToApp).toBeCalledWith('foo'); + const { container, getByTestId, findByText } = render( + + ); + expect(container).toMatchInlineSnapshot(` +
+ + + +
+ `); + + fireEvent.click(getByTestId('openDevToolsModal')); + await findByText('Dev tools title'); }); }); diff --git a/src/plugins/dev_tools/public/dev_tools_icon.tsx b/src/plugins/dev_tools/public/dev_tools_icon.tsx index d198cd2b0638..57593cdbd517 100644 --- a/src/plugins/dev_tools/public/dev_tools_icon.tsx +++ b/src/plugins/dev_tools/public/dev_tools_icon.tsx @@ -3,25 +3,136 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import React, { useRef, useCallback, useState, useEffect } from 'react'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiOverlayMask, + EuiPanel, + EuiSmallButton, + EuiSpacer, + EuiText, + EuiToolTip, +} from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { CoreStart } from 'opensearch-dashboards/public'; +import { MemoryRouter } from 'react-router-dom'; +import { MainApp } from './application'; +import { DevToolApp } from './dev_tool'; +import { DevToolsSetupDependencies } from './plugin'; +import './dev_tools_icon.scss'; + +export function DevToolsIcon({ + core, + devTools, + deps, + title, +}: { + core: CoreStart; + devTools: readonly DevToolApp[]; + deps: DevToolsSetupDependencies; + title: string; +}) { + const [modalVisible, setModalVisible] = useState(false); + const elementRef = useRef(null); + const setMountPoint = useCallback((renderFn) => { + renderFn(elementRef.current); + return () => {}; + }, []); + + useEffect(() => { + if (modalVisible) { + document.body.classList.add('noScrollByDevTools'); + } else { + document.body.classList.remove('noScrollByDevTools'); + } + + return () => { + document.body.classList.remove('noScrollByDevTools'); + }; + }, [modalVisible]); -export function DevToolsIcon({ core, appId }: { core: CoreStart; appId: string }) { return ( - - { - core.application.navigateToApp(appId); - }} - /> - + <> + + { + setModalVisible(true); + }} + /> + + {modalVisible ? ( + /** + * We can not use OuiModal component here because OuiModal uses OuiOverlayMask as its parent node + * but overlay mask has a default padding bottom that prevent the modal from covering the whole page. + */ + +
+ setModalVisible(false)} + className="euiModal__closeIcon" + color="text" + aria-label="close modal" + /> + + + + + + + +

{title}

+
+
+ +
(elementRef.current = element)} /> + + + + + + + setModalVisible(false)} + className="devToolsCloseButton" + minWidth="unset" + > + {i18n.translate('dev_tools.modal.close.label', { + defaultMessage: 'Close', + })} + + + + +
+ + ) : null} + ); } diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index 34bc74c8d518..e6478807da7a 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -75,6 +75,7 @@ export interface DevToolsSetup { export class DevToolsPlugin implements Plugin { private readonly devTools = new Map(); private appStateUpdater = new BehaviorSubject(() => ({})); + private setupDeps: DevToolsSetupDependencies | undefined; private getSortedDevTools(): readonly DevToolApp[] { return sortBy([...this.devTools.values()], 'order'); @@ -89,6 +90,7 @@ export class DevToolsPlugin implements Plugin { public setup(coreSetup: CoreSetup, deps: DevToolsSetupDependencies) { const { application: applicationSetup, getStartServices } = coreSetup; const { urlForwarding, managementOverview } = deps; + this.setupDeps = deps; applicationSetup.register({ id: this.id, @@ -145,6 +147,9 @@ export class DevToolsPlugin implements Plugin { React.createElement(DevToolsIcon, { core, appId: this.id, + devTools: this.getSortedDevTools(), + deps: this.setupDeps as DevToolsSetupDependencies, + title: this.title, }) ), }); From 774909e3eba292de73a2ea29529790b69ce67b8d Mon Sep 17 00:00:00 2001 From: Miki Date: Tue, 1 Oct 2024 20:10:58 -0700 Subject: [PATCH 11/31] Fix flaky IndexPatternSelect test in data plugin (#8429) Signed-off-by: Miki --- .../index_pattern_select.test.tsx | 127 ++++++++++++++---- 1 file changed, 103 insertions(+), 24 deletions(-) diff --git a/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.test.tsx b/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.test.tsx index dcf008fcf2d7..3b87700e664e 100644 --- a/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.test.tsx +++ b/src/plugins/data/public/ui/index_pattern_select/index_pattern_select.test.tsx @@ -3,54 +3,133 @@ * SPDX-License-Identifier: Apache-2.0 */ import { shallow } from 'enzyme'; -import { SavedObjectsClientContract } from '../../../../../core/public'; import React from 'react'; import IndexPatternSelect from './index_pattern_select'; +import { savedObjectsServiceMock } from '../../../../../core/public/mocks'; describe('IndexPatternSelect', () => { - let client: SavedObjectsClientContract; - const bulkGetMock = jest.fn(); + const savedObjectsClient = savedObjectsServiceMock.createStartContract().client; + const onChangeMock = jest.fn(); - const nextTick = () => new Promise((res) => process.nextTick(res)); + jest.useFakeTimers(); beforeEach(() => { - client = { - find: jest.fn().mockResolvedValue({ + onChangeMock.mockReset(); + + jest.spyOn(savedObjectsClient, 'get').mockReturnValue( + // @ts-ignore + Promise.resolve({ + id: '3', + type: 'data-source', + references: [{ id: 'testDataSourceId3', type: 'data-source' }], + attributes: { title: 'testTitle3' }, + }) + ); + + jest.spyOn(savedObjectsClient, 'bulkGet').mockReturnValue( + // @ts-ignore + Promise.resolve({ savedObjects: [ { - references: [{ id: 'testDataSourceId', type: 'data-source' }], + id: '4', + type: 'data-source', + attributes: { title: 'testTitle4' }, + }, + ], + }) + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should use the data-source IDs to make a bulkGet call', async () => { + jest.spyOn(savedObjectsClient, 'find').mockReturnValue( + // @ts-ignore + Promise.resolve({ + total: 2, + perPage: 10, + page: 1, + savedObjects: [ + { + id: '1', + type: 'data-source', + references: [{ id: 'testDataSourceId1', type: 'data-source' }], attributes: { title: 'testTitle1' }, }, { - references: [{ id: 'testDataSourceId', type: 'data-source' }], + id: '2', + type: 'data-source', + references: [{ id: 'testDataSourceId2', type: 'data-source' }], attributes: { title: 'testTitle2' }, }, ], - }), - bulkGet: bulkGetMock, - get: jest.fn().mockResolvedValue({ - references: [{ id: 'someId', type: 'data-source' }], - attributes: { title: 'testTitle' }, - }), - } as any; + }) + ); + + const compInstance = shallow( + + ).instance(); + + const call = compInstance.debouncedFetch(''); + jest.advanceTimersByTime(10000); + await call; + await compInstance.debouncedFetch.flush(); + + expect(savedObjectsClient.bulkGet).toBeCalledWith([ + { id: 'testDataSourceId1', type: 'data-source' }, + { id: 'testDataSourceId2', type: 'data-source' }, + ]); }); - it('should render index pattern select', async () => { - const onChangeMock = jest.fn(); - const compInstance = shallow( + it('should combine saved-objects with common data-source IDs when making a bulkGet call', async () => { + jest.spyOn(savedObjectsClient, 'find').mockReturnValue( + // @ts-ignore + Promise.resolve({ + total: 2, + perPage: 10, + page: 1, + savedObjects: [ + { + id: '1', + type: 'data-source', + references: [{ id: 'testDataSourceId0', type: 'data-source' }], + attributes: { title: 'testTitle1' }, + }, + { + id: '2', + type: 'data-source', + references: [{ id: 'testDataSourceId0', type: 'data-source' }], + attributes: { title: 'testTitle2' }, + }, + ], + }) + ); + + const compInstance = shallow( ).instance(); - bulkGetMock.mockResolvedValue({ savedObjects: [{ attributes: { title: 'test1' } }] }); - compInstance.debouncedFetch(''); - await new Promise((resolve) => setTimeout(resolve, 600)); - await nextTick(); - expect(bulkGetMock).toBeCalledWith([{ id: 'testDataSourceId', type: 'data-source' }]); + const call = compInstance.debouncedFetch(''); + jest.advanceTimersByTime(10000); + await call; + await compInstance.debouncedFetch.flush(); + + expect(savedObjectsClient.bulkGet).toBeCalledWith([ + { id: 'testDataSourceId0', type: 'data-source' }, + ]); }); }); From 96d436edc57c4ef80ce9b7c86a7c1a4c48c79bbf Mon Sep 17 00:00:00 2001 From: Miki Date: Tue, 1 Oct 2024 21:19:16 -0700 Subject: [PATCH 12/31] Fix dynamic uses of i18n in home plugin (#8403) * Fix dynamic uses of i18n in home plugin Signed-off-by: Miki * Changeset file for PR #8403 created/updated --------- Signed-off-by: Miki Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8403.yml | 2 ++ src/plugins/home/server/ui_settings.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 changelogs/fragments/8403.yml diff --git a/changelogs/fragments/8403.yml b/changelogs/fragments/8403.yml new file mode 100644 index 000000000000..34d00d2e2651 --- /dev/null +++ b/changelogs/fragments/8403.yml @@ -0,0 +1,2 @@ +fix: +- Fix dynamic uses of i18n in home plugin ([#8403](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8403)) \ No newline at end of file diff --git a/src/plugins/home/server/ui_settings.ts b/src/plugins/home/server/ui_settings.ts index 7df590ba5480..ff0fb0ab1420 100644 --- a/src/plugins/home/server/ui_settings.ts +++ b/src/plugins/home/server/ui_settings.ts @@ -17,11 +17,11 @@ import { USE_NEW_HOME_PAGE } from '../common/constants'; export const uiSettings: Record = { [USE_NEW_HOME_PAGE]: { - name: i18n.translate('core.ui_settings.params.useNewHomePage', { + name: i18n.translate('home.ui_settings.useNewHomePage.label', { defaultMessage: 'Use New Home Page', }), value: false, - description: i18n.translate('core.ui_settings.params.useNewHomePage', { + description: i18n.translate('home.ui_settings.useNewHomePage.description', { defaultMessage: 'Try the new home page', }), schema: schema.boolean(), From cec8a1e9cfc3adaee8917d652a31852b9b30ae51 Mon Sep 17 00:00:00 2001 From: Miki Date: Tue, 1 Oct 2024 21:19:27 -0700 Subject: [PATCH 13/31] Fix unprefixed i18n identifiers in management plugin (#8408) * Fix unprefixed i18n identifiers in management plugin Signed-off-by: Miki * Changeset file for PR #8408 created/updated --------- Signed-off-by: Miki Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8408.yml | 2 ++ src/plugins/management/public/components/settings_icon.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 changelogs/fragments/8408.yml diff --git a/changelogs/fragments/8408.yml b/changelogs/fragments/8408.yml new file mode 100644 index 000000000000..3fac7577a5db --- /dev/null +++ b/changelogs/fragments/8408.yml @@ -0,0 +1,2 @@ +fix: +- Fix unprefixed i18n identifiers in management plugin ([#8408](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8408)) \ No newline at end of file diff --git a/src/plugins/management/public/components/settings_icon.tsx b/src/plugins/management/public/components/settings_icon.tsx index aef9bc28b52a..ecae0394d963 100644 --- a/src/plugins/management/public/components/settings_icon.tsx +++ b/src/plugins/management/public/components/settings_icon.tsx @@ -53,7 +53,7 @@ export function SettingsIcon({ core }: { core: CoreStart }) { id="popoverForSettingsIcon" button={ From fd31398a41de3f801316a12c9bd1629eea314dde Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Tue, 1 Oct 2024 21:54:32 -0700 Subject: [PATCH 14/31] [Discover] Support data connections and multi-select table in dataset picker (#8255) * initial implementation of cloud watch data set type Signed-off-by: Joshua Li * wip-- support selecting multiple datasets Signed-off-by: Joshua Li * Revert "wip-- support selecting multiple datasets" This reverts commit 4977cc96573bee9c5bff30601bf1f69bd74d2e0c. Signed-off-by: Joshua Li * add multi-select dataset table Signed-off-by: Joshua Li * add token based pagination in dataset table Signed-off-by: Joshua Li * display data connection in dataset title Signed-off-by: Joshua Li * avoid overlap of dataset title and language selector Signed-off-by: Joshua Li * Revert "avoid overlap of dataset title and language selector" This reverts commit 87d599db892ccbe501322e231da81c67bd6a5fa0. Wait for https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8204 Signed-off-by: Joshua Li * revert query enhancement changes for cloudwatch Signed-off-by: Joshua Li * Changeset file for PR #8255 created/updated * Revert "revert query enhancement changes for cloudwatch" This reverts commit 5eb065a5623935e40a3da93e748355b0f1144f50. Signed-off-by: Joshua Li * address comments Signed-off-by: Joshua Li * revert query enhancement changes for cloudwatch Signed-off-by: Joshua Li * address comments Signed-off-by: Joshua Li --------- Signed-off-by: Joshua Li Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8255.yml | 2 + src/plugins/data/common/datasets/types.ts | 2 + .../dataset_service/dataset_service.ts | 16 ++- .../query_string/dataset_service/types.ts | 21 ++- .../data/public/query/query_string/index.ts | 7 +- .../dataset_selector/_dataset_explorer.scss | 12 ++ .../public/ui/dataset_selector/_index.scss | 5 + .../ui/dataset_selector/dataset_explorer.tsx | 101 ++++++++------ .../dataset_selector/dataset_table.test.tsx | 128 ++++++++++++++++++ .../ui/dataset_selector/dataset_table.tsx | 101 ++++++++++++++ .../data_source/opensearch_dashboards.json | 2 +- 11 files changed, 348 insertions(+), 49 deletions(-) create mode 100644 changelogs/fragments/8255.yml create mode 100644 src/plugins/data/public/ui/dataset_selector/dataset_table.test.tsx create mode 100644 src/plugins/data/public/ui/dataset_selector/dataset_table.tsx diff --git a/changelogs/fragments/8255.yml b/changelogs/fragments/8255.yml new file mode 100644 index 000000000000..3f2d5361e70e --- /dev/null +++ b/changelogs/fragments/8255.yml @@ -0,0 +1,2 @@ +feat: +- Support data connections and multi-select table in dataset picker ([#8255](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8255)) \ No newline at end of file diff --git a/src/plugins/data/common/datasets/types.ts b/src/plugins/data/common/datasets/types.ts index b9dbcdc3173b..58a5dc65932d 100644 --- a/src/plugins/data/common/datasets/types.ts +++ b/src/plugins/data/common/datasets/types.ts @@ -120,6 +120,8 @@ export interface DataStructure { /** Optional array of child data structures */ children?: DataStructure[]; hasNext?: boolean; + paginationToken?: string; + multiSelect?: boolean; columnHeader?: string; /** Optional metadata for the data structure */ meta?: DataStructureMeta; diff --git a/src/plugins/data/public/query/query_string/dataset_service/dataset_service.ts b/src/plugins/data/public/query/query_string/dataset_service/dataset_service.ts index 36faead38308..5f10b3d67193 100644 --- a/src/plugins/data/public/query/query_string/dataset_service/dataset_service.ts +++ b/src/plugins/data/public/query/query_string/dataset_service/dataset_service.ts @@ -14,7 +14,7 @@ import { DataStorage, CachedDataStructure, } from '../../../../common'; -import { DatasetTypeConfig } from './types'; +import { DatasetTypeConfig, DataStructureFetchOptions } from './types'; import { indexPatternTypeConfig, indexTypeConfig } from './lib'; import { IndexPatternsContract } from '../../../index_patterns'; import { IDataPluginServices } from '../../../types'; @@ -91,7 +91,8 @@ export class DatasetService { public async fetchOptions( services: IDataPluginServices, path: DataStructure[], - dataType: string + dataType: string, + options?: DataStructureFetchOptions ): Promise { const type = this.typesRegistry.get(dataType); if (!type) { @@ -99,14 +100,19 @@ export class DatasetService { } const lastPathItem = path[path.length - 1]; - const cacheKey = `${dataType}.${lastPathItem.id}`; + const fetchOptionsKey = Object.entries(options || {}) + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + .map(([key, value]) => `${key}=${value}`) + .join('&'); + const cacheKey = + `${dataType}.${lastPathItem.id}` + (fetchOptionsKey.length ? `?${fetchOptionsKey}` : ''); const cachedDataStructure = this.sessionStorage.get(cacheKey); if (cachedDataStructure?.children?.length > 0) { return this.cacheToDataStructure(dataType, cachedDataStructure); } - const fetchedDataStructure = await type.fetch(services, path); + const fetchedDataStructure = await type.fetch(services, path, options); this.cacheDataStructure(dataType, fetchedDataStructure); return fetchedDataStructure; } @@ -146,6 +152,8 @@ export class DatasetService { parent: dataStructure.parent?.id || '', children: dataStructure.children?.map((child) => child.id) || [], hasNext: dataStructure.hasNext, + paginationToken: dataStructure.paginationToken, + multiSelect: dataStructure.multiSelect, columnHeader: dataStructure.columnHeader, meta: dataStructure.meta, }; diff --git a/src/plugins/data/public/query/query_string/dataset_service/types.ts b/src/plugins/data/public/query/query_string/dataset_service/types.ts index c68aed8b6459..36a6cc3eb598 100644 --- a/src/plugins/data/public/query/query_string/dataset_service/types.ts +++ b/src/plugins/data/public/query/query_string/dataset_service/types.ts @@ -6,6 +6,16 @@ import { EuiIconProps } from '@elastic/eui'; import { Dataset, DatasetField, DatasetSearchOptions, DataStructure } from '../../../../common'; import { IDataPluginServices } from '../../../types'; +/** + * Options for fetching the data structure. + */ +export interface DataStructureFetchOptions { + /** Search string to filter results */ + search?: string; + /** Token for paginated results */ + paginationToken?: string; +} + /** * Configuration for handling dataset operations. */ @@ -28,12 +38,17 @@ export interface DatasetTypeConfig { */ toDataset: (path: DataStructure[]) => Dataset; /** - * Fetches child options for a given DataStructure. + * Fetches child data structures and populates corresponding properties for a given DataStructure. * @param {IDataPluginServices} services - The data plugin services. * @param {DataStructure} dataStructure - The parent DataStructure. - * @returns {Promise} A promise that resolves to a DatasetHandlerFetchResponse. + * @param {DataStructureFetchOptions} options - The fetch options for pagination and search. + * @returns {Promise} A promise that resolves to the updated DataStructure. */ - fetch: (services: IDataPluginServices, path: DataStructure[]) => Promise; + fetch: ( + services: IDataPluginServices, + path: DataStructure[], + options?: DataStructureFetchOptions + ) => Promise; /** * Fetches fields for the dataset. * @returns {Promise} A promise that resolves to an array of DatasetFields. diff --git a/src/plugins/data/public/query/query_string/index.ts b/src/plugins/data/public/query/query_string/index.ts index a004103e971b..0a1ad963ef9e 100644 --- a/src/plugins/data/public/query/query_string/index.ts +++ b/src/plugins/data/public/query/query_string/index.ts @@ -29,7 +29,12 @@ */ export { QueryStringContract, QueryStringManager } from './query_string_manager'; -export { DatasetServiceContract, DatasetService, DatasetTypeConfig } from './dataset_service'; +export { + DataStructureFetchOptions, + DatasetService, + DatasetServiceContract, + DatasetTypeConfig, +} from './dataset_service'; export { LanguageServiceContract, LanguageService, diff --git a/src/plugins/data/public/ui/dataset_selector/_dataset_explorer.scss b/src/plugins/data/public/ui/dataset_selector/_dataset_explorer.scss index 704a49f010a6..5908e9cc8f33 100644 --- a/src/plugins/data/public/ui/dataset_selector/_dataset_explorer.scss +++ b/src/plugins/data/public/ui/dataset_selector/_dataset_explorer.scss @@ -1,3 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + .datasetExplorer { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 240px)) minmax(300px, 1fr); @@ -26,4 +31,11 @@ padding: $euiSizeS; border-bottom: $euiBorderThin; } + + .datasetTable { + &__loadMore { + text-align: center; + padding: $euiSizeS; + } + } } diff --git a/src/plugins/data/public/ui/dataset_selector/_index.scss b/src/plugins/data/public/ui/dataset_selector/_index.scss index a16a39a501c7..cdbb0bee469e 100644 --- a/src/plugins/data/public/ui/dataset_selector/_index.scss +++ b/src/plugins/data/public/ui/dataset_selector/_index.scss @@ -1,3 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + @import "./dataset_explorer"; @import "./dataset_selector"; @import "./dataset_configurator"; diff --git a/src/plugins/data/public/ui/dataset_selector/dataset_explorer.tsx b/src/plugins/data/public/ui/dataset_selector/dataset_explorer.tsx index dbcf7fe5acfc..9d430b2bb312 100644 --- a/src/plugins/data/public/ui/dataset_selector/dataset_explorer.tsx +++ b/src/plugins/data/public/ui/dataset_selector/dataset_explorer.tsx @@ -23,8 +23,9 @@ import { import { FormattedMessage } from '@osd/i18n/react'; import moment from 'moment'; import { BaseDataset, DATA_STRUCTURE_META_TYPES, DataStructure } from '../../../common'; -import { QueryStringContract } from '../../query'; +import { DataStructureFetchOptions, QueryStringContract } from '../../query'; import { IDataPluginServices } from '../../types'; +import { DatasetTable } from './dataset_table'; export const DatasetExplorer = ({ services, @@ -44,12 +45,23 @@ export const DatasetExplorer = ({ const uiSettings = services.uiSettings; const [explorerDataset, setExplorerDataset] = useState(undefined); const [loading, setLoading] = useState(false); + const datasetService = queryString.getDatasetService(); - const selectDataStructure = async (item: DataStructure, newPath: DataStructure[]) => { + const fetchNextDataStructure = async ( + nextPath: DataStructure[], + dataType: string, + options?: DataStructureFetchOptions + ) => datasetService.fetchOptions(services, nextPath, dataType, options); + + const selectDataStructure = async (item: DataStructure | undefined, newPath: DataStructure[]) => { + if (!item) { + setExplorerDataset(undefined); + return; + } const lastPathItem = newPath[newPath.length - 1]; const nextPath = [...newPath, item]; - const typeConfig = queryString.getDatasetService().getType(nextPath[1].id); + const typeConfig = datasetService.getType(nextPath[1].id); if (!typeConfig) return; if (!lastPathItem.hasNext) { @@ -59,9 +71,7 @@ export const DatasetExplorer = ({ } setLoading(true); - const nextDataStructure = await queryString - .getDatasetService() - .fetchOptions(services, nextPath, typeConfig.id); + const nextDataStructure = await fetchNextDataStructure(nextPath, typeConfig.id); setLoading(false); setPath([...newPath, nextDataStructure]); @@ -161,41 +171,52 @@ export const DatasetExplorer = ({

{current.columnHeader}

- ({ - label: child.parent ? `${child.parent.title}::${child.title}` : child.title, - value: child.id, - prepend: child.meta?.type === DATA_STRUCTURE_META_TYPES.TYPE && - child.meta?.icon && , - append: appendIcon(child), - checked: isChecked(child, index, path, explorerDataset), - }))} - onChange={(options) => { - const selected = options.find((option) => option.checked); - if (selected) { - const item = current.children?.find((child) => child.id === selected.value); - if (item) { - selectDataStructure(item, path.slice(0, index + 1)); + {current.multiSelect ? ( + + ) : ( + ({ + label: child.parent ? `${child.parent.title}::${child.title}` : child.title, + value: child.id, + prepend: child.meta?.type === DATA_STRUCTURE_META_TYPES.TYPE && + child.meta?.icon && , + append: appendIcon(child), + checked: isChecked(child, index, path, explorerDataset), + }))} + onChange={(options) => { + const selected = options.find((option) => option.checked); + if (selected) { + const item = current.children?.find((child) => child.id === selected.value); + if (item) { + selectDataStructure(item, path.slice(0, index + 1)); + } } - } - }} - singleSelection - {...(isFinal && { - searchProps: { - compressed: true, - }, - searchable: true, - })} - height="full" - className="datasetExplorer__selectable" - > - {(list, search) => ( - <> - {isFinal && search} - {list} - - )} - + }} + singleSelection + {...(isFinal && { + searchProps: { + compressed: true, + }, + searchable: true, + })} + height="full" + className="datasetExplorer__selectable" + > + {(list, search) => ( + <> + {isFinal && search} + {list} + + )} + + )}
); })} diff --git a/src/plugins/data/public/ui/dataset_selector/dataset_table.test.tsx b/src/plugins/data/public/ui/dataset_selector/dataset_table.test.tsx new file mode 100644 index 000000000000..164a665ba33a --- /dev/null +++ b/src/plugins/data/public/ui/dataset_selector/dataset_table.test.tsx @@ -0,0 +1,128 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React, { ComponentProps } from 'react'; +import { IntlProvider } from 'react-intl'; +import { DataStructure } from '../../../common'; +import { queryServiceMock } from '../../query/mocks'; +import { getQueryService } from '../../services'; +import { DatasetTable } from './dataset_table'; + +jest.mock('../../services', () => ({ + getQueryService: jest.fn(), +})); + +describe('DataSetTable', () => { + const mockQueryService = queryServiceMock.createSetupContract(); + const mockedTypeId = mockQueryService.queryString.getDatasetService().getType('test-type')?.id; + + const mockPath: DataStructure[] = [ + { id: 'root', title: 'Root', type: 'root' }, + { id: 'type1', title: 'Type 1', type: 'indexes' }, + { + id: 'parent', + title: 'Parent', + type: 'cluster', + children: [ + { id: 'child1', title: 'Child 1', type: 'index' }, + { id: 'child2', title: 'Child 2', type: 'index' }, + ], + paginationToken: 'token', + multiSelect: true, + }, + ]; + + const mockProps: ComponentProps = { + path: mockPath, + setPath: jest.fn(), + index: 2, + explorerDataset: undefined, + selectDataStructure: jest.fn(), + fetchNextDataStructure: jest.fn().mockResolvedValue([]), + }; + + const renderWithIntl = (component: React.ReactNode) => + render({component}); + + beforeEach(() => { + jest.clearAllMocks(); + (getQueryService as jest.Mock).mockReturnValue(mockQueryService); + }); + + it('renders the DataSetTable component', () => { + renderWithIntl(); + + expect(screen.getByText('Child 1')).toBeInTheDocument(); + expect(screen.getByText('Child 2')).toBeInTheDocument(); + expect(screen.getByText('Load more')).toBeInTheDocument(); + }); + + it('calls selectDataStructure when an index is selected', async () => { + renderWithIntl(); + + const checkbox = screen.getByTestId('checkboxSelectRow-child1'); + fireEvent.click(checkbox); + + await waitFor(() => { + expect(mockProps.selectDataStructure).toHaveBeenCalledWith( + { + id: 'child1', + title: 'Child 1', + type: 'index', + }, + mockPath.slice(0, mockPath.length) + ); + }); + }); + + it('calls selectDataStructure with undefined when all items are deselected', async () => { + const propsWithSelection = { + ...mockProps, + explorerDataset: { id: 'child1,child2', title: 'Child 1,Child 2', type: 'index' }, + }; + renderWithIntl(); + + const checkbox1 = screen.getByTestId('checkboxSelectRow-child1'); + const checkbox2 = screen.getByTestId('checkboxSelectRow-child2'); + + fireEvent.click(checkbox1); + fireEvent.click(checkbox2); + + await waitFor(() => { + expect(mockProps.selectDataStructure).toHaveBeenCalledWith(undefined, mockPath.slice(0, 3)); + }); + }); + + it('calls onTableChange when search is performed', async () => { + renderWithIntl(); + const searchInput = screen.getByRole('searchbox'); + fireEvent.change(searchInput, { target: { value: 'test' } }); + fireEvent.keyDown(searchInput, { key: 'Enter', code: 'Enter' }); + + expect(mockedTypeId).toBeDefined(); + + await waitFor(() => { + expect(mockProps.fetchNextDataStructure).toHaveBeenCalledWith( + mockPath, + mockedTypeId, + expect.objectContaining({ search: 'test' }) + ); + }); + }); + + it('calls onTableChange when Load more is clicked', async () => { + renderWithIntl(); + fireEvent.click(screen.getByText('Load more')); + + await waitFor(() => { + expect(mockProps.fetchNextDataStructure).toHaveBeenCalledWith( + mockPath, + mockedTypeId, + expect.objectContaining({ paginationToken: 'token' }) + ); + }); + }); +}); diff --git a/src/plugins/data/public/ui/dataset_selector/dataset_table.tsx b/src/plugins/data/public/ui/dataset_selector/dataset_table.tsx new file mode 100644 index 000000000000..148f25691987 --- /dev/null +++ b/src/plugins/data/public/ui/dataset_selector/dataset_table.tsx @@ -0,0 +1,101 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiBasicTable, EuiFieldSearch, EuiLink, EuiText } from '@elastic/eui'; +import React, { useRef, useState } from 'react'; +import { FormattedMessage } from '@osd/i18n/react'; +import { DataStructure } from '../../../common'; +import { getQueryService } from '../../services'; +import { DataStructureFetchOptions } from '../../query'; + +interface DatasetTableProps { + path: DataStructure[]; + setPath: (newPath: DataStructure[]) => void; + index: number; + explorerDataset: DataStructure | undefined; + selectDataStructure: (item: DataStructure | undefined, newPath: DataStructure[]) => Promise; + fetchNextDataStructure: ( + nextPath: DataStructure[], + dataType: string, + options?: DataStructureFetchOptions + ) => Promise; +} + +export const DatasetTable: React.FC = (props) => { + const datasetService = getQueryService().queryString.getDatasetService(); + const [loading, setLoading] = useState(false); + const searchRef = useRef(null); + + const initialSelectedIds = props.explorerDataset?.id.split(','); + const dataStructures = props.path[props.index].children || []; + const paginationToken = props.path[props.index].paginationToken; + + const onTableChange = async (options: DataStructureFetchOptions) => { + const typeConfig = datasetService.getType(props.path[1].id); + if (!typeConfig) return; + + setLoading(true); + await props + .fetchNextDataStructure(props.path, typeConfig.id, options) + .then((newDataStructure) => { + props.setPath([...props.path.slice(0, props.index), newDataStructure]); + }) + .finally(() => setLoading(false)); + }; + + return ( +
+ (searchRef.current = node)} + onSearch={(value) => onTableChange({ search: value })} + /> + + { + if (items.length === 0) { + props.selectDataStructure(undefined, props.path.slice(0, props.index + 1)); + return; + } + if (!items.every((item) => item.type === items[0].type)) { + throw new Error('All items must be of the same type'); + } + const newItem: DataStructure = { + id: items.map((item) => item.id).join(','), + title: items.map((item) => item.title).join(','), + type: items[0].type, + }; + props.selectDataStructure(newItem, props.path.slice(0, props.index + 1)); + }, + initialSelected: + (initialSelectedIds?.length && + dataStructures?.filter((item) => initialSelectedIds.includes(item.id))) || + [], + }} + /> + + {paginationToken && ( +
+ onTableChange({ paginationToken, search: searchRef.current?.value })} + > + + + + +
+ )} +
+ ); +}; diff --git a/src/plugins/data_source/opensearch_dashboards.json b/src/plugins/data_source/opensearch_dashboards.json index b67bf5ff429c..e22a99ac3eca 100644 --- a/src/plugins/data_source/opensearch_dashboards.json +++ b/src/plugins/data_source/opensearch_dashboards.json @@ -6,5 +6,5 @@ "ui": true, "requiredPlugins": ["opensearchDashboardsUtils"], "optionalPlugins": [], - "extraPublicDirs": ["common","common/data_sources", "common/util"] + "extraPublicDirs": ["common","common/data_sources", "common/data_connections", "common/util"] } From 26dca198cbe8328a6fdc0122e81f9d47e4b1378c Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Wed, 2 Oct 2024 13:00:29 +0800 Subject: [PATCH 15/31] [Workspace] Remove whats new in workspace overview page (#8435) * remove whats new Signed-off-by: Hailong Cui * Changeset file for PR #8435 created/updated --------- Signed-off-by: Hailong Cui Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8435.yml | 2 ++ .../components/home_list_card.test.tsx | 34 +++---------------- .../application/components/home_list_card.tsx | 18 ++-------- .../public/application/home_render.test.tsx | 12 ++----- .../home/public/application/home_render.tsx | 16 ++------- 5 files changed, 13 insertions(+), 69 deletions(-) create mode 100644 changelogs/fragments/8435.yml diff --git a/changelogs/fragments/8435.yml b/changelogs/fragments/8435.yml new file mode 100644 index 000000000000..959d3e9b9cf2 --- /dev/null +++ b/changelogs/fragments/8435.yml @@ -0,0 +1,2 @@ +fix: +- [Workspace]Remove what's new card in workspace overview page ([#8435](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8435)) \ No newline at end of file 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 index 1492d82dc544..161bd66a8324 100644 --- a/src/plugins/home/public/application/components/home_list_card.test.tsx +++ b/src/plugins/home/public/application/components/home_list_card.test.tsx @@ -75,21 +75,9 @@ describe('Register HomeListCardToPages', () => { it('register to use case overview page', () => { registerHomeListCardToPage(contentManagementStartMock, docLinkMock); - expect(contentManagementStartMock.registerContentProvider).toHaveBeenCalledTimes(4); + expect(contentManagementStartMock.registerContentProvider).toHaveBeenCalledTimes(2); - let whatsNewCall = registerContentProviderFn.mock.calls[0]; - expect(whatsNewCall[0].getTargetArea()).toEqual('essentials_overview/service_cards'); - expect(whatsNewCall[0].getContent()).toMatchInlineSnapshot(` - Object { - "id": "whats_new", - "kind": "custom", - "order": 10, - "render": [Function], - "width": 24, - } - `); - - let learnOpenSearchCall = registerContentProviderFn.mock.calls[1]; + let learnOpenSearchCall = registerContentProviderFn.mock.calls[0]; expect(learnOpenSearchCall[0].getTargetArea()).toEqual('essentials_overview/service_cards'); expect(learnOpenSearchCall[0].getContent()).toMatchInlineSnapshot(` Object { @@ -97,23 +85,11 @@ describe('Register HomeListCardToPages', () => { "kind": "custom", "order": 20, "render": [Function], - "width": 24, - } - `); - - whatsNewCall = registerContentProviderFn.mock.calls[2]; - expect(whatsNewCall[0].getTargetArea()).toEqual('all_overview/service_cards'); - expect(whatsNewCall[0].getContent()).toMatchInlineSnapshot(` - Object { - "id": "whats_new", - "kind": "custom", - "order": 30, - "render": [Function], - "width": undefined, + "width": 48, } `); - learnOpenSearchCall = registerContentProviderFn.mock.calls[3]; + learnOpenSearchCall = registerContentProviderFn.mock.calls[1]; expect(learnOpenSearchCall[0].getTargetArea()).toEqual('all_overview/service_cards'); expect(learnOpenSearchCall[0].getContent()).toMatchInlineSnapshot(` Object { @@ -121,7 +97,7 @@ describe('Register HomeListCardToPages', () => { "kind": "custom", "order": 40, "render": [Function], - "width": undefined, + "width": 16, } `); }); diff --git a/src/plugins/home/public/application/components/home_list_card.tsx b/src/plugins/home/public/application/components/home_list_card.tsx index 745291dae6e7..7c7fbb3c725b 100644 --- a/src/plugins/home/public/application/components/home_list_card.tsx +++ b/src/plugins/home/public/application/components/home_list_card.tsx @@ -165,27 +165,12 @@ export const registerHomeListCardToPage = ( contentManagement: ContentManagementPluginStart, docLinks: DocLinksStart ) => { - registerHomeListCard(contentManagement, { - id: 'whats_new', - order: 10, - config: getWhatsNewConfig(docLinks), - target: ESSENTIAL_OVERVIEW_CONTENT_AREAS.SERVICE_CARDS, - width: 24, - }); - registerHomeListCard(contentManagement, { id: 'learn_opensearch_new', order: 20, config: getLearnOpenSearchConfig(docLinks), target: ESSENTIAL_OVERVIEW_CONTENT_AREAS.SERVICE_CARDS, - width: 24, - }); - - registerHomeListCard(contentManagement, { - id: 'whats_new', - order: 30, - config: getWhatsNewConfig(docLinks), - target: ANALYTICS_ALL_OVERVIEW_CONTENT_AREAS.SERVICE_CARDS, + width: 48, }); registerHomeListCard(contentManagement, { @@ -193,5 +178,6 @@ export const registerHomeListCardToPage = ( order: 40, config: getLearnOpenSearchConfig(docLinks), target: ANALYTICS_ALL_OVERVIEW_CONTENT_AREAS.SERVICE_CARDS, + width: 16, }); }; diff --git a/src/plugins/home/public/application/home_render.test.tsx b/src/plugins/home/public/application/home_render.test.tsx index 63cd57b3915d..8f3037d99097 100644 --- a/src/plugins/home/public/application/home_render.test.tsx +++ b/src/plugins/home/public/application/home_render.test.tsx @@ -130,22 +130,14 @@ describe('initHome', () => { }; initHome(contentManagementStartMock, core); - expect(registerHomeListCard).toHaveBeenCalledTimes(2); - - expect(registerHomeListCard).toHaveBeenCalledWith(contentManagementStartMock, { - id: 'whats_new', - order: 10, - config: getWhatsNewConfig(core.docLinks), - target: HOME_CONTENT_AREAS.SERVICE_CARDS, - width: 16, - }); + expect(registerHomeListCard).toHaveBeenCalledTimes(1); expect(registerHomeListCard).toHaveBeenCalledWith(contentManagementStartMock, { id: 'learn_opensearch_new', order: 11, config: getLearnOpenSearchConfig(core.docLinks), target: HOME_CONTENT_AREAS.SERVICE_CARDS, - width: 16, + width: 48, }); }); }); diff --git a/src/plugins/home/public/application/home_render.tsx b/src/plugins/home/public/application/home_render.tsx index d4d247ec1652..5f714a719481 100644 --- a/src/plugins/home/public/application/home_render.tsx +++ b/src/plugins/home/public/application/home_render.tsx @@ -16,11 +16,7 @@ import { OBSERVABILITY_OVERVIEW_PAGE_ID, SECURITY_ANALYTICS_OVERVIEW_PAGE_ID, } from '../../../../plugins/content_management/public'; -import { - getWhatsNewConfig, - getLearnOpenSearchConfig, - registerHomeListCard, -} from './components/home_list_card'; +import { getLearnOpenSearchConfig, registerHomeListCard } from './components/home_list_card'; import { registerUseCaseCard } from './components/use_case_card'; @@ -90,19 +86,11 @@ export const initHome = (contentManagement: ContentManagementPluginStart, core: }); } - registerHomeListCard(contentManagement, { - id: 'whats_new', - order: 10, - config: getWhatsNewConfig(core.docLinks), - target: HOME_CONTENT_AREAS.SERVICE_CARDS, - width: 16, - }); - registerHomeListCard(contentManagement, { id: 'learn_opensearch_new', order: 11, config: getLearnOpenSearchConfig(core.docLinks), target: HOME_CONTENT_AREAS.SERVICE_CARDS, - width: 16, + width: workspaceEnabled ? 32 : 48, }); }; From 7d8d840b268cce58862a88a4ba98f3a1952f1806 Mon Sep 17 00:00:00 2001 From: Sumukh Swamy Date: Wed, 2 Oct 2024 00:08:27 -0700 Subject: [PATCH 16/31] [BUG] fixed wrapping of date (#8247) * fixed wrapping of date Signed-off-by: sumukhswamy * addressed comments Signed-off-by: sumukhswamy * addressed comments for all time-fields Signed-off-by: sumukhswamy --------- Signed-off-by: sumukhswamy --- .../default_discover_table/table_row.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/plugins/discover/public/application/components/default_discover_table/table_row.tsx b/src/plugins/discover/public/application/components/default_discover_table/table_row.tsx index beb3a0656f19..a326674789d6 100644 --- a/src/plugins/discover/public/application/components/default_discover_table/table_row.tsx +++ b/src/plugins/discover/public/application/components/default_discover_table/table_row.tsx @@ -9,16 +9,16 @@ * GitHub history for details. */ +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSmallButtonIcon } from '@elastic/eui'; import { i18n } from '@osd/i18n'; -import React, { useState, useCallback } from 'react'; -import { EuiSmallButtonIcon, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; import dompurify from 'dompurify'; -import { TableCell } from './table_cell'; -import { DocViewerLinks } from '../doc_viewer_links/doc_viewer_links'; -import { DocViewer } from '../doc_viewer/doc_viewer'; -import { DocViewFilterFn, OpenSearchSearchHit } from '../../doc_views/doc_views_types'; +import React, { useCallback, useState } from 'react'; import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { DocViewFilterFn, OpenSearchSearchHit } from '../../doc_views/doc_views_types'; import { fetchSourceTypeDataCell } from '../data_grid/data_grid_table_cell_value'; +import { DocViewer } from '../doc_viewer/doc_viewer'; +import { DocViewerLinks } from '../doc_viewer_links/doc_viewer_links'; +import { TableCell } from './table_cell'; export interface TableRowProps { row: OpenSearchSearchHit; @@ -111,7 +111,11 @@ const TableRowUI = ({
{/* eslint-disable-next-line react/no-danger */} From 584205f3b562e0eaedd14808134a3627f0882d5b Mon Sep 17 00:00:00 2001 From: Miki Date: Wed, 2 Oct 2024 00:21:57 -0700 Subject: [PATCH 17/31] Fix unprefixed and duplicate i18n identifiers in visAugmenter plugin (#8409) * Fix unprefixed and duplicate i18n identifiers in visAugmenter plugin Signed-off-by: Miki * Changeset file for PR #8409 created/updated --------- Signed-off-by: Miki Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8409.yml | 2 ++ .../public/actions/plugin_resource_delete_action.ts | 2 +- .../public/actions/saved_object_delete_action.ts | 5 +++-- .../actions/open_events_flyout_action.ts | 2 +- .../actions/view_events_option_action.tsx | 2 +- src/plugins/vis_augmenter/server/plugin.ts | 8 ++++---- 6 files changed, 12 insertions(+), 9 deletions(-) create mode 100644 changelogs/fragments/8409.yml diff --git a/changelogs/fragments/8409.yml b/changelogs/fragments/8409.yml new file mode 100644 index 000000000000..3cdf6d787a77 --- /dev/null +++ b/changelogs/fragments/8409.yml @@ -0,0 +1,2 @@ +fix: +- Fix unprefixed and duplicate i18n identifiers in visAugmenter plugin ([#8409](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8409)) \ No newline at end of file diff --git a/src/plugins/vis_augmenter/public/actions/plugin_resource_delete_action.ts b/src/plugins/vis_augmenter/public/actions/plugin_resource_delete_action.ts index 6e3939820d28..1f29eb996315 100644 --- a/src/plugins/vis_augmenter/public/actions/plugin_resource_delete_action.ts +++ b/src/plugins/vis_augmenter/public/actions/plugin_resource_delete_action.ts @@ -23,7 +23,7 @@ export class PluginResourceDeleteAction implements Action } public getDisplayName() { - return i18n.translate('dashboard.actions.deleteSavedObject.name', { - defaultMessage: 'Clean up augment-vis saved objects associated to a deleted vis', + return i18n.translate('visAugmenter.actions.deleteSavedObject.name', { + defaultMessage: + 'Clean up all augment-vis saved objects associated to the deleted visualization', }); } diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.ts b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.ts index cb47e5d6a85c..dfc8888165f0 100644 --- a/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.ts +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.ts @@ -32,7 +32,7 @@ export class OpenEventsFlyoutAction implements Action { } public getDisplayName() { - return i18n.translate('dashboard.actions.viewEvents.displayName', { + return i18n.translate('visAugmenter.actions.viewEvents.displayName', { defaultMessage: 'View Events', }); } diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.tsx index 6410e8a13634..ac7f795c586e 100644 --- a/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.tsx +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.tsx @@ -37,7 +37,7 @@ export class ViewEventsOptionAction implements Action { } public getDisplayName() { - return i18n.translate('dashboard.actions.viewEvents.displayName', { + return i18n.translate('visAugmenter.actions.viewEvents.displayName', { defaultMessage: 'View Events', }); } diff --git a/src/plugins/vis_augmenter/server/plugin.ts b/src/plugins/vis_augmenter/server/plugin.ts index e482265ee290..17d492fac247 100644 --- a/src/plugins/vis_augmenter/server/plugin.ts +++ b/src/plugins/vis_augmenter/server/plugin.ts @@ -53,23 +53,23 @@ export class VisAugmenterPlugin if (isAugmentationEnabled) { core.uiSettings.register({ [PLUGIN_AUGMENTATION_ENABLE_SETTING]: { - name: i18n.translate('visualization.enablePluginAugmentationTitle', { + name: i18n.translate('visAugmenter.enablePluginAugmentationTitle', { defaultMessage: 'Enable plugin augmentation', }), value: true, - description: i18n.translate('visualization.enablePluginAugmentationText', { + description: i18n.translate('visAugmenter.enablePluginAugmentationText', { defaultMessage: 'Plugin functionality can be accessed from line chart visualizations', }), category: ['visualization'], schema: schema.boolean(), }, [PLUGIN_AUGMENTATION_MAX_OBJECTS_SETTING]: { - name: i18n.translate('visualization.enablePluginAugmentation.maxPluginObjectsTitle', { + name: i18n.translate('visAugmenter.enablePluginAugmentation.maxPluginObjectsTitle', { defaultMessage: 'Max number of associated augmentations', }), value: 10, description: i18n.translate( - 'visualization.enablePluginAugmentation.maxPluginObjectsText', + 'visAugmenter.enablePluginAugmentation.maxPluginObjectsText', { defaultMessage: 'Associating more than 10 plugin resources per visualization can lead to performance ' + From 7b5a3797a885943d5b7037fc58dd8c9fb978c7dd Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Wed, 2 Oct 2024 15:56:45 +0800 Subject: [PATCH 18/31] [Workspace]Refactor use case selector in workspace creation page (#8413) * Refactor use case selector in workspace create Signed-off-by: Lin Wang * Add gap for details panel title Signed-off-by: Lin Wang * Update use case name and description Signed-off-by: Lin Wang * Filter out hidden nav links Signed-off-by: Lin Wang * Changeset file for PR #8413 created/updated * Add test case for different feature details Signed-off-by: Lin Wang * Change back to "Analytics" Signed-off-by: Lin Wang * Add all features suffix for all use case Signed-off-by: Lin Wang * Fix failed unit tests Signed-off-by: Lin Wang * Filter out getting started links Signed-off-by: Lin Wang * Renaming isFlyoutVisible to isUseCaseFlyoutVisible Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8413.yml | 2 + src/core/utils/default_nav_groups.ts | 14 +- .../use_case_overview/setup_overview.test.tsx | 2 +- .../creator_details_panel.tsx | 2 + .../public/components/workspace_form/index.ts | 1 + .../public/components/workspace_form/types.ts | 5 +- .../workspace_use_case.test.tsx | 58 +++--- .../workspace_form/workspace_use_case.tsx | 189 +++++++++--------- .../workspace_use_case_flyout.test.tsx | 81 ++++++++ .../workspace_use_case_flyout.tsx | 128 ++++++++++++ .../default_workspace.test.tsx.snap | 2 +- .../__snapshots__/index.test.tsx.snap | 2 +- .../workspace_list/default_workspace.test.tsx | 2 +- .../components/workspace_list/index.test.tsx | 2 +- .../public/services/use_case_service.test.ts | 23 +++ .../public/services/use_case_service.ts | 35 ++-- src/plugins/workspace/public/types.ts | 1 + src/plugins/workspace/public/utils.test.ts | 180 +++++++++++++++-- src/plugins/workspace/public/utils.ts | 70 +++++-- 19 files changed, 612 insertions(+), 187 deletions(-) create mode 100644 changelogs/fragments/8413.yml create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.tsx diff --git a/changelogs/fragments/8413.yml b/changelogs/fragments/8413.yml new file mode 100644 index 000000000000..b23b994010e4 --- /dev/null +++ b/changelogs/fragments/8413.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace]Refactor use case selector in workspace creation page ([#8413](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8413)) \ No newline at end of file diff --git a/src/core/utils/default_nav_groups.ts b/src/core/utils/default_nav_groups.ts index 76fedcf803ea..b58bd43e5b08 100644 --- a/src/core/utils/default_nav_groups.ts +++ b/src/core/utils/default_nav_groups.ts @@ -38,10 +38,11 @@ const defaultNavGroups = { all: { id: ALL_USE_CASE_ID, title: i18n.translate('core.ui.group.all.title', { - defaultMessage: 'Analytics (All)', + defaultMessage: 'Analytics', }), description: i18n.translate('core.ui.group.all.description', { - defaultMessage: 'This is a use case contains all the features.', + defaultMessage: + 'If you aren’t sure where to start with OpenSearch, or if you have needs that cut across multiple use cases.', }), order: 3000, icon: 'wsAnalytics', @@ -52,7 +53,8 @@ const defaultNavGroups = { defaultMessage: 'Observability', }), description: i18n.translate('core.ui.group.observability.description', { - defaultMessage: 'Gain visibility into your applications and infrastructure.', + defaultMessage: + 'Gain visibility into system health, performance, and reliability through monitoring of logs, metrics and traces.', }), order: 4000, icon: 'wsObservability', @@ -63,7 +65,8 @@ const defaultNavGroups = { defaultMessage: 'Security Analytics', }), description: i18n.translate('core.ui.group.security.analytics.description', { - defaultMessage: 'Enhance your security posture with advanced analytics.', + defaultMessage: + 'Detect and investigate potential security threats and vulnerabilities across your systems and data.', }), order: 5000, icon: 'wsSecurityAnalytics', @@ -86,7 +89,8 @@ const defaultNavGroups = { defaultMessage: 'Search', }), description: i18n.translate('core.ui.group.search.description', { - defaultMessage: 'Discover and query your data with ease.', + defaultMessage: + "Quickly find and explore relevant information across your organization's data sources.", }), order: 6000, icon: 'wsSearch', diff --git a/src/plugins/workspace/public/components/use_case_overview/setup_overview.test.tsx b/src/plugins/workspace/public/components/use_case_overview/setup_overview.test.tsx index a895534e47a9..ea0a12b930cf 100644 --- a/src/plugins/workspace/public/components/use_case_overview/setup_overview.test.tsx +++ b/src/plugins/workspace/public/components/use_case_overview/setup_overview.test.tsx @@ -141,7 +141,7 @@ describe('Setup use case overview', () => { "titleElement": "h4", "titleSize": "s", }, - "description": "Gain visibility into your applications and infrastructure.", + "description": "Gain visibility into system health, performance, and reliability through monitoring of logs, metrics and traces.", "getIcon": [Function], "id": "observability", "kind": "card", diff --git a/src/plugins/workspace/public/components/workspace_creator/creator_details_panel.tsx b/src/plugins/workspace/public/components/workspace_creator/creator_details_panel.tsx index 64519d9a1bde..f654ea327fd4 100644 --- a/src/plugins/workspace/public/components/workspace_creator/creator_details_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/creator_details_panel.tsx @@ -13,6 +13,7 @@ import { EuiFormControlLayout, EuiFormRow, EuiPanel, + EuiSpacer, EuiText, } from '@elastic/eui'; import { EuiColorPickerOutput } from '@elastic/eui/src/components/color_picker/color_picker'; @@ -46,6 +47,7 @@ export const CreatorDetailsPanel = ({ })} + diff --git a/src/plugins/workspace/public/components/workspace_form/index.ts b/src/plugins/workspace/public/components/workspace_form/index.ts index a58b2918a1b3..fef18d7c8c5e 100644 --- a/src/plugins/workspace/public/components/workspace_form/index.ts +++ b/src/plugins/workspace/public/components/workspace_form/index.ts @@ -12,6 +12,7 @@ export { WorkspaceCancelModal } from './workspace_cancel_modal'; export { WorkspaceNameField, WorkspaceDescriptionField } from './fields'; export { ConnectionTypeIcon } from './connection_type_icon'; export { DataSourceConnectionTable } from './data_source_connection_table'; +export { WorkspaceUseCaseFlyout } from './workspace_use_case_flyout'; export { WorkspaceFormSubmitData, WorkspaceFormProps, WorkspaceFormDataState } from './types'; export { diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts index f160f18b4357..9c8da46e5be9 100644 --- a/src/plugins/workspace/public/components/workspace_form/types.ts +++ b/src/plugins/workspace/public/components/workspace_form/types.ts @@ -88,7 +88,10 @@ export interface WorkspaceFormProps { } export interface AvailableUseCaseItem - extends Pick { + extends Pick< + WorkspaceUseCase, + 'id' | 'title' | 'features' | 'description' | 'systematic' | 'icon' + > { disabled?: boolean; } diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx index 8a0b14782e91..85725c452051 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx @@ -4,7 +4,7 @@ */ import React from 'react'; -import { fireEvent, render, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor, within } from '@testing-library/react'; import { DEFAULT_NAV_GROUPS } from '../../../../../core/public'; import { WorkspaceUseCase, WorkspaceUseCaseProps } from './workspace_use_case'; import { WorkspaceFormErrors } from './types'; @@ -76,13 +76,13 @@ describe('WorkspaceUseCase', () => { ], }); await waitFor(() => { - expect(renderResult.getByText('Essentials')).toHaveClass( + expect(renderResult.getByText('Essentials').closest('label')).toHaveClass( 'euiCheckableCard__label-isDisabled' ); }); }); - it('should be able to toggle use case features', async () => { + it('should open flyout and expanded selected use cases', async () => { const { renderResult } = setup({ availableUseCases: [ { @@ -93,58 +93,46 @@ describe('WorkspaceUseCase', () => { ], }, ], + value: DEFAULT_NAV_GROUPS.observability.id, }); + + fireEvent.click(renderResult.getByText('Learn more.')); + await waitFor(() => { - expect(renderResult.getByText('See more....')).toBeInTheDocument(); - expect(renderResult.queryByText('Feature 1')).toBe(null); - expect(renderResult.queryByText('Feature 2')).toBe(null); + expect(within(renderResult.getByRole('dialog')).getByText('Use cases')).toBeInTheDocument(); + expect( + within(renderResult.getByRole('dialog')).getByText('Observability') + ).toBeInTheDocument(); + expect(within(renderResult.getByRole('dialog')).getByText('Feature 1')).toBeInTheDocument(); + expect(within(renderResult.getByRole('dialog')).getByText('Feature 2')).toBeInTheDocument(); }); + }); - fireEvent.click(renderResult.getByText('See more....')); + it('should close flyout after close button clicked', async () => { + const { renderResult } = setup({}); + fireEvent.click(renderResult.getByText('Learn more.')); await waitFor(() => { - expect(renderResult.getByText('See less....')).toBeInTheDocument(); - expect(renderResult.getByText('Feature 1')).toBeInTheDocument(); - expect(renderResult.getByText('Feature 2')).toBeInTheDocument(); + expect(within(renderResult.getByRole('dialog')).getByText('Use cases')).toBeInTheDocument(); }); - fireEvent.click(renderResult.getByText('See less....')); + fireEvent.click(renderResult.getByTestId('euiFlyoutCloseButton')); await waitFor(() => { - expect(renderResult.getByText('See more....')).toBeInTheDocument(); - expect(renderResult.queryByText('Feature 1')).toBe(null); - expect(renderResult.queryByText('Feature 2')).toBe(null); + expect(renderResult.queryByText('dialog')).toBeNull(); }); }); - it('should show static all use case features', async () => { + it('should render "(all features)" suffix for "all use case"', () => { const { renderResult } = setup({ availableUseCases: [ { ...DEFAULT_NAV_GROUPS.all, - features: [ - { id: 'feature1', title: 'Feature 1' }, - { id: 'feature2', title: 'Feature 2' }, - ], + features: [], }, ], }); - fireEvent.click(renderResult.getByText('See more....')); - - await waitFor(() => { - expect(renderResult.getByText('Discover')).toBeInTheDocument(); - expect(renderResult.getByText('Dashboards')).toBeInTheDocument(); - expect(renderResult.getByText('Visualize')).toBeInTheDocument(); - expect( - renderResult.getByText('Observability services, metrics, traces, and more') - ).toBeInTheDocument(); - expect( - renderResult.getByText('Security analytics threat alerts, findings, correlations, and more') - ).toBeInTheDocument(); - expect( - renderResult.getByText('Search studio, relevance tuning, vector search, and more') - ).toBeInTheDocument(); - }); + expect(renderResult.getByText('(all features)')).toBeInTheDocument(); }); }); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx index 5eebdc8fa369..94defbdaefad 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useCallback, useState, useMemo } from 'react'; +import React, { useCallback, useState } from 'react'; import { i18n } from '@osd/i18n'; import { EuiCheckableCard, @@ -12,14 +12,19 @@ import { EuiCompressedFormRow, EuiText, EuiLink, + EuiPanel, + EuiIcon, + EuiSpacer, } from '@elastic/eui'; +import { ALL_USE_CASE_ID } from '../../../../../core/public'; -import { ALL_USE_CASE_ID, DEFAULT_NAV_GROUPS } from '../../../../../core/public'; import { WorkspaceFormErrors, AvailableUseCaseItem } from './types'; +import { WorkspaceUseCaseFlyout } from './workspace_use_case_flyout'; import './workspace_use_case.scss'; interface WorkspaceUseCaseCardProps { id: string; + icon?: string; title: string; checked: boolean; disabled?: boolean; @@ -30,92 +35,57 @@ interface WorkspaceUseCaseCardProps { const WorkspaceUseCaseCard = ({ id, + icon, title, - features, description, checked, disabled, onChange, }: WorkspaceUseCaseCardProps) => { - const [isExpanded, setIsExpanded] = useState(false); - const featureItems = useMemo(() => { - if (id === DEFAULT_NAV_GROUPS.essentials.id) { - return []; - } - if (id === ALL_USE_CASE_ID) { - return [ - i18n.translate('workspace.form.useCase.feature.all.discover', { - defaultMessage: 'Discover', - }), - i18n.translate('workspace.form.useCase.feature.all.dashboards', { - defaultMessage: 'Dashboards', - }), - i18n.translate('workspace.form.useCase.feature.all.visualize', { - defaultMessage: 'Visualize', - }), - i18n.translate('workspace.form.useCase.feature.all.observability', { - defaultMessage: 'Observability services, metrics, traces, and more', - }), - i18n.translate('workspace.form.useCase.feature.all.securityAnalytics', { - defaultMessage: 'Security analytics threat alerts, findings, correlations, and more', - }), - i18n.translate('workspace.form.useCase.feature.all.search', { - defaultMessage: 'Search studio, relevance tuning, vector search, and more', - }), - ]; - } - - const featureTitles = features.flatMap((feature) => (feature.title ? [feature.title] : [])); - return featureTitles; - }, [features, id]); - const handleChange = useCallback(() => { onChange(id); }, [id, onChange]); - const toggleExpanded = useCallback(() => { - setIsExpanded((flag) => !flag); - }, []); return ( + {icon && ( + + + + )} + + +

+ {title} + {id === ALL_USE_CASE_ID && ( + <> +   + + + {i18n.translate('workspace.forms.useCaseCard.allUseCaseSuffix', { + defaultMessage: '(all features)', + })} + + + + )} +

+
+
+ + } checked={checked} className="workspace-use-case-item" onChange={handleChange} data-test-subj={`workspaceUseCase-${id}`} disabled={disabled} + style={{ width: '100%' }} > {description} - {featureItems.length > 0 && ( - - {isExpanded && ( - <> - {i18n.translate('workspace.form.useCase.featureExpandedTitle', { - defaultMessage: 'Feature includes:', - })} -
    - {featureItems.map((feature, index) => ( -
  • {feature}
  • - ))} -
- - )} - - - {isExpanded - ? i18n.translate('workspace.form.useCase.showLessButton', { - defaultMessage: 'See less....', - }) - : i18n.translate('workspace.form.useCase.showMoreButton', { - defaultMessage: 'See more....', - })} - - -
- )}
); }; @@ -133,29 +103,68 @@ export const WorkspaceUseCase = ({ formErrors, availableUseCases, }: WorkspaceUseCaseProps) => { + const [isUseCaseFlyoutVisible, setIsUseCaseFlyoutVisible] = useState(false); + const handleLearnMoreClick = useCallback(() => { + setIsUseCaseFlyoutVisible(true); + }, []); + const handleFlyoutClose = useCallback(() => { + setIsUseCaseFlyoutVisible(false); + }, []); + return ( - - - {availableUseCases.map(({ id, title, description, features, disabled }) => ( - - - - ))} - - + + +

+ {i18n.translate('workspace.form.panels.useCase.title', { + defaultMessage: 'Use case and features', + })} +

+
+ + {i18n.translate('workspace.form.panels.useCase.description', { + defaultMessage: + 'The use case defines the set of features that will be available in the workspace. You can change the use case later only to one with more features than the current use case.', + })} +   + + {i18n.translate('workspace.form.panels.useCase.learnMore', { + defaultMessage: 'Learn more.', + })} + + {isUseCaseFlyoutVisible && ( + + )} + + + + + {availableUseCases.map(({ id, icon, title, description, features, disabled }) => ( + + + + ))} + + +
); }; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.test.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.test.tsx new file mode 100644 index 000000000000..2cc4efd8cf6d --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { DEFAULT_NAV_GROUPS } from '../../../../../core/public'; + +import { WorkspaceUseCaseFlyout } from './workspace_use_case_flyout'; + +const mockAvailableUseCases = [ + { + id: 'use-case-1', + icon: 'logoElasticsearch', + title: 'Use Case 1', + description: 'This is the description for Use Case 1', + features: [ + { + id: 'feature-1', + title: 'Feature 1', + details: ['Detail 1', 'Detail 2'], + }, + { + id: 'feature-2', + title: 'Feature 2', + details: [], + }, + ], + }, + { + id: 'use-case-2', + icon: 'logoKibana', + title: 'Use Case 2', + description: 'This is the description for Use Case 2', + features: [], + }, +]; + +describe('WorkspaceUseCaseFlyout', () => { + it('should render the flyout with the correct title and available use cases', () => { + render( + + ); + const title = screen.getByText('Use cases'); + expect(title).toBeInTheDocument(); + expect(screen.getByText(mockAvailableUseCases[0].title)).toBeInTheDocument(); + expect(screen.getByText(mockAvailableUseCases[0].title)).toBeInTheDocument(); + }); + + it('should call the onClose callback when the close button is clicked', () => { + const onCloseMock = jest.fn(); + render( + + ); + const closeButton = screen.getByTestId('euiFlyoutCloseButton'); + fireEvent.click(closeButton); + expect(onCloseMock).toHaveBeenCalled(); + }); + + it('should expand the default use case if provided', () => { + render( + + ); + const useCaseDescription = screen.getByText(/This is the description for Use Case 1/); + expect(useCaseDescription).toBeInTheDocument(); + }); + + it('should render "(all features)" suffix for "all use case"', () => { + render( + + ); + expect(screen.getByText('(all features)')).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.tsx new file mode 100644 index 000000000000..7617f3ec14ea --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.tsx @@ -0,0 +1,128 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiAccordion, + EuiSpacer, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, +} from '@elastic/eui'; +import { ALL_USE_CASE_ID } from '../../../../../core/public'; + +import { AvailableUseCaseItem } from './types'; + +const WORKSPACE_USE_CASE_FLYOUT_TITLE_ID = 'workspaceUseCaseFlyoutTitle'; + +export interface WorkspaceUseCaseFlyoutProps { + onClose: () => void; + availableUseCases: AvailableUseCaseItem[]; + defaultExpandUseCase?: string; +} + +export const WorkspaceUseCaseFlyout = ({ + onClose, + availableUseCases, + defaultExpandUseCase, +}: WorkspaceUseCaseFlyoutProps) => { + return ( + + + +

+ {i18n.translate('workspace.forms.useCaseFlyout.title', { defaultMessage: 'Use cases' })} +

+
+
+ + {availableUseCases.map(({ id, icon, title, description, features }, index) => ( + + + {icon && ( + + + + )} + + +

+ {title} + {id === ALL_USE_CASE_ID && ( + <> +   + + + {i18n.translate('workspace.forms.useCaseFlyout.allUseCaseSuffix', { + defaultMessage: '(all features)', + })} + + + + )} +

+
+
+ + } + paddingSize="l" + initialIsOpen={id === defaultExpandUseCase} + > + + {description} +
+ {features && features.length > 0 && ( + <> + {i18n.translate('workspace.forms.useCaseFlyout.featuresIncluded', { + defaultMessage: 'Features included:', + })} +
+
    + {features.map(({ id: featureId, title: featureTitle, details }) => ( +
  • + + {i18n.translate('workspace.forms.useCaseFlyout.featureTitle', { + defaultMessage: + '{featureTitle}{detailsCount, plural, =0 {} other {: }}', + values: { featureTitle, detailsCount: details?.length ?? 0 }, + })} + + {details?.join( + i18n.translate( + 'workspace.forms.useCaseFlyout.featuresDetails.delimiter', + { + defaultMessage: ', ', + } + ) + )} +
  • + ))} +
+ + )} +
+
+ {index < availableUseCases.length - 1 && } +
+ ))} +
+
+ ); +}; diff --git a/src/plugins/workspace/public/components/workspace_list/__snapshots__/default_workspace.test.tsx.snap b/src/plugins/workspace/public/components/workspace_list/__snapshots__/default_workspace.test.tsx.snap index 0bb3f51ead3d..57c41452231e 100644 --- a/src/plugins/workspace/public/components/workspace_list/__snapshots__/default_workspace.test.tsx.snap +++ b/src/plugins/workspace/public/components/workspace_list/__snapshots__/default_workspace.test.tsx.snap @@ -248,7 +248,7 @@ exports[`UserDefaultWorkspace should render title and table normally 1`] = ` - Analytics (All) + Analytics
diff --git a/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap b/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap index b32655bafd98..b30c67b37643 100644 --- a/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap +++ b/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap @@ -411,7 +411,7 @@ exports[`WorkspaceList should render title and table normally 1`] = ` - Analytics (All) + Analytics
diff --git a/src/plugins/workspace/public/components/workspace_list/default_workspace.test.tsx b/src/plugins/workspace/public/components/workspace_list/default_workspace.test.tsx index f6a50b1c1350..eeeec9e06fb0 100644 --- a/src/plugins/workspace/public/components/workspace_list/default_workspace.test.tsx +++ b/src/plugins/workspace/public/components/workspace_list/default_workspace.test.tsx @@ -137,7 +137,7 @@ describe('UserDefaultWorkspace', () => { expect(getByText('name2')).toBeInTheDocument(); // should display use case - expect(getByText('Analytics (All)')).toBeInTheDocument(); + expect(getByText('Analytics')).toBeInTheDocument(); expect(getByText('Observability')).toBeInTheDocument(); // owner column not display diff --git a/src/plugins/workspace/public/components/workspace_list/index.test.tsx b/src/plugins/workspace/public/components/workspace_list/index.test.tsx index a945866fd7bf..62709e4fe053 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.test.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.test.tsx @@ -153,7 +153,7 @@ describe('WorkspaceList', () => { expect(getByText('name2')).toBeInTheDocument(); // should display use case - expect(getByText('Analytics (All)')).toBeInTheDocument(); + expect(getByText('Analytics')).toBeInTheDocument(); expect(getByText('Observability')).toBeInTheDocument(); }); diff --git a/src/plugins/workspace/public/services/use_case_service.test.ts b/src/plugins/workspace/public/services/use_case_service.test.ts index bdd819145f82..d810dc570bb3 100644 --- a/src/plugins/workspace/public/services/use_case_service.test.ts +++ b/src/plugins/workspace/public/services/use_case_service.test.ts @@ -47,12 +47,18 @@ const setupUseCaseStart = (options?: { navGroupEnabled?: boolean }) => { ]); const navGroupsMap$ = new BehaviorSubject>(mockNavGroupsMap); const useCase = new UseCaseService(); + const navLinks$ = new BehaviorSubject([ + { id: 'dashboards', title: 'Dashboards', baseUrl: '', href: '' }, + { id: 'searchRelevance', title: 'Search Relevance', baseUrl: '', href: '' }, + ]); chrome.navGroup.getNavGroupEnabled.mockImplementation(() => options?.navGroupEnabled ?? true); chrome.navGroup.getNavGroupsMap$.mockImplementation(() => navGroupsMap$); + chrome.navLinks.getNavLinks$.mockImplementation(() => navLinks$); return { chrome, + navLinks$, navGroupsMap$, workspaceConfigurableApps$, useCaseStart: useCase.start({ @@ -187,6 +193,23 @@ describe('UseCaseService', () => { }); expect(fn).toHaveBeenCalledTimes(2); }); + + it('should not emit after navLinks$ emit same value', async () => { + const { useCaseStart, navLinks$ } = setupUseCaseStart(); + const registeredUseCases$ = useCaseStart.getRegisteredUseCases$(); + const fn = jest.fn(); + + registeredUseCases$.subscribe(fn); + + expect(fn).toHaveBeenCalledTimes(1); + + navLinks$.next([...navLinks$.getValue()]); + expect(fn).toHaveBeenCalledTimes(1); + + navLinks$.next([...navLinks$.getValue()].slice(1)); + expect(fn).toHaveBeenCalledTimes(2); + }); + it('should move all use case to the last one', async () => { const { useCaseStart, navGroupsMap$ } = setupUseCaseStart(); diff --git a/src/plugins/workspace/public/services/use_case_service.ts b/src/plugins/workspace/public/services/use_case_service.ts index 31e15977d9a5..ca4f2b55c223 100644 --- a/src/plugins/workspace/public/services/use_case_service.ts +++ b/src/plugins/workspace/public/services/use_case_service.ts @@ -118,12 +118,13 @@ export class UseCaseService { return { getRegisteredUseCases$: () => { if (chrome.navGroup.getNavGroupEnabled()) { - return chrome.navGroup - .getNavGroupsMap$() + return combineLatest([chrome.navGroup.getNavGroupsMap$(), chrome.navLinks.getNavLinks$()]) .pipe( - map((navGroupsMap) => - Object.values(navGroupsMap).map(convertNavGroupToWorkspaceUseCase) - ) + map(([navGroupsMap, allNavLinks]) => { + return Object.values(navGroupsMap).map((navGroup) => + convertNavGroupToWorkspaceUseCase(navGroup, allNavLinks) + ); + }) ) .pipe( distinctUntilChanged((useCases, anotherUseCases) => { @@ -169,17 +170,23 @@ export class UseCaseService { .filter((useCase) => { return useCase.features.some((featureId) => configurableAppsId.includes(featureId)); }) - .map((item) => ({ - ...item, - features: item.features.map((featureId) => ({ - title: configurableApps.find((app) => app.id === featureId)?.title, - id: featureId, - })), - })) + .map( + (item) => + ({ + ...item, + features: item.features.map((featureId) => ({ + title: configurableApps.find((app) => app.id === featureId)?.title, + id: featureId, + })), + } as WorkspaceUseCase) + ) .concat({ ...DEFAULT_NAV_GROUPS.all, - features: configurableApps.map((app) => ({ id: app.id, title: app.title })), - }) as WorkspaceUseCase[]; + features: configurableApps.map((app) => ({ + id: app.id, + title: app.title, + })), + } as WorkspaceUseCase); }) ); }, diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts index 53f2be4dfb36..997e8c67fd05 100644 --- a/src/plugins/workspace/public/types.ts +++ b/src/plugins/workspace/public/types.ts @@ -20,6 +20,7 @@ export type Services = CoreStart & { export interface WorkspaceUseCaseFeature { id: string; title?: string; + details?: string[]; } export interface WorkspaceUseCase { diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts index 9f79beafea12..a5f2a9c435e2 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AppNavLinkStatus, NavGroupType, PublicAppInfo } from '../../../core/public'; +import { AppNavLinkStatus, ChromeNavLink, NavGroupType, PublicAppInfo } from '../../../core/public'; import { featureMatchesConfig, filterWorkspaceConfigurableApps, @@ -19,7 +19,7 @@ import { } from './utils'; import { WorkspaceAvailability } from '../../../core/public'; import { coreMock } from '../../../core/public/mocks'; -import { WORKSPACE_DETAIL_APP_ID, USE_CASE_PREFIX } from '../common/constants'; +import { USE_CASE_PREFIX } from '../common/constants'; import { SigV4ServiceName, DataSourceEngineType, @@ -37,10 +37,23 @@ const useCaseMock = { id: 'foo', title: 'Foo', description: 'Foo description', - features: [{ id: 'bar' }], + features: [{ id: 'bar' }, { id: 'baz', title: 'Baz', details: ['Qux'] }], systematic: false, order: 1, }; +const allNavLinksMock: ChromeNavLink[] = [ + { id: 'foo', title: 'Foo', baseUrl: '', href: '' }, + { id: 'bar', title: 'Bar', baseUrl: '', href: '' }, + { id: 'baz', title: 'Baz', baseUrl: '', href: '' }, + { id: 'qux', title: 'Qux', baseUrl: '', href: '' }, + { id: 'observability_overview', title: 'Observability Overview', baseUrl: '', href: '' }, + { + id: 'observability-gettingStarted', + title: 'Observability Getting Started', + baseUrl: '', + href: '', + }, +]; describe('workspace utils: featureMatchesConfig', () => { it('feature configured with `*` should match any features', () => { @@ -530,13 +543,16 @@ describe('workspace utils: getIsOnlyAllowEssentialUseCase', () => { describe('workspace utils: convertNavGroupToWorkspaceUseCase', () => { it('should convert nav group to consistent workspace use case', () => { expect( - convertNavGroupToWorkspaceUseCase({ - id: 'foo', - title: 'Foo', - description: 'Foo description', - navLinks: [{ id: 'bar', title: 'Bar' }], - icon: 'wsAnalytics', - }) + convertNavGroupToWorkspaceUseCase( + { + id: 'foo', + title: 'Foo', + description: 'Foo description', + navLinks: [{ id: 'bar', title: 'Bar' }], + icon: 'wsAnalytics', + }, + allNavLinksMock + ) ).toEqual({ id: 'foo', title: 'Foo', @@ -547,13 +563,16 @@ describe('workspace utils: convertNavGroupToWorkspaceUseCase', () => { }); expect( - convertNavGroupToWorkspaceUseCase({ - id: 'foo', - title: 'Foo', - description: 'Foo description', - navLinks: [{ id: 'bar', title: 'Bar' }], - type: NavGroupType.SYSTEM, - }) + convertNavGroupToWorkspaceUseCase( + { + id: 'foo', + title: 'Foo', + description: 'Foo description', + navLinks: [{ id: 'bar', title: 'Bar' }], + type: NavGroupType.SYSTEM, + }, + allNavLinksMock + ) ).toEqual({ id: 'foo', title: 'Foo', @@ -562,6 +581,111 @@ describe('workspace utils: convertNavGroupToWorkspaceUseCase', () => { systematic: true, }); }); + + it('should filter out overview features', () => { + expect( + convertNavGroupToWorkspaceUseCase( + { + id: 'foo', + title: 'Foo', + description: 'Foo description', + navLinks: [{ id: 'observability_overview', title: 'Observability Overview' }], + icon: 'wsAnalytics', + }, + allNavLinksMock + ) + ).toEqual( + expect.objectContaining({ + id: 'foo', + title: 'Foo', + description: 'Foo description', + features: [], + systematic: false, + icon: 'wsAnalytics', + }) + ); + }); + + it('should filter out getting started features', () => { + expect( + convertNavGroupToWorkspaceUseCase( + { + id: 'foo', + title: 'Foo', + description: 'Foo description', + navLinks: [ + { id: 'observability-gettingStarted', title: 'Observability Getting Started' }, + ], + icon: 'wsAnalytics', + }, + allNavLinksMock + ) + ).toEqual( + expect.objectContaining({ + id: 'foo', + title: 'Foo', + description: 'Foo description', + features: [], + systematic: false, + icon: 'wsAnalytics', + }) + ); + }); + + it('should grouped nav links by category', () => { + expect( + convertNavGroupToWorkspaceUseCase( + { + id: 'foo', + title: 'Foo', + description: 'Foo description', + navLinks: [ + { id: 'bar', title: 'Bar', category: { id: 'category-1', label: 'Category 1' } }, + { id: 'baz', title: 'Baz' }, + { id: 'qux', title: 'Qux', category: { id: 'category-1', label: 'Category 1' } }, + ], + icon: 'wsAnalytics', + }, + allNavLinksMock + ) + ).toEqual( + expect.objectContaining({ + id: 'foo', + title: 'Foo', + description: 'Foo description', + features: [ + { id: 'baz', title: 'Baz' }, + { id: 'category-1', title: 'Category 1', details: ['Bar', 'Qux'] }, + ], + systematic: false, + icon: 'wsAnalytics', + }) + ); + }); + + it('should filter out custom features', () => { + expect( + convertNavGroupToWorkspaceUseCase( + { + id: 'foo', + title: 'Foo', + description: 'Foo description', + navLinks: [{ id: 'bar', title: 'Bar', category: { id: 'custom', label: 'Custom' } }], + icon: 'wsAnalytics', + }, + allNavLinksMock + ) + ).toEqual( + expect.objectContaining({ + id: 'foo', + title: 'Foo', + description: 'Foo description', + features: [], + systematic: false, + icon: 'wsAnalytics', + }) + ); + }); }); describe('workspace utils: isEqualWorkspaceUseCase', () => { @@ -661,6 +785,28 @@ describe('workspace utils: isEqualWorkspaceUseCase', () => { ) ).toEqual(true); }); + + it('should return false for different feature details', () => { + const featureWithDetails = { + id: 'foo', + title: 'Foo', + details: ['Bar'], + }; + const featureWithOtherDetails = { + id: 'foo', + title: 'Foo', + details: ['Baz'], + }; + expect( + isEqualWorkspaceUseCase( + { ...useCaseMock, features: [featureWithDetails] }, + { + ...useCaseMock, + features: [featureWithOtherDetails], + } + ) + ).toEqual(false); + }); it('should return true when all properties equal', () => { expect( isEqualWorkspaceUseCase(useCaseMock, { diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index 9cadc2f54054..77c005cd7eea 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -15,8 +15,8 @@ import { ApplicationStart, HttpSetup, NotificationsStart, -} from '../../../core/public'; -import { + fulfillRegistrationLinksToChromeNavLinks, + ChromeNavLink, App, AppCategory, AppNavLinkStatus, @@ -360,23 +360,53 @@ export const getIsOnlyAllowEssentialUseCase = async (client: SavedObjectsStart[' return false; }; -export const convertNavGroupToWorkspaceUseCase = ({ - id, - title, - description, - navLinks, - type, - order, - icon, -}: NavGroupItemInMap): WorkspaceUseCase => ({ - id, - title, - description, - features: navLinks.map((item) => ({ id: item.id, title: item.title })), - systematic: type === NavGroupType.SYSTEM || id === ALL_USE_CASE_ID, - order, - icon, -}); +export const convertNavGroupToWorkspaceUseCase = ( + { id, title, description, navLinks, type, order, icon }: NavGroupItemInMap, + allNavLinks: ChromeNavLink[] +): WorkspaceUseCase => { + const visibleNavLinks = allNavLinks.filter((link) => !link.hidden); + const visibleNavLinksWithinNavGroup = fulfillRegistrationLinksToChromeNavLinks( + navLinks, + visibleNavLinks + ); + const features: WorkspaceUseCaseFeature[] = []; + const category2NavLinks: { [key: string]: WorkspaceUseCaseFeature & { details: string[] } } = {}; + for (const { id: featureId, title: featureTitle, category } of visibleNavLinksWithinNavGroup) { + const lowerFeatureId = featureId.toLowerCase(); + // Filter out overview and getting started links + if (lowerFeatureId.endsWith('overview') || lowerFeatureId.endsWith('started')) { + continue; + } + if (!category) { + features.push({ id: featureId, title: featureTitle }); + continue; + } + // Filter out custom features + if (category.id === 'custom') { + continue; + } + if (!category2NavLinks[category.id]) { + category2NavLinks[category.id] = { + id: category.id, + title: category.label, + details: [], + }; + } + if (featureTitle) { + category2NavLinks[category.id].details.push(featureTitle); + } + } + features.push(...Object.values(category2NavLinks)); + return { + id, + title, + description, + features, + systematic: type === NavGroupType.SYSTEM || id === ALL_USE_CASE_ID, + order, + icon, + }; +}; const compareFeatures = ( features1: WorkspaceUseCaseFeature[], @@ -384,7 +414,7 @@ const compareFeatures = ( ) => { const featuresSerializer = (features: WorkspaceUseCaseFeature[]) => features - .map(({ id, title }) => `${id}-${title}`) + .map(({ id, title, details }) => `${id}-${title}-${details?.join('')}`) .sort() .join(); return featuresSerializer(features1) === featuresSerializer(features2); From 03ec9b9c314cac58d657ede7cb0a7ff2c1c5aecf Mon Sep 17 00:00:00 2001 From: Miki Date: Wed, 2 Oct 2024 10:34:22 -0700 Subject: [PATCH 19/31] Temporary fix for a bug with Chrome 129 when handling mask-image (#8340) * Mitigate @elastic/charts' use of `mask-image` Signed-off-by: Miki --- src/core/public/styles/_base.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/public/styles/_base.scss b/src/core/public/styles/_base.scss index e3b92e861449..237d15d35faa 100644 --- a/src/core/public/styles/_base.scss +++ b/src/core/public/styles/_base.scss @@ -83,6 +83,8 @@ $euiCollapsibleNavWidth: $euiSize * 20; .euiFlyoutBody .euiFlyoutBody__overflow.euiFlyoutBody__overflow--hasBanner, .euiModalBody .euiModalBody__overflow, .euiSelectableList__list, +// From @elastic/charts: any ruleset containing mask-image +.echLegend .echLegendListContainer, // For OSD: consumers of eui?ScrollWithShadows .osdQueryBar__textarea:not(:focus):not(:invalid), .osdSavedQueryManagement__list, From 391e2ac950456d91e3ab2b1d3205929e530f0d7d Mon Sep 17 00:00:00 2001 From: Miki Date: Wed, 2 Oct 2024 10:54:30 -0700 Subject: [PATCH 20/31] Fix dynamic uses of i18n and correct duplicate i18n identifiers in console plugin (#8393) * Fix dynamic uses of i18n and correct duplicate i18n identifiers in console plugin Signed-off-by: Miki * Changeset file for PR #8393 created/updated --------- Signed-off-by: Miki Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8393.yml | 2 ++ .../application/components/import_flyout.tsx | 2 +- .../editor/legacy/console_editor/editor.tsx | 24 +++++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) create mode 100644 changelogs/fragments/8393.yml diff --git a/changelogs/fragments/8393.yml b/changelogs/fragments/8393.yml new file mode 100644 index 000000000000..e1b1031516af --- /dev/null +++ b/changelogs/fragments/8393.yml @@ -0,0 +1,2 @@ +fix: +- Fix dynamic uses of i18n and correct duplicate i18n identifiers in console plugin ([#8393](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8393)) \ No newline at end of file diff --git a/src/plugins/console/public/application/components/import_flyout.tsx b/src/plugins/console/public/application/components/import_flyout.tsx index 0dd0112048e1..ef16dbefd6ef 100644 --- a/src/plugins/console/public/application/components/import_flyout.tsx +++ b/src/plugins/console/public/application/components/import_flyout.tsx @@ -215,7 +215,7 @@ export const ImportFlyout = ({ close, refresh }: ImportFlyoutProps) => { } else { setStatus('error'); setError( - i18n.translate('console.ImportFlyout.importFileErrorMessage', { + i18n.translate('console.ImportFlyout.importFileErrorMessage.notJSON', { defaultMessage: 'The selected file is not valid. Please select a valid JSON file.', }) ); diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx index 4422cbc4743f..e45cf876d1e4 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx @@ -228,10 +228,14 @@ function EditorUI({ initialTextValue, dataSourceId }: EditorProps) { }); }, [sendCurrentRequestToOpenSearch, openDocumentation]); - const tooltipDefaultMessage = - dataSourceId === undefined ? `Select a data source` : `Click to send request`; - - const toolTipButtonDiasbled = dataSourceId === undefined; + const toolTipButtonDisabled = dataSourceId === undefined; + const sendLabel = toolTipButtonDisabled + ? i18n.translate('console.sendRequestButtonTooltip.withoutDataSourceId', { + defaultMessage: 'Select a data source', + }) + : i18n.translate('console.sendRequestButtonTooltip', { + defaultMessage: 'Send request', + }); return (
@@ -243,19 +247,13 @@ function EditorUI({ initialTextValue, dataSourceId }: EditorProps) { responsive={false} > - + From 73787386ddb9b3459b4302232395f6a04d0fb7f1 Mon Sep 17 00:00:00 2001 From: Viraj Sanghvi Date: Wed, 2 Oct 2024 11:27:05 -0700 Subject: [PATCH 21/31] fix: Show loading indicator in recents popover button (#8430) Signed-off-by: Viraj Sanghvi --- .../header/__snapshots__/header.test.tsx.snap | 178 +++++++++++++++++- .../__snapshots__/recent_items.test.tsx.snap | 42 +++++ src/core/public/chrome/ui/header/header.tsx | 1 + .../public/chrome/ui/header/recent_items.scss | 9 + .../chrome/ui/header/recent_items.test.tsx | 8 + .../public/chrome/ui/header/recent_items.tsx | 53 ++++-- 6 files changed, 272 insertions(+), 19 deletions(-) diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 2efa9b705b84..28d88b9bf277 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -9339,7 +9339,45 @@ exports[`Header renders application header without title and breadcrumbs 1`] = ` "closed": false, "hasError": false, "isStopped": false, - "observers": Array [], + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], "thrownError": null, } } @@ -10263,6 +10301,55 @@ exports[`Header renders application header without title and breadcrumbs 1`] = ` "put": [MockFunction], } } + loadingCount$={ + BehaviorSubject { + "_isScalar": false, + "_value": 0, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } navLinks$={ BehaviorSubject { "_isScalar": false, @@ -18621,7 +18708,45 @@ exports[`Header renders page header with application title 1`] = ` "closed": false, "hasError": false, "isStopped": false, - "observers": Array [], + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], "thrownError": null, } } @@ -19544,6 +19669,55 @@ exports[`Header renders page header with application title 1`] = ` "put": [MockFunction], } } + loadingCount$={ + BehaviorSubject { + "_isScalar": false, + "_value": 0, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } navLinks$={ BehaviorSubject { "_isScalar": false, diff --git a/src/core/public/chrome/ui/header/__snapshots__/recent_items.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/recent_items.test.tsx.snap index 17634ee1c3fd..5dd325c9dfed 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/recent_items.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/recent_items.test.tsx.snap @@ -33,3 +33,45 @@ exports[`Recent items should render base element normally 1`] = `
`; + +exports[`Recent items should show loading indicator if loading count > 0 1`] = ` + +
+
+
+ + + +
+
+
+ +`; diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 784eb0dfc464..b46a65be30c8 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -491,6 +491,7 @@ export function Header({ navigateToUrl={application.navigateToUrl} renderBreadcrumbs={renderBreadcrumbs(true, true)} buttonSize={useApplicationHeader ? 's' : 'xs'} + loadingCount$={observables.loadingCount$} /> ); diff --git a/src/core/public/chrome/ui/header/recent_items.scss b/src/core/public/chrome/ui/header/recent_items.scss index b82a533ca1c4..a7d88f668d05 100644 --- a/src/core/public/chrome/ui/header/recent_items.scss +++ b/src/core/public/chrome/ui/header/recent_items.scss @@ -7,4 +7,13 @@ .primaryApplicationHeader & { margin-top: $euiSizeXS; } + + // Make the spinner the same size as an xs icon button + &.headerRecentItemsButton--loadingIndicator { + width: $euiButtonHeightXSmall; + + & > span { + padding: 0; + } + } } diff --git a/src/core/public/chrome/ui/header/recent_items.test.tsx b/src/core/public/chrome/ui/header/recent_items.test.tsx index 6d3f6f329907..d01912e9c27f 100644 --- a/src/core/public/chrome/ui/header/recent_items.test.tsx +++ b/src/core/public/chrome/ui/header/recent_items.test.tsx @@ -53,6 +53,7 @@ const defaultMockProps = { basePath: httpServiceMock.createStartContract().basePath, http: httpServiceMock.createSetupContract(), renderBreadcrumbs: <>, + loadingCount$: new BehaviorSubject(0), }; jest.spyOn(defaultMockProps.http, 'get').mockImplementation( @@ -150,4 +151,11 @@ describe('Recent items', () => { fireEvent.click(preferencesButton); expect(screen.getByTestId('preferencesSettingPopover')).toBeInTheDocument(); }); + + it('should show loading indicator if loading count > 0', () => { + const { baseElement } = render( + + ); + expect(baseElement).toMatchSnapshot(); + }); }); diff --git a/src/core/public/chrome/ui/header/recent_items.tsx b/src/core/public/chrome/ui/header/recent_items.tsx index 153193780a21..ee0554ece08d 100644 --- a/src/core/public/chrome/ui/header/recent_items.tsx +++ b/src/core/public/chrome/ui/header/recent_items.tsx @@ -30,6 +30,7 @@ import { WorkspaceObject } from '../../../workspace'; import { HttpStart } from '../../../http'; import { createRecentNavLink } from './nav_link'; import { ChromeNavLink } from '../../../'; +import { LoadingIndicator } from '../loading_indicator'; import './recent_items.scss'; const widthForRightMargin = 8; @@ -43,6 +44,7 @@ export interface Props { renderBreadcrumbs: React.JSX.Element; buttonSize?: EuiHeaderSectionItemButtonProps['size']; http: HttpStart; + loadingCount$: Rx.Observable; } interface SavedObjectMetadata { @@ -109,6 +111,7 @@ export const RecentItems = ({ renderBreadcrumbs, buttonSize = 's', http, + loadingCount$, }: Props) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isPreferencesPopoverOpen, setIsPreferencesPopoverOpen] = useState(false); @@ -118,7 +121,8 @@ export const RecentItems = ({ const [detailedSavedObjects, setDetailedSavedObjects] = useState( [] ); - const navLinks = useObservable(navLinks$, []).filter((link) => !link.hidden); + const navLinks = useObservable(navLinks$, []); + const loadingCount = useObservable(loadingCount$, 0); const handleItemClick = (link: string) => { navigateToUrl(link); @@ -163,6 +167,15 @@ export const RecentItems = ({ /> ); + + const recentButtonCommonProps = { + 'aria-expanded': isPopoverOpen, + 'aria-label': i18n.translate('core.ui.chrome.headerGlobalNav.viewRecentItemsAriaLabel', { + defaultMessage: 'View recents', + }), + onClick: () => setIsPopoverOpen((prev) => !prev), + 'data-test-subj': 'recentItemsSectionButton', + }; const recentButton = ( - { - setIsPopoverOpen((prev) => !prev); - }} - data-test-subj="recentItemsSectionButton" - className="headerRecentItemsButton" - /> + {!(loadingCount > 0) ? ( + + ) : ( + + + + )} ); @@ -199,6 +216,7 @@ export const RecentItems = ({ if (savedObjects.length) { bulkGetDetail(savedObjects, http).then((res) => { + const filteredNavLinks = navLinks.filter((link) => !link.hidden); const formatDetailedSavedObjects = res.map((obj) => { const recentAccessItem = recentlyAccessedItems.find( (item) => item.id === obj.id @@ -213,7 +231,8 @@ export const RecentItems = ({ ...recentAccessItem.meta, updatedAt: moment(obj?.updated_at).valueOf(), workspaceName: findWorkspace?.name, - link: createRecentNavLink(recentAccessItem, navLinks, basePath, navigateToUrl).href, + link: createRecentNavLink(recentAccessItem, filteredNavLinks, basePath, navigateToUrl) + .href, }; }); // here I write this argument to avoid Unnecessary re-rendering From 2d4394d139939d25cad84d663dc3cd648a3b0c9f Mon Sep 17 00:00:00 2001 From: Miki Date: Wed, 2 Oct 2024 11:35:45 -0700 Subject: [PATCH 22/31] Fix dynamic uses of i18n in opensearchDashboardsReact plugin (#8404) * Fix dynamic uses of i18n in opensearchDashboardsReact plugin Signed-off-by: Miki * Update edit_action_dropdown.tsx Signed-off-by: Miki * Changeset file for PR #8404 created/updated --------- Signed-off-by: Miki Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8404.yml | 2 ++ .../public/table_list_view/edit_action_dropdown.tsx | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 changelogs/fragments/8404.yml diff --git a/changelogs/fragments/8404.yml b/changelogs/fragments/8404.yml new file mode 100644 index 000000000000..e3b43a20056d --- /dev/null +++ b/changelogs/fragments/8404.yml @@ -0,0 +1,2 @@ +fix: +- Fix dynamic uses of i18n in opensearchDashboardsReact plugin ([#8404](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8404)) \ No newline at end of file diff --git a/src/plugins/opensearch_dashboards_react/public/table_list_view/edit_action_dropdown.tsx b/src/plugins/opensearch_dashboards_react/public/table_list_view/edit_action_dropdown.tsx index 2b155baa0328..d71a3ba3f9f1 100644 --- a/src/plugins/opensearch_dashboards_react/public/table_list_view/edit_action_dropdown.tsx +++ b/src/plugins/opensearch_dashboards_react/public/table_list_view/edit_action_dropdown.tsx @@ -90,7 +90,9 @@ export const EditActionDropdown: React.FC = ({ }} data-test-subj="dashboardEditDashboard" > - {i18n.translate('editActionDropdown.edit', { defaultMessage: 'Edit' })} + {i18n.translate('opensearch-dashboards-react.editActionDropdown.edit', { + defaultMessage: 'Edit', + })} , ]; if (isVisBuilderCompatible) { @@ -101,7 +103,7 @@ export const EditActionDropdown: React.FC = ({ onClick={handleImportToVisBuilder} data-test-subj="dashboardImportToVisBuilder" > - {i18n.translate('editActionDropdown.importToVisBuilder', { + {i18n.translate('opensearch-dashboards-react.editActionDropdown.importToVisBuilder', { defaultMessage: 'Import to VisBuilder', })} From 1f9da21259517dd7ab4d8daab54e7fd0fde45e99 Mon Sep 17 00:00:00 2001 From: Miki Date: Wed, 2 Oct 2024 12:12:52 -0700 Subject: [PATCH 23/31] Fix dynamic uses of i18n in visTypeTimeline plugin (#8400) Signed-off-by: Miki --- .../server/series_functions/graphite.js | 11 +++-------- .../server/series_functions/graphite.test.js | 6 ++---- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/plugins/vis_type_timeline/server/series_functions/graphite.js b/src/plugins/vis_type_timeline/server/series_functions/graphite.js index abf4b0a691f3..d305fdff820a 100644 --- a/src/plugins/vis_type_timeline/server/series_functions/graphite.js +++ b/src/plugins/vis_type_timeline/server/series_functions/graphite.js @@ -35,12 +35,6 @@ import moment from 'moment'; import Datasource from '../lib/classes/datasource'; import { isValidConfig } from './helpers/graphite_helper'; -const MISS_CHECKLIST_MESSAGE = `Please configure on the opensearch_dashboards.yml file. -You can always enable the default allowlist configuration.`; - -const INVALID_URL_MESSAGE = `The Graphite URL provided by you is invalid. -Please update your config from OpenSearch Dashboards's Advanced Setting.`; - export default new Datasource('graphite', { args: [ { @@ -72,7 +66,8 @@ export default new Datasource('graphite', { if (allowedUrls.length === 0 && blockedIPs.length === 0) { throw new Error( i18n.translate('timeline.help.functions.missCheckGraphiteConfig', { - defaultMessage: MISS_CHECKLIST_MESSAGE, + defaultMessage: + 'Please configure on the opensearch_dashboards.yml file. You can always enable the default allowlist configuration.', }) ); } @@ -80,7 +75,7 @@ export default new Datasource('graphite', { if (!isValidConfig(blockedIPs, allowedUrls, configuredUrl)) { throw new Error( i18n.translate('timeline.help.functions.invalidGraphiteConfig', { - defaultMessage: INVALID_URL_MESSAGE, + defaultMessage: `The Graphite URL provided by you is invalid. Please update your config from OpenSearch Dashboards' Advanced Settings.`, }) ); } diff --git a/src/plugins/vis_type_timeline/server/series_functions/graphite.test.js b/src/plugins/vis_type_timeline/server/series_functions/graphite.test.js index e251f3963a17..10d75ffcd599 100644 --- a/src/plugins/vis_type_timeline/server/series_functions/graphite.test.js +++ b/src/plugins/vis_type_timeline/server/series_functions/graphite.test.js @@ -32,11 +32,9 @@ const expect = require('chai').expect; import fn from './graphite'; -const MISS_CHECKLIST_MESSAGE = `Please configure on the opensearch_dashboards.yml file. -You can always enable the default allowlist configuration.`; +const MISS_CHECKLIST_MESSAGE = `Please configure on the opensearch_dashboards.yml file. You can always enable the default allowlist configuration.`; -const INVALID_URL_MESSAGE = `The Graphite URL provided by you is invalid. -Please update your config from OpenSearch Dashboards's Advanced Setting.`; +const INVALID_URL_MESSAGE = `The Graphite URL provided by you is invalid. Please update your config from OpenSearch Dashboards' Advanced Settings.`; jest.mock('node-fetch', () => (url) => { if (url.includes('redirect')) { From 8ff905f88029a2a0d8e8a0ddf3c6fc34633618a5 Mon Sep 17 00:00:00 2001 From: Miki Date: Wed, 2 Oct 2024 16:18:57 -0700 Subject: [PATCH 24/31] Fix dynamic uses of i18n in core (#8392) * Fix dynamic uses of i18n in core Signed-off-by: Miki * Changeset file for PR #8392 created/updated --------- Signed-off-by: Miki Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8392.yml | 2 ++ src/core/public/i18n/i18n_eui_mapping.tsx | 2 +- src/core/server/rendering/views/template.tsx | 7 +++++-- 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 changelogs/fragments/8392.yml diff --git a/changelogs/fragments/8392.yml b/changelogs/fragments/8392.yml new file mode 100644 index 000000000000..618bfdf06517 --- /dev/null +++ b/changelogs/fragments/8392.yml @@ -0,0 +1,2 @@ +fix: +- Fix dynamic uses of i18n in core ([#8392](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8392)) \ No newline at end of file diff --git a/src/core/public/i18n/i18n_eui_mapping.tsx b/src/core/public/i18n/i18n_eui_mapping.tsx index d3294085e096..5196199c6804 100644 --- a/src/core/public/i18n/i18n_eui_mapping.tsx +++ b/src/core/public/i18n/i18n_eui_mapping.tsx @@ -315,7 +315,7 @@ export const getEuiContextMapping = () => { ), 'euiFilterButton.filterBadge': ({ count, hasActiveFilters }: EuiValues) => i18n.translate('core.euiFilterButton.filterBadge', { - defaultMessage: '${count} ${filterCountLabel} filters', + defaultMessage: '{count} {filterCountLabel} filters', values: { count, filterCountLabel: hasActiveFilters ? 'active' : 'available' }, }), 'euiForm.addressFormErrors': i18n.translate('core.euiForm.addressFormErrors', { diff --git a/src/core/server/rendering/views/template.tsx b/src/core/server/rendering/views/template.tsx index 992cd117be6a..bb5fd83d74ea 100644 --- a/src/core/server/rendering/views/template.tsx +++ b/src/core/server/rendering/views/template.tsx @@ -170,11 +170,14 @@ export const Template: FunctionComponent = ({
{i18n('core.ui.welcomeMessage', { - defaultMessage: `Loading ${applicationTitle}`, + defaultMessage: 'Loading {applicationTitle}', + values: { applicationTitle }, })}
{/* Show a progress bar if a static custom branded logo is used */} From 90364ee8139dae78530672bda1197617e5f18f25 Mon Sep 17 00:00:00 2001 From: Miki Date: Wed, 2 Oct 2024 16:25:58 -0700 Subject: [PATCH 25/31] Fix unprefixed i18n identifiers in dashboard plugin (#8401) * Fix unprefixed i18n identifiers in dashboard plugin Signed-off-by: Miki * Changeset file for PR #8401 created/updated --------- Signed-off-by: Miki Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8401.yml | 2 ++ .../components/dashboard_top_nav/dashboard_top_nav.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 changelogs/fragments/8401.yml diff --git a/changelogs/fragments/8401.yml b/changelogs/fragments/8401.yml new file mode 100644 index 000000000000..5bc0654bf3a9 --- /dev/null +++ b/changelogs/fragments/8401.yml @@ -0,0 +1,2 @@ +fix: +- Fix unprefixed i18n identifiers in dashboard plugin ([#8401](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8401)) \ No newline at end of file diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/components/dashboard_top_nav/dashboard_top_nav.tsx index 925d954bd88f..c4133a3e6c6d 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/dashboard_top_nav.tsx @@ -147,7 +147,7 @@ const TopNav = ({ className={isFullScreenMode ? 'osdTopNavMenu-isFullScreen' : undefined} screenTitle={ currentAppState.title || - i18n.translate('discover.savedSearch.newTitle', { + i18n.translate('dashboard.savedSearch.newTitle', { defaultMessage: 'New dashboard', }) } From db7d354b5c6f0810fadf19d0d776d0d0e311b7d4 Mon Sep 17 00:00:00 2001 From: Sean Li Date: Wed, 2 Oct 2024 17:32:04 -0700 Subject: [PATCH 26/31] fixing i18n on cache string (#8455) Signed-off-by: Sean Li --- .../data/public/ui/dataset_selector/dataset_explorer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/data/public/ui/dataset_selector/dataset_explorer.tsx b/src/plugins/data/public/ui/dataset_selector/dataset_explorer.tsx index 9d430b2bb312..20b6b203d528 100644 --- a/src/plugins/data/public/ui/dataset_selector/dataset_explorer.tsx +++ b/src/plugins/data/public/ui/dataset_selector/dataset_explorer.tsx @@ -117,13 +117,13 @@ export const DatasetExplorer = ({ + />{' '} From 638f753241dc0eccb089e3225e7a574ee41355a0 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Thu, 3 Oct 2024 10:12:16 +0800 Subject: [PATCH 27/31] [Workspace]Fix use case hidden features not accessible (#8445) * Fix use case hidden features not accessible Signed-off-by: Lin Wang * Changeset file for PR #8445 created/updated * Change to useCaseWithDisplayedFeatures Signed-off-by: Lin Wang * Remove not used import Signed-off-by: Lin Wang * Update src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.tsx Co-authored-by: Miki Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> Co-authored-by: Miki --- changelogs/fragments/8445.yml | 2 + .../workspace_use_case.test.tsx | 65 +++-- .../workspace_use_case_flyout.test.tsx | 219 +++++++++++++-- .../workspace_use_case_flyout.tsx | 257 +++++++++++++----- .../public/services/use_case_service.test.ts | 22 -- .../public/services/use_case_service.ts | 9 +- src/plugins/workspace/public/types.ts | 1 - src/plugins/workspace/public/utils.test.ts | 175 +----------- src/plugins/workspace/public/utils.ts | 52 +--- 9 files changed, 460 insertions(+), 342 deletions(-) create mode 100644 changelogs/fragments/8445.yml diff --git a/changelogs/fragments/8445.yml b/changelogs/fragments/8445.yml new file mode 100644 index 000000000000..a67ae4520557 --- /dev/null +++ b/changelogs/fragments/8445.yml @@ -0,0 +1,2 @@ +fix: +- [Workspace]Fix use case hidden features not accessible ([#8445](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8445)) \ No newline at end of file diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx index 85725c452051..a8273e5e6dab 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx @@ -5,33 +5,60 @@ import React from 'react'; import { fireEvent, render, waitFor, within } from '@testing-library/react'; +import { BehaviorSubject } from 'rxjs'; + import { DEFAULT_NAV_GROUPS } from '../../../../../core/public'; +import { coreMock } from '../../../../../core/public/mocks'; +import { OpenSearchDashboardsContextProvider } from '../../../../../plugins/opensearch_dashboards_react/public'; import { WorkspaceUseCase, WorkspaceUseCaseProps } from './workspace_use_case'; import { WorkspaceFormErrors } from './types'; const setup = (options?: Partial) => { + const coreStartMock = coreMock.createStart(); const onChangeMock = jest.fn(); const formErrors: WorkspaceFormErrors = {}; - const renderResult = render( - + new BehaviorSubject({ + [DEFAULT_NAV_GROUPS.observability.id]: { + ...DEFAULT_NAV_GROUPS.observability, + navLinks: [ + { id: 'feature1', title: 'Feature 1' }, + { id: 'feature2', title: 'Feature 2' }, + ], }, - ]} - value="" - onChange={onChangeMock} - formErrors={formErrors} - {...options} - /> + }) + ); + + coreStartMock.chrome.navLinks.getNavLinks$.mockImplementation( + () => + new BehaviorSubject([ + { id: 'feature1', title: 'Feature 1', baseUrl: '', href: '' }, + { id: 'feature2', title: 'Feature 2', baseUrl: '', href: '' }, + ]) + ); + const renderResult = render( + + + ); return { renderResult, diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.test.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.test.tsx index 2cc4efd8cf6d..6507c9df5412 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.test.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.test.tsx @@ -4,9 +4,16 @@ */ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; -import { DEFAULT_NAV_GROUPS } from '../../../../../core/public'; +import { DEFAULT_NAV_GROUPS, NavGroupItemInMap } from '../../../../../core/public'; +import { coreMock } from '../../../../../core/public/mocks'; +import { OpenSearchDashboardsContextProvider } from '../../../../../plugins/opensearch_dashboards_react/public'; -import { WorkspaceUseCaseFlyout } from './workspace_use_case_flyout'; +import { + WorkspaceUseCaseFlyout, + fulfillUseCaseWithDisplayedFeatures, +} from './workspace_use_case_flyout'; +import { AvailableUseCaseItem } from './types'; +import { BehaviorSubject } from 'rxjs'; const mockAvailableUseCases = [ { @@ -18,7 +25,6 @@ const mockAvailableUseCases = [ { id: 'feature-1', title: 'Feature 1', - details: ['Detail 1', 'Detail 2'], }, { id: 'feature-2', @@ -36,46 +42,213 @@ const mockAvailableUseCases = [ }, ]; +const setup = ({ + onClose = jest.fn(), + availableUseCases = mockAvailableUseCases, + defaultExpandUseCase, + unsetChrome = false, +}: Partial<{ + availableUseCases: AvailableUseCaseItem[]; + onClose: () => void; + defaultExpandUseCase: string; + unsetChrome: boolean; +}> = {}) => { + const coreStartMock = coreMock.createStart(); + const navGroupsMap$ = new BehaviorSubject>({ + 'use-case-1': { + id: 'use-case-1', + title: 'Use case 1', + description: 'This is the description for Use Case 1', + navLinks: [ + { + id: 'feature-1', + title: 'Feature 1', + }, + { + id: 'feature-2', + title: 'Feature 2', + }, + ], + }, + 'use-case-2': { + id: 'use-case-2', + title: 'Use case 2', + description: 'This is the description for Use Case 2', + navLinks: [], + }, + [DEFAULT_NAV_GROUPS.all.id]: { + ...DEFAULT_NAV_GROUPS.all, + navLinks: [], + }, + }); + const navLinks$ = new BehaviorSubject([ + { id: 'feature-1', title: 'Feature 1', baseUrl: '', href: '' }, + { id: 'feature-2', title: 'Feature 2', baseUrl: '', href: '' }, + ]); + + coreStartMock.chrome.navGroup.getNavGroupsMap$.mockImplementation(() => navGroupsMap$); + coreStartMock.chrome.navLinks.getNavLinks$.mockImplementation(() => navLinks$); + const renderResult = render( + + + + ); + return { + renderResult, + coreStartMock, + }; +}; + describe('WorkspaceUseCaseFlyout', () => { it('should render the flyout with the correct title and available use cases', () => { - render( - - ); + setup(); const title = screen.getByText('Use cases'); expect(title).toBeInTheDocument(); expect(screen.getByText(mockAvailableUseCases[0].title)).toBeInTheDocument(); - expect(screen.getByText(mockAvailableUseCases[0].title)).toBeInTheDocument(); + expect(screen.getByText(mockAvailableUseCases[1].title)).toBeInTheDocument(); }); it('should call the onClose callback when the close button is clicked', () => { const onCloseMock = jest.fn(); - render( - - ); + setup({ onClose: onCloseMock }); const closeButton = screen.getByTestId('euiFlyoutCloseButton'); fireEvent.click(closeButton); expect(onCloseMock).toHaveBeenCalled(); }); it('should expand the default use case if provided', () => { - render( - - ); + setup({ defaultExpandUseCase: 'use-case-1' }); const useCaseDescription = screen.getByText(/This is the description for Use Case 1/); expect(useCaseDescription).toBeInTheDocument(); }); it('should render "(all features)" suffix for "all use case"', () => { - render( - - ); + setup({ + availableUseCases: [{ ...DEFAULT_NAV_GROUPS.all, features: [] }], + }); expect(screen.getByText('(all features)')).toBeInTheDocument(); }); + + it('should render use case without "Features included:" if not exists in nav groups map', () => { + setup({ + availableUseCases: [ + { + id: 'not-exist', + title: 'Not exist use case', + description: 'This is a not exist use case', + features: [], + }, + ], + }); + expect(screen.getByText('Not exist use case')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Not exist use case')); + expect(screen.queryByText('Features included:')).toBeNull(); + }); + + it('should not render use case if chrome not provided', () => { + setup({ unsetChrome: true }); + + fireEvent.click(screen.getByText(mockAvailableUseCases[0].title)); + expect(screen.queryByText('Features included:')).toBeNull(); + }); +}); + +describe('fulfillUseCaseWithDisplayedFeatures', () => { + const allNavLinks = [ + { id: 'link1', title: 'Link 1', hidden: false, baseUrl: '', href: '' }, + { id: 'link2', title: 'Link 2', hidden: true, baseUrl: '', href: '' }, + { id: 'link3', title: 'Link 3', hidden: false, baseUrl: '', href: '' }, + { id: 'link4', title: 'Link 4', hidden: false, baseUrl: '', href: '' }, + { id: 'link5', title: 'Link 5', hidden: false, baseUrl: '', href: '' }, + { id: 'a_overview', title: 'A Overview', hidden: false, baseUrl: '', href: '' }, + { id: 'b-gettingStarted', title: 'B Getting Started', hidden: false, baseUrl: '', href: '' }, + ]; + + const useCase = { + id: 'useCase1', + title: 'Use Case 1', + features: [], + description: '', + }; + it('should filter out hidden links', () => { + const result = fulfillUseCaseWithDisplayedFeatures({ + allNavLinks, + navGroupNavLinks: [{ id: 'link2', title: 'Link 2' }], + useCase, + }); + + expect(result.displayedFeatures).toHaveLength(0); + }); + + it('should filter out overview/getting started links', () => { + const result = fulfillUseCaseWithDisplayedFeatures({ + allNavLinks, + navGroupNavLinks: [ + { id: 'a_overview', title: 'Overview' }, + { id: 'b-gettingStarted', title: 'Getting Started' }, + ], + useCase, + }); + + expect(result.displayedFeatures).toHaveLength(0); + }); + + it('should handle links without category', () => { + const result = fulfillUseCaseWithDisplayedFeatures({ + allNavLinks: [{ id: 'link1', title: 'Link 1', hidden: false, baseUrl: '', href: '' }], + navGroupNavLinks: [{ id: 'link1', title: 'Link 1' }], + useCase, + }); + + expect(result.displayedFeatures).toHaveLength(1); + expect(result.displayedFeatures).toContainEqual({ id: 'link1', title: 'Link 1' }); + }); + + it('should filter out custom features', () => { + const result = fulfillUseCaseWithDisplayedFeatures({ + allNavLinks, + navGroupNavLinks: [ + { + id: 'link3', + title: 'Link 3', + category: { id: 'custom', label: 'Custom' }, + }, + ], + useCase, + }); + + expect(result.displayedFeatures).toHaveLength(0); + }); + + it('should group features by category', () => { + const navGroupNavLinks = [ + { id: 'link1', title: 'Link 1', category: { id: 'category1', label: 'Category 1' } }, + { id: 'link4', title: 'Link 4', category: { id: 'category1', label: 'Category 1' } }, + { id: 'link5', title: 'Link 5' }, + ]; + + const result = fulfillUseCaseWithDisplayedFeatures({ + allNavLinks, + navGroupNavLinks, + useCase, + }); + + expect(result.displayedFeatures).toHaveLength(2); + + expect(result.displayedFeatures).toContainEqual({ + id: 'category1', + title: 'Category 1', + details: ['Link 1', 'Link 4'], + }); + expect(result.displayedFeatures).toContainEqual({ + id: 'link5', + title: 'Link 5', + }); + }); }); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.tsx index 7617f3ec14ea..74f758047ba2 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_use_case_flyout.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { i18n } from '@osd/i18n'; import { EuiFlyout, @@ -17,10 +17,79 @@ import { EuiFlexItem, EuiIcon, } from '@elastic/eui'; -import { ALL_USE_CASE_ID } from '../../../../../core/public'; +import { combineLatest } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { + ALL_USE_CASE_ID, + ChromeNavLink, + ChromeRegistrationNavLink, + fulfillRegistrationLinksToChromeNavLinks, +} from '../../../../../core/public'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { AvailableUseCaseItem } from './types'; +interface DisplayedFeature { + id: string; + title?: string; + details?: string[]; +} + +interface UseCaseWithDisplayedFeatures extends AvailableUseCaseItem { + displayedFeatures?: DisplayedFeature[]; +} + +export const fulfillUseCaseWithDisplayedFeatures = ({ + allNavLinks, + navGroupNavLinks, + useCase, +}: { + allNavLinks: ChromeNavLink[]; + navGroupNavLinks: ChromeRegistrationNavLink[]; + useCase: AvailableUseCaseItem; +}) => { + const visibleNavLinks = allNavLinks.filter((link) => !link.hidden); + const visibleNavLinksWithinNavGroup = fulfillRegistrationLinksToChromeNavLinks( + navGroupNavLinks, + visibleNavLinks + ); + const displayedFeatures: DisplayedFeature[] = []; + const category2NavLinks: { + [key: string]: DisplayedFeature & { details: string[] }; + } = {}; + for (const { id: featureId, title: featureTitle, category } of visibleNavLinksWithinNavGroup) { + const lowerFeatureId = featureId.toLowerCase(); + // Filter out overview and getting started links + if (lowerFeatureId.endsWith('overview') || lowerFeatureId.endsWith('started')) { + continue; + } + if (!category) { + displayedFeatures.push({ id: featureId, title: featureTitle }); + continue; + } + // Filter out custom features + if (category.id === 'custom') { + continue; + } + if (!category2NavLinks[category.id]) { + category2NavLinks[category.id] = { + id: category.id, + title: category.label, + details: [], + }; + } + if (featureTitle) { + category2NavLinks[category.id].details.push(featureTitle); + } + } + displayedFeatures.push(...Object.values(category2NavLinks)); + return { + ...useCase, + displayedFeatures, + }; +}; + const WORKSPACE_USE_CASE_FLYOUT_TITLE_ID = 'workspaceUseCaseFlyoutTitle'; export interface WorkspaceUseCaseFlyoutProps { @@ -34,6 +103,45 @@ export const WorkspaceUseCaseFlyout = ({ availableUseCases, defaultExpandUseCase, }: WorkspaceUseCaseFlyoutProps) => { + const [useCaseWithDisplayedFeatures, setUseCaseWithDisplayedFeatures] = useState< + UseCaseWithDisplayedFeatures[] + >([]); + const { + services: { chrome }, + } = useOpenSearchDashboards(); + + useEffect(() => { + if (!chrome) { + setUseCaseWithDisplayedFeatures(availableUseCases); + return; + } + const subscription = combineLatest([ + chrome.navGroup.getNavGroupsMap$(), + chrome.navLinks.getNavLinks$(), + ]) + .pipe( + map(([navGroupsMap, allNavLinks]) => { + return availableUseCases.flatMap((availableUseCase) => { + const navGroup = navGroupsMap[availableUseCase.id]; + if (!navGroup) { + return availableUseCase; + } + return fulfillUseCaseWithDisplayedFeatures({ + allNavLinks, + navGroupNavLinks: navGroup.navLinks, + useCase: availableUseCase, + }); + }); + }) + ) + .subscribe((fulfilledUseCases) => { + setUseCaseWithDisplayedFeatures(fulfilledUseCases); + }); + return () => { + subscription.unsubscribe(); + }; + }, [chrome, availableUseCases]); + return ( - {availableUseCases.map(({ id, icon, title, description, features }, index) => ( - - - {icon && ( - - + {useCaseWithDisplayedFeatures.map( + ({ id, icon, title, description, displayedFeatures }, index) => ( + + + {icon && ( + + + + )} + + +

+ {title} + {id === ALL_USE_CASE_ID && ( + <> +   + + + {i18n.translate( + 'workspace.forms.useCaseFlyout.allUseCaseSuffix', + { + defaultMessage: '(all features)', + } + )} + + + + )} +

+
- )} - - -

- {title} - {id === ALL_USE_CASE_ID && ( - <> -   - - - {i18n.translate('workspace.forms.useCaseFlyout.allUseCaseSuffix', { - defaultMessage: '(all features)', + + } + paddingSize="l" + initialIsOpen={id === defaultExpandUseCase} + > + + {description} +
+ {Array.isArray(displayedFeatures) && displayedFeatures.length > 0 && ( + <> + {i18n.translate('workspace.forms.useCaseFlyout.featuresIncluded', { + defaultMessage: 'Features included:', + })} +
+
    + {displayedFeatures.map( + ({ id: featureId, title: featureTitle, details }) => ( +
  • + + {i18n.translate('workspace.forms.useCaseFlyout.featureTitle', { + defaultMessage: + '{featureTitle}{detailsCount, plural, =0 {} other {: }}', + values: { featureTitle, detailsCount: details?.length ?? 0 }, })} - - - + + {details?.join( + i18n.translate( + 'workspace.forms.useCaseFlyout.featuresDetails.delimiter', + { + defaultMessage: ', ', + } + ) + )} +
  • + ) )} -

-
-
- - } - paddingSize="l" - initialIsOpen={id === defaultExpandUseCase} - > - - {description} -
- {features && features.length > 0 && ( - <> - {i18n.translate('workspace.forms.useCaseFlyout.featuresIncluded', { - defaultMessage: 'Features included:', - })} -
-
    - {features.map(({ id: featureId, title: featureTitle, details }) => ( -
  • - - {i18n.translate('workspace.forms.useCaseFlyout.featureTitle', { - defaultMessage: - '{featureTitle}{detailsCount, plural, =0 {} other {: }}', - values: { featureTitle, detailsCount: details?.length ?? 0 }, - })} - - {details?.join( - i18n.translate( - 'workspace.forms.useCaseFlyout.featuresDetails.delimiter', - { - defaultMessage: ', ', - } - ) - )} -
  • - ))} -
- - )} -
-
- {index < availableUseCases.length - 1 && } -
- ))} + + + )} + +
+ {index < useCaseWithDisplayedFeatures.length - 1 && } +
+ ) + )}
); diff --git a/src/plugins/workspace/public/services/use_case_service.test.ts b/src/plugins/workspace/public/services/use_case_service.test.ts index d810dc570bb3..43ca03490505 100644 --- a/src/plugins/workspace/public/services/use_case_service.test.ts +++ b/src/plugins/workspace/public/services/use_case_service.test.ts @@ -47,18 +47,12 @@ const setupUseCaseStart = (options?: { navGroupEnabled?: boolean }) => { ]); const navGroupsMap$ = new BehaviorSubject>(mockNavGroupsMap); const useCase = new UseCaseService(); - const navLinks$ = new BehaviorSubject([ - { id: 'dashboards', title: 'Dashboards', baseUrl: '', href: '' }, - { id: 'searchRelevance', title: 'Search Relevance', baseUrl: '', href: '' }, - ]); chrome.navGroup.getNavGroupEnabled.mockImplementation(() => options?.navGroupEnabled ?? true); chrome.navGroup.getNavGroupsMap$.mockImplementation(() => navGroupsMap$); - chrome.navLinks.getNavLinks$.mockImplementation(() => navLinks$); return { chrome, - navLinks$, navGroupsMap$, workspaceConfigurableApps$, useCaseStart: useCase.start({ @@ -194,22 +188,6 @@ describe('UseCaseService', () => { expect(fn).toHaveBeenCalledTimes(2); }); - it('should not emit after navLinks$ emit same value', async () => { - const { useCaseStart, navLinks$ } = setupUseCaseStart(); - const registeredUseCases$ = useCaseStart.getRegisteredUseCases$(); - const fn = jest.fn(); - - registeredUseCases$.subscribe(fn); - - expect(fn).toHaveBeenCalledTimes(1); - - navLinks$.next([...navLinks$.getValue()]); - expect(fn).toHaveBeenCalledTimes(1); - - navLinks$.next([...navLinks$.getValue()].slice(1)); - expect(fn).toHaveBeenCalledTimes(2); - }); - it('should move all use case to the last one', async () => { const { useCaseStart, navGroupsMap$ } = setupUseCaseStart(); diff --git a/src/plugins/workspace/public/services/use_case_service.ts b/src/plugins/workspace/public/services/use_case_service.ts index ca4f2b55c223..443f32323f74 100644 --- a/src/plugins/workspace/public/services/use_case_service.ts +++ b/src/plugins/workspace/public/services/use_case_service.ts @@ -118,12 +118,11 @@ export class UseCaseService { return { getRegisteredUseCases$: () => { if (chrome.navGroup.getNavGroupEnabled()) { - return combineLatest([chrome.navGroup.getNavGroupsMap$(), chrome.navLinks.getNavLinks$()]) + return chrome.navGroup + .getNavGroupsMap$() .pipe( - map(([navGroupsMap, allNavLinks]) => { - return Object.values(navGroupsMap).map((navGroup) => - convertNavGroupToWorkspaceUseCase(navGroup, allNavLinks) - ); + map((navGroupsMap) => { + return Object.values(navGroupsMap).map(convertNavGroupToWorkspaceUseCase); }) ) .pipe( diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts index 997e8c67fd05..53f2be4dfb36 100644 --- a/src/plugins/workspace/public/types.ts +++ b/src/plugins/workspace/public/types.ts @@ -20,7 +20,6 @@ export type Services = CoreStart & { export interface WorkspaceUseCaseFeature { id: string; title?: string; - details?: string[]; } export interface WorkspaceUseCase { diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts index a5f2a9c435e2..25e8d3085d43 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -37,23 +37,10 @@ const useCaseMock = { id: 'foo', title: 'Foo', description: 'Foo description', - features: [{ id: 'bar' }, { id: 'baz', title: 'Baz', details: ['Qux'] }], + features: [{ id: 'bar' }, { id: 'baz', title: 'Baz' }], systematic: false, order: 1, }; -const allNavLinksMock: ChromeNavLink[] = [ - { id: 'foo', title: 'Foo', baseUrl: '', href: '' }, - { id: 'bar', title: 'Bar', baseUrl: '', href: '' }, - { id: 'baz', title: 'Baz', baseUrl: '', href: '' }, - { id: 'qux', title: 'Qux', baseUrl: '', href: '' }, - { id: 'observability_overview', title: 'Observability Overview', baseUrl: '', href: '' }, - { - id: 'observability-gettingStarted', - title: 'Observability Getting Started', - baseUrl: '', - href: '', - }, -]; describe('workspace utils: featureMatchesConfig', () => { it('feature configured with `*` should match any features', () => { @@ -543,16 +530,13 @@ describe('workspace utils: getIsOnlyAllowEssentialUseCase', () => { describe('workspace utils: convertNavGroupToWorkspaceUseCase', () => { it('should convert nav group to consistent workspace use case', () => { expect( - convertNavGroupToWorkspaceUseCase( - { - id: 'foo', - title: 'Foo', - description: 'Foo description', - navLinks: [{ id: 'bar', title: 'Bar' }], - icon: 'wsAnalytics', - }, - allNavLinksMock - ) + convertNavGroupToWorkspaceUseCase({ + id: 'foo', + title: 'Foo', + description: 'Foo description', + navLinks: [{ id: 'bar', title: 'Bar' }], + icon: 'wsAnalytics', + }) ).toEqual({ id: 'foo', title: 'Foo', @@ -563,16 +547,13 @@ describe('workspace utils: convertNavGroupToWorkspaceUseCase', () => { }); expect( - convertNavGroupToWorkspaceUseCase( - { - id: 'foo', - title: 'Foo', - description: 'Foo description', - navLinks: [{ id: 'bar', title: 'Bar' }], - type: NavGroupType.SYSTEM, - }, - allNavLinksMock - ) + convertNavGroupToWorkspaceUseCase({ + id: 'foo', + title: 'Foo', + description: 'Foo description', + navLinks: [{ id: 'bar', title: 'Bar' }], + type: NavGroupType.SYSTEM, + }) ).toEqual({ id: 'foo', title: 'Foo', @@ -581,111 +562,6 @@ describe('workspace utils: convertNavGroupToWorkspaceUseCase', () => { systematic: true, }); }); - - it('should filter out overview features', () => { - expect( - convertNavGroupToWorkspaceUseCase( - { - id: 'foo', - title: 'Foo', - description: 'Foo description', - navLinks: [{ id: 'observability_overview', title: 'Observability Overview' }], - icon: 'wsAnalytics', - }, - allNavLinksMock - ) - ).toEqual( - expect.objectContaining({ - id: 'foo', - title: 'Foo', - description: 'Foo description', - features: [], - systematic: false, - icon: 'wsAnalytics', - }) - ); - }); - - it('should filter out getting started features', () => { - expect( - convertNavGroupToWorkspaceUseCase( - { - id: 'foo', - title: 'Foo', - description: 'Foo description', - navLinks: [ - { id: 'observability-gettingStarted', title: 'Observability Getting Started' }, - ], - icon: 'wsAnalytics', - }, - allNavLinksMock - ) - ).toEqual( - expect.objectContaining({ - id: 'foo', - title: 'Foo', - description: 'Foo description', - features: [], - systematic: false, - icon: 'wsAnalytics', - }) - ); - }); - - it('should grouped nav links by category', () => { - expect( - convertNavGroupToWorkspaceUseCase( - { - id: 'foo', - title: 'Foo', - description: 'Foo description', - navLinks: [ - { id: 'bar', title: 'Bar', category: { id: 'category-1', label: 'Category 1' } }, - { id: 'baz', title: 'Baz' }, - { id: 'qux', title: 'Qux', category: { id: 'category-1', label: 'Category 1' } }, - ], - icon: 'wsAnalytics', - }, - allNavLinksMock - ) - ).toEqual( - expect.objectContaining({ - id: 'foo', - title: 'Foo', - description: 'Foo description', - features: [ - { id: 'baz', title: 'Baz' }, - { id: 'category-1', title: 'Category 1', details: ['Bar', 'Qux'] }, - ], - systematic: false, - icon: 'wsAnalytics', - }) - ); - }); - - it('should filter out custom features', () => { - expect( - convertNavGroupToWorkspaceUseCase( - { - id: 'foo', - title: 'Foo', - description: 'Foo description', - navLinks: [{ id: 'bar', title: 'Bar', category: { id: 'custom', label: 'Custom' } }], - icon: 'wsAnalytics', - }, - allNavLinksMock - ) - ).toEqual( - expect.objectContaining({ - id: 'foo', - title: 'Foo', - description: 'Foo description', - features: [], - systematic: false, - icon: 'wsAnalytics', - }) - ); - }); }); describe('workspace utils: isEqualWorkspaceUseCase', () => { @@ -786,27 +662,6 @@ describe('workspace utils: isEqualWorkspaceUseCase', () => { ).toEqual(true); }); - it('should return false for different feature details', () => { - const featureWithDetails = { - id: 'foo', - title: 'Foo', - details: ['Bar'], - }; - const featureWithOtherDetails = { - id: 'foo', - title: 'Foo', - details: ['Baz'], - }; - expect( - isEqualWorkspaceUseCase( - { ...useCaseMock, features: [featureWithDetails] }, - { - ...useCaseMock, - features: [featureWithOtherDetails], - } - ) - ).toEqual(false); - }); it('should return true when all properties equal', () => { expect( isEqualWorkspaceUseCase(useCaseMock, { diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index 77c005cd7eea..4daa59690b3f 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -15,8 +15,6 @@ import { ApplicationStart, HttpSetup, NotificationsStart, - fulfillRegistrationLinksToChromeNavLinks, - ChromeNavLink, App, AppCategory, AppNavLinkStatus, @@ -360,48 +358,20 @@ export const getIsOnlyAllowEssentialUseCase = async (client: SavedObjectsStart[' return false; }; -export const convertNavGroupToWorkspaceUseCase = ( - { id, title, description, navLinks, type, order, icon }: NavGroupItemInMap, - allNavLinks: ChromeNavLink[] -): WorkspaceUseCase => { - const visibleNavLinks = allNavLinks.filter((link) => !link.hidden); - const visibleNavLinksWithinNavGroup = fulfillRegistrationLinksToChromeNavLinks( - navLinks, - visibleNavLinks - ); - const features: WorkspaceUseCaseFeature[] = []; - const category2NavLinks: { [key: string]: WorkspaceUseCaseFeature & { details: string[] } } = {}; - for (const { id: featureId, title: featureTitle, category } of visibleNavLinksWithinNavGroup) { - const lowerFeatureId = featureId.toLowerCase(); - // Filter out overview and getting started links - if (lowerFeatureId.endsWith('overview') || lowerFeatureId.endsWith('started')) { - continue; - } - if (!category) { - features.push({ id: featureId, title: featureTitle }); - continue; - } - // Filter out custom features - if (category.id === 'custom') { - continue; - } - if (!category2NavLinks[category.id]) { - category2NavLinks[category.id] = { - id: category.id, - title: category.label, - details: [], - }; - } - if (featureTitle) { - category2NavLinks[category.id].details.push(featureTitle); - } - } - features.push(...Object.values(category2NavLinks)); +export const convertNavGroupToWorkspaceUseCase = ({ + id, + title, + description, + navLinks, + type, + order, + icon, +}: NavGroupItemInMap): WorkspaceUseCase => { return { id, title, description, - features, + features: navLinks.map((navLink) => ({ id: navLink.id, title: navLink.title })), systematic: type === NavGroupType.SYSTEM || id === ALL_USE_CASE_ID, order, icon, @@ -414,7 +384,7 @@ const compareFeatures = ( ) => { const featuresSerializer = (features: WorkspaceUseCaseFeature[]) => features - .map(({ id, title, details }) => `${id}-${title}-${details?.join('')}`) + .map(({ id, title }) => `${id}-${title}`) .sort() .join(); return featuresSerializer(features1) === featuresSerializer(features2); From 49cca7bb69feb1e13b939133b98d47a8365ebd82 Mon Sep 17 00:00:00 2001 From: Tianyu Gao Date: Thu, 3 Oct 2024 13:20:24 +0800 Subject: [PATCH 28/31] [Workspace] feat: support use case populate for workspace create and list page (#8422) * feat: support use case populate for workspace create and list page Signed-off-by: tygao * style: update data source column width Signed-off-by: tygao * Changeset file for PR #8422 created/updated * update navigate function Signed-off-by: tygao * test: add utils test 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/8422.yml | 2 + src/plugins/workspace/public/application.tsx | 16 ++- .../public/components/utils/workspace.test.ts | 14 ++- .../public/components/utils/workspace.ts | 14 ++- .../workspace_creator.test.tsx | 10 ++ .../workspace_creator/workspace_creator.tsx | 35 ++++-- .../__snapshots__/index.test.tsx.snap | 118 +++--------------- .../workspace_list/default_workspace.test.tsx | 10 ++ .../components/workspace_list/index.test.tsx | 21 ++-- .../components/workspace_list/index.tsx | 83 ++++++------ src/plugins/workspace/public/utils.ts | 18 +++ 11 files changed, 164 insertions(+), 177 deletions(-) create mode 100644 changelogs/fragments/8422.yml diff --git a/changelogs/fragments/8422.yml b/changelogs/fragments/8422.yml new file mode 100644 index 000000000000..d8afcd0a7008 --- /dev/null +++ b/changelogs/fragments/8422.yml @@ -0,0 +1,2 @@ +feat: +- Support use case populate for workspace create and list page ([#8422](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8422)) \ No newline at end of file diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx index 27f0cf75aab9..bd197b5b7d3d 100644 --- a/src/plugins/workspace/public/application.tsx +++ b/src/plugins/workspace/public/application.tsx @@ -25,7 +25,13 @@ export const renderCreatorApp = ( ) => { ReactDOM.render( - + + + + + + + , element ); @@ -56,7 +62,13 @@ export const renderListApp = ( ) => { ReactDOM.render( - + + + + + + + , element ); diff --git a/src/plugins/workspace/public/components/utils/workspace.test.ts b/src/plugins/workspace/public/components/utils/workspace.test.ts index f1ab368fe8ce..2934ddd53cea 100644 --- a/src/plugins/workspace/public/components/utils/workspace.test.ts +++ b/src/plugins/workspace/public/components/utils/workspace.test.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { navigateToWorkspaceDetail } from './workspace'; +import { navigateToWorkspaceDetail, navigateToWorkspaceListWithUseCase } from './workspace'; import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; jest.mock('../../../../../core/public/utils'); @@ -62,4 +62,16 @@ describe('workspace utils', () => { expect(mockNavigateToUrl).not.toBeCalled(); }); }); + + describe('navigateToWorkspaceListWithUseCase', () => { + it('should redirect if newUrl is returned', () => { + coreStartMock.application.getUrlForApp.mockImplementation( + () => 'localhost:5601/app/workspace_list' + ); + navigateToWorkspaceListWithUseCase(coreStartMock.application, 'Search'); + expect(mockNavigateToUrl).toHaveBeenCalledWith( + 'localhost:5601/app/workspace_list#/?useCase=Search' + ); + }); + }); }); diff --git a/src/plugins/workspace/public/components/utils/workspace.ts b/src/plugins/workspace/public/components/utils/workspace.ts index e9565a07b857..7fabc73b39c7 100644 --- a/src/plugins/workspace/public/components/utils/workspace.ts +++ b/src/plugins/workspace/public/components/utils/workspace.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { WORKSPACE_DETAIL_APP_ID } from '../../../common/constants'; +import { WORKSPACE_DETAIL_APP_ID, WORKSPACE_LIST_APP_ID } from '../../../common/constants'; import { CoreStart } from '../../../../../core/public'; import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; import { DetailTab } from '../workspace_form/constants'; @@ -23,6 +23,18 @@ export const navigateToWorkspaceDetail = ( ); }; +export const navigateToWorkspaceListWithUseCase = ( + application: Core['application'], + useCaseTitle: string +) => { + const newUrl = application.getUrlForApp(WORKSPACE_LIST_APP_ID, { absolute: true }); + if (newUrl) { + const url = new URL(newUrl); + url.hash = `/?useCase=${useCaseTitle}`; + application.navigateToUrl(url.toString()); + } +}; + export const navigateToAppWithinWorkspace = ( { application, http }: Core, workspaceId: string, diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx index fd394a8bb574..c6edf7ac355d 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -19,6 +19,16 @@ import { DataSourceEngineType } from '../../../../data_source/common/data_source import { DataSourceConnectionType } from '../../../common/types'; import * as utils from '../../utils'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + search: '', + pathname: '', + hash: '', + state: undefined, + }), +})); + const workspaceClientCreate = jest .fn() .mockReturnValue({ result: { id: 'successResult' }, success: true }); diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index 13b868c164c6..96f6c1acc88f 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -3,11 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useState, useMemo } from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, euiPaletteColorBlind } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { BehaviorSubject } from 'rxjs'; +import { useLocation } from 'react-router-dom'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { WorkspaceFormSubmitData, WorkspaceOperationType } from '../workspace_form'; import { WORKSPACE_DETAIL_APP_ID } from '../../../common/constants'; @@ -53,16 +54,28 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { onlyAllowEssentialEnabled: true, }); - const defaultSelectedUseCase = availableUseCases?.[0]; - const defaultWorkspaceFormValues: Partial = { - color: euiPaletteColorBlind()[0], - ...(defaultSelectedUseCase - ? { - name: defaultSelectedUseCase.title, - features: [getUseCaseFeatureConfig(defaultSelectedUseCase.id)], - } - : {}), - }; + const location = useLocation(); + + const defaultWorkspaceFormValues = useMemo(() => { + let defaultSelectedUseCase; + const params = new URLSearchParams(location.search); + const useCaseTitle = params.get('useCase'); + if (useCaseTitle) { + defaultSelectedUseCase = + availableUseCases?.find(({ title }) => title === useCaseTitle) || availableUseCases?.[0]; + } else { + defaultSelectedUseCase = availableUseCases?.[0]; + } + return { + color: euiPaletteColorBlind()[0], + ...(defaultSelectedUseCase + ? { + name: defaultSelectedUseCase.title, + features: [getUseCaseFeatureConfig(defaultSelectedUseCase.id)], + } + : {}), + }; + }, [location.search, availableUseCases]); const handleWorkspaceFormSubmit = useCallback( async (data: WorkspaceFormSubmitData) => { diff --git a/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap b/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap index b30c67b37643..31b7c92abcbf 100644 --- a/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap +++ b/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap @@ -248,7 +248,7 @@ exports[`WorkspaceList should render title and table normally 1`] = ` data-test-subj="tableHeaderCell_description_2" role="columnheader" scope="col" - style="width: 15%;" + style="width: 18%;" > - - - Owners - - - -
-
- Owners -
-
- admin -   - - - - - - -
- -
-
- Owners -
-
- -
-
- Owners -
-
- -
{ }; }); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + search: '', + pathname: '', + hash: '', + state: undefined, + }), +})); + function getWrapUserDefaultWorkspaceList( workspaceList = [ { diff --git a/src/plugins/workspace/public/components/workspace_list/index.test.tsx b/src/plugins/workspace/public/components/workspace_list/index.test.tsx index 62709e4fe053..f7d031b4115b 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.test.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.test.tsx @@ -16,6 +16,16 @@ import { WorkspaceList } from './index'; jest.mock('../utils/workspace'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + search: '', + pathname: '', + hash: '', + state: undefined, + }), +})); + const mockNavigatorWrite = jest.fn(); jest.mock('@elastic/eui', () => { @@ -279,15 +289,4 @@ describe('WorkspaceList', () => { }); expect(navigateToWorkspaceDetail).toHaveBeenCalledTimes(1); }); - - it('should render owners badge when more than one owners', async () => { - const { getByTestId } = render(getWrapWorkspaceListInContext()); - expect(navigateToWorkspaceDetail).not.toHaveBeenCalled(); - await waitFor(() => { - const badge = getByTestId('workspaceList-more-collaborators-badge'); - expect(badge).toBeInTheDocument(); - fireEvent.click(badge); - }); - expect(navigateToWorkspaceDetail).toHaveBeenCalledTimes(1); - }); }); diff --git a/src/plugins/workspace/public/components/workspace_list/index.tsx b/src/plugins/workspace/public/components/workspace_list/index.tsx index 68b4e2acc886..65287a8ba89d 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.tsx @@ -22,17 +22,15 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, + Query, } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; import { BehaviorSubject, of } from 'rxjs'; import { i18n } from '@osd/i18n'; import { isString } from 'lodash'; import { startCase } from 'lodash'; -import { - DEFAULT_NAV_GROUPS, - WorkspaceAttribute, - WorkspaceAttributeWithPermission, -} from '../../../../../core/public'; +import { useLocation } from 'react-router-dom'; +import { WorkspaceAttribute, WorkspaceAttributeWithPermission } from '../../../../../core/public'; import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; import { navigateToWorkspaceDetail } from '../utils/workspace'; import { DetailTab } from '../workspace_form/constants'; @@ -40,10 +38,9 @@ import { DetailTab } from '../workspace_form/constants'; import { DEFAULT_WORKSPACE, WORKSPACE_CREATE_APP_ID } from '../../../common/constants'; import { DeleteWorkspaceModal } from '../delete_workspace_modal'; -import { getFirstUseCaseOfFeatureConfigs, getDataSourcesList } from '../../utils'; +import { getDataSourcesList, extractUseCaseTitleFromFeatures } from '../../utils'; import { WorkspaceUseCase } from '../../types'; import { NavigationPublicPluginStart } from '../../../../../plugins/navigation/public'; -import { WorkspacePermissionMode } from '../../../common/constants'; import { DataSourceAttributesWithWorkspaces } from '../../types'; export interface WorkspaceListProps { @@ -112,25 +109,11 @@ export const WorkspaceListInner = ({ const [allDataSources, setAllDataSources] = useState([]); // default workspace state const [defaultWorkspaceId, setDefaultWorkspaceId] = useState(undefined); + const [query, setQuery] = useState(undefined); - const dateFormat = uiSettings?.get('dateFormat'); + const location = useLocation(); - const extractUseCaseFromFeatures = useCallback( - (features: string[]) => { - if (!features || features.length === 0) { - return ''; - } - const useCaseId = getFirstUseCaseOfFeatureConfigs(features); - const usecase = - useCaseId === DEFAULT_NAV_GROUPS.all.id - ? DEFAULT_NAV_GROUPS.all - : registeredUseCases?.find(({ id }) => id === useCaseId); - if (usecase) { - return usecase.title; - } - }, - [registeredUseCases] - ); + const dateFormat = uiSettings?.get('dateFormat'); useEffect(() => { setDefaultWorkspaceId(uiSettings?.get(DEFAULT_WORKSPACE)); @@ -149,12 +132,33 @@ export const WorkspaceListInner = ({ .map((ds) => ds.title as string); return { ...workspace, - useCase: extractUseCaseFromFeatures(workspace.features ?? []), + useCase: extractUseCaseTitleFromFeatures( + registeredUseCases ?? [], + workspace.features ?? [] + ), dataSources: associatedDataSourcesTitles, }; } ); - }, [workspaceList, extractUseCaseFromFeatures, allDataSources]); + }, [workspaceList, allDataSources, registeredUseCases]); + + const useCaseFilterOptions = useMemo(() => { + return Array.from(new Set(newWorkspaceList.map(({ useCase }) => useCase).filter(Boolean))).map( + (useCase) => ({ + value: useCase!, + name: useCase!, + }) + ); + }, [newWorkspaceList]); + + useEffect(() => { + const params = new URLSearchParams(location.search); + const useCase = params.get('useCase'); + if (useCase && useCaseFilterOptions.find((item) => item.value === useCase)) { + setQuery((Query.parse(`useCase:"${useCase}"`) as unknown) as EuiSearchBarProps['query']); + } + }, [location.search, useCaseFilterOptions]); + const workspaceCreateUrl = useMemo(() => { if (!application) { return ''; @@ -341,18 +345,15 @@ export const WorkspaceListInner = ({ box: { incremental: true, }, + query, + onChange: (args) => setQuery((args.query as unknown) as EuiSearchBarProps['query']), filters: [ { type: 'field_value_selection', field: 'useCase', name: 'Use Case', multiSelect: false, - options: Array.from( - new Set(newWorkspaceList.map(({ useCase }) => useCase).filter(Boolean)) - ).map((useCase) => ({ - value: useCase!, - name: useCase!, - })), + options: useCaseFilterOptions, }, ], toolsLeft: renderToolsLeft(), @@ -397,7 +398,7 @@ export const WorkspaceListInner = ({ name: i18n.translate('workspace.list.columns.description.title', { defaultMessage: 'Description', }), - width: '15%', + width: '18%', render: (description: string) => ( ), }, - { - field: 'permissions', - name: i18n.translate('workspace.list.columns.owners.title', { defaultMessage: 'Owners' }), - width: '15%', - render: ( - permissions: WorkspaceAttributeWithPermission['permissions'], - item: WorkspaceAttributeWithPermission - ) => { - const owners = permissions?.[WorkspacePermissionMode.Write]?.users ?? []; - return renderDataWithMoreBadge(owners, 1, item.id, DetailTab.Collaborators); - }, - }, { field: 'permissionMode', name: i18n.translate('workspace.list.columns.permissions.title', { @@ -451,7 +440,7 @@ export const WorkspaceListInner = ({ name: i18n.translate('workspace.list.columns.lastUpdated.title', { defaultMessage: 'Last updated', }), - width: '15%', + width: '18%', truncateText: false, render: (lastUpdatedTime: string) => { return moment(lastUpdatedTime).format(dateFormat); @@ -459,7 +448,7 @@ export const WorkspaceListInner = ({ }, { field: 'dataSources', - width: '15%', + width: '18%', name: i18n.translate('workspace.list.columns.dataSources.title', { defaultMessage: 'Data sources', }), diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index 4daa59690b3f..d767b7576b50 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -22,6 +22,7 @@ import { PublicAppInfo, WorkspaceObject, WorkspaceAvailability, + DEFAULT_NAV_GROUPS, } from '../../../core/public'; import { WORKSPACE_DETAIL_APP_ID, USE_CASE_PREFIX } from '../common/constants'; @@ -430,6 +431,23 @@ export function enrichBreadcrumbsWithWorkspace( }); } +export const extractUseCaseTitleFromFeatures = ( + registeredUseCases: WorkspaceUseCase[], + features: string[] +) => { + if (!features || features.length === 0) { + return ''; + } + const useCaseId = getFirstUseCaseOfFeatureConfigs(features); + const usecase = + useCaseId === DEFAULT_NAV_GROUPS.all.id + ? DEFAULT_NAV_GROUPS.all + : registeredUseCases?.find(({ id }) => id === useCaseId); + if (usecase) { + return usecase.title; + } +}; + /** * prepend workspace or its use case to breadcrumbs * @param core CoreStart From 6677891915372db3e87cfb1560f631f75965bfb1 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Thu, 3 Oct 2024 14:55:50 +0800 Subject: [PATCH 29/31] [navigation]Refactor: flatten left nav in Analytics(all) use case (#8332) * feat: move the logic to construct navLinks in all use case to nav_group_service Signed-off-by: SuZhou-Joe * feat: change category Signed-off-by: SuZhou-Joe * feat: register search overview to all use case Signed-off-by: SuZhou-Joe * Changeset file for PR #8332 created/updated * feat: fix bootstrap Signed-off-by: SuZhou-Joe * feat: show icon in category to expand or collapse Signed-off-by: SuZhou-Joe * feat: merge fix/fit-finish Signed-off-by: SuZhou-Joe * feat: finish whole refactor Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: style update Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * fix: unit tets Signed-off-by: SuZhou-Joe * Changeset file for PR #8332 created/updated * feat: remove useless code Signed-off-by: SuZhou-Joe * feat: update snapshot Signed-off-by: SuZhou-Joe * fix: i18n Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update style Signed-off-by: SuZhou-Joe * feat: update style Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: remove useless scss Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: change icon color to text and update the style a little bit Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update naming Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * fix: warning Signed-off-by: SuZhou-Joe * fix: bootstrap Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * feat: optimize code based on comments Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8332.yml | 3 + .../nav_group/nav_group_service.test.ts | 103 ++++--- .../chrome/nav_group/nav_group_service.ts | 124 ++++++-- ...ollapsible_nav_group_enabled.test.tsx.snap | 271 +----------------- .../collapsible_nav_groups.test.tsx.snap | 84 ++++-- .../header/__snapshots__/header.test.tsx.snap | 106 ++----- .../chrome/ui/header/collapsible_nav.tsx | 15 +- .../header/collapsible_nav_group_enabled.scss | 74 +++-- .../collapsible_nav_group_enabled.test.tsx | 90 +----- .../header/collapsible_nav_group_enabled.tsx | 128 ++------- .../collapsible_nav_group_enabled_top.scss | 3 - ...collapsible_nav_group_enabled_top.test.tsx | 64 +---- .../collapsible_nav_group_enabled_top.tsx | 91 ++---- .../ui/header/collapsible_nav_groups.test.tsx | 6 +- .../ui/header/collapsible_nav_groups.tsx | 61 +++- .../header/collapsible_nav_groups_label.tsx | 54 ++++ src/core/public/chrome/ui/header/header.tsx | 94 +++--- .../chrome/ui/header/header_help_menu.tsx | 2 +- .../public/chrome/ui/header/variables.scss | 1 + src/core/public/chrome/utils.ts | 14 + src/core/utils/default_app_categories.ts | 12 +- .../dev_tools/public/dev_tools_icon.test.tsx | 2 +- .../dev_tools/public/dev_tools_icon.tsx | 3 +- .../application/components/home_icon.tsx | 1 + src/plugins/home/public/plugin.ts | 1 + .../__snapshots__/feature_cards.test.tsx.snap | 174 +++-------- .../feature_cards/feature_cards.test.tsx | 3 +- .../feature_cards/feature_cards.tsx | 61 ++-- .../public/components/settings_icon.tsx | 1 + src/plugins/visualize/public/plugin.ts | 12 +- .../workspace_menu/workspace_menu.tsx | 1 + .../workspace_selector.scss | 5 + .../workspace_selector/workspace_selector.tsx | 11 +- src/plugins/workspace/public/plugin.ts | 2 - 34 files changed, 652 insertions(+), 1025 deletions(-) create mode 100644 changelogs/fragments/8332.yml delete mode 100644 src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.scss create mode 100644 src/core/public/chrome/ui/header/collapsible_nav_groups_label.tsx create mode 100644 src/core/public/chrome/ui/header/variables.scss create mode 100644 src/plugins/workspace/public/components/workspace_selector/workspace_selector.scss diff --git a/changelogs/fragments/8332.yml b/changelogs/fragments/8332.yml new file mode 100644 index 000000000000..57dce1102a30 --- /dev/null +++ b/changelogs/fragments/8332.yml @@ -0,0 +1,3 @@ +feat: +- [navigation] flatten left nav in Analytics(all) use case ([#8332](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8332)) +- [navigation] Adjust the appearances of the left navigation menu and the landing page ([#8332](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8332)) \ 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 d3918bdeec3d..a8c8e22a96a3 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 @@ -231,6 +231,66 @@ describe('ChromeNavGroupService#start()', () => { expect(groupsMap[mockedGroupBar.id].navLinks.length).toEqual(1); }); + it('should populate links with custom category if the nav link is inside second level but no entry in all use case', async () => { + const chromeNavGroupService = new ChromeNavGroupService(); + const uiSettings = uiSettingsServiceMock.createSetupContract(); + const chromeNavGroupServiceSetup = chromeNavGroupService.setup({ uiSettings }); + + chromeNavGroupServiceSetup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.all, [ + { + id: 'foo', + }, + ]); + chromeNavGroupServiceSetup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.essentials, [ + { + id: 'bar', + title: 'bar', + }, + { + id: 'foo', + title: 'foo', + }, + ]); + const navLinkServiceStart = mockedNavLink.start({ + http: mockedHttpService, + application: mockedApplicationService, + }); + navLinkServiceStart.getNavLinks$ = jest.fn().mockReturnValue( + new Rx.BehaviorSubject([ + { + id: 'foo', + }, + { + id: 'bar', + }, + { + id: 'customized_app', + }, + ]) + ); + const chromeStart = await chromeNavGroupService.start({ + navLinks: navLinkServiceStart, + application: mockedApplicationService, + breadcrumbsEnricher$: new Rx.BehaviorSubject(undefined), + workspaces: workspacesServiceMock.createStartContract(), + }); + const groupsMap = await chromeStart.getNavGroupsMap$().pipe(first()).toPromise(); + expect(groupsMap[ALL_USE_CASE_ID].navLinks).toEqual([ + { + id: 'foo', + }, + { + id: 'bar', + title: 'bar', + category: { id: 'custom', label: 'Custom', order: 8500 }, + }, + { + id: 'customized_app', + category: { id: 'custom', label: 'Custom', order: 8500 }, + }, + ]); + }); + it('should return navGroupEnabled from ui settings', async () => { const chromeNavGroupService = new ChromeNavGroupService(); const uiSettings = uiSettingsServiceMock.createSetupContract(); @@ -381,49 +441,6 @@ describe('ChromeNavGroupService#start()', () => { expect(currentNavGroup?.title).toEqual('barGroupTitle'); }); - it('should be able to find the right nav group when visible nav group is all', 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: ALL_USE_CASE_ID, - title: 'fooGroupTitle', - description: 'foo description', - }, - [mockedNavLinkFoo] - ); - - chromeNavGroupServiceSetup.addNavLinksToGroup( - { - id: 'bar-group', - title: 'barGroupTitle', - description: 'bar description', - status: NavGroupStatus.Hidden, - }, - [mockedNavLinkFoo, mockedNavLinkBar] - ); - - const chromeNavGroupServiceStart = await chromeNavGroupService.start({ - navLinks: mockedNavLinkService, - application: mockedApplicationService, - breadcrumbsEnricher$: new Rx.BehaviorSubject(undefined), - workspaces: workspacesServiceMock.createStartContract(), - }); - mockedApplicationService.navigateToApp(mockedNavLinkBar.id); - - const currentNavGroup = await chromeNavGroupServiceStart - .getCurrentNavGroup$() - .pipe(first()) - .toPromise(); - - expect(currentNavGroup?.id).toEqual('bar-group'); - }); - it('should be able to find the right nav group when visible nav group length is 1 and is not all nav group', async () => { const uiSettings = uiSettingsServiceMock.createSetupContract(); const navGroupEnabled$ = new Rx.BehaviorSubject(true); 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 e3911d219ee0..5ff758a056ea 100644 --- a/src/core/public/chrome/nav_group/nav_group_service.ts +++ b/src/core/public/chrome/nav_group/nav_group_service.ts @@ -11,6 +11,7 @@ import { ChromeNavLink, WorkspacesStart, } from 'opensearch-dashboards/public'; +import { i18n } from '@osd/i18n'; import { map, switchMap, takeUntil } from 'rxjs/operators'; import { IUiSettingsClient } from '../../ui_settings'; import { @@ -22,7 +23,7 @@ import { ChromeNavLinks } from '../nav_links'; import { InternalApplicationStart } from '../../application'; import { NavGroupStatus, NavGroupType } from '../../../../core/types'; import { ChromeBreadcrumb, ChromeBreadcrumbEnricher } from '../chrome_service'; -import { ALL_USE_CASE_ID } from '../../../utils'; +import { ALL_USE_CASE_ID, DEFAULT_APP_CATEGORIES } from '../../../utils'; export const CURRENT_NAV_GROUP_ID = 'core.chrome.currentNavGroupId'; @@ -74,6 +75,14 @@ export interface ChromeNavGroupServiceStartContract { setCurrentNavGroup: (navGroupId: string | undefined) => void; } +// 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 after manage category +const customCategory: AppCategory = { + id: 'custom', + label: i18n.translate('core.ui.customNavList.label', { defaultMessage: 'Custom' }), + order: (DEFAULT_APP_CATEGORIES.manage.order || 0) + 500, +}; + /** @internal */ export class ChromeNavGroupService { private readonly navGroupsMap$ = new BehaviorSubject>({}); @@ -114,12 +123,87 @@ export class ChromeNavGroupService { } private sortNavGroupNavLinks( - navGroup: NavGroupItemInMap, + navLinks: NavGroupItemInMap['navLinks'], allValidNavLinks: Array> ) { - return getSortedNavLinks( - fulfillRegistrationLinksToChromeNavLinks(navGroup.navLinks, allValidNavLinks) - ); + return getSortedNavLinks(fulfillRegistrationLinksToChromeNavLinks(navLinks, allValidNavLinks)); + } + + private getNavLinksForAllUseCase( + navGroupsMap: Record, + navLinks: Array> + ) { + // Note: we need to use a new pointer when `assign navGroupsMap[ALL_USE_CASE_ID]?.navLinks` + // because we will mutate the array directly in the following code. + const navLinksResult: ChromeRegistrationNavLink[] = [ + ...(navGroupsMap[ALL_USE_CASE_ID]?.navLinks || []), + ]; + + // Append all the links that do not have use case info to keep backward compatible + const linkIdsWithNavGroupInfo = Object.values(navGroupsMap).reduce((accumulator, navGroup) => { + // Nav groups without type will be regarded as use case, + // we should transform use cases to a category and append links with `showInAllNavGroup: true` under the category + if (!navGroup.type) { + // Append use case section into left navigation + const categoryInfo = { + id: navGroup.id, + label: navGroup.title, + order: navGroup.order, + }; + + const fulfilledLinksOfNavGroup = fulfillRegistrationLinksToChromeNavLinks( + navGroup.navLinks, + navLinks + ); + + const linksForAllUseCaseWithinNavGroup: ChromeRegistrationNavLink[] = []; + + fulfilledLinksOfNavGroup.forEach((navLink) => { + if (!navLink.showInAllNavGroup) { + return; + } + + linksForAllUseCaseWithinNavGroup.push({ + ...navLink, + category: categoryInfo, + }); + }); + + navLinksResult.push(...linksForAllUseCaseWithinNavGroup); + + if (!linksForAllUseCaseWithinNavGroup.length) { + /** + * Find if there are any links inside a use case but without a `see all` entry. + * If so, append these features into custom category as a fallback + */ + fulfillRegistrationLinksToChromeNavLinks(navGroup.navLinks, navLinks).forEach( + (navLink) => { + // Links that already exists in all use case do not need to reappend + if (navLinksResult.find((navLinkInAll) => navLinkInAll.id === navLink.id)) { + return; + } + navLinksResult.push({ + ...navLink, + category: customCategory, + }); + } + ); + } + } + + return [...accumulator, ...navGroup.navLinks.map((navLink) => navLink.id)]; + }, [] as string[]); + navLinks.forEach((navLink) => { + if (linkIdsWithNavGroupInfo.includes(navLink.id)) { + return; + } + navLinksResult.push({ + ...navLink, + category: customCategory, + }); + }); + + return navLinksResult; } private getSortedNavGroupsMap$() { @@ -129,10 +213,20 @@ export class ChromeNavGroupService { map(([navGroupsMap, navLinks]) => { return Object.keys(navGroupsMap).reduce((sortedNavGroupsMap, navGroupId) => { const navGroup = navGroupsMap[navGroupId]; - sortedNavGroupsMap[navGroupId] = { - ...navGroup, - navLinks: this.sortNavGroupNavLinks(navGroup, navLinks), - }; + if (navGroupId === ALL_USE_CASE_ID) { + sortedNavGroupsMap[navGroupId] = { + ...navGroup, + navLinks: this.sortNavGroupNavLinks( + this.getNavLinksForAllUseCase(navGroupsMap, navLinks), + navLinks + ), + }; + } else { + sortedNavGroupsMap[navGroupId] = { + ...navGroup, + navLinks: this.sortNavGroupNavLinks(navGroup.navLinks, navLinks), + }; + } return sortedNavGroupsMap; }, {} as Record); }) @@ -270,14 +364,10 @@ export class ChromeNavGroupService { }); }; if (visibleUseCases.length === 1) { - if (visibleUseCases[0].id === ALL_USE_CASE_ID) { - // If the only visible use case is all use case - // All the other nav groups will be visible because all use case can visit all of the nav groups. - Object.values(navGroupMap).forEach((navGroup) => mapAppIdToNavGroup(navGroup)); - } else { - // It means we are in a workspace, we should only use the visible use cases - visibleUseCases.forEach((navGroup) => mapAppIdToNavGroup(navGroup)); - } + // The length will be 1 if inside a workspace + // as workspace plugin will register a filter to only make the selected nav group visible. + // In order to tell which nav group we are in, we should use the only visible use case if the visibleUseCases.length equals 1. + visibleUseCases.forEach((navGroup) => mapAppIdToNavGroup(navGroup)); } else { // Nav group of Hidden status should be filtered out when counting navGroups the currentApp belongs to Object.values(navGroupMap).forEach((navGroup) => { 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 b3a010659c21..74dfa496c9cb 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap @@ -11,7 +11,7 @@ exports[` should render correctly 1`] = ` class="eui-fullHeight left-navigation-wrapper" >
should render correctly 1`] = ` class="euiSideNav euiSideNav-isOpenMobile" >
should render correctly 1`] = ` title="link-in-all" >
link-in-all
@@ -58,176 +59,6 @@ exports[` should render correctly 1`] = `
-
-
- - - - - -
-
- - -
-
-
-
- - - - - -
-
- - -
-
@@ -267,80 +98,7 @@ exports[` should render correctly 2`] = `
`; -exports[` should show all use case by default and able to click see all 1`] = ` -
-
-
-
- -
-
-
- -
-
-
-
-
-
-
-`; - -exports[` should show all use case when current nav group is \`all\` 1`] = ` +exports[` should show use case nav when current nav group is valid 1`] = `
should show all use case when current na class="eui-fullHeight left-navigation-wrapper" >
should show all use case when current na class="euiSideNav euiSideNav-isOpenMobile" >
should render correctly 1`] = ` title="category-1" > @@ -118,8 +146,7 @@ exports[` should render correctly 1`] = ` > + @@ -10174,7 +10169,6 @@ exports[`Header renders application header without title and breadcrumbs 1`] = ` color="text" data-test-subj="toggleNavButton" flush="both" - isSmallScreen={true} onClick={[Function]} > - + title="Menu" + /> + + @@ -19493,7 +19470,6 @@ exports[`Header renders page header with application title 1`] = ` className="newPageTopNavExpander navToggleInSmallScreen eui-hideFor--xl eui-hideFor--l eui-hideFor--xxl eui-hideFor--xxxl" data-test-subj="toggleNavButton" flush="both" - isSmallScreen={true} onClick={[Function]} > - + @@ -19543,7 +19515,6 @@ exports[`Header renders page header with application title 1`] = ` color="text" data-test-subj="toggleNavButton" flush="both" - isSmallScreen={true} onClick={[Function]} > - + title="Menu" + /> + + diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 43f1eb33dfd9..f51ed93d854e 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -51,6 +51,7 @@ import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; import { createEuiListItem, createRecentNavLink, isModifiedOrPrevented } from './nav_link'; import type { Logos } from '../../../../common/types'; +import { getIsCategoryOpen, setIsCategoryOpen } from '../../utils'; function getAllCategories(allCategorizedLinks: Record) { const allCategories = {} as Record; @@ -72,20 +73,6 @@ function getOrderedCategories( ); } -function getCategoryLocalStorageKey(id: string) { - return `core.navGroup.${id}`; -} - -function getIsCategoryOpen(id: string, storage: Storage) { - const value = storage.getItem(getCategoryLocalStorageKey(id)) ?? 'true'; - - return value === 'true'; -} - -function setIsCategoryOpen(id: string, isOpen: boolean, storage: Storage) { - storage.setItem(getCategoryLocalStorageKey(id), `${isOpen}`); -} - interface Props { appId$: InternalApplicationStart['currentAppId$']; basePath: HttpStart['basePath']; 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 27537d9af8eb..529f0f749ebb 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 @@ -1,33 +1,60 @@ +@import "./variables"; + .context-nav-wrapper { border: none !important; + border-top-right-radius: $euiSizeL; + border-bottom-right-radius: $euiSizeL; + background-color: $ouiSideNavBackgroundColorTemp; + overflow: hidden; .nav-link-item { - padding: calc($euiSize / 4) $euiSize; - border-radius: $euiSize; + padding: $euiSizeS; + border-radius: $euiSizeS; box-shadow: none; margin-bottom: 0; margin-top: 0; .nav-link-item-btn { margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; + } + } - &::after { - display: none; - } + .nav-link-parent-item { + padding-top: 0; + padding-bottom: 0; + margin-bottom: $euiSizeS; + + > .nav-link-item-btn { + padding: $euiSizeS; + margin-bottom: $euiSizeXS; + } + + // Hide the expand / collapse button as we will use + / - + svg { + display: none; + } + + // Show the customized icon + .leftNavCustomizedAccordionIcon { + display: inline-block; } } - .nav-link-parent-item-button { - > span { - flex-direction: row-reverse; + .nav-link-item-category-button { + margin-bottom: $euiSizeXS; - > * { - margin-right: $euiSizeS; - margin-left: 2px; - } + // Use a smaller vertical padding so that category title looks more grouped to the items + .nav-link-item { + padding: $euiSizeXS $euiSizeS; } } + .nav-link-item-category-item { + margin-top: $euiSizeL; + } + .nav-link-fake-item { margin-top: 0; } @@ -37,22 +64,22 @@ } .nav-nested-item { - margin-bottom: 4px; + padding: $euiSizeS 0; - &::after { - height: unset; + &::after, + .nav-link-item-btn::after { + background-color: $euiColorDarkShade; } - .nav-link-item-btn { - padding-left: 0; - padding-right: 0; + // The height is used to comply with the extra padding + &:last-of-type::after { + height: 20px; } } .left-navigation-wrapper { display: flex; flex-direction: column; - border-right: $euiBorderThin; } .flex-1-container { @@ -77,13 +104,16 @@ } &.bottom-container-expanded { + @include euiBottomShadowLarge($euiColorMediumShade, 0.1, true, true); + gap: 16px; - padding-top: $euiSize; - padding-bottom: $euiSize; + padding-top: $euiSizeM; + padding-bottom: $euiSizeM; } } .navGroupEnabledNavTopWrapper { - padding: 0 $euiSizeL; + padding: 0 $euiSizeS; + padding-left: $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 e332cb2dac59..709b79597ada 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 @@ -45,7 +45,6 @@ const defaultNavGroupMap = { { id: 'link-in-observability', title: 'link-in-observability', - showInAllNavGroup: true, }, ], }, @@ -126,7 +125,6 @@ describe('', () => { { id: 'link-in-essentials', title: 'link-in-essentials', - showInAllNavGroup: true, }, ], }, @@ -151,52 +149,16 @@ describe('', () => { expect(getAllByTestId('collapsibleNavAppLink-link-in-observability').length).toEqual(1); }); - it('should show all use case by default and able to click see all', async () => { - const props = mockProps({ - navGroupsMap: { - ...defaultNavGroupMap, - [DEFAULT_NAV_GROUPS.essentials.id]: { - ...DEFAULT_NAV_GROUPS.essentials, - navLinks: [ - { - id: 'link-in-essentials', - title: 'link-in-essentials', - showInAllNavGroup: true, - }, - ], - }, - }, - }); - const { container, getAllByTestId } = render( - - ); - fireEvent.click(getAllByTestId('collapsibleNavAppLink-link-in-essentials')[1]); - expect(getAllByTestId('collapsibleNavAppLink-link-in-essentials').length).toEqual(1); - expect(container).toMatchSnapshot(); - }); - - it('should show all use case when current nav group is `all`', async () => { + it('should show use case nav when current nav group is valid', async () => { const props = mockProps({ currentNavGroupId: ALL_USE_CASE_ID, - navGroupsMap: { - ...defaultNavGroupMap, - [DEFAULT_NAV_GROUPS.essentials.id]: { - ...DEFAULT_NAV_GROUPS.essentials, - navLinks: [ - { - id: 'link-in-essentials', - title: 'link-in-essentials', - showInAllNavGroup: true, - }, - ], - }, - }, + navGroupsMap: defaultNavGroupMap, }); const { container, getAllByTestId } = render( ); - fireEvent.click(getAllByTestId('collapsibleNavAppLink-link-in-essentials')[1]); - expect(getAllByTestId('collapsibleNavAppLink-link-in-essentials').length).toEqual(1); + fireEvent.click(getAllByTestId('collapsibleNavAppLink-link-in-all')[0]); + expect(getAllByTestId('collapsibleNavAppLink-link-in-all').length).toEqual(1); expect(container).toMatchSnapshot(); }); @@ -211,7 +173,6 @@ describe('', () => { { id: 'link-in-essentials-but-hidden', title: 'link-in-essentials-but-hidden', - showInAllNavGroup: true, }, ], }, @@ -233,49 +194,6 @@ describe('', () => { expect(queryAllByTestId('collapsibleNavAppLink-link-in-all').length).toEqual(1); }); - it('should show links with custom category if the nav link is inside second level but no entry in all use case', async () => { - const props = mockProps({ - currentNavGroupId: ALL_USE_CASE_ID, - navGroupsMap: { - ...defaultNavGroupMap, - [DEFAULT_NAV_GROUPS.essentials.id]: { - ...DEFAULT_NAV_GROUPS.essentials, - navLinks: [ - { - id: 'link-in-essentials', - title: 'link-in-essentials', - }, - { - id: 'link-in-all', - title: 'link-in-all', - }, - ], - }, - }, - navLinks: [ - { - id: 'link-in-essentials', - title: 'link-in-essentials', - baseUrl: '', - href: '', - }, - { - id: 'link-in-all', - title: 'link-in-all', - baseUrl: '', - href: '', - }, - ], - }); - const { queryAllByTestId, getByText, getByTestId } = render( - - ); - // Should render custom category - expect(getByText('Custom')).toBeInTheDocument(); - expect(getByTestId('collapsibleNavAppLink-link-in-essentials')).toBeInTheDocument(); - expect(queryAllByTestId('collapsibleNavAppLink-link-in-all').length).toEqual(1); - }); - it('should render manage category when in all use case if workspace disabled', () => { const props = mockProps({ currentNavGroupId: ALL_USE_CASE_ID, 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 4387eb6c1769..c9e2752055e1 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 * as Rx from 'rxjs'; import classNames from 'classnames'; import { WorkspacesStart } from 'src/core/public/workspace'; import { ChromeNavControl, ChromeNavLink } from '../..'; -import { AppCategory, NavGroupType } from '../../../../types'; +import { NavGroupType } from '../../../../types'; import { InternalApplicationStart } from '../../../application/types'; import { HttpStart } from '../../../http'; import { createEuiListItem } from './nav_link'; @@ -60,14 +60,6 @@ const titleForSeeAll = i18n.translate('core.ui.primaryNav.seeAllLabel', { defaultMessage: 'See all...', }); -// 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 after manage category -const customCategory: AppCategory = { - id: 'custom', - label: i18n.translate('core.ui.customNavList.label', { defaultMessage: 'Custom' }), - order: (DEFAULT_APP_CATEGORIES.manage.order || 0) + 500, -}; - enum NavWidth { Expanded = 270, Collapsed = 48, // The Collasped width is supposed to be aligned with the hamburger icon on the top left navigation. @@ -78,7 +70,6 @@ export function CollapsibleNavGroupEnabled({ id, isNavOpen, storage = window.localStorage, - currentWorkspace$, closeNav, navigateToApp, navigateToUrl, @@ -94,6 +85,8 @@ export function CollapsibleNavGroupEnabled({ const appId = useObservable(observables.appId$, ''); const navGroupsMap = useObservable(observables.navGroupsMap$, {}); const currentNavGroup = useObservable(observables.currentNavGroup$, undefined); + const currentWorkspace = useObservable(observables.currentWorkspace$); + const visibleUseCases = useMemo(() => getVisibleUseCases(navGroupsMap), [navGroupsMap]); const currentNavGroupId = useMemo(() => { @@ -146,85 +139,8 @@ export function CollapsibleNavGroupEnabled({ const navLinksResult: ChromeRegistrationNavLink[] = []; - if (currentNavGroupId && currentNavGroupId !== ALL_USE_CASE_ID) { - navLinksResult.push(...(navGroupsMap[currentNavGroupId].navLinks || [])); - } - - if (currentNavGroupId === ALL_USE_CASE_ID) { - // Append all the links that do not have use case info to keep backward compatible - const linkIdsWithNavGroupInfo = Object.values(navGroupsMap).reduce((total, navGroup) => { - return [...total, ...navGroup.navLinks.map((navLink) => navLink.id)]; - }, [] as string[]); - navLinks.forEach((navLink) => { - if (linkIdsWithNavGroupInfo.includes(navLink.id)) { - return; - } - navLinksResult.push({ - ...navLink, - category: customCategory, - }); - }); - - // Append all the links registered to all use case - navGroupsMap[ALL_USE_CASE_ID]?.navLinks.forEach((navLink) => { - navLinksResult.push(navLink); - }); - - // Append use case section into left navigation - Object.values(navGroupsMap).forEach((group) => { - if (group.type) { - return; - } - const categoryInfo = { - id: group.id, - label: group.title, - order: group.order, - }; - - const fulfilledLinksOfNavGroup = fulfillRegistrationLinksToChromeNavLinks( - group.navLinks, - navLinks - ); - - const linksForAllUseCaseWithinNavGroup: ChromeRegistrationNavLink[] = []; - - fulfilledLinksOfNavGroup.forEach((navLink) => { - if (!navLink.showInAllNavGroup) { - return; - } - - linksForAllUseCaseWithinNavGroup.push({ - ...navLink, - category: categoryInfo, - }); - }); - - navLinksResult.push(...linksForAllUseCaseWithinNavGroup); - - if (linksForAllUseCaseWithinNavGroup.length) { - navLinksResult.push({ - id: fulfilledLinksOfNavGroup[0].id, - title: titleForSeeAll, - order: Number.MAX_SAFE_INTEGER, - category: categoryInfo, - }); - } else { - /** - * Find if there are any links inside a use case but without a `see all` entry. - * If so, append these features into custom category as a fallback - */ - fulfillRegistrationLinksToChromeNavLinks(group.navLinks, navLinks).forEach((navLink) => { - // Links that already exists in all use case do not need to reappend - if (navLinksResult.find((navLinkInAll) => navLinkInAll.id === navLink.id)) { - return; - } - navLinksResult.push({ - ...navLink, - category: customCategory, - }); - }); - } - }); + if (currentNavGroupId) { + navLinksResult.push(...(navGroupsMap[currentNavGroupId]?.navLinks || [])); } if (shouldAppendManageCategory) { @@ -291,21 +207,17 @@ export function CollapsibleNavGroupEnabled({ borderRadius="none" paddingSize="s" hasShadow={false} + color="transparent" style={{ flexGrow: 0 }} > )} @@ -313,9 +225,11 @@ export function CollapsibleNavGroupEnabled({ )} @@ -355,17 +271,17 @@ export function CollapsibleNavGroupEnabled({ return ( <> {rendeLeftNav()} - - {isNavOpen - ? rendeLeftNav({ - type: 'overlay', - size: undefined, - outsideClickCloses: true, - paddingSize: undefined, - ownFocus: true, - }) - : null} - + {isNavOpen ? ( + + {rendeLeftNav({ + type: 'overlay', + size: undefined, + outsideClickCloses: true, + paddingSize: undefined, + ownFocus: true, + })} + + ) : null} ); } diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.scss b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.scss deleted file mode 100644 index 25f4385775ec..000000000000 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.scss +++ /dev/null @@ -1,3 +0,0 @@ -.leftNavTopIcon { - color: $euiColorMediumShade; -} diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.test.tsx index 0fd0ceaadb68..42eb3abce767 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.test.tsx @@ -9,10 +9,9 @@ import { ChromeNavLink } from '../../nav_links'; import { ChromeRegistrationNavLink } from '../../nav_group'; import { httpServiceMock } from '../../../mocks'; import { getLogos } from '../../../../common'; -import { CollapsibleNavTop, CollapsibleNavTopProps } from './collapsible_nav_group_enabled_top'; +import { CollapsibleNavTop } from './collapsible_nav_group_enabled_top'; import { BehaviorSubject } from 'rxjs'; import { WorkspaceObject } from 'src/core/public/workspace'; -import { ALL_USE_CASE_ID, DEFAULT_NAV_GROUPS } from '../../../'; const mockBasePath = httpServiceMock.createSetupContract({ basePath: '/test' }).basePath; @@ -40,53 +39,6 @@ describe('', () => { }; }; - it('should render back icon when inside a workspace of all use case', async () => { - const props: CollapsibleNavTopProps = { - ...getMockedProps(), - currentWorkspace$: new BehaviorSubject({ id: 'foo', name: 'foo' }), - visibleUseCases: [ - { - ...DEFAULT_NAV_GROUPS.all, - title: 'navGroupFoo', - description: 'navGroupFoo', - navLinks: [ - { - id: 'firstVisibleNavLinkOfAllUseCase', - }, - ], - }, - ], - navGroupsMap: { - [DEFAULT_NAV_GROUPS.all.id]: { - ...DEFAULT_NAV_GROUPS.all, - title: 'navGroupFoo', - description: 'navGroupFoo', - navLinks: [ - { - id: 'firstVisibleNavLinkOfAllUseCase', - }, - ], - }, - }, - navLinks: [ - getMockedNavLink({ - id: 'firstVisibleNavLinkOfAllUseCase', - }), - ], - currentNavGroup: { - id: 'navGroupFoo', - title: 'navGroupFoo', - description: 'navGroupFoo', - navLinks: [], - }, - }; - const { findByTestId, getByTestId } = render(); - await findByTestId(`collapsibleNavIcon-${DEFAULT_NAV_GROUPS.all.icon}`); - fireEvent.click(getByTestId(`collapsibleNavIcon-${DEFAULT_NAV_GROUPS.all.icon}`)); - expect(props.navigateToApp).toBeCalledWith('firstVisibleNavLinkOfAllUseCase'); - expect(props.setCurrentNavGroup).toBeCalledWith(ALL_USE_CASE_ID); - }); - it('should render home icon when not in a workspace', async () => { const props = getMockedProps(); const { findByTestId, getByTestId } = render(); @@ -101,18 +53,4 @@ describe('', () => { ); await findByTestId('collapsibleNavShrinkButton'); }); - - it('should render successfully without error when visibleUseCases is empty but inside a workspace', async () => { - expect(() => - render( - ({ id: 'foo', name: 'bar' }) - } - shouldShrinkNavigation - /> - ) - ).not.toThrow(); - }); }); diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx index c40cbf33f66d..8a950ba87b84 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx @@ -4,8 +4,7 @@ */ import React, { useCallback, useMemo } from 'react'; -import useObservable from 'react-use/lib/useObservable'; -import { Logos, WorkspacesStart } from 'opensearch-dashboards/public'; +import { Logos } from 'opensearch-dashboards/public'; import { EuiButtonEmpty, EuiButtonIcon, @@ -18,25 +17,17 @@ import { } from '@elastic/eui'; import { InternalApplicationStart } from 'src/core/public/application'; import { createEuiListItem } from './nav_link'; -import { ChromeNavGroupServiceStartContract, NavGroupItemInMap } from '../../nav_group'; +import { NavGroupItemInMap } from '../../nav_group'; import { ChromeNavLink } from '../../nav_links'; -import { ALL_USE_CASE_ID } from '../../../../../core/utils'; -import { fulfillRegistrationLinksToChromeNavLinks } from '../../utils'; -import './collapsible_nav_group_enabled_top.scss'; export interface CollapsibleNavTopProps { collapsibleNavHeaderRender?: () => JSX.Element | null; homeLink?: ChromeNavLink; - navGroupsMap: Record; currentNavGroup?: NavGroupItemInMap; navigateToApp: InternalApplicationStart['navigateToApp']; logos: Logos; onClickShrink?: () => void; shouldShrinkNavigation: boolean; - visibleUseCases: NavGroupItemInMap[]; - currentWorkspace$: WorkspacesStart['currentWorkspace$']; - setCurrentNavGroup: ChromeNavGroupServiceStartContract['setCurrentNavGroup']; - navLinks: ChromeNavLink[]; } export const CollapsibleNavTop = ({ @@ -46,42 +37,9 @@ export const CollapsibleNavTop = ({ logos, onClickShrink, shouldShrinkNavigation, - visibleUseCases, - currentWorkspace$, - setCurrentNavGroup, homeLink, - navGroupsMap, - navLinks, }: CollapsibleNavTopProps) => { - const currentWorkspace = useObservable(currentWorkspace$); - const firstVisibleNavLinkInFirstVisibleUseCase = useMemo( - () => - fulfillRegistrationLinksToChromeNavLinks( - navGroupsMap[visibleUseCases[0]?.id]?.navLinks || [], - navLinks - )[0], - [navGroupsMap, navLinks, visibleUseCases] - ); - - /** - * We can ensure that left nav is inside second level once all the following conditions are met: - * 1. Inside a workspace - * 2. The use case type of current workspace is all use case - * 3. current nav group is not all use case - */ - const isInsideSecondLevelOfAllWorkspace = - !!currentWorkspace && - visibleUseCases[0]?.id === ALL_USE_CASE_ID && - currentNavGroup?.id !== ALL_USE_CASE_ID; - const homeIcon = logos.Mark.url; - const icon = - !!currentWorkspace && visibleUseCases.length === 1 - ? visibleUseCases[0].icon || homeIcon - : homeIcon; - - const shouldShowBackButton = !shouldShrinkNavigation && isInsideSecondLevelOfAllWorkspace; - const shouldShowHomeLink = !shouldShrinkNavigation && !shouldShowBackButton; const homeLinkProps = useMemo(() => { if (homeLink) { @@ -102,38 +60,28 @@ export const CollapsibleNavTop = ({ const onIconClick = useCallback( (e: React.MouseEvent) => { - if (shouldShowBackButton || visibleUseCases.length === 1) { - if (firstVisibleNavLinkInFirstVisibleUseCase) { - navigateToApp(firstVisibleNavLinkInFirstVisibleUseCase.id); - } - - setCurrentNavGroup(visibleUseCases[0].id); - } else if (shouldShowHomeLink) { - homeLinkProps.onClick?.(e); - } + homeLinkProps.onClick?.(e); }, - [ - homeLinkProps, - shouldShowBackButton, - firstVisibleNavLinkInFirstVisibleUseCase, - navigateToApp, - setCurrentNavGroup, - visibleUseCases, - shouldShowHomeLink, - ] + [homeLinkProps] ); return ( - - + + {/* The spacer here is used for align with the page header */} + + {!shouldShrinkNavigation ? ( @@ -143,9 +91,10 @@ export const CollapsibleNavTop = ({ onClick={onClickShrink} iconType={shouldShrinkNavigation ? 'menu' : 'menuLeft'} color="subdued" - display={shouldShrinkNavigation ? 'empty' : 'base'} + display="empty" aria-label="shrink-button" data-test-subj="collapsibleNavShrinkButton" + size="xs" /> @@ -155,7 +104,9 @@ export const CollapsibleNavTop = ({ <> {currentNavGroup?.type ? ( - {currentNavGroup?.title} + +

{currentNavGroup.title}

+
) : ( collapsibleNavHeaderRender?.() )} diff --git a/src/core/public/chrome/ui/header/collapsible_nav_groups.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav_groups.test.tsx index 75865190cad8..f536896e455c 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_groups.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_groups.test.tsx @@ -43,8 +43,8 @@ describe('', () => { }, }), getMockedNavLink({ - id: 'link-in-category-2', - title: 'link-in-category-2', + id: 'link-2-in-category', + title: 'link-2-in-category', category: { id: 'category-1', label: 'category-1', @@ -68,7 +68,7 @@ describe('', () => { expect(container.querySelectorAll('.nav-link-item-btn').length).toEqual(5); fireEvent.click(getByTestId('collapsibleNavAppLink-pure')); expect(navigateToApp).toBeCalledTimes(0); - // The accordion is collapsed + // The accordion is collapsed by default expect(queryByTestId('collapsibleNavAppLink-subLink')).toBeNull(); // Expand the accordion diff --git a/src/core/public/chrome/ui/header/collapsible_nav_groups.tsx b/src/core/public/chrome/ui/header/collapsible_nav_groups.tsx index 53a75aeaaddd..c22920a633ef 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_groups.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_groups.tsx @@ -6,12 +6,13 @@ import './collapsible_nav_group_enabled.scss'; import { EuiFlexItem, EuiSideNavItemType, EuiSideNav, EuiText } from '@elastic/eui'; import { i18n } from '@osd/i18n'; -import React from 'react'; +import React, { useState } from 'react'; import classNames from 'classnames'; import { ChromeNavLink } from '../..'; import { InternalApplicationStart } from '../../../application/types'; import { createEuiListItem } from './nav_link'; import { getOrderedLinksOrCategories, LinkItem, LinkItemType } from '../../utils'; +import { CollapsibleNavGroupsLabel, getIsCategoryOpen } from './collapsible_nav_groups_label'; export interface NavGroupsProps { navLinks: ChromeNavLink[]; @@ -23,6 +24,8 @@ export interface NavGroupsProps { event: React.MouseEvent, navItem: ChromeNavLink ) => void; + categoryCollapsible?: boolean; + currentWorkspaceId?: string; } const titleForSeeAll = i18n.translate('core.ui.primaryNav.seeAllLabel', { @@ -38,7 +41,10 @@ export function NavGroups({ appId, navigateToApp, onNavItemClick, + categoryCollapsible, + currentWorkspaceId, }: NavGroupsProps) { + const [, setRenderKey] = useState(Date.now()); const createNavItem = ({ link, className, @@ -58,7 +64,7 @@ export function NavGroups({ return { id: `${link.id}-${link.title}`, - name: {link.title}, + name: {link.title}, onClick: euiListItem.onClick, href: euiListItem.href, emphasize: euiListItem.isActive, @@ -94,17 +100,37 @@ export function NavGroups({ if (navLink.itemType === LinkItemType.PARENT_LINK && navLink.link) { const props = createNavItem({ link: navLink.link }); + const parentOpenKey = `${currentWorkspaceId ? `${currentWorkspaceId}-` : ''}${ + navLink.link.id + }`; const parentItem = { ...props, forceOpen: true, + /** + * The Tree component inside SideNav is not a controllable component, + * so we need to change the id(will pass as key into the Tree component) to remount the component. + */ + id: `${props.id}-${!!getIsCategoryOpen(parentOpenKey)}`, /** * The href and onClick should both be undefined to make parent item rendered as accordion. */ href: undefined, onClick: undefined, + /** + * The data-test-subj has to be undefined because we render the element with the attribute in CollapsibleNavGroupsLabel + */ + 'data-test-subj': undefined, className: classNames(props.className, 'nav-link-parent-item'), - buttonClassName: classNames(props.buttonClassName, 'nav-link-parent-item-button'), - items: navLink.links.map((subNavLink) => + name: ( + setRenderKey(Date.now())} + data-test-subj={props['data-test-subj']} + /> + ), + items: (getIsCategoryOpen(parentOpenKey) ? navLink.links : []).map((subNavLink) => createSideNavItem(subNavLink, level + 1, 'nav-nested-item') ), }; @@ -126,11 +152,32 @@ export function NavGroups({ } if (navLink.itemType === LinkItemType.CATEGORY) { + const categoryOpenKey = `${currentWorkspaceId ? `${currentWorkspaceId}-` : ''}${ + navLink.category?.id + }`; return { id: navLink.category?.id ?? '', - name:
{navLink.category?.label ?? ''}
, - items: navLink.links?.map((link) => createSideNavItem(link, level + 1)), + name: ( + + + {navLink.category?.label ?? ''} + + + } + collapsible={!!categoryCollapsible} + storageKey={categoryOpenKey} + onToggle={() => setRenderKey(Date.now())} + /> + ), + items: (!categoryCollapsible || getIsCategoryOpen(categoryOpenKey) + ? navLink.links + : [] + )?.map((link) => createSideNavItem(link, level + 1)), 'aria-label': navLink.category?.label, + className: 'nav-link-item-category-item', + buttonClassName: 'nav-link-item-category-button', }; } @@ -143,7 +190,7 @@ export function NavGroups({ return ( - + {suffix} ); diff --git a/src/core/public/chrome/ui/header/collapsible_nav_groups_label.tsx b/src/core/public/chrome/ui/header/collapsible_nav_groups_label.tsx new file mode 100644 index 000000000000..0e0dce8e48d7 --- /dev/null +++ b/src/core/public/chrome/ui/header/collapsible_nav_groups_label.tsx @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './collapsible_nav_group_enabled.scss'; +import { EuiFlexItem, EuiFlexGroup, EuiIcon, EuiFlexGroupProps } from '@elastic/eui'; +import React, { useState } from 'react'; +import { getIsCategoryOpen as getIsCategoryOpenFromStorage, setIsCategoryOpen } from '../../utils'; + +export interface CollapsibleNavGroupsLabelProps { + collapsible: boolean; + storageKey: string; + storage?: Storage; + label?: React.ReactNode; + onToggle?: (isOpen: boolean) => void; + 'data-test-subj'?: EuiFlexGroupProps['data-test-subj']; +} + +export function getIsCategoryOpen(storageKey: string, storage: Storage = window.localStorage) { + return getIsCategoryOpenFromStorage(storageKey, storage); +} + +export function CollapsibleNavGroupsLabel(props: CollapsibleNavGroupsLabelProps) { + const { collapsible, storageKey, storage = window.localStorage, label, onToggle } = props; + const [, setRenderKey] = useState(Date.now()); + const isOpen = collapsible ? getIsCategoryOpen(storageKey, storage) : true; + return ( + { + e.stopPropagation(); + if (!collapsible) { + return; + } + + setIsCategoryOpen(storageKey, !isOpen, storage); + // Trigger the element to rerender because `setIsCategoryOpen` is not updating component's state + setRenderKey(Date.now()); + onToggle?.(!isOpen); + }} + > + {label} + {collapsible ? ( + + + + ) : null} + + ); +} diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index b46a65be30c8..bb08a24305e6 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -28,7 +28,6 @@ * under the License. */ import { - EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiHeader, @@ -262,49 +261,56 @@ export function Header({ const renderNavToggle = () => { const renderNavToggleWithExtraProps = ( props: EuiHeaderSectionItemButtonProps & { isSmallScreen?: boolean } - ) => ( - setIsNavOpen(!isNavOpen)} - aria-expanded={isNavOpen} - aria-pressed={isNavOpen} - aria-controls={navId} - ref={toggleCollapsibleNavRef} - {...props} - className={classnames( - useUpdatedHeader - ? useApplicationHeader - ? 'newAppTopNavExpander' - : 'newPageTopNavExpander' - : undefined, - props.className - )} - > - {props.isSmallScreen ? ( - - ) : ( - - )} - - ); + ) => { + const { isSmallScreen, ...others } = props; + return ( + setIsNavOpen(!isNavOpen)} + aria-expanded={isNavOpen} + aria-pressed={isNavOpen} + aria-controls={navId} + ref={toggleCollapsibleNavRef} + {...others} + className={classnames( + useUpdatedHeader + ? useApplicationHeader + ? 'newAppTopNavExpander' + : 'newPageTopNavExpander' + : undefined, + props.className + )} + > + {props.isSmallScreen ? ( + /** + * Using here will introduce a warning in console + * because button can not be a child of a button. In order to give the looks of a bordered icon, + * here we use the classes to imitate the style + */ + + + + ) : ( + + )} + + ); + }; return useUpdatedHeader ? ( <> {isNavOpen diff --git a/src/core/public/chrome/ui/header/header_help_menu.tsx b/src/core/public/chrome/ui/header/header_help_menu.tsx index 1734c4076684..c5642339ba16 100644 --- a/src/core/public/chrome/ui/header/header_help_menu.tsx +++ b/src/core/public/chrome/ui/header/header_help_menu.tsx @@ -333,7 +333,7 @@ class HeaderHelpMenuUI extends Component { > navGroup.status !== NavGroupStatus.Hidden && navGroup.type === undefined ); }; + +function getCategoryLocalStorageKey(id: string) { + return `core.navGroup.${id}`; +} + +export function getIsCategoryOpen(id: string, storage: Storage) { + const value = storage.getItem(getCategoryLocalStorageKey(id)) ?? 'true'; + + return value === 'true'; +} + +export function setIsCategoryOpen(id: string, isOpen: boolean, storage: Storage) { + storage.setItem(getCategoryLocalStorageKey(id), `${isOpen}`); +} diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index 412336817f15..9d9bfa68f82f 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -78,7 +78,7 @@ export const DEFAULT_APP_CATEGORIES: Record = Object.freeze label: i18n.translate('core.ui.investigate.label', { defaultMessage: 'Investigate', }), - order: 1000, + order: 2000, }, // TODO remove this default category dashboardAndReport: { @@ -86,14 +86,14 @@ export const DEFAULT_APP_CATEGORIES: Record = Object.freeze label: i18n.translate('core.ui.visualizeAndReport.label', { defaultMessage: 'Visualize and report', }), - order: 3000, + order: 2000, }, visualizeAndReport: { id: 'visualizeAndReport', label: i18n.translate('core.ui.visualizeAndReport.label', { defaultMessage: 'Visualize and report', }), - order: 3000, + order: 1000, }, analyzeSearch: { id: 'analyzeSearch', @@ -107,14 +107,14 @@ export const DEFAULT_APP_CATEGORIES: Record = Object.freeze label: i18n.translate('core.ui.detect.label', { defaultMessage: 'Detect', }), - order: 3000, + order: 8000, }, configure: { id: 'configure', label: i18n.translate('core.ui.configure.label', { defaultMessage: 'Configure', }), - order: 2000, + order: 3000, }, manage: { id: 'manage', @@ -135,6 +135,6 @@ export const DEFAULT_APP_CATEGORIES: Record = Object.freeze label: i18n.translate('core.ui.manageWorkspaceNav.label', { defaultMessage: 'Manage workspace', }), - order: 8000, + order: 9000, }, }); diff --git a/src/plugins/dev_tools/public/dev_tools_icon.test.tsx b/src/plugins/dev_tools/public/dev_tools_icon.test.tsx index b465ec3c8b07..1f0ca532f6cd 100644 --- a/src/plugins/dev_tools/public/dev_tools_icon.test.tsx +++ b/src/plugins/dev_tools/public/dev_tools_icon.test.tsx @@ -37,7 +37,7 @@ describe('', () => { > -
-

- title1 -

-
+
render with complex navLinks 1`] = ` id="generated-idTitle" > -
-

- title2 -

-
+
render with complex navLinks 1`] = ` id="generated-idTitle" > -
-

- title3 -

-
+
render with complex navLinks 1`] = ` id="generated-idTitle" > -
-

- title4 -

-
+
-
-
render with complex navLinks 1`] = ` id="generated-idTitle" > -
-

- title5 -

-
+
-
-
-
render with complex navLinks 1`] = ` class="euiSpacer euiSpacer--m" />
render with complex navLinks 1`] = ` id="generated-idTitle" > -
-

- title1 -

-
+
render with complex navLinks 1`] = ` id="generated-idTitle" > -
-

- title2 -

-
+
render with complex navLinks 1`] = ` id="generated-idTitle" > -
-

- title3 -

-
+
render with complex navLinks 1`] = ` id="generated-idTitle" > -
-

- title4 -

-
+
-
-
render with complex navLinks 1`] = ` id="generated-idTitle" > -
-

- title5 -

-
+
-
-
-
', () => { }); it('render with complex navLinks', () => { - const { container, getAllByTestId } = render( + const { container } = render( ', () => { /> ); expect(container).toMatchSnapshot(); - expect(getAllByTestId('landingPageRow_1').length).toEqual(2); }); it('click item', () => { diff --git a/src/plugins/management/public/components/feature_cards/feature_cards.tsx b/src/plugins/management/public/components/feature_cards/feature_cards.tsx index d308f185d602..5255b56b93b4 100644 --- a/src/plugins/management/public/components/feature_cards/feature_cards.tsx +++ b/src/plugins/management/public/components/feature_cards/feature_cards.tsx @@ -30,26 +30,20 @@ export const FeatureCards = ({ pageDescription, navigationUI: { HeaderControl }, }: FeatureCardsProps) => { - const itemsPerRow = 4; const groupedCardForDisplay = useMemo(() => { - const grouped: Array<{ category?: AppCategory; navLinks: ChromeNavLink[][] }> = []; + const grouped: Array<{ category?: AppCategory; navLinks: ChromeNavLink[] }> = []; + let lastGroup: { category?: AppCategory; navLinks: ChromeNavLink[] } | undefined; // The navLinks has already been sorted based on link / category's order, // so it is safe to group the links here. navLinks.forEach((link) => { - let lastGroup = grouped.length ? grouped[grouped.length - 1] : undefined; if (!lastGroup || lastGroup.category?.id !== link.category?.id) { - lastGroup = { category: link.category, navLinks: [[]] }; + lastGroup = { category: link.category, navLinks: [] }; grouped.push(lastGroup); } - const lastRow = lastGroup.navLinks[lastGroup.navLinks.length - 1]; - if (lastRow.length < itemsPerRow) { - lastRow.push(link); - } else { - lastGroup.navLinks.push([link]); - } + lastGroup.navLinks.push(link); }); return grouped; - }, [itemsPerRow, navLinks]); + }, [navLinks]); if (!navLinks.length) { return null; } @@ -64,38 +58,31 @@ export const FeatureCards = ({ setMountPoint={setAppDescriptionControls} /> - {groupedCardForDisplay.map((group) => ( -
+ {groupedCardForDisplay.map((group, groupIndex) => ( +
{group.category && (

{group.category.label}

)} - {group.navLinks.map((row, rowIndex) => { - return ( - - {Array.from({ length: itemsPerRow }).map((item, itemIndexInRow) => { - const link = row[itemIndexInRow]; - const content = link ? ( - navigateToApp(link.id)} - titleSize="xs" - /> - ) : null; - return ( - - {content} - - ); - })} - - ); - })} + + {group.navLinks.map((link, index) => { + return ( + + navigateToApp(link.id)} + titleSize="xs" + style={{ width: 240 }} + /> + + ); + })} +
))} diff --git a/src/plugins/management/public/components/settings_icon.tsx b/src/plugins/management/public/components/settings_icon.tsx index ecae0394d963..659cb307abb1 100644 --- a/src/plugins/management/public/components/settings_icon.tsx +++ b/src/plugins/management/public/components/settings_icon.tsx @@ -61,6 +61,7 @@ export function SettingsIcon({ core }: { core: CoreStart }) { aria-label="show-apps" iconType="managementApp" onClick={() => setPopover(true)} + color="text" /> } diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index 835bfb306796..6df26eb08d42 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -236,7 +236,7 @@ export class VisualizePlugin { id: visualizeAppId, category: DEFAULT_APP_CATEGORIES.visualizeAndReport, - order: 200, + order: 100, title: titleInLeftNav, }, ]); @@ -244,7 +244,7 @@ export class VisualizePlugin { id: visualizeAppId, category: DEFAULT_APP_CATEGORIES.visualizeAndReport, - order: 200, + order: 100, title: titleInLeftNav, }, ]); @@ -259,16 +259,16 @@ export class VisualizePlugin core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.search, [ { id: visualizeAppId, - category: DEFAULT_APP_CATEGORIES.analyzeSearch, - order: 400, + category: DEFAULT_APP_CATEGORIES.visualizeAndReport, + order: 100, title: titleInLeftNav, }, ]); core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.all, [ { id: visualizeAppId, - category: undefined, - order: 400, + category: DEFAULT_APP_CATEGORIES.visualizeAndReport, + order: 100, title: titleInLeftNav, }, ]); 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 a68aea576ae1..984a22aa94c1 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -82,6 +82,7 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { onClick={openPopover} aria-label="workspace-select-button" data-test-subj="workspace-select-button" + color="text" /> ); diff --git a/src/plugins/workspace/public/components/workspace_selector/workspace_selector.scss b/src/plugins/workspace/public/components/workspace_selector/workspace_selector.scss new file mode 100644 index 000000000000..4f9b92b074c5 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_selector/workspace_selector.scss @@ -0,0 +1,5 @@ +@import "../../../../../core/public/chrome/ui/header/variables"; + +.workspaceNameLabel { + background-color: $ouiSideNavBackgroundColorTemp; +} diff --git a/src/plugins/workspace/public/components/workspace_selector/workspace_selector.tsx b/src/plugins/workspace/public/components/workspace_selector/workspace_selector.tsx index ca6431da1e1d..e3a530ce507f 100644 --- a/src/plugins/workspace/public/components/workspace_selector/workspace_selector.tsx +++ b/src/plugins/workspace/public/components/workspace_selector/workspace_selector.tsx @@ -25,6 +25,7 @@ import { getFirstUseCaseOfFeatureConfigs } from '../../utils'; import { WorkspaceUseCase } from '../../types'; import { validateWorkspaceColor } from '../../../common/utils'; import { WorkspacePickerContent } from '../workspace_picker_content/workspace_picker_content'; +import './workspace_selector.scss'; const createWorkspaceButton = i18n.translate('workspace.menu.button.createWorkspace', { defaultMessage: 'Create workspace', @@ -82,13 +83,13 @@ export const WorkspaceSelector = ({ coreStart, registeredUseCases$ }: Props) => padding: '0 5px', }} > - + {i18n.translate('workspace.left.nav.selector.label', { defaultMessage: 'WORKSPACE', })} - + @@ -112,11 +113,7 @@ export const WorkspaceSelector = ({ coreStart, registeredUseCases$ }: Props) => color="subdued" data-test-subj="workspace-selector-current-title" > - - {i18n.translate('workspace.left.nav.selector.title', { - defaultMessage: getUseCase(currentWorkspace)?.title || '', - })} - + {getUseCase(currentWorkspace)?.title} diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index ff6ce2c75898..aea0136023a0 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -7,7 +7,6 @@ import { BehaviorSubject, combineLatest, Subscription } from 'rxjs'; import React from 'react'; import { i18n } from '@osd/i18n'; import { map } from 'rxjs/operators'; -import { EuiPanel } from '@elastic/eui'; import { Plugin, CoreStart, @@ -59,7 +58,6 @@ import { toMountPoint } from '../../opensearch_dashboards_react/public'; import { UseCaseService } from './services/use_case_service'; import { WorkspaceListCard } from './components/service_card'; import { NavigationPublicPluginStart } from '../../../plugins/navigation/public'; -import { WorkspacePickerContent } from './components/workspace_picker_content/workspace_picker_content'; import { WorkspaceSelector } from './components/workspace_selector/workspace_selector'; import { HOME_CONTENT_AREAS } from '../../../plugins/content_management/public'; import { From ec4b9d736e9940fe57d8dd4d12ae8c76d67a062b Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Thu, 3 Oct 2024 17:49:15 +0800 Subject: [PATCH 30/31] [Workspace]Restrict at least one data source in workspace creation page (#8461) * Restrict at least one data sources for workspace creation Signed-off-by: Lin Wang * Update empty data sources panel UI Signed-off-by: Lin Wang * Changeset file for PR #8461 created/updated * Fix wrong testing id for data source empty state Signed-off-by: Lin Wang * Remove populate workspace name Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8461.yml | 2 + .../workspace_create_action_panel.test.tsx | 59 ++++++++++- .../workspace_create_action_panel.tsx | 7 +- .../workspace_creator.test.tsx | 17 +++- .../workspace_creator/workspace_creator.tsx | 1 - .../workspace_creator_form.tsx | 1 + .../workspace_form_summary_panel.test.tsx | 47 +++++++-- .../workspace_form_summary_panel.tsx | 29 +++--- .../data_source_connection_table.tsx | 5 +- .../select_data_source_panel.test.tsx | 12 +++ .../select_data_source_panel.tsx | 98 ++++++++++++++----- 11 files changed, 226 insertions(+), 52 deletions(-) create mode 100644 changelogs/fragments/8461.yml diff --git a/changelogs/fragments/8461.yml b/changelogs/fragments/8461.yml new file mode 100644 index 000000000000..74c6bcd475d7 --- /dev/null +++ b/changelogs/fragments/8461.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace]Restrict at least one data source in workspace creation page ([#8461](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8461)) \ No newline at end of file diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_create_action_panel.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_create_action_panel.test.tsx index 3fd6e78ac7d1..0329b2179df5 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_create_action_panel.test.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_create_action_panel.test.tsx @@ -9,6 +9,7 @@ import { MAX_WORKSPACE_DESCRIPTION_LENGTH, MAX_WORKSPACE_NAME_LENGTH, } from '../../../common/constants'; +import { DataSourceConnectionType } from '../../../common/types'; import { WorkspaceCreateActionPanel } from './workspace_create_action_panel'; const mockApplication = applicationServiceMock.createStartContract(); @@ -18,6 +19,14 @@ describe('WorkspaceCreateActionPanel', () => { const formData = { name: 'Test Workspace', description: 'This is a test workspace', + selectedDataSourceConnections: [ + { + id: 'data-source-1', + name: 'Data Source 1', + type: '', + connectionType: DataSourceConnectionType.OpenSearchConnection, + }, + ], }; it('should disable the "Create Workspace" button when name exceeds the maximum length', () => { @@ -25,9 +34,13 @@ describe('WorkspaceCreateActionPanel', () => { render( ); const createButton = screen.getByText('Create workspace'); @@ -39,15 +52,53 @@ describe('WorkspaceCreateActionPanel', () => { render( ); const createButton = screen.getByText('Create workspace'); expect(createButton.closest('button')).toBeDisabled(); }); + it('should disable the "Create Workspace" button when data source enabled and no data sources selected', () => { + render( + + ); + const createButton = screen.getByText('Create workspace'); + expect(createButton.closest('button')).toBeDisabled(); + }); + + it('should enable the "Create Workspace" button when no data sources selected but no data source enabled', () => { + render( + + ); + const createButton = screen.getByText('Create workspace'); + expect(createButton.closest('button')).not.toBeDisabled(); + }); + it('should enable the "Create Workspace" button when name and description are within the maximum length', () => { render( { formData={formData} application={mockApplication} isSubmitting={false} + dataSourceEnabled /> ); const createButton = screen.getByText('Create workspace'); @@ -65,9 +117,10 @@ describe('WorkspaceCreateActionPanel', () => { render( ); expect(screen.getByText('Create workspace').closest('button')).toBeDisabled(); diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_create_action_panel.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_create_action_panel.tsx index 88f1b60f0d69..3feb000d09c2 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_create_action_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_create_action_panel.tsx @@ -21,9 +21,10 @@ import { interface WorkspaceCreateActionPanelProps { formId: string; - formData: Pick; + formData: Pick; application: ApplicationStart; isSubmitting: boolean; + dataSourceEnabled: boolean; } export const WorkspaceCreateActionPanel = ({ @@ -31,13 +32,15 @@ export const WorkspaceCreateActionPanel = ({ formData, application, isSubmitting, + dataSourceEnabled, }: WorkspaceCreateActionPanelProps) => { const [isCancelModalVisible, setIsCancelModalVisible] = useState(false); const closeCancelModal = useCallback(() => setIsCancelModalVisible(false), []); const showCancelModal = useCallback(() => setIsCancelModalVisible(true), []); const createButtonDisabled = (formData.name?.length ?? 0) > MAX_WORKSPACE_NAME_LENGTH || - (formData.description?.length ?? 0) > MAX_WORKSPACE_DESCRIPTION_LENGTH; + (formData.description?.length ?? 0) > MAX_WORKSPACE_DESCRIPTION_LENGTH || + (dataSourceEnabled && formData.selectedDataSourceConnections.length === 0); return ( <> diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx index c6edf7ac355d..95797ffe81c8 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -112,8 +112,9 @@ jest.spyOn(utils, 'fetchDataSourceConnections').mockImplementation(async (passed const WorkspaceCreator = ({ isDashboardAdmin = false, + dataSourceEnabled = false, ...props -}: Partial) => { +}: Partial) => { const { Provider } = createOpenSearchDashboardsReactContext({ ...mockCoreStart, ...{ @@ -153,7 +154,7 @@ const WorkspaceCreator = ({ }), }, }, - dataSourceManagement: {}, + dataSourceManagement: dataSourceEnabled ? {} : undefined, navigationUI: { HeaderControl: () => null, }, @@ -374,7 +375,7 @@ describe('WorkspaceCreator', () => { value: 600, }); const { getByTestId, getAllByText, getByText } = render( - + ); // Ensure workspace create form rendered @@ -432,7 +433,7 @@ describe('WorkspaceCreator', () => { value: 600, }); const { getByTestId, getAllByText, getByText } = render( - + ); // Ensure workspace create form rendered @@ -492,6 +493,10 @@ describe('WorkspaceCreator', () => { await waitFor(() => { expect(getByTestId('workspaceForm-bottomBar-createButton')).toBeInTheDocument(); }); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); expect(workspaceClientCreate).toHaveBeenCalledTimes(1); @@ -512,6 +517,10 @@ describe('WorkspaceCreator', () => { await waitFor(() => { expect(getByTestId('workspaceForm-bottomBar-createButton')).toBeInTheDocument(); }); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); jest.useFakeTimers(); jest.runAllTimers(); diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index 96f6c1acc88f..9b51ba22d0fe 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -70,7 +70,6 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { color: euiPaletteColorBlind()[0], ...(defaultSelectedUseCase ? { - name: defaultSelectedUseCase.title, features: [getUseCaseFeatureConfig(defaultSelectedUseCase.id)], } : {}), diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator_form.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator_form.tsx index 4193db6ce26a..7340c5398cb0 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator_form.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator_form.tsx @@ -197,6 +197,7 @@ export const WorkspaceCreatorForm = (props: WorkspaceCreatorFormProps) => { formId={formId} application={application} isSubmitting={props.isSubmitting} + dataSourceEnabled={!!isDataSourceEnabled} />
diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_form_summary_panel.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_form_summary_panel.test.tsx index 20975d2a064e..5b26f89a2ed2 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_form_summary_panel.test.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form_summary_panel.test.tsx @@ -9,6 +9,8 @@ import { WorkspaceFormSummaryPanel, FieldSummaryItem } from './workspace_form_su import { RightSidebarScrollField } from './utils'; import { WorkspacePermissionItemType } from '../workspace_form'; import { applicationServiceMock } from '../../../../../../src/core/public/mocks'; +import { DataSourceConnectionType } from '../../../common/types'; +import { WorkspacePermissionMode } from '../../../common/constants'; describe('WorkspaceFormSummaryPanel', () => { const formData = { @@ -18,28 +20,43 @@ describe('WorkspaceFormSummaryPanel', () => { description: 'This is a test workspace', color: '#000000', selectedDataSourceConnections: [ - { id: 'data-source-1', name: 'Data Source 1' }, - { id: 'data-source-2', name: 'Data Source 2' }, - { id: 'data-source-3', name: 'Data Source 3' }, + { + id: 'data-source-1', + name: 'Data Source 1', + type: '', + connectionType: DataSourceConnectionType.OpenSearchConnection, + }, + { + id: 'data-source-2', + name: 'Data Source 2', + type: '', + connectionType: DataSourceConnectionType.OpenSearchConnection, + }, + { + id: 'data-source-3', + name: 'Data Source 3', + type: '', + connectionType: DataSourceConnectionType.OpenSearchConnection, + }, ], permissionSettings: [ { id: 1, type: WorkspacePermissionItemType.User, userId: 'user1', - modes: ['library_write', 'write'], + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], }, { id: 2, type: WorkspacePermissionItemType.Group, group: 'group1', - modes: ['library_read', 'read'], + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], }, { id: 3, type: WorkspacePermissionItemType.User, userId: 'user2', - modes: ['library_write', 'read'], + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Read], }, ], }; @@ -71,6 +88,7 @@ describe('WorkspaceFormSummaryPanel', () => { formId="id" application={applicationMock} isSubmitting={false} + dataSourceEnabled /> ); @@ -107,6 +125,7 @@ describe('WorkspaceFormSummaryPanel', () => { formId="id" application={applicationMock} isSubmitting={false} + dataSourceEnabled /> ); @@ -148,6 +167,7 @@ describe('WorkspaceFormSummaryPanel', () => { formId="id" application={applicationMock} isSubmitting={false} + dataSourceEnabled /> ); expect(screen.getByText('user1')).toBeInTheDocument(); @@ -161,6 +181,21 @@ describe('WorkspaceFormSummaryPanel', () => { fireEvent.click(screen.getByText('Show less')); expect(screen.queryByText('user2')).toBeNull(); }); + + it('should hide "Data sources" if data source not enabled', () => { + render( + + ); + expect(screen.queryByText('Data sources')).toBeNull(); + }); }); describe('FieldSummaryItem', () => { diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_form_summary_panel.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_form_summary_panel.tsx index 28efcee4d69e..fd1647ab87e6 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_form_summary_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form_summary_panel.tsx @@ -165,6 +165,7 @@ interface WorkspaceFormSummaryPanelProps { formId: string; application: ApplicationStart; isSubmitting: boolean; + dataSourceEnabled: boolean; } export const WorkspaceFormSummaryPanel = ({ @@ -174,6 +175,7 @@ export const WorkspaceFormSummaryPanel = ({ formId, application, isSubmitting, + dataSourceEnabled, }: WorkspaceFormSummaryPanelProps) => { const useCase = availableUseCases.find((item) => item.id === formData.useCase); const userAndGroups: UserAndGroups[] = formData.permissionSettings.flatMap((setting) => { @@ -213,18 +215,20 @@ export const WorkspaceFormSummaryPanel = ({ {useCase && {useCase.title}} - - {formData.selectedDataSourceConnections.length > 0 && ( - ( -
    -
  • {connection.name}
  • -
- ))} - collapseDisplayCount={3} - /> - )} -
+ {dataSourceEnabled && ( + + {formData.selectedDataSourceConnections.length > 0 && ( + ( +
    +
  • {connection.name}
  • +
+ ))} + collapseDisplayCount={3} + /> + )} +
+ )} {permissionEnabled && ( {userAndGroups.length > 0 && ( @@ -240,6 +244,7 @@ export const WorkspaceFormSummaryPanel = ({ formId={formId} application={application} isSubmitting={isSubmitting} + dataSourceEnabled={dataSourceEnabled} /> ); diff --git a/src/plugins/workspace/public/components/workspace_form/data_source_connection_table.tsx b/src/plugins/workspace/public/components/workspace_form/data_source_connection_table.tsx index 9cc940c85fa6..686d689ffef8 100644 --- a/src/plugins/workspace/public/components/workspace_form/data_source_connection_table.tsx +++ b/src/plugins/workspace/public/components/workspace_form/data_source_connection_table.tsx @@ -34,7 +34,10 @@ interface DataSourceConnectionTableProps { onUnlinkDataSource: (dataSources: DataSourceConnection) => void; onSelectionChange: (selections: DataSourceConnection[]) => void; dataSourceConnections: DataSourceConnection[]; - tableProps?: Pick, 'pagination' | 'search'>; + tableProps?: Pick< + EuiInMemoryTableProps, + 'pagination' | 'search' | 'message' + >; } export const DataSourceConnectionTable = forwardRef< diff --git a/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.test.tsx b/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.test.tsx index 460921530d4b..6ec5c23a1b87 100644 --- a/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.test.tsx +++ b/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.test.tsx @@ -229,4 +229,16 @@ describe('SelectDataSourcePanel', () => { ) ).toBeNull(); }); + + it('should render empty message and action buttons', () => { + const { getByText, getByTestId } = setup({ + assignedDataSourceConnections: [], + }); + + expect(getByText('Associated data sources will appear here')).toBeInTheDocument(); + expect( + getByTestId('workspace-creator-emptyPrompt-dataSources-assign-button') + ).toBeInTheDocument(); + expect(getByTestId('workspace-creator-emptyPrompt-dqc-assign-button')).toBeInTheDocument(); + }); }); diff --git a/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.tsx b/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.tsx index 282a4dc77efd..7960f6c4138a 100644 --- a/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.tsx @@ -4,7 +4,15 @@ */ import React, { useState } from 'react'; -import { EuiSpacer, EuiFlexItem, EuiSmallButton, EuiFlexGroup, EuiPanel } from '@elastic/eui'; +import { + EuiSpacer, + EuiFlexItem, + EuiSmallButton, + EuiFlexGroup, + EuiPanel, + EuiEmptyPrompt, + EuiText, +} from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { SavedObjectsStart, CoreStart } from '../../../../../core/public'; import { DataSourceConnection } from '../../../common/types'; @@ -55,28 +63,16 @@ export const SelectDataSourcePanel = ({ handleUnassignDataSources([connection]); }; - const renderTableContent = () => { - return ( - - - - ); - }; - - const addOpenSearchConnectionsButton = ( + const renderAddOpenSearchConnectionsButton = ( + testingId = 'workspace-creator-dataSources-assign-button' + ) => ( { setToggleIdSelected(AssociationDataSourceModalMode.OpenSearchConnections); setModalVisible(true); }} - data-test-subj="workspace-creator-dataSources-assign-button" + data-test-subj={testingId} > {i18n.translate('workspace.form.selectDataSourcePanel.addNew', { defaultMessage: 'Associate OpenSearch connections', @@ -84,14 +80,16 @@ export const SelectDataSourcePanel = ({ ); - const addDirectQueryConnectionsButton = ( + const renderAddDirectQueryConnectionsButton = ( + testingId = 'workspace-creator-dqc-assign-button' + ) => ( { setToggleIdSelected(AssociationDataSourceModalMode.DirectQueryConnections); setModalVisible(true); }} - data-test-subj="workspace-creator-dqc-assign-button" + data-test-subj={testingId} > {i18n.translate('workspace.form.selectDataSourcePanel.addNewDQCs', { defaultMessage: 'Associate direct query connections', @@ -114,6 +112,60 @@ export const SelectDataSourcePanel = ({ ); + const renderTableContent = () => { + const message = + assignedDataSourceConnections.length === 0 ? ( + +

+ {i18n.translate('workspaces.forms.selectDataSourcePanel.emptyTableTitle', { + defaultMessage: 'Associated data sources will appear here', + })} +

+ + } + body={ + + {i18n.translate('workspaces.forms.selectDataSourcePanel.emptyTableDescription', { + defaultMessage: 'At least one data source is required to create a workspace.', + })} + + } + actions={ + showDataSourceManagement && ( + + + {renderAddOpenSearchConnectionsButton( + 'workspace-creator-emptyPrompt-dataSources-assign-button' + )} + + + {renderAddDirectQueryConnectionsButton( + 'workspace-creator-emptyPrompt-dqc-assign-button' + )} + + + ) + } + /> + ) : undefined; + return ( + + + + ); + }; + return (
@@ -124,14 +176,14 @@ export const SelectDataSourcePanel = ({ {removeButton} )} {showDataSourceManagement && ( - {addOpenSearchConnectionsButton} + {renderAddOpenSearchConnectionsButton()} )} {showDataSourceManagement && ( - {addDirectQueryConnectionsButton} + {renderAddDirectQueryConnectionsButton()} )} - {assignedDataSourceConnections.length > 0 && } - {assignedDataSourceConnections.length > 0 && renderTableContent()} + + {renderTableContent()} {modalVisible && chrome && ( Date: Thu, 3 Oct 2024 09:27:36 -0700 Subject: [PATCH 31/31] Split .i18nrc across `src`, `packages`, and `examples` (#8414) Also: * Force validation of i18n even when no translations are available * Allow including translations in built artifacts Signed-off-by: Miki --- .i18nrc.json | 77 ----------------- examples/.i18nrc.json | 12 +++ packages/.i18nrc.json | 6 ++ src/.i18nrc.json | 85 +++++++++++++++++++ src/dev/build/args.test.ts | 10 +++ src/dev/build/args.ts | 2 + src/dev/build/build_distributables.ts | 6 ++ src/dev/build/cli.ts | 1 + src/dev/build/tasks/copy_source_task.ts | 1 - src/dev/build/tasks/copy_translations_task.ts | 51 +++++++++++ src/dev/build/tasks/index.ts | 1 + src/dev/i18n/config.ts | 38 +++++++-- src/dev/i18n/constants.ts | 3 + src/dev/i18n/index.ts | 9 +- src/dev/i18n/tasks/check_configs.ts | 10 ++- src/dev/i18n/tasks/merge_configs.ts | 11 ++- src/dev/run_i18n_check.ts | 4 +- src/dev/run_i18n_extract.ts | 3 +- src/dev/run_i18n_integrate.ts | 3 +- src/legacy/server/i18n/index.ts | 1 - src/plugins/data_explorer/.i18nrc.json | 7 -- src/plugins/query_enhancements/.i18nrc.json | 7 -- 22 files changed, 237 insertions(+), 111 deletions(-) delete mode 100644 .i18nrc.json create mode 100644 examples/.i18nrc.json create mode 100644 packages/.i18nrc.json create mode 100644 src/.i18nrc.json create mode 100644 src/dev/build/tasks/copy_translations_task.ts delete mode 100644 src/plugins/data_explorer/.i18nrc.json delete mode 100644 src/plugins/query_enhancements/.i18nrc.json diff --git a/.i18nrc.json b/.i18nrc.json deleted file mode 100644 index 91d3b80b80a0..000000000000 --- a/.i18nrc.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "paths": { - "common.ui": "src/legacy/ui", - "console": "src/plugins/console", - "core": "src/core", - "discover": "src/plugins/discover", - "dashboard": "src/plugins/dashboard", - "data": "src/plugins/data", - "embeddableApi": "src/plugins/embeddable", - "embeddableExamples": "examples/embeddable_examples", - "uiActionsExamples": "examples/ui_action_examples", - "share": "src/plugins/share", - "home": "src/plugins/home", - "flot": "packages/osd-ui-shared-deps/flot_charts", - "charts": "src/plugins/charts", - "opensearchUi": "src/plugins/opensearch_ui_shared", - "devTools": "src/plugins/dev_tools", - "expressions": "src/plugins/expressions", - "inputControl": "src/plugins/input_control_vis", - "inspector": "src/plugins/inspector", - "inspectorViews": "src/legacy/core_plugins/inspector_views", - "interpreter": "src/legacy/core_plugins/interpreter", - "osd": "src/legacy/core_plugins/opensearch-dashboards", - "osdDocViews": "src/legacy/core_plugins/osd_doc_views", - "osdDocViewsLinks": "src/legacy/core_plugins/osd_doc_views_links", - "management": [ - "src/legacy/core_plugins/management", - "src/plugins/management" - ], - "maps_legacy": "src/plugins/maps_legacy", - "indexPatternManagement": "src/plugins/index_pattern_management", - "advancedSettings": "src/plugins/advanced_settings", - "opensearch_dashboards_legacy": "src/plugins/opensearch_dashboards_legacy", - "opensearchDashboardsOverview": "src/plugins/opensearch_dashboards_overview", - "opensearch_dashboards_react": "src/legacy/core_plugins/opensearch_dashboards_react", - "opensearch-dashboards-react": "src/plugins/opensearch_dashboards_react", - "opensearch_dashboards_utils": "src/plugins/opensearch_dashboards_utils", - "navigation": "src/plugins/navigation", - "newsfeed": "src/plugins/newsfeed", - "regionMap": "src/plugins/region_map", - "savedObjects": "src/plugins/saved_objects", - "savedObjectsManagement": "src/plugins/saved_objects_management", - "security": "src/plugins/security_oss", - "server": "src/legacy/server", - "statusPage": "src/legacy/core_plugins/status_page", - "telemetry": [ - "src/plugins/telemetry", - "src/plugins/telemetry_management_section" - ], - "tileMap": "src/plugins/tile_map", - "timeline": ["src/plugins/vis_type_timeline"], - "uiActions": "src/plugins/ui_actions", - "visDefaultEditor": "src/plugins/vis_default_editor", - "visTypeMarkdown": "src/plugins/vis_type_markdown", - "visTypeMetric": "src/plugins/vis_type_metric", - "visTypeTable": "src/plugins/vis_type_table", - "visTypeTagCloud": "src/plugins/vis_type_tagcloud", - "visTypeTimeseries": "src/plugins/vis_type_timeseries", - "visTypeVega": "src/plugins/vis_type_vega", - "visTypeVislib": "src/plugins/vis_type_vislib", - "visTypeXy": "src/plugins/vis_type_xy", - "visualizations": "src/plugins/visualizations", - "visualize": "src/plugins/visualize", - "usageCollection": "src/plugins/usage_collection" - }, - "exclude": [ - "src/legacy/ui/ui_render/ui_render_mixin.js", - "src/plugins/home/public/application/components/tutorial", - "src/plugins/home/server/tutorials", - "src/core/server/rendering/views/template.tsx", - "src/plugins/data/public/search/errors/timeout_error.tsx", - "src/plugins/home/public/application/components/welcome.tsx", - "src/plugins/vis_type_timeline/server/series_functions/graphite.js", - "src/plugins/vis_type_timeseries/public/application/components/aggs/serial_diff.js" - ], - "translations": [] -} diff --git a/examples/.i18nrc.json b/examples/.i18nrc.json new file mode 100644 index 000000000000..a9e5ac5e83e4 --- /dev/null +++ b/examples/.i18nrc.json @@ -0,0 +1,12 @@ +{ + "paths": { + "embeddableExamples": "embeddable_examples", + "expressionsExample": "expressions_example", + "multipleDataSourceExample": "multiple_data_source_examples", + "searchExamples": "search_examples", + "stateContainerExamples": "state_containers_examples", + "uiActionsExamples": "ui_action_examples", + "uiActionsExplorer": "ui_actions_explorer" + }, + "translations": [] +} diff --git a/packages/.i18nrc.json b/packages/.i18nrc.json new file mode 100644 index 000000000000..210e55d70b58 --- /dev/null +++ b/packages/.i18nrc.json @@ -0,0 +1,6 @@ +{ + "paths": { + "flot": "osd-ui-shared-deps/flot_charts" + }, + "translations": [] +} diff --git a/src/.i18nrc.json b/src/.i18nrc.json new file mode 100644 index 000000000000..00e3e20afe8e --- /dev/null +++ b/src/.i18nrc.json @@ -0,0 +1,85 @@ +{ + "paths": { + "advancedSettings": "plugins/advanced_settings", + "apmOss": "plugins/apm_oss", + "charts": "plugins/charts", + "common.ui": "legacy/ui", + "console": "plugins/console", + "contentManagement": "plugins/content_management", + "core": "core", + "dashboard": "plugins/dashboard", + "data": "plugins/data", + "dataExplorer": "plugins/data_explorer", + "dataSourcesManagement": "plugins/data_source_management", + "devTools": "plugins/dev_tools", + "discover": "plugins/discover", + "embeddableApi": "plugins/embeddable", + "expressions": "plugins/expressions", + "home": "plugins/home", + "indexPatternManagement": "plugins/index_pattern_management", + "inputControl": "plugins/input_control_vis", + "inspector": "plugins/inspector", + "inspectorViews": "legacy/core_plugins/inspector_views", + "interpreter": "legacy/core_plugins/interpreter", + "management": [ + "legacy/core_plugins/management", + "plugins/management", + "plugins/management_overview" + ], + "maps_legacy": "plugins/maps_legacy", + "navigation": "plugins/navigation", + "newsfeed": "plugins/newsfeed", + "opensearch_dashboards_legacy": "plugins/opensearch_dashboards_legacy", + "opensearchDashboardsOverview": "plugins/opensearch_dashboards_overview", + "opensearch-dashboards-react": "plugins/opensearch_dashboards_react", + "opensearch_dashboards_react": "legacy/core_plugins/opensearch_dashboards_react", + "opensearch_dashboards_utils": "plugins/opensearch_dashboards_utils", + "opensearchUi": "plugins/opensearch_ui_shared", + "osd": "legacy/core_plugins/opensearch-dashboards", + "osdDocViews": "legacy/core_plugins/osd_doc_views", + "osdDocViewsLinks": "legacy/core_plugins/osd_doc_views_links", + "queryEnhancements": "plugins/query_enhancements", + "regionMap": "plugins/region_map", + "savedObjects": "plugins/saved_objects", + "savedObjectsManagement": "plugins/saved_objects_management", + "security": "plugins/security_oss", + "server": "legacy/server", + "share": "plugins/share", + "statusPage": "legacy/core_plugins/status_page", + "telemetry": [ + "plugins/telemetry", + "plugins/telemetry_management_section" + ], + "tileMap": "plugins/tile_map", + "timeline": [ + "plugins/vis_type_timeline" + ], + "uiActions": "plugins/ui_actions", + "usageCollection": "plugins/usage_collection", + "visAugmenter": "plugins/vis_augmenter", + "visBuilder": "plugins/vis_builder", + "visDefaultEditor": "plugins/vis_default_editor", + "visTypeMarkdown": "plugins/vis_type_markdown", + "visTypeMetric": "plugins/vis_type_metric", + "visTypeTable": "plugins/vis_type_table", + "visTypeTagCloud": "plugins/vis_type_tagcloud", + "visTypeTimeseries": "plugins/vis_type_timeseries", + "visTypeVega": "plugins/vis_type_vega", + "visTypeVislib": "plugins/vis_type_vislib", + "visTypeXy": "plugins/vis_type_xy", + "visualizations": "plugins/visualizations", + "visualize": "plugins/visualize", + "workspace": "plugins/workspace" + }, + "exclude": [ + "legacy/ui/ui_render/ui_render_mixin.js", + "plugins/home/public/application/components/tutorial", + "plugins/home/server/tutorials", + "core/server/rendering/views/template.tsx", + "plugins/data/public/search/errors/timeout_error.tsx", + "plugins/home/public/application/components/welcome.tsx", + "plugins/vis_type_timeline/server/series_functions/graphite.js", + "plugins/vis_type_timeseries/public/application/components/aggs/serial_diff.js" + ], + "translations": [] +} diff --git a/src/dev/build/args.test.ts b/src/dev/build/args.test.ts index 4a3489044eb7..8b5c3592607c 100644 --- a/src/dev/build/args.test.ts +++ b/src/dev/build/args.test.ts @@ -66,6 +66,7 @@ it('build dist for current platform, without packages, by default', () => { "windows": false, }, "versionQualifier": "", + "withTranslations": false, }, "log": , "showHelp": false, @@ -96,6 +97,7 @@ it('build dist for linux x64 platform, without packages, if --linux is passed', "windows": false, }, "versionQualifier": "", + "withTranslations": false, }, "log": , "showHelp": false, @@ -126,6 +128,7 @@ it('build dist for linux arm64 platform, without packages, if --linux-arm is pas "windows": false, }, "versionQualifier": "", + "withTranslations": false, }, "log": , "showHelp": false, @@ -156,6 +159,7 @@ it('build dist for darwin x64 platform, without packages, if --darwin is passed' "windows": false, }, "versionQualifier": "", + "withTranslations": false, }, "log": , "showHelp": false, @@ -186,6 +190,7 @@ it('build dist for windows x64 platform, without packages, if --windows is passe "windows": true, }, "versionQualifier": "", + "withTranslations": false, }, "log": , "showHelp": false, @@ -216,6 +221,7 @@ it('builds packages if --all-platforms is passed', () => { "windows": false, }, "versionQualifier": "", + "withTranslations": false, }, "log": , "showHelp": false, @@ -246,6 +252,7 @@ it('limits packages if --rpm passed with --all-platforms', () => { "windows": false, }, "versionQualifier": "", + "withTranslations": false, }, "log": , "showHelp": false, @@ -276,6 +283,7 @@ it('limits packages if --deb passed with --all-platforms', () => { "windows": false, }, "versionQualifier": "", + "withTranslations": false, }, "log": , "showHelp": false, @@ -307,6 +315,7 @@ it('limits packages if --docker passed with --all-platforms', () => { "windows": false, }, "versionQualifier": "", + "withTranslations": false, }, "log": , "showHelp": false, @@ -338,6 +347,7 @@ it('limits packages if --docker passed with --skip-docker-ubi and --all-platform "windows": false, }, "versionQualifier": "", + "withTranslations": false, }, "log": , "showHelp": false, diff --git a/src/dev/build/args.ts b/src/dev/build/args.ts index c7b6c9aaf32f..4da619b889ca 100644 --- a/src/dev/build/args.ts +++ b/src/dev/build/args.ts @@ -60,6 +60,7 @@ export function readCliArgs(argv: string[]) { 'silent', 'debug', 'help', + 'with-translations', ], alias: { v: 'verbose', @@ -139,6 +140,7 @@ export function readCliArgs(argv: string[]) { linuxArm: Boolean(flags['linux-arm']), }, targetAllPlatforms: Boolean(flags['all-platforms']), + withTranslations: Boolean(flags['with-translations']), }; return { diff --git a/src/dev/build/build_distributables.ts b/src/dev/build/build_distributables.ts index d764c5df280b..193ece29862e 100644 --- a/src/dev/build/build_distributables.ts +++ b/src/dev/build/build_distributables.ts @@ -46,6 +46,7 @@ export interface BuildOptions { versionQualifier: string | undefined; targetAllPlatforms: boolean; targetPlatforms: TargetPlatforms; + withTranslations: boolean; } export async function buildDistributables(log: ToolingLog, options: BuildOptions) { @@ -70,6 +71,11 @@ export async function buildDistributables(log: ToolingLog, options: BuildOptions * run platform-generic build tasks */ await run(Tasks.CopySource); + + if (options.withTranslations) { + // control w/ --with-translations + await run(Tasks.CopyTranslations); + } await run(Tasks.CopyBinScripts); await run(Tasks.CreateEmptyDirsAndFiles); await run(Tasks.CreateReadme); diff --git a/src/dev/build/cli.ts b/src/dev/build/cli.ts index 2287696c87ca..4935a45823e4 100644 --- a/src/dev/build/cli.ts +++ b/src/dev/build/cli.ts @@ -70,6 +70,7 @@ if (showHelp) { --release {dim Produce a release-ready distributable} --version-qualifier {dim Suffix version with a qualifier} --skip-node-download {dim Reuse existing downloads of node.js} + --with-translations {dim Include available translations} --verbose,-v {dim Turn on verbose logging} --no-debug {dim Turn off debug logging} `) + '\n' diff --git a/src/dev/build/tasks/copy_source_task.ts b/src/dev/build/tasks/copy_source_task.ts index 9605e1922c28..9d8cf1c525df 100644 --- a/src/dev/build/tasks/copy_source_task.ts +++ b/src/dev/build/tasks/copy_source_task.ts @@ -61,7 +61,6 @@ export const CopySource: Task = { 'config/opensearch_dashboards.yml', 'config/node.options', 'tsconfig*.json', - '.i18nrc.json', 'opensearch_dashboards.d.ts', ], }); diff --git a/src/dev/build/tasks/copy_translations_task.ts b/src/dev/build/tasks/copy_translations_task.ts new file mode 100644 index 000000000000..112224bcddf8 --- /dev/null +++ b/src/dev/build/tasks/copy_translations_task.ts @@ -0,0 +1,51 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18nLoader } from '@osd/i18n'; +import { write, Task } from '../lib'; +import { getTranslationPaths } from '../../../legacy/server/i18n/get_translations_path'; +import { I18N_RC, DEFAULT_DIRS_WITH_RC_FILES } from '../../i18n/constants'; + +const TRANSLATIONS_PATH = 'i18n'; + +export const CopyTranslations: Task = { + description: 'Copying translations into platform-generic build directory', + + async run(config, log, build) { + const repoRoot = config.resolveFromRepo(); + + log.info('Gathering translations'); + + const allTranslationPaths = await getTranslationPaths({ + cwd: repoRoot, + // `,.` is added for backward compatibility + // ToDo: Remove `,.` for next major release + glob: `{${DEFAULT_DIRS_WITH_RC_FILES.join(',')},.}/${I18N_RC}`, + }); + + i18nLoader.registerTranslationFiles(allTranslationPaths); + + log.info('Combining translations'); + + const translationFiles: string[] = []; + + for (const locale of i18nLoader.getRegisteredLocales()) { + const { formats, messages } = await i18nLoader.getTranslationsByLocale(locale); + const translationFilename = `${locale}.json`; + translationFiles.push(translationFilename); + await write( + build.resolvePath(`${TRANSLATIONS_PATH}/${translationFilename}`), + JSON.stringify({ formats, messages }) + ); + } + + log.info('Generating translation manifest'); + + await write( + build.resolvePath(`${TRANSLATIONS_PATH}/${I18N_RC}`), + JSON.stringify({ translations: translationFiles }) + ); + }, +}; diff --git a/src/dev/build/tasks/index.ts b/src/dev/build/tasks/index.ts index f6b7a2d6726d..28399f9cb702 100644 --- a/src/dev/build/tasks/index.ts +++ b/src/dev/build/tasks/index.ts @@ -33,6 +33,7 @@ export * from './build_opensearch_dashboards_platform_plugins'; export * from './build_packages_task'; export * from './clean_tasks'; export * from './copy_source_task'; +export * from './copy_translations_task'; export * from './create_archives_sources_task'; export * from './create_archives_task'; export * from './create_empty_dirs_and_files_task'; diff --git a/src/dev/i18n/config.ts b/src/dev/i18n/config.ts index 03b41ca7886f..2430517558d9 100644 --- a/src/dev/i18n/config.ts +++ b/src/dev/i18n/config.ts @@ -28,10 +28,10 @@ * under the License. */ -import { resolve } from 'path'; +import { resolve, dirname } from 'path'; // @ts-ignore -import { normalizePath, readFileAsync } from '.'; +import { normalizePath, readFileAsync, accessAsync } from '.'; export interface I18nConfig { paths: Record; @@ -40,7 +40,19 @@ export interface I18nConfig { prefix?: string; } -export async function checkConfigNamespacePrefix(configPath: string) { +export async function checkConfigNamespacePrefix( + configPath: string, + failOnNotFound: boolean = true +) { + if (failOnNotFound === false) { + try { + await accessAsync(resolve(configPath)); + } catch (ex) { + // If the file doesn't exist, return silently + return; + } + } + const { prefix, paths } = JSON.parse(await readFileAsync(resolve(configPath))); for (const [namespace] of Object.entries(paths)) { if (prefix && prefix !== namespace.split('.')[0]) { @@ -51,8 +63,18 @@ export async function checkConfigNamespacePrefix(configPath: string) { export async function assignConfigFromPath( config: I18nConfig = { exclude: [], translations: [], paths: {} }, - configPath: string + configPath: string, + failOnNotFound: boolean = true ) { + if (failOnNotFound === false) { + try { + await accessAsync(resolve(configPath)); + } catch (ex) { + // If the file doesn't exist, return the untouched config + return config; + } + } + const additionalConfig: I18nConfig = { paths: {}, exclude: [], @@ -60,17 +82,19 @@ export async function assignConfigFromPath( ...JSON.parse(await readFileAsync(resolve(configPath))), }; + const configDirName = dirname(configPath); + for (const [namespace, namespacePaths] of Object.entries(additionalConfig.paths)) { const paths = Array.isArray(namespacePaths) ? namespacePaths : [namespacePaths]; - config.paths[namespace] = paths.map((path) => normalizePath(resolve(configPath, '..', path))); + config.paths[namespace] = paths.map((path) => normalizePath(resolve(configDirName, path))); } for (const exclude of additionalConfig.exclude) { - config.exclude.push(normalizePath(resolve(configPath, '..', exclude))); + config.exclude.push(normalizePath(resolve(configDirName, exclude))); } for (const translations of additionalConfig.translations) { - config.translations.push(normalizePath(resolve(configPath, '..', translations))); + config.translations.push(normalizePath(resolve(configDirName, translations))); } return config; diff --git a/src/dev/i18n/constants.ts b/src/dev/i18n/constants.ts index adbf5cde98e1..9fd2c031b622 100644 --- a/src/dev/i18n/constants.ts +++ b/src/dev/i18n/constants.ts @@ -32,3 +32,6 @@ export const DEFAULT_MESSAGE_KEY = 'defaultMessage'; export const DESCRIPTION_KEY = 'description'; export const VALUES_KEY = 'values'; export const I18N_RC = '.i18nrc.json'; + +// ToDo: Recursively look for I18N_RC in these 3 locations +export const DEFAULT_DIRS_WITH_RC_FILES: string[] = ['src', 'examples', 'packages']; diff --git a/src/dev/i18n/index.ts b/src/dev/i18n/index.ts index 8b5f67d03e81..c59bee6fcb8d 100644 --- a/src/dev/i18n/index.ts +++ b/src/dev/i18n/index.ts @@ -32,7 +32,14 @@ export { extractMessagesFromPathToMap } from './extract_default_translations'; // @ts-ignore export { matchEntriesWithExctractors } from './extract_default_translations'; -export { arrayify, writeFileAsync, readFileAsync, normalizePath, ErrorReporter } from './utils'; +export { + arrayify, + writeFileAsync, + readFileAsync, + accessAsync, + normalizePath, + ErrorReporter, +} from './utils'; export { serializeToJson, serializeToJson5 } from './serializers'; export { I18nConfig, diff --git a/src/dev/i18n/tasks/check_configs.ts b/src/dev/i18n/tasks/check_configs.ts index 2e37a5e00cf0..0c7f13c4c9a6 100644 --- a/src/dev/i18n/tasks/check_configs.ts +++ b/src/dev/i18n/tasks/check_configs.ts @@ -29,19 +29,23 @@ */ import { resolve, join } from 'path'; import { ListrContext } from '.'; -import { I18N_RC } from '../constants'; +import { DEFAULT_DIRS_WITH_RC_FILES, I18N_RC } from '../constants'; import { checkConfigNamespacePrefix, arrayify } from '..'; export function checkConfigs(additionalConfigPaths: string | string[] = []) { const root = join(__dirname, '../../../../'); + const defaultRCs = DEFAULT_DIRS_WITH_RC_FILES.map((value) => resolve(root, value, I18N_RC)); + + // For backward compatibility + // ToDo: Remove for next major release const opensearchDashboardsRC = resolve(root, I18N_RC); - const configPaths = [opensearchDashboardsRC, ...arrayify(additionalConfigPaths)]; + const configPaths = [opensearchDashboardsRC, ...defaultRCs, ...arrayify(additionalConfigPaths)]; return configPaths.map((configPath) => ({ task: async (context: ListrContext) => { try { - await checkConfigNamespacePrefix(configPath); + await checkConfigNamespacePrefix(configPath, false); } catch (err) { const { reporter } = context; const reporterWithContext = reporter.withContext({ name: configPath }); diff --git a/src/dev/i18n/tasks/merge_configs.ts b/src/dev/i18n/tasks/merge_configs.ts index 79c1fde91e95..7687dce2abd0 100644 --- a/src/dev/i18n/tasks/merge_configs.ts +++ b/src/dev/i18n/tasks/merge_configs.ts @@ -30,17 +30,22 @@ import { resolve, join } from 'path'; import { ListrContext } from '.'; import { assignConfigFromPath, arrayify } from '..'; +import { DEFAULT_DIRS_WITH_RC_FILES, I18N_RC } from '../constants'; export function mergeConfigs(additionalConfigPaths: string | string[] = []) { const root = join(__dirname, '../../../../'); - const opensearchDashboardsRC = resolve(root, '.i18nrc.json'); + const defaultRCs = DEFAULT_DIRS_WITH_RC_FILES.map((value) => resolve(root, value, I18N_RC)); - const configPaths = [opensearchDashboardsRC, ...arrayify(additionalConfigPaths)]; + // For backward compatibility + // ToDo: Remove for next major release + const opensearchDashboardsRC = resolve(root, I18N_RC); + + const configPaths = [opensearchDashboardsRC, ...defaultRCs, ...arrayify(additionalConfigPaths)]; return configPaths.map((configPath) => ({ task: async (context: ListrContext) => { try { - context.config = await assignConfigFromPath(context.config, configPath); + context.config = await assignConfigFromPath(context.config, configPath, false); } catch (err) { const { reporter } = context; const reporterWithContext = reporter.withContext({ name: configPath }); diff --git a/src/dev/run_i18n_check.ts b/src/dev/run_i18n_check.ts index 17fc397a7bf1..08ff3a6f09b1 100644 --- a/src/dev/run_i18n_check.ts +++ b/src/dev/run_i18n_check.ts @@ -41,6 +41,7 @@ import { mergeConfigs, ListrContext, } from './i18n/tasks'; +import { DEFAULT_DIRS_WITH_RC_FILES } from './i18n/constants'; const skipOnNoTranslations = (context: ListrContext) => !context.config?.translations?.length && 'No translations found.'; @@ -83,7 +84,7 @@ run( throw createFailError(`${chalk.white.bgRed(' I18N ERROR ')} --fix can't have a value`); } - const srcPaths = Array().concat(path || ['./src', './packages']); + const srcPaths = Array().concat(path || DEFAULT_DIRS_WITH_RC_FILES); const list = new Listr( [ @@ -104,7 +105,6 @@ run( }, { title: 'Validating Default Messages', - skip: skipOnNoTranslations, task: ({ config }) => { return new Listr(extractDefaultMessages(config, srcPaths), { exitOnError: true }); }, diff --git a/src/dev/run_i18n_extract.ts b/src/dev/run_i18n_extract.ts index 6de6b0904abb..abe7dcc4a3c3 100644 --- a/src/dev/run_i18n_extract.ts +++ b/src/dev/run_i18n_extract.ts @@ -35,6 +35,7 @@ import { resolve } from 'path'; import { createFailError, run } from '@osd/dev-utils'; import { ErrorReporter, serializeToJson, serializeToJson5, writeFileAsync } from './i18n'; import { extractDefaultMessages, mergeConfigs, ListrContext } from './i18n/tasks'; +import { DEFAULT_DIRS_WITH_RC_FILES } from './i18n/constants'; run( async ({ @@ -57,7 +58,7 @@ run( `${chalk.white.bgRed(' I18N ERROR ')} --path and --include-config require a value` ); } - const srcPaths = Array().concat(path || ['./src', './packages']); + const srcPaths = Array().concat(path || DEFAULT_DIRS_WITH_RC_FILES); const list = new Listr([ { diff --git a/src/dev/run_i18n_integrate.ts b/src/dev/run_i18n_integrate.ts index 6f0314e757e2..4a11c5b79214 100644 --- a/src/dev/run_i18n_integrate.ts +++ b/src/dev/run_i18n_integrate.ts @@ -34,6 +34,7 @@ import Listr from 'listr'; import { createFailError, run } from '@osd/dev-utils'; import { ErrorReporter, integrateLocaleFiles } from './i18n'; import { extractDefaultMessages, mergeConfigs, ListrContext } from './i18n/tasks'; +import { DEFAULT_DIRS_WITH_RC_FILES } from './i18n/constants'; run( async ({ @@ -88,7 +89,7 @@ run( ); } - const srcPaths = Array().concat(path || ['./src', './packages']); + const srcPaths = Array().concat(path || DEFAULT_DIRS_WITH_RC_FILES); const list = new Listr([ { diff --git a/src/legacy/server/i18n/index.ts b/src/legacy/server/i18n/index.ts index 8a571144cdf5..f82822b7a672 100644 --- a/src/legacy/server/i18n/index.ts +++ b/src/legacy/server/i18n/index.ts @@ -29,7 +29,6 @@ */ import { i18n, i18nLoader } from '@osd/i18n'; -import { basename } from 'path'; import { Server } from '@hapi/hapi'; import { fromRoot } from '../../../core/server/utils'; import type { UsageCollectionSetup } from '../../../plugins/usage_collection/server'; diff --git a/src/plugins/data_explorer/.i18nrc.json b/src/plugins/data_explorer/.i18nrc.json deleted file mode 100644 index 1ea4ccdd1e7b..000000000000 --- a/src/plugins/data_explorer/.i18nrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "prefix": "dataExplorer", - "paths": { - "dataExplorer": "." - }, - "translations": ["translations/ja-JP.json"] -} diff --git a/src/plugins/query_enhancements/.i18nrc.json b/src/plugins/query_enhancements/.i18nrc.json deleted file mode 100644 index bb9a3ef5e506..000000000000 --- a/src/plugins/query_enhancements/.i18nrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "prefix": "queryEnhancements", - "paths": { - "queryEnhancements": "." - }, - "translations": ["translations/ja-JP.json"] -}