From 82c451466313401ec5926276fc1148a5148390d1 Mon Sep 17 00:00:00 2001 From: logonoff Date: Wed, 25 Feb 2026 13:23:59 -0500 Subject: [PATCH 1/4] NO-JIRA: HelmReleaseDetails use renderWithProviders Can't use both renderWithProviders and a router provider --- .../__tests__/HelmReleaseDetails.spec.tsx | 55 +++++-------------- 1 file changed, 14 insertions(+), 41 deletions(-) diff --git a/frontend/packages/helm-plugin/src/components/details-page/__tests__/HelmReleaseDetails.spec.tsx b/frontend/packages/helm-plugin/src/components/details-page/__tests__/HelmReleaseDetails.spec.tsx index 29b0ff6fdf..756b90f49c 100644 --- a/frontend/packages/helm-plugin/src/components/details-page/__tests__/HelmReleaseDetails.spec.tsx +++ b/frontend/packages/helm-plugin/src/components/details-page/__tests__/HelmReleaseDetails.spec.tsx @@ -1,7 +1,5 @@ import type { ComponentProps } from 'react'; -import { screen, render } from '@testing-library/react'; -import { BrowserRouter } from 'react-router-dom'; -import * as Router from 'react-router-dom-v5-compat'; +import { screen } from '@testing-library/react'; import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; import { mockHelmReleases } from '../../__tests__/helm-release-mock-data'; import type HelmReleaseDetails from '../HelmReleaseDetails'; @@ -12,8 +10,14 @@ let loadedHelmReleaseDetailsProps: ComponentProps ({ ...jest.requireActual('react-router-dom-v5-compat'), - useParams: jest.fn(), - useLocation: jest.fn(() => ({ pathname: '', location: '' })), + useParams: jest.fn().mockReturnValue({ ns: 'xyz' }), + useLocation: jest.fn().mockReturnValue({ + pathname: '/helm-releases/ns/xyz/release/helm-mysql', + search: '', + state: null, + hash: '', + key: 'default', + }), useNavigate: jest.fn(), })); @@ -57,57 +61,30 @@ describe('HelmReleaseDetails', () => { data: mockHelmReleases[0], }, }; - - jest.spyOn(Router, 'useParams').mockReturnValue({ - ns: 'xyz', - }); - jest.spyOn(Router, 'useLocation').mockReturnValue({ - pathname: '/helm-releases/ns/xyz/release/helm-mysql', - search: '', - state: null, - hash: '', - key: 'default', - }); }); it('should show the loading box if helm release data is not loaded', () => { loadedHelmReleaseDetailsProps.helmRelease.loaded = false; - render( - - - , - ); + renderWithProviders(); expect(screen.getByTestId('loading-box')).toBeTruthy(); }); it('should show an error if helm release data could not be loaded', () => { loadedHelmReleaseDetailsProps.helmRelease.loadError = new Error('An error!'); - render( - - - , - ); + renderWithProviders(); expect(screen.getByTestId('console-empty-state')).toBeTruthy(); }); it('should show the loading box if secret is not loaded', () => { loadedHelmReleaseDetailsProps.secrets.loaded = false; loadedHelmReleaseDetailsProps.secrets.loadError = undefined; - render( - - - , - ); + renderWithProviders(); expect(screen.getByTestId('loading-box')).toBeTruthy(); }); it('should show the status box if there is an error loading the secret', () => { loadedHelmReleaseDetailsProps.secrets.loadError = 'error 404'; - render( - - - , - ); + renderWithProviders(); expect(screen.getByTestId('console-empty-state')).toBeTruthy(); }); @@ -122,11 +99,7 @@ describe('HelmReleaseDetails', () => { it('should show the ErrorPage404 for an incorrect release name in the url', () => { loadedHelmReleaseDetailsProps.secrets.data = []; - renderWithProviders( - - - , - ); + renderWithProviders(); expect(screen.getByText('404: Page Not Found')).toBeTruthy(); expect(screen.getByText('Page Not Found (404)')).toBeTruthy(); }); From 30571acdd73c47eb6c1af2c3709a15575d2b85a4 Mon Sep 17 00:00:00 2001 From: logonoff Date: Wed, 25 Feb 2026 13:31:55 -0500 Subject: [PATCH 2/4] CONSOLE-5018: Remove Status/health dead code --- frontend/public/components/graphs/health.jsx | 85 ------------ frontend/public/components/graphs/index.tsx | 1 - frontend/public/components/graphs/status.jsx | 137 ------------------- 3 files changed, 223 deletions(-) delete mode 100644 frontend/public/components/graphs/health.jsx delete mode 100644 frontend/public/components/graphs/status.jsx diff --git a/frontend/public/components/graphs/health.jsx b/frontend/public/components/graphs/health.jsx deleted file mode 100644 index ee9b3afd3f..0000000000 --- a/frontend/public/components/graphs/health.jsx +++ /dev/null @@ -1,85 +0,0 @@ -import { connect } from 'react-redux'; - -import { Status, errorStatus } from './'; -import { coFetch, coFetchJSON } from '../../co-fetch'; -import { featureReducerName } from '../../reducers/features'; -import { FLAGS } from '@console/shared'; -import { k8sBasePath } from '../../module/k8s'; -import { Grid, GridItem } from '@patternfly/react-core'; - -// Use the shorter 'OpenShift Console' instead of 'OpenShift Container Platform Console' since the title appears in the chart. -const consoleName = window.SERVER_FLAGS.branding === 'okd' ? 'OKD Console' : 'OpenShift Console'; - -const fetchHealth = () => - coFetch(`${k8sBasePath}/healthz`) - .then((response) => response.text()) - .then((body) => { - if (body === 'ok') { - return { short: 'UP', long: 'All good', status: 'OK' }; - } - return { short: 'ERROR', long: body, status: 'ERROR' }; - }) - .catch(errorStatus); - -const fetchConsoleHealth = () => - coFetchJSON('health') - .then(() => ({ short: 'UP', long: 'All good', status: 'OK' })) - .catch(() => ({ - short: 'ERROR', - long: 'The console service cannot be reached', - status: 'ERROR', - })); - -export const KubernetesHealth = () => ; - -export const ConsoleHealth = () => ; - -const alertsFiringStateToProps = (state) => ({ - canAccessMonitoring: !!state[featureReducerName].get(FLAGS.CAN_GET_NS), -}); - -const AlertsFiring_ = ({ canAccessMonitoring, namespace }) => { - const toProp = - canAccessMonitoring && !!window.SERVER_FLAGS.prometheusBaseURL ? { to: '/monitoring' } : {}; - return ( - - ); -}; -const AlertsFiring = connect(alertsFiringStateToProps)(AlertsFiring_); - -const CrashloopingPods = ({ namespace }) => ( - 5 )`} - to={`/k8s/${namespace ? `ns/${namespace}` : 'all-namespaces'}/pods?status=CrashLoopBackOff`} - /> -); - -export const Health = ({ namespace }) => ( - - - - - - - - - - - - - - -); diff --git a/frontend/public/components/graphs/index.tsx b/frontend/public/components/graphs/index.tsx index af8e1e20e6..a791e858c7 100644 --- a/frontend/public/components/graphs/index.tsx +++ b/frontend/public/components/graphs/index.tsx @@ -13,7 +13,6 @@ export { } from './consts'; // Components -export { errorStatus, Status } from './status'; export const Area = (props) => ( import('./graph-loader').then((c) => c.Area)} {...props} /> ); diff --git a/frontend/public/components/graphs/status.jsx b/frontend/public/components/graphs/status.jsx deleted file mode 100644 index b310a4216c..0000000000 --- a/frontend/public/components/graphs/status.jsx +++ /dev/null @@ -1,137 +0,0 @@ -/* eslint-disable tsdoc/syntax */ -import * as _ from 'lodash'; -import { Component } from 'react'; -import { css } from '@patternfly/react-styles'; -import { Link } from 'react-router-dom-v5-compat'; -import { Title } from '@patternfly/react-core'; - -import { coFetchJSON } from '../../co-fetch'; -import { PROMETHEUS_BASE_PATH, PROMETHEUS_TENANCY_BASE_PATH } from './consts'; - -export const errorStatus = (err) => { - if (_.get(err.response, 'ok') === false) { - return { - short: '?', - status: '', // Gray - long: err.message, - }; - } - // Generic network error handling. - return { - short: 'ERROR', - long: err.message, - status: 'ERROR', - }; -}; - -const fetchQuery = (q, long, namespace) => { - const nsParam = namespace ? `&namespace=${encodeURIComponent(namespace)}` : ''; - const basePath = namespace ? PROMETHEUS_TENANCY_BASE_PATH : PROMETHEUS_BASE_PATH; - return coFetchJSON(`${basePath}/api/v1/query?query=${encodeURIComponent(q)}${nsParam}`) - .then((res) => { - const short = parseInt(_.get(res, 'data.result[0].value[1]'), 10) || 0; - return { - short, - long, - status: short === 0 ? 'OK' : 'WARN', - }; - }) - .catch(errorStatus); -}; - -/** @augments {React.Component<{fetch?: () => Promise, query?: string, title: string, href?: string, rel?: string, target?: string}}>} */ -export class Status extends Component { - constructor(props) { - super(props); - this.interval = null; - this.state = { - status: '...', - }; - this.clock = 0; - } - - fetch(props = this.props) { - const clock = this.clock; - const promise = props.query - ? fetchQuery(props.query, props.name, props.namespace) - : props.fetch(); - - const ignorePromise = (cb) => (...args) => { - if (clock !== this.clock) { - return; - } - cb(...args); - }; - promise - .then(ignorePromise(({ short, long, status }) => this.setState({ short, long, status }))) - .catch(ignorePromise(() => this.setState({ short: 'BAD', long: 'Error', status: 'ERROR' }))) - .then( - ignorePromise( - () => - (this.interval = setTimeout(() => { - if (this.isMounted_) { - this.fetch(); - } - }, 30000)), - ), - ); - } - - UNSAFE_componentWillReceiveProps(nextProps) { - if (_.isEqual(nextProps, this.props)) { - return; - } - this.clock += 1; - // Don't show stale data if we changed the query. - this.setState({ - status: '...', - short: undefined, - long: undefined, - }); - this.fetch(nextProps); - } - - UNSAFE_componentWillMount() { - clearInterval(this.interval); - this.fetch(); - } - - componentWillUnmount() { - clearInterval(this.interval); - } - - render() { - const title = this.props.title; - const { short, long, status } = this.state; - const shortStatusClassName = css('graph-status__short', { - 'graph-status__short--ok': status === 'OK', - 'graph-status__short--warn': status === 'WARN', - 'graph-status__short--error': status === 'ERROR', - }); - - const statusElem = ( -
- {title && ( - - {title} - - )} -
- - {short} - -
{long}
-
-
- ); - const linkProps = _.pick(this.props, ['rel', 'target', 'to']); - if (_.isEmpty(linkProps)) { - return statusElem; - } - return ( - - {statusElem} - - ); - } -} From a55752b16bdfbc0e4a17ecdd991d61ad88ea72c1 Mon Sep 17 00:00:00 2001 From: logonoff Date: Wed, 25 Feb 2026 13:41:11 -0500 Subject: [PATCH 3/4] CONSOLE-5018: Refactor ResourceDropdown to functional component Replace the class component with hooks (useState, useEffect, useMemo, useCallback, useRef) and replace withTranslation HOC with useTranslation hook. Derived data (resourceMap, items, displayTitle) is now computed via useMemo instead of stored in state. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/dropdown/ResourceDropdown.tsx | 394 ++++++++++-------- .../formik-fields/ResourceDropdownField.tsx | 28 +- 2 files changed, 228 insertions(+), 194 deletions(-) diff --git a/frontend/packages/console-shared/src/components/dropdown/ResourceDropdown.tsx b/frontend/packages/console-shared/src/components/dropdown/ResourceDropdown.tsx index 5231238870..85212544a4 100644 --- a/frontend/packages/console-shared/src/components/dropdown/ResourceDropdown.tsx +++ b/frontend/packages/console-shared/src/components/dropdown/ResourceDropdown.tsx @@ -1,9 +1,8 @@ -import type { FC } from 'react'; -import { Component } from 'react'; +import type { FC, ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import * as fuzzy from 'fuzzysearch'; -import type { TFunction } from 'i18next'; import * as _ from 'lodash'; -import { withTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; import type { ConsoleSelectProps } from '@console/internal/components/utils/console-select'; import { ConsoleSelect } from '@console/internal/components/utils/console-select'; import { ResourceIcon } from '@console/internal/components/utils/resource-icon'; @@ -30,12 +29,6 @@ const DropdownItem: FC = ({ model, name }) => ( export type ResourceDropdownItems = ConsoleSelectProps['items']; -interface State { - resources: {}; - items: ResourceDropdownItems; - title: ConsoleSelectProps['title']; -} - export interface ResourceDropdownProps { actionItems?: ConsoleSelectProps['actionItems']; ariaLabel?: ConsoleSelectProps['ariaLabel']; @@ -68,138 +61,116 @@ export interface ResourceDropdownProps { resources?: FirehoseResult[]; autoSelect?: boolean; resourceFilter?: (resource: K8sResourceKind) => boolean; - onChange?: (key: string, name?: string | object, selectedResource?: K8sResourceKind) => void; + onChange?: ( + key: string, + name?: ResourceDropdownItems[keyof ResourceDropdownItems], + selectedResource?: K8sResourceKind, + ) => void; onLoad?: (items: ResourceDropdownItems) => void; showBadge?: boolean; customResourceKey?: (key: string, resource: K8sResourceKind) => string; appendItems?: ResourceDropdownItems; } -class ResourceDropdownInternal extends Component { - constructor(props) { - super(props); - this.state = { - resources: this.props.loaded ? this.getResourceList(props) : {}, - items: this.props.loaded ? this.getDropdownList(props, false) : {}, - title: this.props.loaded ? ( - {this.props.placeholder} - ) : ( - - ), - }; +const craftResourceKey = ( + resource: K8sResourceKind, + dataSelector: ResourceDropdownProps['dataSelector'], + resourceFilter: ResourceDropdownProps['resourceFilter'], + customResourceKey: ResourceDropdownProps['customResourceKey'], +): { customKey: string; key: string } => { + let key; + if (resourceFilter && resourceFilter(resource)) { + key = _.get(resource, dataSelector); + } else if (!resourceFilter) { + key = _.get(resource, dataSelector); } + return { + customKey: customResourceKey ? customResourceKey(key, resource) : key, + key, + }; +}; - // eslint-disable-next-line @typescript-eslint/naming-convention - UNSAFE_componentWillReceiveProps(nextProps: ResourceDropdownProps) { - const { - loaded, - loadError, - autoSelect, - selectedKey, - placeholder, - onLoad, - title, - actionItems, - } = nextProps; - if (!loaded && !loadError) { - this.setState({ title: }); - return; - } - - // If autoSelect is true only then have an item pre-selected based on selectedKey. - if (!this.props.loadError && !autoSelect && (!this.props.loaded || !selectedKey)) { - this.setState({ - title: {placeholder}, - }); - } - - if (loadError) { - this.setState({ - title: ( - - {this.props.t('console-shared~Error loading - {{placeholder}}', { placeholder })} - - ), - }); - return; - } - - const resourceList = this.getDropdownList({ ...this.props, ...nextProps }, true); - // set placeholder as title if resourceList is empty no actionItems are there - if (loaded && !loadError && _.isEmpty(resourceList) && !actionItems && placeholder && !title) { - this.setState({ - title: {placeholder}, - }); - } - this.setState({ items: resourceList }); - if (nextProps.loaded && onLoad) { - onLoad(resourceList); - } - this.setState({ resources: this.getResourceList(nextProps) }); - } +export const ResourceDropdown: FC = ({ + actionItems, + allSelectorItem, + appendItems, + ariaLabel, + autocompleteFilter, + autoSelect, + buttonClassName, + className, + customResourceKey, + dataSelector, + disabled, + id, + isFullWidth, + loaded, + loadError, + menuClassName, + noneSelectorItem, + onChange, + onLoad, + placeholder, + resourceFilter, + resources, + selectedKey, + showBadge = false, + storageKey, + title: titleProp, + titlePrefix, + transformLabel, + userSettingsPrefix, +}) => { + const { t } = useTranslation(); + const [selectedTitle, setSelectedTitle] = useState(null); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; - shouldComponentUpdate(nextProps, nextState) { - if (_.isEqual(this.state, nextState) && _.isEqual(this.props, nextProps)) { - return false; - } - return true; - } + // Track mount state and previous selectedKey to match class component behavior: + // the class constructor never auto-selects, and componentWillReceiveProps + // compares against the previous selectedKey (this.props) for onChange calls. + const mountedRef = useRef(false); + const prevSelectedKeyRef = useRef(selectedKey); - private craftResourceKey = ( - resource: K8sResourceKind, - props: ResourceDropdownProps, - ): { customKey: string; key: string } => { - const { customResourceKey, resourceFilter, dataSelector } = props; - let key; - if (resourceFilter && resourceFilter(resource)) { - key = _.get(resource, dataSelector); - } else if (!resourceFilter) { - key = _.get(resource, dataSelector); + // Compute resource map: key -> K8sResourceKind + const resourceMap = useMemo(() => { + if (!loaded) { + return {}; } - return { - customKey: customResourceKey ? customResourceKey(key, resource) : key, - key, - }; - }; - - private getResourceList = (nextProps: ResourceDropdownProps) => { - const { resources } = nextProps; - const resourceList = {}; + const map: Record = {}; _.each(resources, ({ data }) => { - _.each(data, (resource) => { - const { customKey, key } = this.craftResourceKey(resource, nextProps); + _.each(data, (resource: K8sResourceKind) => { + const { customKey, key } = craftResourceKey( + resource, + dataSelector, + resourceFilter, + customResourceKey, + ); const indexKey = customKey || key; if (indexKey) { - resourceList[indexKey] = resource; + map[indexKey] = resource; } }); }); - return resourceList; - }; + return map; + }, [loaded, resources, dataSelector, resourceFilter, customResourceKey]); - private getDropdownList = ( - props: ResourceDropdownProps, - updateSelection: boolean, - ): ResourceDropdownItems => { - const { - loaded, - actionItems, - autoSelect, - selectedKey, - resources, - transformLabel, - allSelectorItem, - noneSelectorItem, - showBadge = false, - appendItems, - } = props; - - const unsortedList = { ...appendItems }; + // Compute sorted dropdown items + const items = useMemo(() => { + if (!loaded || loadError) { + return {}; + } + const unsortedList: ResourceDropdownItems = { ...appendItems }; _.each(resources, ({ data, kind }) => { _.reduce( data, - (acc, resource) => { - const { customKey, key: name } = this.craftResourceKey(resource, props); + (acc, resource: K8sResourceKind) => { + const { customKey, key: name } = craftResourceKey( + resource, + dataSelector, + resourceFilter, + customResourceKey, + ); const dataValue = customKey || name; if (dataValue) { if (showBadge) { @@ -218,80 +189,147 @@ class ResourceDropdownInternal extends Component { sortedList[key] = unsortedList[key]; }); - if (updateSelection) { - let selectedItem = selectedKey; - if ( - (_.isEmpty(sortedList) || !sortedList[selectedKey]) && - allSelectorItem && - allSelectorItem.allSelectorKey !== selectedKey - ) { - selectedItem = allSelectorItem.allSelectorKey; - } else if (autoSelect && !selectedKey) { - selectedItem = - loaded && _.isEmpty(sortedList) && actionItems - ? actionItems[0].actionKey - : _.get(_.keys(sortedList), 0); - } - selectedItem && this.handleChange(selectedItem, sortedList); - } return sortedList; - }; + }, [ + loaded, + loadError, + resources, + dataSelector, + resourceFilter, + customResourceKey, + appendItems, + showBadge, + transformLabel, + allSelectorItem, + noneSelectorItem, + ]); - private handleChange = (key, items) => { - const name = items[key]; - const { actionItems, onChange, selectedKey } = this.props; - const selectedActionItem = actionItems && actionItems.find((ai) => key === ai.actionKey); - const title = selectedActionItem ? selectedActionItem.actionTitle : name; - if (title !== this.state.title) { - this.setState({ title }); + // Auto-selection and title sync when items or selection changes. + // Skip the initial mount to match class component behavior (constructor never auto-selects). + useEffect(() => { + if (!mountedRef.current) { + mountedRef.current = true; + prevSelectedKeyRef.current = selectedKey; + return; } - if (key !== selectedKey) { - onChange && onChange(key, name, this.state.resources[key]); + + if (!loaded || loadError) { + prevSelectedKeyRef.current = selectedKey; + return; } - }; - private onChange = (key: string) => { - this.handleChange(key, this.state.items); - }; + let selectedItem = selectedKey; + if ( + (_.isEmpty(items) || !items[selectedKey]) && + allSelectorItem && + allSelectorItem.allSelectorKey !== selectedKey + ) { + selectedItem = allSelectorItem.allSelectorKey; + } else if (autoSelect && !selectedKey) { + selectedItem = + loaded && _.isEmpty(items) && actionItems + ? actionItems[0].actionKey + : _.get(_.keys(items), 0); + } - render() { - return ( - - ); - } -} + if (selectedItem) { + const name = items[selectedItem]; + const selectedActionItem = + actionItems && actionItems.find((ai) => selectedItem === ai.actionKey); + const title = selectedActionItem ? selectedActionItem.actionTitle : name; + setSelectedTitle(title); + if (selectedItem !== prevSelectedKeyRef.current) { + onChangeRef.current?.(selectedItem, name, resourceMap[selectedItem]); + } + } + + prevSelectedKeyRef.current = selectedKey; + }, [ + loaded, + loadError, + items, + selectedKey, + autoSelect, + allSelectorItem, + actionItems, + resourceMap, + ]); -export const ResourceDropdown = withTranslation()(ResourceDropdownInternal); + // Notify parent when items are loaded + useEffect(() => { + if (loaded && onLoad) { + onLoad(items); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loaded, items]); + + // Compute display title + const displayTitle = useMemo(() => { + if (!loaded && !loadError) { + return ; + } + if (loadError) { + return ( + + {t('console-shared~Error loading - {{placeholder}}', { placeholder })} + + ); + } + if (titleProp) { + return titleProp; + } + if (selectedTitle) { + return selectedTitle; + } + return {placeholder}; + }, [loaded, loadError, titleProp, selectedTitle, placeholder, t]); + + const handleChange = useCallback( + (key) => { + const name = items[key]; + const selectedActionItem = actionItems && actionItems.find((ai) => key === ai.actionKey); + const title = selectedActionItem ? selectedActionItem.actionTitle : name; + setSelectedTitle(title); + if (key !== selectedKey) { + onChangeRef.current?.(key, name, resourceMap[key]); + } + }, + [items, actionItems, selectedKey, resourceMap], + ); + + return ( + + ); +}; diff --git a/frontend/packages/console-shared/src/components/formik-fields/ResourceDropdownField.tsx b/frontend/packages/console-shared/src/components/formik-fields/ResourceDropdownField.tsx index be29ad8763..f296ab43ab 100644 --- a/frontend/packages/console-shared/src/components/formik-fields/ResourceDropdownField.tsx +++ b/frontend/packages/console-shared/src/components/formik-fields/ResourceDropdownField.tsx @@ -4,30 +4,26 @@ import type { FormikValues } from 'formik'; import { useField, useFormikContext } from 'formik'; import { Firehose } from '@console/internal/components/utils/firehose'; import type { FirehoseResource } from '@console/internal/components/utils/types'; -import type { K8sResourceKind } from '@console/internal/module/k8s'; import { useFormikValidationFix } from '../../hooks/formik-validation-fix'; -import type { ResourceDropdownItems } from '../dropdown/ResourceDropdown'; +import type { ResourceDropdownProps } from '../dropdown/ResourceDropdown'; import { ResourceDropdown } from '../dropdown/ResourceDropdown'; import type { DropdownFieldProps } from './field-types'; import { getFieldId } from './field-utils'; export interface ResourceDropdownFieldProps extends DropdownFieldProps { - dataSelector: string[] | number[] | symbol[]; + dataSelector: ResourceDropdownProps['dataSelector']; resources: FirehoseResource[]; - showBadge?: boolean; - onLoad?: (items: ResourceDropdownItems) => void; - onChange?: (key: string, name?: string | object, resource?: K8sResourceKind) => void; - resourceFilter?: (resource: K8sResourceKind) => boolean; - autoSelect?: boolean; + showBadge?: ResourceDropdownProps['showBadge']; + onLoad?: ResourceDropdownProps['onLoad']; + onChange?: ResourceDropdownProps['onChange']; + resourceFilter?: ResourceDropdownProps['resourceFilter']; + autoSelect?: ResourceDropdownProps['autoSelect']; placeholder?: string; - actionItems?: { - actionTitle: string; - actionKey: string; - }[]; - appendItems?: ResourceDropdownItems; - customResourceKey?: (key: string, resource: K8sResourceKind) => string; + actionItems?: ResourceDropdownProps['actionItems']; + appendItems?: ResourceDropdownProps['appendItems']; + customResourceKey?: ResourceDropdownProps['customResourceKey']; dataTest?: string; - menuClassName?: string; + menuClassName?: ResourceDropdownProps['menuClassName']; } const ResourceDropdownField: FC = ({ @@ -61,7 +57,7 @@ const ResourceDropdownField: FC = ({ isFullWidth={fullWidth} onLoad={onLoad} resourceFilter={resourceFilter} - onChange={(value: string, name: string | object, resource: K8sResourceKind) => { + onChange={(value, name, resource) => { props.onChange && props.onChange(value, name, resource); setFieldValue(props.name, value); setFieldTouched(props.name, true); From 44372831e2c86d87794ce95be4aa9ad97a9421b8 Mon Sep 17 00:00:00 2001 From: logonoff Date: Wed, 25 Feb 2026 13:56:45 -0500 Subject: [PATCH 4/4] CONSOLE-5018: Refactor StorageClassDropdown to functional component Replace class component with hooks, merge StorageClassDropdownInner and StorageClassDropdown into a single component, and replace withTranslation HOC with useTranslation hook. Derived data (items, defaultClass, displayTitle) is now computed via useMemo. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../utils/storage-class-dropdown.tsx | 385 ++++++++---------- 1 file changed, 165 insertions(+), 220 deletions(-) diff --git a/frontend/public/components/utils/storage-class-dropdown.tsx b/frontend/public/components/utils/storage-class-dropdown.tsx index 689ca958d4..3423e8ad20 100644 --- a/frontend/public/components/utils/storage-class-dropdown.tsx +++ b/frontend/public/components/utils/storage-class-dropdown.tsx @@ -1,61 +1,102 @@ -/* eslint-disable @typescript-eslint/no-use-before-define */ import * as _ from 'lodash'; -import type { ReactNode } from 'react'; -import { Component } from 'react'; +import type { FC, ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import * as fuzzy from 'fuzzysearch'; -/* eslint-disable import/named */ -import { WithTranslation, withTranslation } from 'react-i18next'; - +import { useTranslation } from 'react-i18next'; +import { css } from '@patternfly/react-styles'; import { ConsoleSelect } from '@console/internal/components/utils/console-select'; -import { Firehose } from './firehose'; import { LoadingInline } from './status-box'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; import { ResourceName, ResourceIcon } from './resource-icon'; import { isDefaultClass } from '../storage-class'; -import { css } from '@patternfly/react-styles'; +import { StorageClassResourceKind } from '@console/internal/module/k8s'; -/* Component StorageClassDropdown - creates a dropdown list of storage classes */ +export type StorageClassDropdownProps = { + id?: string; + name?: string; + onChange: (object) => void; + describedBy?: string; + desc?: string; + defaultClass?: string; + required?: boolean; + hideClassName?: string; + filter?: (param) => boolean; + selectedKey?: string; + 'data-test'?: string; +}; -export class StorageClassDropdownInnerWithTranslation extends Component< - StorageClassDropdownInnerProps, - StorageClassDropdownInnerState -> { - readonly state: StorageClassDropdownInnerState = { - items: {}, - name: this.props.name, - selectedKey: this.props.selectedKey, - title: , - defaultClass: this.props.defaultClass, - }; +const getTitle = (storageClass: { kindLabel: string; name: string }): ReactNode => { + return storageClass.kindLabel ? ( + + ) : ( + {storageClass.name} + ); +}; - UNSAFE_componentWillMount() { - this.UNSAFE_componentWillReceiveProps(this.props); - } +const StorageClassDropdownEntry = (props) => { + const storageClassProperties = [ + props.default ? ' (default)' : '', + props.description, + props.accessMode, + props.provisioner, + props.type, + props.zone, + ]; + const storageClassDescriptionLine = _.compact(storageClassProperties).join(' | '); + return ( + + + + {props.name} +
+ {' '} + {storageClassDescriptionLine} +
+
+
+ ); +}; - UNSAFE_componentWillReceiveProps(nextProps) { - const { loaded, loadError, resources, t } = nextProps; +const StorageClassDropdownNoStorageClassOption: FC<{ name: string }> = ({ name }) => { + return ( + + {name} + + ); +}; - if (loadError) { - this.setState({ - title: ( -
- {t('public~Error loading {{desc}}', { desc: nextProps.desc })} -
- ), - }); - return; - } - if (!loaded) { - return; +/* Creates a dropdown list of storage classes */ +export const StorageClassDropdown: FC = ({ + id, + onChange: onChangeProp, + describedBy, + desc, + required, + hideClassName, + filter, + selectedKey: selectedKeyProp, + 'data-test': dataTest, +}) => { + const { t } = useTranslation('public'); + const [data, loaded, loadError] = useK8sWatchResource({ + kind: 'StorageClass', + isList: true, + }); + + const [selectedKey, setSelectedKey] = useState(selectedKeyProp); + const onChangeRef = useRef(onChangeProp); + onChangeRef.current = onChangeProp; + + // Process resources into sorted items and find default storage class + const { items, defaultClass } = useMemo(() => { + if (!loaded || loadError) { + return { items: {} as Record, defaultClass: '' }; } - const state = { - items: {}, - title: undefined, - defaultClass: '', - }; - let unorderedItems = {}; - const noStorageClass = t('public~No default StorageClass'); - _.map(resources.StorageClass.data, (resource) => { + const noStorageClass = t('No default StorageClass'); + let unorderedItems: Record = {}; + + _.map(data, (resource: StorageClassResourceKind) => { unorderedItems[resource.metadata.name] = { kindLabel: 'StorageClass', name: resource.metadata.name, @@ -74,203 +115,107 @@ export class StorageClassDropdownInnerWithTranslation extends Component< }; }); - //Filter if user provides a custom function - if (nextProps.filter) { + if (filter) { unorderedItems = Object.keys(unorderedItems) - .filter((sc) => nextProps.filter(unorderedItems[sc])) + .filter((sc) => filter(unorderedItems[sc])) .reduce((acc, key) => { acc[key] = unorderedItems[key]; return acc; - }, {}); + }, {} as Record); } - // Determine if there is a default storage class - state.defaultClass = _.findKey(unorderedItems, 'default'); - const { selectedKey } = this.state; - if (!state.defaultClass) { - // Add No Storage Class option if there is not a default storage class + const foundDefault = _.findKey(unorderedItems, 'default') || ''; + if (!foundDefault) { unorderedItems[''] = { kindLabel: '', name: noStorageClass }; } - if (!this.props.loaded || !selectedKey || !unorderedItems[selectedKey || state.defaultClass]) { - state.title = ( - {t('public~Select StorageClass')} - ); - } - - const selectedItem = unorderedItems[selectedKey || state.defaultClass]; - if (selectedItem) { - state.title = this.getTitle(selectedItem); - } - + const sortedItems: Record = {}; Object.keys(unorderedItems) .sort() .forEach((key) => { - state.items[key] = unorderedItems[key]; + sortedItems[key] = unorderedItems[key]; }); - this.setState(state); - } - - componentDidMount() { - const { defaultClass } = this.state; - if (defaultClass) { - this.onChange(defaultClass); - } - } - - componentDidUpdate() { - const { defaultClass, selectedKey } = this.state; - if (selectedKey) { - this.onChange(selectedKey); - } else if (defaultClass) { - this.onChange(defaultClass); - } - } - - shouldComponentUpdate(nextProps, nextState) { - return !_.isEqual(this.state, nextState); - } - autocompleteFilter = (text, item) => fuzzy(text, item.props.name); + return { items: sortedItems, defaultClass: foundDefault }; + }, [loaded, loadError, data, filter, t]); - getTitle = (storageClass) => { - return storageClass.kindLabel ? ( - - ) : ( - {storageClass.name} - ); - }; + const effectiveKey = selectedKey ?? defaultClass; - onChange = (key) => { - const storageClass = _.get(this.state, ['items', key], {}); - this.setState( - { - selectedKey: key, - title: this.getTitle(storageClass), - }, - () => this.props.onChange(storageClass.resource), - ); - }; - - render() { - const { id, loaded, describedBy, t } = this.props; - const items = {}; - _.each( - this.state.items, - (props, key) => - (items[key] = key ? ( - - ) : ( - - )), - ); + // Notify parent when selection or items change + useEffect(() => { + if (!loaded || loadError) { + return; + } + const selectedItem = items[effectiveKey]; + if (selectedItem) { + onChangeRef.current(selectedItem.resource); + } + }, [loaded, loadError, effectiveKey, items]); - const { selectedKey, defaultClass } = this.state; + // Compute display title + const displayTitle = useMemo(() => { + if (loadError) { + return
{t('Error loading {{desc}}', { desc })}
; + } + if (!loaded) { + return ; + } + const selectedItem = items[effectiveKey]; + if (selectedItem) { + return getTitle(selectedItem); + } + return {t('Select StorageClass')}; + }, [loadError, loaded, items, effectiveKey, desc, t]); + + // Build dropdown item elements + const dropdownItems = useMemo(() => { + const result: Record = {}; + _.each(items, (props, key) => { + result[key] = key ? ( + + ) : ( + + ); + }); + return result; + }, [items]); - // Only show the dropdown if 'no storage class' is not the only option which depends on defaultClass - const itemsAvailableToShow = defaultClass || _.size(items) > 1; - return ( - <> - {loaded && itemsAvailableToShow && ( -
- - - {describedBy && ( -

- {t('public~StorageClass for the new claim')} -

- )} -
- )} - - ); - } -} + const handleChange = useCallback((key: string) => { + setSelectedKey(key); + }, []); -export const StorageClassDropdownInner = withTranslation()( - StorageClassDropdownInnerWithTranslation, -); + const autocompleteFilter = useCallback((text, item) => fuzzy(text, item.props.name), []); -export const StorageClassDropdown = (props) => { - return ( - - - - ); -}; + const itemsAvailableToShow = defaultClass || _.size(dropdownItems) > 1; -const StorageClassDropdownEntry = (props) => { - const storageClassProperties = [ - props.default ? ' (default)' : '', - props.description, - props.accessMode, - props.provisioner, - props.type, - props.zone, - ]; - const storageClassDescriptionLine = _.compact(storageClassProperties).join(' | '); return ( - - - - {props.name} -
- {' '} - {storageClassDescriptionLine} + <> + {loaded && itemsAvailableToShow ? ( +
+ + + {describedBy ? ( +

+ {t('StorageClass for the new claim')} +

+ ) : null}
- - - ); -}; - -const StorageClassDropdownNoStorageClassOption = (props) => { - return ( - - {props.name} - + ) : null} + ); }; - -export type StorageClassDropdownInnerState = { - items: any; - name: string; - selectedKey: string; - title: ReactNode; - defaultClass: string; -}; - -export type StorageClassDropdownInnerProps = WithTranslation & { - id?: string; - loaded?: boolean; - loadError?: any; - resources?: any; - name: string; - onChange: (object) => void; - describedBy: string; - desc?: string; - defaultClass: string; - required?: boolean; - hideClassName?: string; - filter?: (param) => boolean; - selectedKey?: string; -};