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/changelogs/fragments/8382.yml b/changelogs/fragments/8382.yml new file mode 100644 index 000000000000..2b0dd5d85d09 --- /dev/null +++ b/changelogs/fragments/8382.yml @@ -0,0 +1,2 @@ +feat: +- Refactor collaborators panel at workspace create ([#8382](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8382)) \ No newline at end of file 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/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" + > + + + + + + )} + { > )} {permissionEnabled && ( - <> - + - {usersAndPermissionsCreatePageTitle} - + {usersAndPermissionsCreatePageTitle} + {i18n.translate('workspace.creator.form.usersAndPermissionsDescription', { - defaultMessage: - 'You will be added as an owner to the workspace. Select additional users and user groups as workspace collaborators with different access levels.', + defaultMessage: 'Manage access and permissions', })} - - > + + {i18n.translate('workspace.creator.collaborators.panel.fields.name.title', { + defaultMessage: 'Workspace access', + })} + + } + description={i18n.translate( + 'workspace.creator.collaborators.panel.fields.name.description', + { + defaultMessage: + 'You will be added as an owner to the workspace. Select additional users and user groups as workspace collaborators with different access levels.', + } + )} + > + + + )} diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx index 1b94fa315633..d126ab1be034 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx @@ -144,7 +144,7 @@ export const WorkspacePermissionSettingInput = ({ placeholder={ type === WorkspacePermissionItemType.User ? i18n.translate('workspaceForm.permissionSetting.selectUser', { - defaultMessage: 'Enter user name or uer ID', + defaultMessage: 'Enter user name or user ID', }) : i18n.translate('workspaceForm.permissionSetting.selectUserGroup', { defaultMessage: 'Enter group name or group ID', diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx index 17193574c474..6722b96aca25 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx @@ -11,6 +11,7 @@ import { EuiCompressedFormRow, EuiSpacer, EuiFormLabel, + EuiButtonIcon, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { WorkspaceFormError, WorkspacePermissionSetting } from './types'; @@ -135,31 +136,43 @@ export const WorkspacePermissionSettingPanel = ({ return ( <> - - - - {i18n.translate('workspaceForm.permissionSetting.typeLabel', { - defaultMessage: 'Type', - })} - - - - - {i18n.translate('workspaceForm.permissionSetting.collaboratorLabel', { - defaultMessage: 'Collaborator', - })} - - - - - {i18n.translate('workspaceForm.permissionSetting.accessLevelLabel', { - defaultMessage: 'Access level', - })} - - - - - + {permissionSettings.length > 0 && ( + <> + + + + {i18n.translate('workspaceForm.permissionSetting.typeLabel', { + defaultMessage: 'Type', + })} + + + + + {i18n.translate('workspaceForm.permissionSetting.collaboratorLabel', { + defaultMessage: 'Collaborator', + })} + + + + + {i18n.translate('workspaceForm.permissionSetting.accessLevelLabel', { + defaultMessage: 'Access level', + })} + + + {/* Placeholder to vertically align form labels with their respective inputs */} + + + + + + > + )} {permissionSettings.map((item, index) => ( ', () => { 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} /> - + + - {queriedRecentWorkspace.length > 0 && - getWorkspaceListGroup(queriedRecentWorkspace, 'recent')} - - {queriedWorkspace.length > 0 && getWorkspaceListGroup(queriedWorkspace, 'all')} + {queriedWorkspace.length > 0 && getWorkspaceListGroup(queriedWorkspace)} {queriedWorkspace.length === 0 && getEmptyStatePrompt()} 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$, }); }); }
- - + + + + - -
+ + + + +