diff --git a/changelogs/fragments/7805.yml b/changelogs/fragments/7805.yml new file mode 100644 index 00000000000..f4a1832c142 --- /dev/null +++ b/changelogs/fragments/7805.yml @@ -0,0 +1,2 @@ +feat: +- Refractor the style of recent items card ([#7805](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7805)) \ No newline at end of file diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 82511b32bc0..e737ecec289 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -349,6 +349,7 @@ export class ChromeService { getHeaderComponent: () => (
@@ -8951,7 +9035,7 @@ exports[`Header renders application header without title and breadcrumbs 1`] = ` initialFocus={false} isOpen={false} ownFocus={true} - panelPaddingSize="s" + panelPaddingSize="m" repositionOnScroll={true} >
@@ -11048,6 +11132,34 @@ exports[`Header renders condensed header 1`] = ` } } homeHref="/" + http={ + Object { + "addLoadingCountSource": [MockFunction], + "anonymousPaths": Object { + "isAnonymous": [MockFunction], + "register": [MockFunction], + }, + "basePath": BasePath { + "basePath": "/test", + "clientBasePath": "", + "get": [Function], + "getBasePath": [Function], + "prepend": [Function], + "remove": [Function], + "serverBasePath": "/test", + }, + "delete": [MockFunction], + "fetch": [MockFunction], + "get": [MockFunction], + "getLoadingCount$": [MockFunction], + "head": [MockFunction], + "intercept": [MockFunction], + "options": [MockFunction], + "patch": [MockFunction], + "post": [MockFunction], + "put": [MockFunction], + } + } intl={ Object { "defaultFormats": Object {}, @@ -16023,6 +16135,34 @@ exports[`Header renders page header with application title 1`] = ` } } homeHref="/" + http={ + Object { + "addLoadingCountSource": [MockFunction], + "anonymousPaths": Object { + "isAnonymous": [MockFunction], + "register": [MockFunction], + }, + "basePath": BasePath { + "basePath": "/test", + "clientBasePath": "", + "get": [Function], + "getBasePath": [Function], + "prepend": [Function], + "remove": [Function], + "serverBasePath": "/test", + }, + "delete": [MockFunction], + "fetch": [MockFunction], + "get": [MockFunction], + "getLoadingCount$": [MockFunction], + "head": [MockFunction], + "intercept": [MockFunction], + "options": [MockFunction], + "patch": [MockFunction], + "post": [MockFunction], + "put": [MockFunction], + } + } intl={ Object { "defaultFormats": Object {}, @@ -16994,6 +17134,34 @@ exports[`Header renders page header with application title 1`] = ` } } buttonSize="xs" + http={ + Object { + "addLoadingCountSource": [MockFunction], + "anonymousPaths": Object { + "isAnonymous": [MockFunction], + "register": [MockFunction], + }, + "basePath": BasePath { + "basePath": "/test", + "clientBasePath": "", + "get": [Function], + "getBasePath": [Function], + "prepend": [Function], + "remove": [Function], + "serverBasePath": "/test", + }, + "delete": [MockFunction], + "fetch": [MockFunction], + "get": [MockFunction], + "getLoadingCount$": [MockFunction], + "head": [MockFunction], + "intercept": [MockFunction], + "options": [MockFunction], + "patch": [MockFunction], + "post": [MockFunction], + "put": [MockFunction], + } + } navLinks$={ BehaviorSubject { "_isScalar": false, @@ -17418,7 +17586,7 @@ exports[`Header renders page header with application title 1`] = ` anchorPosition="downCenter" button={ @@ -17441,7 +17609,7 @@ exports[`Header renders page header with application title 1`] = ` initialFocus={false} isOpen={false} ownFocus={true} - panelPaddingSize="s" + panelPaddingSize="m" repositionOnScroll={true} >
@@ -20386,6 +20554,34 @@ exports[`Header toggles primary navigation menu when clicked 1`] = ` } } homeHref="/" + http={ + Object { + "addLoadingCountSource": [MockFunction], + "anonymousPaths": Object { + "isAnonymous": [MockFunction], + "register": [MockFunction], + }, + "basePath": BasePath { + "basePath": "/test", + "clientBasePath": "", + "get": [Function], + "getBasePath": [Function], + "prepend": [Function], + "remove": [Function], + "serverBasePath": "/test", + }, + "delete": [MockFunction], + "fetch": [MockFunction], + "get": [MockFunction], + "getLoadingCount$": [MockFunction], + "head": [MockFunction], + "intercept": [MockFunction], + "options": [MockFunction], + "patch": [MockFunction], + "post": [MockFunction], + "put": [MockFunction], + } + } intl={ Object { "defaultFormats": Object {}, diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index 9cc8652c3e4..7c2f0b90f9e 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -50,6 +50,7 @@ function mockProps() { const application = applicationServiceMock.createInternalStartContract(); return { + http, application, opensearchDashboardsVersion: '1.0.0', appTitle$: new BehaviorSubject('test'), diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 492d9c7b3e7..0658336cabd 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -79,6 +79,7 @@ import { HomeLoader } from './home_loader'; import { RecentItems } from './recent_items'; export interface HeaderProps { + http: HttpStart; opensearchDashboardsVersion: string; application: InternalApplicationStart; appTitle$: Observable; @@ -120,6 +121,7 @@ export interface HeaderProps { } export function Header({ + http, opensearchDashboardsVersion, opensearchDashboardsDocLink, application, @@ -351,11 +353,12 @@ export function Header({ const renderRecentItems = () => ( 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 a6b9a65f9e1..6d3f6f32990 100644 --- a/src/core/public/chrome/ui/header/recent_items.test.tsx +++ b/src/core/public/chrome/ui/header/recent_items.test.tsx @@ -3,11 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, act } from '@testing-library/react'; import React from 'react'; import { BehaviorSubject } from 'rxjs'; import { applicationServiceMock, httpServiceMock } from '../../../mocks'; -import { Props, RecentItems } from './recent_items'; +import { SavedObjectWithMetadata } from './recent_items'; +import { RecentItems } from './recent_items'; jest.mock('./nav_link', () => ({ createRecentNavLink: jest.fn().mockImplementation(() => { @@ -17,73 +18,136 @@ jest.mock('./nav_link', () => ({ }), })); +const mockRecentlyAccessed = new BehaviorSubject([ + { + id: '6ef856c0-5f86-11ef-b7df-1bb1cf26ce5b', + label: 'visualizeMock', + link: '/app/visualize', + workspaceId: 'workspace_1', + meta: { type: 'visualization' }, + }, +]); + +const mockWorkspaceList = new BehaviorSubject([ + { + id: 'workspace_1', + name: 'WorkspaceMock_1', + }, +]); + +const savedObjectsFromServer: SavedObjectWithMetadata[] = [ + { + type: 'visualization', + id: '6ef856c0-5f86-11ef-b7df-1bb1cf26ce5b', + attributes: {}, + references: [], + updated_at: '2024-07-20T00:00:00.000Z', + meta: {}, + }, +]; const defaultMockProps = { navigateToUrl: applicationServiceMock.createStartContract().navigateToUrl, workspaceList$: new BehaviorSubject([]), recentlyAccessed$: new BehaviorSubject([]), navLinks$: new BehaviorSubject([]), basePath: httpServiceMock.createStartContract().basePath, + http: httpServiceMock.createSetupContract(), renderBreadcrumbs: <>, }; -const setup = (props: Props) => { - return render(); -}; + +jest.spyOn(defaultMockProps.http, 'get').mockImplementation( + (url): Promise => { + if (typeof url === 'string') { + if ((url as string).includes('6ef856c0-5f86-11ef-b7df-1bb1cf26ce5b')) { + return Promise.resolve(savedObjectsFromServer[0]); + } else { + return Promise.resolve(savedObjectsFromServer[1]); + } + } + return Promise.reject(new Error('Invalid URL')); + } +); + describe('Recent items', () => { it('should render base element normally', () => { - const { baseElement } = setup(defaultMockProps); + const { baseElement } = render(); expect(baseElement).toMatchSnapshot(); }); - it('should get workspace name through workspace id and render it with brackets wrapper', () => { - const workspaceList$ = new BehaviorSubject([ - { - id: 'workspace_id', - name: 'workspace_name', - }, - ]); - const recentlyAccessed$ = new BehaviorSubject([ - { - label: 'item_label', - link: 'item_link', - id: 'item_id', - workspaceId: 'workspace_id', - }, - ]); - - setup({ + it('render with empty recent work', () => { + const { getByText, getByTestId } = render(); + const mockRecentButton = getByTestId('recentItemsSectionButton'); + fireEvent.click(mockRecentButton); + expect(getByText('No recently viewed items')).toBeInTheDocument(); + }); + + it('should be able to render recent works', async () => { + const mockProps = { ...defaultMockProps, - workspaceList$, - recentlyAccessed$, - navigateToUrl: defaultMockProps.navigateToUrl, + recentlyAccessed$: mockRecentlyAccessed, + }; + + await act(async () => { + render(); }); - const button = screen.getByTestId('recentItemsSectionButton'); - fireEvent.click(button); - expect(screen.getByText('(workspace_name)')).toBeInTheDocument(); + + const mockRecentButton = screen.getByTestId('recentItemsSectionButton'); + fireEvent.click(mockRecentButton); + expect(screen.getByText('visualizeMock')).toBeInTheDocument(); }); - it('should call navigateToUrl with link generated from createRecentNavLink when clicking item', () => { - const workspaceList$ = new BehaviorSubject([]); - const recentlyAccessed$ = new BehaviorSubject([ - { - label: 'item_label', - link: 'item_link', - id: 'item_id', - }, - ]); - const navigateToUrl = jest.fn(); - const renderBreadcrumbs = <>; - setup({ + it('shoulde be able to display workspace name if the asset is attched to a workspace and render it with brackets wrapper ', async () => { + const mockProps = { ...defaultMockProps, - workspaceList$, - recentlyAccessed$, - navigateToUrl, - renderBreadcrumbs, + recentlyAccessed$: mockRecentlyAccessed, + workspaceList$: mockWorkspaceList, + }; + + await act(async () => { + render(); }); + + const mockRecentButton = screen.getByTestId('recentItemsSectionButton'); + fireEvent.click(mockRecentButton); + expect(screen.getByText('(WorkspaceMock_1)')).toBeInTheDocument(); + }); + + it('should call navigateToUrl with link generated from createRecentNavLink when clicking a recent item', async () => { + const mockProps = { + ...defaultMockProps, + recentlyAccessed$: mockRecentlyAccessed, + workspaceList$: mockWorkspaceList, + }; + + const navigateToUrl = jest.fn(); + + await act(async () => { + render(); + }); + const button = screen.getByTestId('recentItemsSectionButton'); fireEvent.click(button); - const item = screen.getByText('item_label'); + const item = screen.getByText('visualizeMock'); expect(navigateToUrl).not.toHaveBeenCalled(); fireEvent.click(item); expect(navigateToUrl).toHaveBeenCalledWith('/recent_nav_link'); }); + + it('should be able to display the preferences popover setting when clicking Preferences button', async () => { + const mockProps = { + ...defaultMockProps, + recentlyAccessed$: mockRecentlyAccessed, + }; + + await act(async () => { + render(); + }); + + const button = screen.getByTestId('recentItemsSectionButton'); + fireEvent.click(button); + + const preferencesButton = screen.getByTestId('preferencesSettingButton'); + fireEvent.click(preferencesButton); + expect(screen.getByTestId('preferencesSettingPopover')).toBeInTheDocument(); + }); }); diff --git a/src/core/public/chrome/ui/header/recent_items.tsx b/src/core/public/chrome/ui/header/recent_items.tsx index c7894104f16..153193780a2 100644 --- a/src/core/public/chrome/ui/header/recent_items.tsx +++ b/src/core/public/chrome/ui/header/recent_items.tsx @@ -2,29 +2,38 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, useEffect } from 'react'; import * as Rx from 'rxjs'; +import moment from 'moment'; import { - EuiPopover, - EuiTextColor, + EuiPanel, EuiListGroup, EuiListGroupItem, EuiTitle, + EuiPopoverTitle, + EuiIcon, EuiText, + EuiButtonEmpty, EuiSpacer, EuiHeaderSectionItemButtonProps, EuiButtonIcon, + EuiRadioGroup, + EuiTextColor, + EuiPopover, EuiToolTip, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import useObservable from 'react-use/lib/useObservable'; -import { ChromeRecentlyAccessedHistoryItem } from '../..'; +import { SavedObjectsNamespaceType } from 'src/core/public'; +import { ChromeRecentlyAccessedHistoryItem, SavedObject } from 'opensearch-dashboards/public'; import { WorkspaceObject } from '../../../workspace'; -import { createRecentNavLink } from './nav_link'; import { HttpStart } from '../../../http'; +import { createRecentNavLink } from './nav_link'; import { ChromeNavLink } from '../../../'; import './recent_items.scss'; +const widthForRightMargin = 8; + export interface Props { recentlyAccessed$: Rx.Observable; workspaceList$: Rx.Observable; @@ -33,7 +42,63 @@ export interface Props { navLinks$: Rx.Observable; renderBreadcrumbs: React.JSX.Element; buttonSize?: EuiHeaderSectionItemButtonProps['size']; + http: HttpStart; +} + +interface SavedObjectMetadata { + icon?: string; + title?: string; + editUrl?: string; + inAppUrl?: { path: string; uiCapabilitiesPath: string }; + namespaceType?: SavedObjectsNamespaceType; } +export type SavedObjectWithMetadata = SavedObject & { + meta: SavedObjectMetadata; +}; + +type DetailedRecentlyAccessedItem = ChromeRecentlyAccessedHistoryItem & + SavedObjectWithMetadata & + ChromeRecentlyAccessedHistoryItem['meta'] & { + updatedAt: number; + workspaceName?: string; + }; + +const bulkGetDetail = (savedObjects: Array>, http: HttpStart) => { + return Promise.all( + savedObjects.map((obj) => + http + .get( + `/api/opensearch-dashboards/management/saved_objects/${encodeURIComponent( + obj.type + )}/${encodeURIComponent(obj.id)}` + ) + .catch((error) => ({ + id: obj.id, + type: obj.type, + error, + attributes: {}, + references: [], + meta: {}, + updated_at: '', + })) + ) + ); +}; + +const recentsRadios = [ + { + id: '5', + label: '5 pages', + }, + { + id: '10', + label: '10 pages', + }, + { + id: '15', + label: '15 pages', + }, +]; export const RecentItems = ({ recentlyAccessed$, @@ -43,35 +108,65 @@ export const RecentItems = ({ basePath, renderBreadcrumbs, buttonSize = 's', + http, }: Props) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); - + const [isPreferencesPopoverOpen, setIsPreferencesPopoverOpen] = useState(false); const recentlyAccessedItems = useObservable(recentlyAccessed$, []); + const [recentsRadioIdSelected, setRecentsRadioIdSelected] = useState(recentsRadios[0].id); const workspaceList = useObservable(workspaceList$, []); + const [detailedSavedObjects, setDetailedSavedObjects] = useState( + [] + ); const navLinks = useObservable(navLinks$, []).filter((link) => !link.hidden); - const items = useMemo(() => { - // Only display five most recent items - return recentlyAccessedItems.slice(0, 5).map((item) => { - return { - link: createRecentNavLink(item, navLinks, basePath, navigateToUrl).href, - label: item.label, - workspaceId: item.workspaceId, - workspaceName: - workspaceList.find((workspace) => workspace.id === item.workspaceId)?.name ?? '', - }; - }); - }, [recentlyAccessedItems, workspaceList, basePath, navLinks, navigateToUrl]); - const handleItemClick = (link: string) => { navigateToUrl(link); setIsPopoverOpen(false); }; - const button = ( + const preferencePopover = ( + { + setIsPreferencesPopoverOpen((IsPreferencesPopoverOpe) => !IsPreferencesPopoverOpe); + }} + > + Preferences + + } + isOpen={isPreferencesPopoverOpen} + anchorPosition="downLeft" + closePopover={() => { + setIsPreferencesPopoverOpen(false); + }} + > + Preferences + { + setRecentsRadioIdSelected(id); + setIsPreferencesPopoverOpen(false); + }} + name="radio group" + legend={{ + children: Recents, + }} + /> + + ); + const recentButton = ( ); + useEffect(() => { + const savedObjects = recentlyAccessedItems + .filter((item) => item.meta?.type) + .map((item) => ({ + type: item.meta?.type || '', + id: item.id, + })); + + if (savedObjects.length) { + bulkGetDetail(savedObjects, http).then((res) => { + const formatDetailedSavedObjects = res.map((obj) => { + const recentAccessItem = recentlyAccessedItems.find( + (item) => item.id === obj.id + ) as ChromeRecentlyAccessedHistoryItem; + + const findWorkspace = workspaceList.find( + (workspace) => workspace.id === recentAccessItem.workspaceId + ); + return { + ...recentAccessItem, + ...obj, + ...recentAccessItem.meta, + updatedAt: moment(obj?.updated_at).valueOf(), + workspaceName: findWorkspace?.name, + link: createRecentNavLink(recentAccessItem, navLinks, basePath, navigateToUrl).href, + }; + }); + // here I write this argument to avoid Unnecessary re-rendering + if (JSON.stringify(formatDetailedSavedObjects) !== JSON.stringify(detailedSavedObjects)) { + setDetailedSavedObjects(formatDetailedSavedObjects); + } + }); + } + }, [ + navLinks, + basePath, + navigateToUrl, + recentlyAccessedItems, + http, + workspaceList, + detailedSavedObjects, + ]); + + const selectedRecentsItems = useMemo(() => { + return detailedSavedObjects.slice(0, Number(recentsRadioIdSelected)); + }, [detailedSavedObjects, recentsRadioIdSelected]); + return ( { setIsPopoverOpen(false); @@ -104,36 +246,52 @@ export const RecentItems = ({ anchorPosition="downCenter" repositionOnScroll initialFocus={false} - panelPaddingSize="s" + panelPaddingSize="m" > {renderBreadcrumbs} - - -

Recents

-
- {items.length > 0 ? ( - - {items.map((item) => ( - handleItemClick(item.link)} - key={item.link} - label={ - <> - {item.label} - {item.workspaceName ? ( - ({item.workspaceName}) - ) : null} - - } - color="text" - size="s" - /> - ))} - - ) : ( - No recently viewed items - )} + + +

Recent

+
+ + {selectedRecentsItems.length > 0 ? ( + + {selectedRecentsItems.map((item) => ( + handleItemClick(item.link)} + key={item.link} + style={{ padding: '1px' }} + label={ + <> + + {item.label} + {item.workspaceName ? ( + ({item.workspaceName}) + ) : null} + + } + color="text" + size="s" + /> + ))} + + ) : ( + + No recently viewed items + + )} + +
+ {preferencePopover}
); };