From 98ac79f2ce330103554ddc331d466d67ff5fb9e9 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Sun, 5 Nov 2023 04:57:16 -0500 Subject: [PATCH] chore(pf5): migrate topology components --- src/app/Topology/Actions/CreateTarget.tsx | 94 ++--- src/app/Topology/Actions/NodeActions.tsx | 38 +- src/app/Topology/Actions/QuickSearchPanel.tsx | 28 +- src/app/Topology/Entity/EntityDetails.tsx | 8 +- src/app/Topology/Toolbar/DisplayOptions.tsx | 57 ++- .../Toolbar/FindByMatchExpression.tsx | 2 +- src/app/Topology/Toolbar/TopologyFilters.tsx | 352 ++++++++++-------- src/app/Topology/Toolbar/utils.ts | 46 +++ 8 files changed, 373 insertions(+), 252 deletions(-) create mode 100644 src/app/Topology/Toolbar/utils.ts diff --git a/src/app/Topology/Actions/CreateTarget.tsx b/src/app/Topology/Actions/CreateTarget.tsx index 686f32f43..cd5c26427 100644 --- a/src/app/Topology/Actions/CreateTarget.tsx +++ b/src/app/Topology/Actions/CreateTarget.tsx @@ -126,7 +126,7 @@ export const CreateTarget: React.FC = ({ prefilled }) => { ); const handleConnectUrlChange = React.useCallback( - (connectUrl: string) => { + (_, connectUrl: string) => { setFormData((old) => ({ ...old, connectUrl, @@ -143,7 +143,7 @@ export const CreateTarget: React.FC = ({ prefilled }) => { ); const handleAliasChange = React.useCallback( - (alias: string) => { + (_, alias: string) => { setFormData((old) => ({ ...old, alias })); resetTestState(); }, @@ -151,7 +151,7 @@ export const CreateTarget: React.FC = ({ prefilled }) => { ); const handleUsernameChange = React.useCallback( - (username: string) => { + (_, username: string) => { setFormData((old) => ({ ...old, username })); resetTestState(); }, @@ -159,7 +159,7 @@ export const CreateTarget: React.FC = ({ prefilled }) => { ); const handlePasswordChange = React.useCallback( - (password: string) => { + (_, password: string) => { setFormData((old) => ({ ...old, password })); resetTestState(); }, @@ -269,6 +269,25 @@ export const CreateTarget: React.FC = ({ prefilled }) => { [], ); + const connectUrlHelperText = React.useMemo(() => { + if (validConnectUrl === ValidatedOptions.error) { + return 'JMX Service URL must not contain empty spaces.'; + } + return ( + <> + JMX Service URL.{' '} + {example && ( + <> + For example, + + {example} + + + )} + + ); + }, [validConnectUrl]); + return ( @@ -287,30 +306,7 @@ export const CreateTarget: React.FC = ({ prefilled }) => { isInline /> - - JMX Service URL.{' '} - {example && ( - <> - For example, - - {example} - - - )} - - } - helperTextInvalid={'JMX Service URL must not contain empty spaces.'} - validated={validConnectUrl} - > + = ({ prefilled }) => { validated={validConnectUrl} data-quickstart-id="ct-connecturl-input" /> + + + {connectUrlHelperText} + + - Connection Nickname (same as Connection URL if not specified). - } - > + = ({ prefilled }) => { isDisabled={loading || testing} data-quickstart-id="ct-alias-input" /> + + + Connection Nickname (same as Connection URL if not specified). + + @@ -359,12 +359,7 @@ export const CreateTarget: React.FC = ({ prefilled }) => { isHidden={!expandedSections.includes('jmx-credential-option')} id={'expanded-jmx-credential-option'} > - Username for JMX connection.} - > + = ({ prefilled }) => { isDisabled={loading || testing} data-quickstart-id="ct-username-input" /> + + + Username for JMX connection. + + - Password for JMX connection.} - > + = ({ prefilled }) => { onChange={handlePasswordChange} data-quickstart-id="ct-password-input" /> + + + Password for JMX connection. + + diff --git a/src/app/Topology/Actions/NodeActions.tsx b/src/app/Topology/Actions/NodeActions.tsx index dd1d66e80..1c7b2dc7e 100644 --- a/src/app/Topology/Actions/NodeActions.tsx +++ b/src/app/Topology/Actions/NodeActions.tsx @@ -17,7 +17,15 @@ import { NotificationsContext } from '@app/Shared/Services/Notifications.service'; import { ServiceContext } from '@app/Shared/Services/Services'; import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; -import { Dropdown, DropdownItem, DropdownProps, DropdownToggle } from '@patternfly/react-core/deprecated'; +import { portalRoot } from '@app/utils/utils'; +import { + Dropdown, + DropdownItem, + DropdownList, + DropdownProps, + MenuToggle, + MenuToggleElement, +} from '@patternfly/react-core'; import { css } from '@patternfly/react-styles'; import { ContextMenuItem as PFContextMenuItem } from '@patternfly/react-topology'; import * as React from 'react'; @@ -107,24 +115,38 @@ export interface ActionDropdownProps extends Omit = ({ className, actions, - position, - menuAppendTo, + popperProps = { + position: 'right', + appendTo: portalRoot, + }, ...props }) => { const [actionOpen, setActionOpen] = React.useState(false); + const handleClose = React.useCallback(() => setActionOpen(false), [setActionOpen]); + + const handleToggle = React.useCallback(() => setActionOpen((open) => !open), [setActionOpen]); + + const toggle = React.useCallback( + (toggleRef: React.Ref) => ( + + Actions + + ), + [handleToggle, actionOpen], + ); + return ( e.stopPropagation()} - toggle={Actions} - dropdownItems={actions} - /> + toggle={toggle} + > + {actions} + ); }; diff --git a/src/app/Topology/Actions/QuickSearchPanel.tsx b/src/app/Topology/Actions/QuickSearchPanel.tsx index 8e7a37bac..22cf29010 100644 --- a/src/app/Topology/Actions/QuickSearchPanel.tsx +++ b/src/app/Topology/Actions/QuickSearchPanel.tsx @@ -127,7 +127,7 @@ export const QuickSearchPanel: React.FC = ({ ...props }) ); const handleSearch = React.useCallback( - (input: string) => { + (_, input: string) => { setSearchText(input); }, [setSearchText], @@ -166,7 +166,7 @@ export const QuickSearchPanel: React.FC = ({ ...props }) placeholder="Add to view..." value={searchText} onChange={handleSearch} - onClear={() => handleSearch('')} + onClear={(e) => handleSearch(e, '')} /> {filteredQuicksearches.length ? ( @@ -180,7 +180,7 @@ export const QuickSearchPanel: React.FC = ({ ...props }) expandable={{ default: 'nonExpandable', md: 'nonExpandable', lg: 'nonExpandable', sm: 'expandable' }} isExpanded={isExpanded} toggleText={isExpanded ? 'Close Tabs' : 'Open Tabs'} - onToggle={setIsExpanded} + onToggle={(_, isExpanded: boolean) => setIsExpanded(isExpanded)} activeKey={activeTab} onSelect={handleTabChange} role={'region'} @@ -214,24 +214,16 @@ export const QuickSearchPanel: React.FC = ({ ...props }) export interface QuickSearchModalProps extends Partial {} -export const QuickSearchModal: React.FC = ({ - isOpen, - onClose, - variant = 'medium', - ..._props -}) => { - const activeLevel = useFeatureLevel(); - - const guide = React.useMemo(() => { - if (activeLevel === FeatureLevel.PRODUCTION) { - return null; - } +export const QuickSearchModal: React.FC = ({ isOpen, onClose, variant = 'medium' }) => { + const description = React.useMemo(() => { return ( - For quickstarts on how to create these entities, visit Quick Starts. + Select an entity to add to view. For quickstarts on how to create these entities, visit{' '} + Quick Starts. ); - }, [activeLevel]); + }, []); + return ( = ({ title={'Topology Entity Catalog'} className={'topology__quick-search-modal'} id={'topology-quick-search-modal'} - description={
Select an entity to add to view. {guide}
} + description={{ description }} >
diff --git a/src/app/Topology/Entity/EntityDetails.tsx b/src/app/Topology/Entity/EntityDetails.tsx index 93059ef6b..51ddcd65d 100644 --- a/src/app/Topology/Entity/EntityDetails.tsx +++ b/src/app/Topology/Entity/EntityDetails.tsx @@ -649,6 +649,12 @@ export const EntityDetailHeader: React.FC = ({ }) => { const [status, extra] = statusContent; const [showBanner, setShowBanner] = React.useState(true); + const variant = React.useMemo(() => { + if (status == NodeStatus.default) { + return 'info'; + } + return status; + }, [status]); return (
@@ -659,7 +665,7 @@ export const EntityDetailHeader: React.FC = ({ {status && showBanner ? ( = ({ - isDisabled = false, - isGraph: isGraphView = true, - ..._props -}) => { - const [open, setOpen] = React.useState(false); +export const DisplayOptions: React.FC = ({ isDisabled = false, isGraph: isGraphView = true }) => { + const toggleRef = React.useRef(null); + const menuRef = React.useRef(); + const [isExpanded, setIsExpanded] = React.useState(false); const { show, groupings } = useSelector((state: RootState) => state.topologyConfigs.displayOptions); const dispatch = useDispatch(); - const handleToggle = React.useCallback(() => setOpen((old) => !old), [setOpen]); + const handleToggle = React.useCallback(() => setIsExpanded((old) => !old), [setIsExpanded]); const getChangeHandler = React.useCallback( (group: OptionCategory, key: string) => { - return (checked: boolean, _) => { + return (_: React.FormEvent, checked: boolean) => { dispatch(topologyDisplayOptionsSetIntent(group, key, checked)); }; }, @@ -96,15 +104,28 @@ export const DisplayOptions: React.FC = ({ ); }, [checkBoxContents, switchContents]); + const toggle = React.useCallback( + (toggleRef: React.Ref) => ( + + ), + [handleToggle, isExpanded, handleToggle, isDisabled], + ); + return ( - `${isGroup ? 'Group' : 'Target'}: ${getDisplayFieldName(selected)}`, - compareTo: (other) => other.category === selected, - ...{ - category: selected, - }, - }} + selected={selected} aria-label={'Filter Categories'} - placeholderText={'Select a category'} - isGrouped > - {options} + {options} ); }; -export const TopologyFilter: React.FC<{ isDisabled?: boolean }> = ({ isDisabled, ...props }) => { +export interface TopologyFilterProps { + isDisabled?: boolean; +} + +export const TopologyFilter: React.FC = ({ isDisabled }) => { const dispatch = useDispatch(); const { isGroup, groupFilters, targetFilters } = useSelector((state: RootState) => state.topologyFilters); const discoveryTree = React.useContext(DiscoveryTreeContext); @@ -156,11 +177,9 @@ export const TopologyFilter: React.FC<{ isDisabled?: boolean }> = ({ isDisabled, [flattenedTree], ); - const generateOnSelect = React.useCallback( - (isGroup: boolean) => { - return (_, { value, nodeType, category }) => { - dispatch(topologyAddFilterIntent(isGroup, nodeType, category, value)); - }; + const onSelect = React.useCallback( + (_: React.MouseEvent | undefined, { isGroup, value, nodeType, category }) => { + dispatch(topologyAddFilterIntent(isGroup, nodeType, category, value)); }, [dispatch], ); @@ -170,9 +189,10 @@ export const TopologyFilter: React.FC<{ isDisabled?: boolean }> = ({ isDisabled, const isShown = isGroup && groupFilters.category === cat; const ariaLabel = `Filter by ${getDisplayFieldName(cat)}...`; - const optionGroup = groupNodeTypes - .map((type) => ({ + const selectOptions = groupNodeTypes + .map((type) => ({ groupLabel: type, + category: cat, options: Array.from( new Set( flattenedTree @@ -180,41 +200,22 @@ export const TopologyFilter: React.FC<{ isDisabled?: boolean }> = ({ isDisabled, .map((groupNode: EnvironmentNode) => fieldValueToStrings(groupNode[categoryToNodeField(cat)])) .reduce((acc, curr) => acc.concat(curr), []) .filter((val) => { - const filters = groupFilters.filters[type] || {}; + const filters = groupFilters.filters[type]; if (filters) { - const criteria = filters[cat] || []; + const criteria = filters[cat]; return !criteria || !criteria.includes(val); } return true; - }), + }) + .map((val) => ({ + value: val, + render: () => (isLabelOrAnnotation(cat) ? : val), + })), ), ), })) .filter((group) => group.options && group.options.length); // Do show show empty groups - const selectOptions = optionGroup.map(({ options, groupLabel }) => { - return ( - - {options.map((opt) => ( - opt, - compareTo: (other) => other.value === opt, - ...{ - nodeType: groupLabel, - value: opt, - category: cat, - }, - }} - > - {isLabelOrAnnotation(cat) ? : opt} - - ))} - - ); - }); - return ( = ({ isDisabled, > - {selectOptions} - + options={selectOptions} + onSelect={onSelect} + /> ); }); - }, [isGroup, groupFilters, flattenedTree, groupNodeTypes, isDisabled, generateOnSelect]); + }, [isGroup, groupFilters, flattenedTree, groupNodeTypes, isDisabled, onSelect]); const targetInputs = React.useMemo(() => { return allowedTargetFilters.map((cat) => { const isShown = !isGroup && targetFilters.category === cat; const ariaLabel = `Filter by ${getDisplayFieldName(cat)}...`; - const options = Array.from( - new Set( - flattenedTree - .filter((n) => isTargetNode(n)) - .map(({ target }: TargetNode) => { - const value = target[categoryToNodeField(cat)]; - if (isAnnotation(cat)) { - return [...fieldValueToStrings(value['platform']), ...fieldValueToStrings(value['cryostat'])]; - } - return fieldValueToStrings(value); - }) - .reduce((acc, curr) => acc.concat(curr), []) - .filter((val) => { - const criteria: string[] = targetFilters.filters[cat]; - return !criteria || !criteria.includes(val); - }), - ), - ); - - const selectOptions = options.map((opt) => { - return ( - opt, - compareTo: (other) => { - const regex = new RegExp(typeof other === 'string' ? other : other.value, 'i'); - return regex.test(opt); - }, - ...{ - nodeType: 'Target', // Ignored by reducer - value: opt, - category: cat, - }, - }} - > - {isLabelOrAnnotation(cat) ? : opt} - - ); - }); + const options: TopologyFilterGroupOption[] = [ + { + category: cat, + options: Array.from( + new Set( + flattenedTree + .filter((n) => isTargetNode(n)) + .map(({ target }: TargetNode) => { + const value = target[categoryToNodeField(cat)]; + if (isAnnotation(cat)) { + return [...fieldValueToStrings(value['platform']), ...fieldValueToStrings(value['cryostat'])]; + } + return fieldValueToStrings(value); + }) + .reduce((acc, curr) => acc.concat(curr), []) + .filter((val) => { + const criteria: string[] = targetFilters.filters[cat]; + return !criteria || !criteria.includes(val); + }) + .map((val) => ({ + value: val, + render: () => (isLabelOrAnnotation(cat) ? : val), + })), + ), + ), + }, + ]; return ( = ({ isDisabled, > - {selectOptions} - + options={options} + onSelect={onSelect} + /> ); }); - }, [isGroup, targetFilters, flattenedTree, isDisabled, generateOnSelect]); + }, [isGroup, targetFilters, flattenedTree, isDisabled, onSelect]); return ( -
+ <> {groupInputs} {targetInputs} -
+ ); }; -export const TopologyFilterSelect: React.FC> = ({ - children: options, - onSelect, +export interface TopologyFilterSelectProps extends Omit { + isDisabled?: boolean; + placeHolder?: string; + options: TopologyFilterGroupOption[]; + onSelect: (_: React.MouseEvent | undefined, { isGroup, value, nodeType, category }) => void; +} + +export const TopologyFilterSelect: React.FC = ({ isDisabled, - placeholderText, + placeHolder, + options = [], + onSelect, ...props }) => { - const [isOpen, setIsOpen] = React.useState(false); + const [isExpanded, setIsExpanded] = React.useState(false); + const [filterValue, setFilterValue] = React.useState(''); + + const handleToggle = React.useCallback(() => setIsExpanded((open) => !open), [setIsExpanded]); + + const onInputChange = React.useCallback((_, value: string) => setFilterValue(value), [setFilterValue]); + + const toggle = React.useCallback( + (toggleRef: React.Ref) => ( + + + + + {filterValue ? ( + + ) : null} + + + + ), + [handleToggle, isExpanded, filterValue, onInputChange, setFilterValue, placeHolder, isDisabled], + ); + + const selectOptions = React.useMemo(() => { + return options.map(({ groupLabel, category, options }) => { + const _opts = options.map(({ value, render = () => undefined }) => ( + { + onSelect(e, { isGroup: !!groupLabel, nodeType: groupLabel, category: category, value: value }); + }} + > + {render()} + + )); + + if (groupLabel) { + return ( + + {_opts} + + ); + } + return _opts; + }); + }, [onSelect, options]); return ( ); }; - -export const fieldValueToStrings = (value: unknown): string[] => { - if (value === undefined || value === null) { - return []; - } - if (typeof value === 'object') { - if (Array.isArray(value)) { - return value.map((v) => `${v}`); - } else { - return Object.entries(value as object).map(([k, v]) => `${k}=${v}`); - } - } else { - return [`${value}`]; - } -}; - -export const isLabelOrAnnotation = (category: string) => /(label|annotation)/i.test(category); - -export const isAnnotation = (category: string) => /annotation/i.test(category); diff --git a/src/app/Topology/Toolbar/utils.ts b/src/app/Topology/Toolbar/utils.ts new file mode 100644 index 000000000..ee1946c60 --- /dev/null +++ b/src/app/Topology/Toolbar/utils.ts @@ -0,0 +1,46 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { NodeType } from '@app/Shared/Services/api.types'; + +export const isLabelOrAnnotation = (category: string) => /(label|annotation)/i.test(category); + +export const isAnnotation = (category: string) => /annotation/i.test(category); + +export const fieldValueToStrings = (value: unknown): string[] => { + if (value === undefined || value === null) { + return []; + } + if (typeof value === 'object') { + if (Array.isArray(value)) { + return value.map((v) => `${v}`); + } else { + return Object.entries(value as object).map(([k, v]) => `${k}=${v}`); + } + } else { + return [`${value}`]; + } +}; + +export interface TopologyFilterGroupOption { + groupLabel?: NodeType; + category: string; + options: TopologyFilterSelectOption[]; +} + +export interface TopologyFilterSelectOption { + value: string; + render?: () => React.ReactNode; +}