Skip to content

Commit 3099c9c

Browse files
authored
feat(gloabl-header): add starred entities dropdown to header (#522)
Signed-off-by: Rohit Rai <rohitkrai03@gmail.com>
1 parent 3c820ea commit 3099c9c

11 files changed

Lines changed: 475 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-global-header': minor
3+
---
4+
5+
Added a new starred dropdown to global header which shows all the starred entities.

workspaces/global-header/docs/index.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
Red Hat Developer Hub includes a new configable and highly extendable global header plugin starting with RHDH 1.5.
44

5-
By default it includes a Search input field, Create, Support[^1] and Notifications[^2] icon buttons and a user profile dropdown.
5+
By default it includes a Search input field, Create, Starred[^1], Support[^2] and Notifications[^3] icon buttons and a user profile dropdown.
66

7-
[^1]: Only when the Support URL is configured in the `app-config.yaml`.
8-
[^2]: Only when the notification plugin is installed.
7+
[^1]: Only when an enitity is starred.
8+
[^2]: Only when the Support URL is configured in the `app-config.yaml`.
9+
[^3]: Only when the notifications plugin is installed.

workspaces/global-header/plugins/global-header/app-config.dynamic.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ dynamicPlugins:
2929
icon: add
3030
to: create
3131

32+
- mountPoint: global.header/component
33+
importName: StarredDropdown
34+
config:
35+
priority: 85
36+
3237
- mountPoint: global.header/component
3338
importName: SupportButton
3439
config:

workspaces/global-header/plugins/global-header/dev/index.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ import React from 'react';
1919
import { createDevApp } from '@backstage/dev-utils';
2020
import { mockApis, MockFetchApi, TestApiProvider } from '@backstage/test-utils';
2121
import { MockSearchApi, searchApiRef } from '@backstage/plugin-search-react';
22-
import { catalogApiRef } from '@backstage/plugin-catalog-react';
22+
import {
23+
catalogApiRef,
24+
MockStarredEntitiesApi,
25+
starredEntitiesApiRef,
26+
} from '@backstage/plugin-catalog-react';
2327
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
2428
import { configApiRef } from '@backstage/core-plugin-api';
2529
import {
@@ -116,6 +120,8 @@ const entities = [
116120

117121
const catalogApi = catalogApiMock({ entities });
118122

123+
const starredEntitiesApi = new MockStarredEntitiesApi();
124+
119125
const mockBaseUrl = 'https://backstage/api/notifications';
120126
const discoveryApi = { getBaseUrl: async () => mockBaseUrl };
121127
const fetchApi = new MockFetchApi();
@@ -137,11 +143,13 @@ const Providers = ({
137143
}),
138144
[mountPoints],
139145
);
146+
starredEntitiesApi.toggleStarred('template:default/mock-starred-template');
140147

141148
return (
142149
<TestApiProvider
143150
apis={[
144151
[catalogApiRef, catalogApi],
152+
[starredEntitiesApiRef, starredEntitiesApi],
145153
[searchApiRef, mockSearchApi],
146154
[configApiRef, mockConfigApi],
147155
[notificationsApiRef, client],

workspaces/global-header/plugins/global-header/report.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,9 @@ export interface SpacerProps {
308308
minWidth?: number | string;
309309
}
310310

311+
// @public
312+
export const StarredDropdown: () => React_2.JSX.Element;
313+
311314
// @public (undocumented)
312315
export const SupportButton: ({
313316
title,
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import React from 'react';
17+
import { screen, fireEvent } from '@testing-library/react';
18+
import { HeaderDropdownComponent } from './HeaderDropdownComponent';
19+
import { renderInTestApp } from '@backstage/test-utils';
20+
21+
describe('HeaderDropdownComponent', () => {
22+
const mockOnOpen = jest.fn();
23+
const mockOnClose = jest.fn();
24+
25+
beforeEach(() => {
26+
jest.clearAllMocks();
27+
28+
jest.spyOn(console, 'warn').mockImplementation(message => {
29+
if (message.includes('⚠️ React Router Future Flag Warning')) {
30+
return; // Suppress React Router warning
31+
}
32+
// eslint-disable-next-line no-console
33+
console.warn(message); // Log other warnings
34+
});
35+
36+
jest.spyOn(console, 'error').mockImplementation(message => {
37+
if (
38+
typeof message === 'string' &&
39+
message.includes('findDOMNode is deprecated')
40+
) {
41+
return; // Suppress findDOMNode warning in tests
42+
}
43+
// eslint-disable-next-line no-console
44+
console.error(message); // Allow other errors to be logged
45+
});
46+
});
47+
48+
it('renders button with provided content', async () => {
49+
await renderInTestApp(
50+
<HeaderDropdownComponent
51+
buttonContent={<span>Click Me</span>}
52+
onOpen={mockOnOpen}
53+
onClose={mockOnClose}
54+
anchorEl={null}
55+
>
56+
<div>Dropdown Content</div>
57+
</HeaderDropdownComponent>,
58+
);
59+
60+
expect(screen.getByText('Click Me')).toBeInTheDocument();
61+
});
62+
63+
it('opens dropdown when button is clicked', async () => {
64+
await renderInTestApp(
65+
<HeaderDropdownComponent
66+
buttonContent={<span>Click Me</span>}
67+
onOpen={mockOnOpen}
68+
onClose={mockOnClose}
69+
anchorEl={null}
70+
>
71+
<div>Dropdown Content</div>
72+
</HeaderDropdownComponent>,
73+
);
74+
75+
fireEvent.click(screen.getByText('Click Me'));
76+
expect(mockOnOpen).toHaveBeenCalled();
77+
});
78+
79+
it('renders as an icon button if isIconButton is true', async () => {
80+
await renderInTestApp(
81+
<HeaderDropdownComponent
82+
buttonContent={<span>Icon</span>}
83+
onOpen={jest.fn()}
84+
onClose={jest.fn()}
85+
anchorEl={null}
86+
isIconButton // Explicitly set to true
87+
>
88+
<div>Dropdown Content</div>
89+
</HeaderDropdownComponent>,
90+
);
91+
92+
// Check that an IconButton is rendered
93+
const iconButton = screen.getByRole('button');
94+
expect(iconButton).toBeInTheDocument();
95+
expect(iconButton).toHaveClass('v5-MuiIconButton-root'); // Ensures it's an IconButton
96+
});
97+
98+
it('displays tooltip when hovered', async () => {
99+
await renderInTestApp(
100+
<HeaderDropdownComponent
101+
buttonContent={<span>Click Me</span>}
102+
onOpen={jest.fn()}
103+
onClose={jest.fn()}
104+
anchorEl={null}
105+
tooltip="Test Tooltip"
106+
>
107+
<div>Dropdown Content</div>
108+
</HeaderDropdownComponent>,
109+
);
110+
111+
// Ensure the tooltip is not visible initially
112+
expect(screen.queryByText('Test Tooltip')).not.toBeInTheDocument();
113+
114+
// Hover over the button to trigger the tooltip
115+
fireEvent.mouseOver(screen.getByRole('button'));
116+
117+
// The tooltip should now be visible
118+
expect(await screen.findByText('Test Tooltip')).toBeInTheDocument();
119+
});
120+
});

workspaces/global-header/plugins/global-header/src/components/HeaderDropdownComponent/HeaderDropdownComponent.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import Menu from '@mui/material/Menu';
1919
import Button from '@mui/material/Button';
2020
import { styled } from '@mui/material/styles';
2121
import Box from '@mui/material/Box';
22+
import IconButton from '@mui/material/IconButton';
23+
import Tooltip from '@mui/material/Tooltip';
2224
import { MenuItemConfig, MenuSectionConfig } from './MenuSection';
2325

2426
interface HeaderDropdownProps {
@@ -30,6 +32,8 @@ interface HeaderDropdownProps {
3032
onOpen: (event: React.MouseEvent<HTMLElement>) => void;
3133
onClose: () => void;
3234
anchorEl: HTMLElement | null;
35+
isIconButton?: boolean;
36+
tooltip?: string;
3337
}
3438

3539
const Listbox = styled('ul')(
@@ -66,17 +70,27 @@ export const HeaderDropdownComponent: React.FC<HeaderDropdownProps> = ({
6670
onOpen,
6771
onClose,
6872
anchorEl,
73+
isIconButton = false,
74+
tooltip,
6975
}) => {
7076
return (
7177
<Box>
72-
<Button
73-
disableRipple
74-
disableTouchRipple
75-
{...buttonProps}
76-
onClick={onOpen}
77-
>
78-
{buttonContent}
79-
</Button>
78+
<Tooltip title={tooltip}>
79+
{isIconButton ? (
80+
<IconButton {...buttonProps} color="inherit" onClick={onOpen}>
81+
{buttonContent}
82+
</IconButton>
83+
) : (
84+
<Button
85+
disableRipple
86+
disableTouchRipple
87+
{...buttonProps}
88+
onClick={onOpen}
89+
>
90+
{buttonContent}
91+
</Button>
92+
)}
93+
</Tooltip>
8094
<Menu
8195
id="menu-appbar"
8296
anchorEl={anchorEl}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import React from 'react';
17+
import { screen, fireEvent } from '@testing-library/react';
18+
import {
19+
useStarredEntities,
20+
useEntityPresentation,
21+
} from '@backstage/plugin-catalog-react';
22+
import { renderInTestApp } from '@backstage/test-utils';
23+
import { StarredDropdown } from './StarredDropdown';
24+
import { useDropdownManager } from '../../hooks';
25+
26+
jest.mock('@backstage/plugin-catalog-react', () => ({
27+
useStarredEntities: jest.fn(),
28+
useEntityPresentation: jest.fn(),
29+
}));
30+
31+
jest.mock('../../hooks', () => ({
32+
useDropdownManager: jest.fn(),
33+
}));
34+
35+
describe('StarredDropdown', () => {
36+
beforeEach(() => {
37+
jest.clearAllMocks();
38+
39+
(useStarredEntities as jest.Mock).mockReturnValue({
40+
starredEntities: new Set(),
41+
toggleStarredEntity: jest.fn(),
42+
isStarredEntity: jest.fn(),
43+
});
44+
45+
(useEntityPresentation as jest.Mock).mockReturnValue({
46+
Icon: () => <svg />, // Mock an icon component
47+
primaryTitle: 'Mock Entity',
48+
secondaryTitle: 'Mock Kind',
49+
});
50+
51+
(useDropdownManager as jest.Mock).mockReturnValue({
52+
anchorEl: null,
53+
handleOpen: jest.fn(),
54+
handleClose: jest.fn(),
55+
});
56+
57+
jest.spyOn(console, 'warn').mockImplementation(message => {
58+
if (message.includes('⚠️ React Router Future Flag Warning')) {
59+
return; // Suppress React Router warning
60+
}
61+
// eslint-disable-next-line no-console
62+
console.warn(message); // Log other warnings
63+
});
64+
65+
jest.spyOn(console, 'error').mockImplementation(message => {
66+
if (
67+
typeof message === 'string' &&
68+
message.includes('findDOMNode is deprecated')
69+
) {
70+
return; // Suppress findDOMNode warning in tests
71+
}
72+
// eslint-disable-next-line no-console
73+
console.error(message); // Allow other errors to be logged
74+
});
75+
});
76+
77+
it('renders an empty state when there are no starred entities', async () => {
78+
await renderInTestApp(<StarredDropdown />);
79+
80+
// Replace this with the actual empty state message in your component
81+
expect(screen.getByText(/No starred items yet/i)).toBeInTheDocument();
82+
});
83+
84+
it('renders starred items when entities exist', async () => {
85+
(useStarredEntities as jest.Mock).mockReturnValue({
86+
starredEntities: new Set(['component:default/my-entity']),
87+
toggleStarredEntity: jest.fn(),
88+
isStarredEntity: jest.fn(),
89+
});
90+
91+
await renderInTestApp(<StarredDropdown />);
92+
expect(screen.getByText(/Your starred items/i)).toBeInTheDocument();
93+
});
94+
95+
it('calls handleOpen when dropdown button is clicked', async () => {
96+
const handleOpen = jest.fn();
97+
(useStarredEntities as jest.Mock).mockReturnValue({
98+
starredEntities: new Set(['component:default/my-entity']),
99+
toggleStarredEntity: jest.fn(),
100+
isStarredEntity: jest.fn(),
101+
});
102+
103+
(useDropdownManager as jest.Mock).mockReturnValue({
104+
anchorEl: null,
105+
handleOpen,
106+
handleClose: jest.fn(),
107+
});
108+
109+
await renderInTestApp(<StarredDropdown />);
110+
fireEvent.click(screen.getByRole('button'));
111+
expect(handleOpen).toHaveBeenCalled();
112+
});
113+
});

0 commit comments

Comments
 (0)