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); 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(); }); 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} - - ); - } -} 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; -};