Skip to content

Commit 9780747

Browse files
rhamiltoclaude
andcommitted
OCPBUGS-77054: Fix Add page issues with PF 6.4.0 and masonry layout
This fixes multiple issues on the Add page: 1. Console accessibility errors for Card components (PF 6.4.0) 2. Add page cards not appearing on first load 3. Add page cards not clickable (PF 6.4.0) 4. Infinite re-rendering when browser DevTools is open PatternFly 6.4.0 compatibility changes: - Remove unnecessary isClickable/isSelectable props from GettingStartedExpandableGrid - Add isControlled={false} to SimpleList (PF 6.4.0 changed default to true) - Replace navigateTo with useNavigate hook and add e.preventDefault() Masonry layout fixes: - Move MeasuredItem component outside render function to prevent re-creation - Add requestAnimationFrame for initial measurement timing - Add debouncing and proper cleanup for resize handling - Wrap components in memo for performance optimization Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 55e73cc commit 9780747

File tree

7 files changed

+207
-128
lines changed

7 files changed

+207
-128
lines changed

frontend/packages/console-shared/src/components/getting-started/GettingStartedExpandableGrid.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,6 @@ export const GettingStartedExpandableGrid: FC<GettingStartedExpandableGridProps>
6868
className="ocs-getting-started-expandable-grid"
6969
variant="secondary"
7070
data-test="getting-started"
71-
isClickable
72-
isSelectable
7371
isExpanded={isOpen}
7472
>
7573
<CardHeader

frontend/packages/dev-console/src/components/add/AddCard.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ReactNode, FC } from 'react';
2-
import { isValidElement } from 'react';
2+
import { isValidElement, memo } from 'react';
33
import { Card, SimpleList, Title } from '@patternfly/react-core';
44
import { ResolvedExtension, AddAction } from '@console/dynamic-plugin-sdk';
55
import { CodeRef } from '@console/dynamic-plugin-sdk/src/types';
@@ -16,7 +16,7 @@ type AddCardProps = {
1616
icon?: CodeRef<ReactNode> | string;
1717
};
1818

19-
const AddCard: FC<AddCardProps> = ({ id, title, items, namespace, icon }) => {
19+
const AddCard: FC<AddCardProps> = memo(({ id, title, items, namespace, icon }) => {
2020
const isTitleFromItem: boolean = items?.length === 1 && items[0].properties.label === title;
2121
const actionIcon = (): JSX.Element => {
2222
if (typeof icon === 'string') {
@@ -46,12 +46,12 @@ const AddCard: FC<AddCardProps> = ({ id, title, items, namespace, icon }) => {
4646
{title}
4747
</Title>
4848
)}
49-
<SimpleList>
49+
<SimpleList isControlled={false}>
5050
{items.map((item) => (
5151
<AddCardItem key={item.properties.id} namespace={namespace} action={item} />
5252
))}
5353
</SimpleList>
5454
</Card>
5555
) : null;
56-
};
56+
});
5757
export default AddCard;
Lines changed: 77 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import type { FC, SyntheticEvent } from 'react';
2-
import { isValidElement } from 'react';
1+
import type { FC } from 'react';
2+
import { isValidElement, memo } from 'react';
33
import { SimpleListItem, Title, Content } from '@patternfly/react-core';
4+
import { useNavigate } from 'react-router-dom-v5-compat';
45
import { ResolvedExtension, AddAction } from '@console/dynamic-plugin-sdk';
56
import { useToast } from '@console/shared/src';
67
import { useTelemetry } from '@console/shared/src/hooks/useTelemetry';
7-
import { navigateTo, resolvedHref } from '../../utils/add-page-utils';
8+
import { resolvedHref } from '../../utils/add-page-utils';
89
import { useShowAddCardItemDetails } from './hooks/useShowAddCardItemDetails';
910
import './AddCardItem.scss';
1011

@@ -13,69 +14,80 @@ type AddCardItemProps = {
1314
namespace: string;
1415
};
1516

16-
const AddCardItem: FC<AddCardItemProps> = ({
17-
action: {
18-
properties: { id, label, icon, href, callback, description },
19-
},
20-
namespace,
21-
}) => {
22-
const fireTelemetryEvent = useTelemetry();
23-
const [showDetails] = useShowAddCardItemDetails();
24-
const toast = useToast();
17+
const AddCardItem: FC<AddCardItemProps> = memo(
18+
({
19+
action: {
20+
properties: { id, label, icon, href, callback, description },
21+
},
22+
namespace,
23+
}) => {
24+
const navigate = useNavigate();
25+
const fireTelemetryEvent = useTelemetry();
26+
const [showDetails] = useShowAddCardItemDetails();
27+
const toast = useToast();
2528

26-
const actionIcon = (): JSX.Element => {
27-
if (typeof icon === 'string') {
28-
return (
29-
<img
30-
className="odc-add-card-item__icon odc-add-card-item__img-icon"
31-
src={icon}
32-
alt={label}
33-
aria-hidden="true"
34-
data-test="add-card-icon"
35-
/>
36-
);
37-
}
38-
if (typeof icon !== 'string' && isValidElement(icon)) {
39-
return (
40-
<span className="odc-add-card-item__icon" aria-hidden="true" data-test="add-card-icon">
41-
{icon}
42-
</span>
43-
);
44-
}
45-
return null;
46-
};
29+
const actionIcon = (): JSX.Element => {
30+
if (typeof icon === 'string') {
31+
return (
32+
<img
33+
className="odc-add-card-item__icon odc-add-card-item__img-icon"
34+
src={icon}
35+
alt={label}
36+
aria-hidden="true"
37+
data-test="add-card-icon"
38+
/>
39+
);
40+
}
41+
if (typeof icon !== 'string' && isValidElement(icon)) {
42+
return (
43+
<span className="odc-add-card-item__icon" aria-hidden="true" data-test="add-card-icon">
44+
{icon}
45+
</span>
46+
);
47+
}
48+
return null;
49+
};
50+
return (
51+
<SimpleListItem
52+
component="a"
53+
componentProps={{
54+
'data-test': `item ${id}`,
55+
}}
56+
href={href ? resolvedHref(href, namespace) : null}
57+
onClick={(e) => {
58+
const isModifiedClick =
59+
'metaKey' in e && (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1);
60+
fireTelemetryEvent('Add Item Selected', {
61+
id,
62+
name: label,
63+
});
4764

48-
return (
49-
<SimpleListItem
50-
component="a"
51-
componentProps={{
52-
'data-test': `item ${id}`,
53-
}}
54-
href={href ? resolvedHref(href, namespace) : null}
55-
onClick={(e: SyntheticEvent) => {
56-
fireTelemetryEvent('Add Item Selected', {
57-
id,
58-
name: label,
59-
});
60-
if (href) {
61-
navigateTo(e, resolvedHref(href, namespace));
62-
} else if (callback) {
63-
callback({ namespace, toast });
64-
}
65-
}}
66-
className="odc-add-card-item"
67-
>
68-
<Title headingLevel="h3" size="md" className="odc-add-card-item__title" data-test="title">
69-
{actionIcon()}
70-
{label}
71-
</Title>
72-
{showDetails && (
73-
<Content component="p" className="odc-add-card-item__description" data-test="description">
74-
{description}
75-
</Content>
76-
)}
77-
</SimpleListItem>
78-
);
79-
};
65+
// Allow modified clicks to open in new tab/window
66+
if (href && isModifiedClick) {
67+
return;
68+
}
69+
70+
e.preventDefault();
71+
if (href) {
72+
navigate(resolvedHref(href, namespace));
73+
} else if (callback) {
74+
callback({ namespace, toast });
75+
}
76+
}}
77+
className="odc-add-card-item"
78+
>
79+
<Title headingLevel="h3" size="md" className="odc-add-card-item__title" data-test="title">
80+
{actionIcon()}
81+
{label}
82+
</Title>
83+
{showDetails && (
84+
<Content component="p" className="odc-add-card-item__description" data-test="description">
85+
{description}
86+
</Content>
87+
)}
88+
</SimpleListItem>
89+
);
90+
},
91+
);
8092

8193
export default AddCardItem;

frontend/packages/dev-console/src/components/add/AddCardSection.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { FC, ReactElement } from 'react';
2+
import { useMemo } from 'react';
23
import { AddActionGroup, ResolvedExtension, AddAction } from '@console/dynamic-plugin-sdk';
34
import type { LoadedExtension } from '@console/dynamic-plugin-sdk/src/types';
45
import { orderExtensionBasedOnInsertBeforeAndAfter } from '@console/shared/';
@@ -28,10 +29,7 @@ const AddCardSection: FC<AddCardSectionProps> = ({
2829
loadingFailed,
2930
accessCheckFailed,
3031
}) => {
31-
if (loadingFailed || accessCheckFailed) {
32-
return <AddCardSectionEmptyState accessCheckFailed={!loadingFailed && accessCheckFailed} />;
33-
}
34-
const getAddCards = (): ReactElement[] => {
32+
const addCards = useMemo((): ReactElement[] => {
3533
if (!extensionsLoaded) {
3634
return [];
3735
}
@@ -44,7 +42,11 @@ const AddCardSection: FC<AddCardSectionProps> = ({
4442
return addGroups.map(({ id, name, items, icon }) => (
4543
<AddCard key={id} id={id} title={name} items={items} namespace={namespace} icon={icon} />
4644
));
47-
};
45+
}, [extensionsLoaded, addActionExtensions, addActionGroupExtensions, namespace]);
46+
47+
if (loadingFailed || accessCheckFailed) {
48+
return <AddCardSectionEmptyState accessCheckFailed={!loadingFailed && accessCheckFailed} />;
49+
}
4850

4951
return (
5052
<div data-test="add-cards">
@@ -53,7 +55,7 @@ const AddCardSection: FC<AddCardSectionProps> = ({
5355
loading={!extensionsLoaded}
5456
LoadingComponent={AddCardSectionSkeleton}
5557
>
56-
{getAddCards()}
58+
{addCards}
5759
</MasonryLayout>
5860
</div>
5961
);

frontend/packages/dev-console/src/components/add/__tests__/MasonryLayout.spec.tsx

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { act } from '@testing-library/react';
1+
import { act, waitFor } from '@testing-library/react';
22
import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils';
33
import AddCardSectionSkeleton from '../AddCardSectionSkeleton';
44
import { MasonryLayout } from '../layout/MasonryLayout';
@@ -11,7 +11,7 @@ describe('Masonry Layout', () => {
1111
} as DOMRect);
1212
};
1313

14-
it('should render loading component if loading is true and LoadingComponent is defined', () => {
14+
it('should render loading component if loading is true and LoadingComponent is defined', async () => {
1515
setWidth(1400);
1616

1717
const { container } = renderWithProviders(
@@ -24,9 +24,11 @@ describe('Masonry Layout', () => {
2424
</MasonryLayout>,
2525
);
2626

27-
// Should show 4 columns (Math.floor(1400 / 300))
28-
const columns = container.querySelectorAll('.odc-masonry-layout__column');
29-
expect(columns).toHaveLength(4);
27+
// Wait for width measurement and rendering to complete
28+
await waitFor(() => {
29+
const columns = container.querySelectorAll('.odc-masonry-layout__column');
30+
expect(columns).toHaveLength(4);
31+
});
3032

3133
// Should render 4 skeleton placeholders (one per column)
3234
const skeletons = container.querySelectorAll(
@@ -35,7 +37,7 @@ describe('Masonry Layout', () => {
3537
expect(skeletons).toHaveLength(4);
3638
});
3739

38-
it('should render children if loading is false', () => {
40+
it('should render children if loading is false', async () => {
3941
setWidth(1400);
4042

4143
const { container } = renderWithProviders(
@@ -48,16 +50,18 @@ describe('Masonry Layout', () => {
4850
</MasonryLayout>,
4951
);
5052

51-
// Should show 4 columns (Math.floor(1400 / 300))
52-
const columns = container.querySelectorAll('.odc-masonry-layout__column');
53-
expect(columns).toHaveLength(4);
53+
// Wait for width measurement and rendering to complete
54+
await waitFor(() => {
55+
const columns = container.querySelectorAll('.odc-masonry-layout__column');
56+
expect(columns).toHaveLength(4);
57+
});
5458

5559
// Should render all children
5660
const children = container.querySelectorAll('div.child');
5761
expect(children).toHaveLength(5);
5862
});
5963

60-
it('should change columns if a resize event exceeds threshold', () => {
64+
it('should change columns if a resize event exceeds threshold', async () => {
6165
setWidth(1200);
6266

6367
const { container } = renderWithProviders(
@@ -70,21 +74,25 @@ describe('Masonry Layout', () => {
7074
</MasonryLayout>,
7175
);
7276

73-
// Should show 4 columns initially
74-
let columns = container.querySelectorAll('.odc-masonry-layout__column');
75-
expect(columns).toHaveLength(4);
77+
// Wait for initial width measurement and rendering to complete
78+
await waitFor(() => {
79+
const columns = container.querySelectorAll('.odc-masonry-layout__column');
80+
expect(columns).toHaveLength(4);
81+
});
7682

7783
act(() => {
7884
setWidth(900);
7985
window.dispatchEvent(new Event('resize'));
8086
});
8187

82-
// Should show 3 columns now (Math.floor(900 / 300))
83-
columns = container.querySelectorAll('.odc-masonry-layout__column');
84-
expect(columns).toHaveLength(3);
88+
// Wait for resize to be processed and re-render to complete
89+
await waitFor(() => {
90+
const columns = container.querySelectorAll('.odc-masonry-layout__column');
91+
expect(columns).toHaveLength(3);
92+
});
8593
});
8694

87-
it('should not change columns if a resize event does not exceed threshold', () => {
95+
it('should not change columns if a resize event does not exceed threshold', async () => {
8896
setWidth(1200);
8997

9098
const { container } = renderWithProviders(
@@ -97,17 +105,24 @@ describe('Masonry Layout', () => {
97105
</MasonryLayout>,
98106
);
99107

100-
// Should show 4 columns initially
101-
let columns = container.querySelectorAll('.odc-masonry-layout__column');
102-
expect(columns).toHaveLength(4);
108+
// Wait for initial width measurement and rendering to complete
109+
await waitFor(() => {
110+
const columns = container.querySelectorAll('.odc-masonry-layout__column');
111+
expect(columns).toHaveLength(4);
112+
});
103113

104114
// Should still show 4 columns because new width does not exceed threshold
105115
act(() => {
106116
setWidth(1190);
107117
window.dispatchEvent(new Event('resize'));
108118
});
109119

110-
columns = container.querySelectorAll('.odc-masonry-layout__column');
120+
// Wait for debounce timeout to ensure resize handler completes
121+
await act(async () => {
122+
await new Promise((resolve) => setTimeout(resolve, 150));
123+
});
124+
125+
const columns = container.querySelectorAll('.odc-masonry-layout__column');
111126
expect(columns).toHaveLength(4);
112127
});
113128
});

0 commit comments

Comments
 (0)