Skip to content

Commit b991a8a

Browse files
fix(global-header): fix to show empty state when help items available (#1232)
1 parent aaaaf2d commit b991a8a

5 files changed

Lines changed: 201 additions & 26 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': patch
3+
---
4+
5+
Show empty state when no help items available

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ export interface HeaderIconProps {
177177
// @public
178178
export const HelpDropdown: ComponentType<HelpDropdownProps>;
179179

180-
// @public
180+
// @public (undocumented)
181181
export interface HelpDropdownProps {
182182
// (undocumented)
183183
layout?: CSSProperties;

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

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ const MockComponent = ({ title, icon }: any) => (
3535
</div>
3636
);
3737

38+
const MockNullComponent = () => null;
39+
3840
describe('HelpDropdown', () => {
3941
const mockHandleOpen = jest.fn();
4042
const mockHandleClose = jest.fn();
@@ -49,20 +51,52 @@ describe('HelpDropdown', () => {
4951
});
5052
});
5153

52-
it('returns null when there are no mount points', async () => {
54+
it('shows empty state when there are no mount points', async () => {
5355
(useHelpDropdownMountPoints as jest.Mock).mockReturnValue([]);
5456

55-
const { container } = await renderInTestApp(<HelpDropdown />);
57+
await renderInTestApp(<HelpDropdown />);
58+
59+
expect(screen.getByRole('button')).toBeInTheDocument();
5660

57-
expect(container.firstChild).toBeNull();
61+
fireEvent.click(screen.getByRole('button'));
62+
63+
expect(await screen.findByText('No support links')).toBeInTheDocument();
5864
});
5965

60-
it('returns null when mount points is undefined', async () => {
66+
it('shows empty state when mount points is undefined', async () => {
6167
(useHelpDropdownMountPoints as jest.Mock).mockReturnValue(undefined);
6268

63-
const { container } = await renderInTestApp(<HelpDropdown />);
69+
await renderInTestApp(<HelpDropdown />);
70+
71+
expect(screen.getByRole('button')).toBeInTheDocument();
72+
73+
fireEvent.click(screen.getByRole('button'));
74+
75+
expect(await screen.findByText('No support links')).toBeInTheDocument();
76+
});
77+
78+
it('shows empty state when all components return null', async () => {
79+
const mockMountPoints: HelpDropdownMountPoint[] = [
80+
{
81+
Component: MockNullComponent,
82+
config: {
83+
props: {
84+
title: 'Null Component',
85+
},
86+
priority: 1,
87+
},
88+
},
89+
];
90+
91+
(useHelpDropdownMountPoints as jest.Mock).mockReturnValue(mockMountPoints);
92+
93+
await renderInTestApp(<HelpDropdown />);
94+
95+
expect(screen.getByRole('button')).toBeInTheDocument();
96+
97+
fireEvent.click(screen.getByRole('button'));
6498

65-
expect(container.firstChild).toBeNull();
99+
expect(await screen.findByText('No support links')).toBeInTheDocument();
66100
});
67101

68102
it('renders help dropdown button when mount points exist', async () => {

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

Lines changed: 97 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,44 +14,112 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { useMemo } from 'react';
18-
import type { CSSProperties } from 'react';
17+
import { useEffect, useMemo, useRef } from 'react';
18+
import type { ComponentType, CSSProperties } from 'react';
1919
import { HeaderDropdownComponent } from './HeaderDropdownComponent';
2020
import { useDropdownManager } from '../../hooks';
2121
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
2222
import { useHelpDropdownMountPoints } from '../../hooks/useHelpDropdownMountPoints';
2323
import { MenuSection } from './MenuSection';
24+
import { DropdownEmptyState } from './DropdownEmptyState';
25+
import SupportAgentIcon from '@mui/icons-material/SupportAgent';
26+
import { useValidComponentTracker } from '../../hooks/useValidComponentTracker';
2427

2528
/**
2629
* @public
27-
* Props for Help Dropdown
2830
*/
2931
export interface HelpDropdownProps {
3032
layout?: CSSProperties;
3133
}
3234

35+
const ValidityTracker = ({
36+
Component,
37+
props,
38+
componentId,
39+
onValidityChange,
40+
}: {
41+
Component: ComponentType<any>;
42+
props: any;
43+
componentId: string;
44+
onValidityChange: (componentId: string, isValid: boolean) => void;
45+
}) => {
46+
const contentRef = useRef<HTMLDivElement>(null);
47+
48+
useEffect(() => {
49+
const checkContent = () => {
50+
if (!contentRef.current) return;
51+
52+
const element = contentRef.current;
53+
const hasText = (element.textContent?.trim().length ?? 0) > 0;
54+
const hasChildren = element.children.length > 0;
55+
const hasChildNodes = element.childNodes.length > 0;
56+
57+
// A component is valid if it renders ANY content at all
58+
const componentIsValid = hasText || hasChildren || hasChildNodes;
59+
60+
onValidityChange(componentId, componentIsValid);
61+
};
62+
63+
// Check after component has had time to render (longer timeout for lazy components)
64+
const timer1 = setTimeout(checkContent, 500);
65+
const timer2 = setTimeout(checkContent, 1500); // Double check later
66+
67+
return () => {
68+
clearTimeout(timer1);
69+
clearTimeout(timer2);
70+
};
71+
}, [componentId, onValidityChange]);
72+
73+
try {
74+
return (
75+
<div ref={contentRef}>
76+
<Component {...props} />
77+
</div>
78+
);
79+
} catch (error) {
80+
onValidityChange(componentId, false);
81+
return null;
82+
}
83+
};
84+
3385
export const HelpDropdown = ({ layout }: HelpDropdownProps) => {
3486
const { anchorEl, handleOpen, handleClose } = useDropdownManager();
35-
3687
const helpDropdownMountPoints = useHelpDropdownMountPoints();
3788

38-
const menuItems = useMemo(() => {
89+
const { shouldShowEmpty, updateComponentValidity } = useValidComponentTracker(
90+
helpDropdownMountPoints?.length ?? 0,
91+
);
92+
93+
// Create all mount point items with validity tracking
94+
const allMenuItems = useMemo(() => {
3995
return (helpDropdownMountPoints ?? [])
40-
.map(mp => ({
41-
Component: mp.Component,
42-
icon: mp.config?.props?.icon,
43-
label: mp.config?.props?.title,
44-
link: mp.config?.props?.link,
45-
tooltip: mp.config?.props?.tooltip,
46-
style: mp.config?.style,
47-
priority: mp.config?.priority ?? 0,
48-
}))
96+
.map((mp, index) => {
97+
const componentId = `${mp.config?.props?.title || 'helpItem'}-${
98+
mp.config?.priority || 0
99+
}-${index}`;
100+
101+
return {
102+
componentId,
103+
Component: () => (
104+
<ValidityTracker
105+
Component={mp.Component}
106+
props={mp.config?.props || {}}
107+
componentId={componentId}
108+
onValidityChange={updateComponentValidity}
109+
/>
110+
),
111+
icon: mp.config?.props?.icon,
112+
label: mp.config?.props?.title,
113+
link: mp.config?.props?.link,
114+
tooltip: mp.config?.props?.tooltip,
115+
style: mp.config?.style,
116+
priority: mp.config?.priority ?? 0,
117+
};
118+
})
49119
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
50-
}, [helpDropdownMountPoints]);
120+
}, [helpDropdownMountPoints, updateComponentValidity]);
51121

52-
if (menuItems.length === 0) {
53-
return null;
54-
}
122+
const menuItems = allMenuItems;
55123

56124
return (
57125
<HeaderDropdownComponent
@@ -66,7 +134,17 @@ export const HelpDropdown = ({ layout }: HelpDropdownProps) => {
66134
onClose={handleClose}
67135
anchorEl={anchorEl}
68136
>
69-
<MenuSection hideDivider items={menuItems} handleClose={handleClose} />
137+
{!shouldShowEmpty ? (
138+
<MenuSection hideDivider items={menuItems} handleClose={handleClose} />
139+
) : (
140+
<DropdownEmptyState
141+
title="No support links"
142+
subTitle="Your administrator needs to set up support links."
143+
icon={
144+
<SupportAgentIcon sx={{ fontSize: 64, color: 'text.disabled' }} />
145+
}
146+
/>
147+
)}
70148
</HeaderDropdownComponent>
71149
);
72150
};
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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+
17+
import { useState, useEffect, useCallback } from 'react';
18+
19+
export const useValidComponentTracker = (totalComponents: number) => {
20+
const [validComponents, setValidComponents] = useState<Set<string>>(
21+
new Set(),
22+
);
23+
const [checkedComponents, setCheckedComponents] = useState<Set<string>>(
24+
new Set(),
25+
);
26+
27+
// Reset when mount points change (user switch, permission changes)
28+
useEffect(() => {
29+
setValidComponents(new Set());
30+
setCheckedComponents(new Set());
31+
}, [totalComponents]);
32+
33+
const updateComponentValidity = useCallback(
34+
(componentId: string, isValid: boolean) => {
35+
setValidComponents(prev => {
36+
const newSet = new Set(prev);
37+
if (isValid) {
38+
newSet.add(componentId);
39+
} else {
40+
newSet.delete(componentId);
41+
}
42+
return newSet;
43+
});
44+
45+
setCheckedComponents(prev => new Set(prev).add(componentId));
46+
},
47+
[],
48+
);
49+
50+
const allChecked = checkedComponents.size >= totalComponents;
51+
const hasValidComponents = validComponents.size > 0;
52+
const shouldShowEmpty = allChecked && !hasValidComponents;
53+
54+
return {
55+
shouldShowEmpty,
56+
updateComponentValidity,
57+
};
58+
};

0 commit comments

Comments
 (0)