From 1abbee0f19e4149992015716ca2101ee911fe32e Mon Sep 17 00:00:00 2001 From: Dipanshu Gupta Date: Thu, 12 Dec 2024 18:31:38 +0530 Subject: [PATCH] Node resource table and modal --- frontend/src/__mocks__/mockHardwareProfile.ts | 6 +- .../k8s/__tests__/hardwareProfiles.spec.ts | 6 +- .../ManageNodeResourceSection.tsx | 49 ++++++++++ .../nodeResource/CountFormField.tsx | 63 +++++++++++++ .../nodeResource/ManageNodeResourceModal.tsx | 91 +++++++++++++++++++ .../nodeResource/NodeResourceForm.tsx | 81 +++++++++++++++++ .../nodeResource/NodeResourceTable.tsx | 84 ++++++++++++----- .../nodeResource/NodeResourceTableRow.tsx | 45 +++++++++ .../nodeResource/__tests__/utils.spec.ts | 65 +++++++++++++ .../hardwareProfiles/nodeResource/const.ts | 43 +++++++++ .../hardwareProfiles/nodeResource/utils.ts | 9 ++ 11 files changed, 515 insertions(+), 27 deletions(-) create mode 100644 frontend/src/pages/hardwareProfiles/ManageNodeResourceSection.tsx create mode 100644 frontend/src/pages/hardwareProfiles/nodeResource/CountFormField.tsx create mode 100644 frontend/src/pages/hardwareProfiles/nodeResource/ManageNodeResourceModal.tsx create mode 100644 frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceForm.tsx create mode 100644 frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTableRow.tsx create mode 100644 frontend/src/pages/hardwareProfiles/nodeResource/__tests__/utils.spec.ts create mode 100644 frontend/src/pages/hardwareProfiles/nodeResource/const.ts create mode 100644 frontend/src/pages/hardwareProfiles/nodeResource/utils.ts diff --git a/frontend/src/__mocks__/mockHardwareProfile.ts b/frontend/src/__mocks__/mockHardwareProfile.ts index a1a9eca282..01039a1ed7 100644 --- a/frontend/src/__mocks__/mockHardwareProfile.ts +++ b/frontend/src/__mocks__/mockHardwareProfile.ts @@ -29,9 +29,9 @@ export const mockHardwareProfile = ({ { displayName: 'Memory', identifier: 'memory', - minCount: 5, - maxCount: 2, - defaultCount: 2, + minCount: '5Gi', + maxCount: '2Gi', + defaultCount: '2Gi', }, ], description = '', diff --git a/frontend/src/api/k8s/__tests__/hardwareProfiles.spec.ts b/frontend/src/api/k8s/__tests__/hardwareProfiles.spec.ts index ba22cfbebb..39b0fa22d7 100644 --- a/frontend/src/api/k8s/__tests__/hardwareProfiles.spec.ts +++ b/frontend/src/api/k8s/__tests__/hardwareProfiles.spec.ts @@ -40,9 +40,9 @@ const data: HardwareProfileKind['spec'] = { { displayName: 'Memory', identifier: 'memory', - minCount: 5, - maxCount: 2, - defaultCount: 2, + minCount: '5Gi', + maxCount: '2Gi', + defaultCount: '2Gi', }, ], description: 'test description', diff --git a/frontend/src/pages/hardwareProfiles/ManageNodeResourceSection.tsx b/frontend/src/pages/hardwareProfiles/ManageNodeResourceSection.tsx new file mode 100644 index 0000000000..595fdfcb54 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/ManageNodeResourceSection.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { FormSection, Flex, FlexItem, Button } from '@patternfly/react-core'; +import { Identifier } from '~/types'; +import NodeResourceTable from './nodeResource/NodeResourceTable'; +import ManageNodeResourceModal from './nodeResource/ManageNodeResourceModal'; + +type ManageNodeResourceSectionProps = { + identifiers: Identifier[]; + setIdentifiers: (identifiers: Identifier[]) => void; +}; + +export const ManageNodeResourceSection: React.FC = ({ + identifiers, + setIdentifiers, +}) => { + const [isNodeResourceModalOpen, setIsNodeResourceModalOpen] = React.useState(false); + return ( + <> + + Node resources + + + + + } + > + setIdentifiers(newIdentifiers)} + /> + + {isNodeResourceModalOpen ? ( + setIsNodeResourceModalOpen(false)} + onSave={(identifier) => setIdentifiers([...identifiers, identifier])} + identifiers={identifiers} + /> + ) : null} + + ); +}; diff --git a/frontend/src/pages/hardwareProfiles/nodeResource/CountFormField.tsx b/frontend/src/pages/hardwareProfiles/nodeResource/CountFormField.tsx new file mode 100644 index 0000000000..aa231fdd3e --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/nodeResource/CountFormField.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import { FormGroup, FormHelperText, HelperText, HelperTextItem } from '@patternfly/react-core'; +import MemoryField from '~/components/MemoryField'; +import CPUField from '~/components/CPUField'; +import NumberInputWrapper from '~/components/NumberInputWrapper'; + +type CountFormFieldProps = { + label: string; + fieldId: string; + size: number | string; + setSize: (value: number | string) => void; + identifier: string; + errorMessage?: string; + isValid?: boolean; +}; + +const CountFormField: React.FC = ({ + label, + fieldId, + size, + setSize, + identifier, + errorMessage, + isValid = true, +}) => { + const renderInputField = () => { + switch (identifier) { + case 'cpu': + return setSize(value)} value={size} />; + case 'memory': + return setSize(value)} value={String(size)} />; + default: + return ( + { + if (value) { + setSize(value); + } + }} + /> + ); + } + }; + + return ( + + {renderInputField()} + {!isValid && errorMessage && ( + + + + {errorMessage} + + + + )} + + ); +}; + +export default CountFormField; diff --git a/frontend/src/pages/hardwareProfiles/nodeResource/ManageNodeResourceModal.tsx b/frontend/src/pages/hardwareProfiles/nodeResource/ManageNodeResourceModal.tsx new file mode 100644 index 0000000000..10cd5a82ad --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/nodeResource/ManageNodeResourceModal.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { Modal } from '@patternfly/react-core/deprecated'; +import DashboardModalFooter from '~/concepts/dashboard/DashboardModalFooter'; +import { Identifier } from '~/types'; +import useGenericObjectState from '~/utilities/useGenericObjectState'; +import { CPU_UNITS, MEMORY_UNITS_FOR_SELECTION, UnitOption } from '~/utilities/valueUnits'; +import { EMPTY_IDENTIFIER } from './const'; +import NodeResourceForm from './NodeResourceForm'; +import { validateDefaultCount, validateMinCount } from './utils'; + +type ManageNodeResourceModalProps = { + onClose: () => void; + existingIdentifier?: Identifier; + onSave: (identifier: Identifier) => void; + identifiers: Identifier[]; +}; + +const ManageNodeResourceModal: React.FC = ({ + onClose, + existingIdentifier, + onSave, + identifiers, +}) => { + const [identifier, setIdentifier] = useGenericObjectState( + existingIdentifier || EMPTY_IDENTIFIER, + ); + + const [unitOptions, setUnitOptions] = React.useState(); + + const isUniqueIdentifier = React.useMemo(() => { + if (existingIdentifier) { + return true; + } + return !identifiers.some((i) => i.identifier === identifier.identifier); + }, [existingIdentifier, identifier.identifier, identifiers]); + + React.useEffect(() => { + switch (identifier.identifier) { + case 'cpu': + setUnitOptions(CPU_UNITS); + break; + case 'memory': + setUnitOptions(MEMORY_UNITS_FOR_SELECTION); + break; + default: + setUnitOptions(undefined); + } + }, [identifier]); + + const isButtonDisabled = React.useMemo(() => { + const isValidCounts = unitOptions + ? validateDefaultCount(identifier, unitOptions) && validateMinCount(identifier, unitOptions) + : true; + + return ( + !identifier.displayName || !identifier.identifier || !isUniqueIdentifier || !isValidCounts + ); + }, [identifier, unitOptions, isUniqueIdentifier]); + + const handleSubmit = () => { + onSave(identifier); + onClose(); + }; + + return ( + + } + > + + + ); +}; + +export default ManageNodeResourceModal; diff --git a/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceForm.tsx b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceForm.tsx new file mode 100644 index 0000000000..84dc73da79 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceForm.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { TextInput, FormGroup, Form } from '@patternfly/react-core'; +import { Identifier } from '~/types'; +import { UpdateObjectAtPropAndValue } from '~/pages/projects/types'; +import { UnitOption } from '~/utilities/valueUnits'; +import { validateDefaultCount, validateMinCount } from './utils'; +import CountFormField from './CountFormField'; + +type NodeResourceFormProps = { + identifier: Identifier; + setIdentifier: UpdateObjectAtPropAndValue; + unitOptions?: UnitOption[]; + existingIdentifier?: boolean; + isUniqueIdentifier?: boolean; +}; + +const NodeResourceForm: React.FC = ({ + identifier, + setIdentifier, + unitOptions, + existingIdentifier, + isUniqueIdentifier, +}) => { + const [validated, setValidated] = React.useState<'default' | 'error' | 'success'>('default'); + + React.useEffect(() => { + setValidated(isUniqueIdentifier ? 'default' : 'error'); + }, [isUniqueIdentifier]); + + return ( +
+ + setIdentifier('displayName', value)} + /> + + + + setIdentifier('identifier', value)} + isDisabled={ + existingIdentifier && + (identifier.identifier === 'cpu' || identifier.identifier === 'memory') + } + validated={validated} + /> + + + setIdentifier('defaultCount', value)} + isValid={unitOptions ? validateDefaultCount(identifier, unitOptions) : true} + errorMessage="Default must be equal to or between the minimum and maximum allowed limits." + /> + + setIdentifier('minCount', value)} + isValid={unitOptions ? validateMinCount(identifier, unitOptions) : true} + errorMessage="Minimum allowed value cannot exceed the maximum allowed value." + /> + + setIdentifier('maxCount', value)} + /> + + ); +}; +export default NodeResourceForm; diff --git a/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTable.tsx b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTable.tsx index 5d9c2aa9a8..4077ca4fdb 100644 --- a/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTable.tsx +++ b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTable.tsx @@ -1,30 +1,72 @@ import React from 'react'; -import { Td, Tr } from '@patternfly/react-table'; import { Table } from '~/components/table'; import { Identifier } from '~/types'; -import { nodeResourceColumns } from '~/pages/hardwareProfiles/const'; +import { nodeResourceColumns } from './const'; +import NodeResourceTableRow from './NodeResourceTableRow'; +import ManageNodeResourceModal from './ManageNodeResourceModal'; type NodeResourceTableProps = { - nodeResources: Identifier[]; + identifiers: Identifier[]; + onUpdate: (identifiers: Identifier[]) => void; + viewOnly?: boolean; }; -const NodeResourceTable: React.FC = ({ nodeResources }) => ( - ( - - - - - - - - )} - /> -); +const NodeResourceTable: React.FC = ({ + identifiers, + onUpdate, + viewOnly, +}) => { + const [editIdentifier, setEditIdentifier] = React.useState(); + const [currentIndex, setCurrentIndex] = React.useState(); + + return ( + <> +
{cr.displayName}{cr.identifier}{cr.defaultCount}{cr.minCount}{cr.maxCount}
column.field !== 'kebab') + : nodeResourceColumns + } + rowRenderer={(identifier, rowIndex) => ( + { + setEditIdentifier(newIdentifier); + setCurrentIndex(rowIndex); + }} + onDelete={() => { + const updatedIdentifiers = [...identifiers]; + updatedIdentifiers.splice(rowIndex, 1); + onUpdate(updatedIdentifiers); + }} + showKebab={!!viewOnly} + /> + )} + /> + {editIdentifier ? ( + { + setEditIdentifier(undefined); + setCurrentIndex(undefined); + }} + onSave={(identifier) => { + if (currentIndex !== undefined) { + const updatedIdentifiers = [...identifiers]; + updatedIdentifiers[currentIndex] = identifier; + onUpdate(updatedIdentifiers); + } + }} + identifiers={identifiers} + /> + ) : null} + + ); +}; export default NodeResourceTable; diff --git a/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTableRow.tsx b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTableRow.tsx new file mode 100644 index 0000000000..fba3390fd0 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTableRow.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; +import { Identifier } from '~/types'; + +type NodeResourceTableRowProps = { + identifier: Identifier; + onDelete: (identifier: Identifier) => void; + onEdit: (identifier: Identifier) => void; + showKebab: boolean; +}; + +const NodeResourceTableRow: React.FC = ({ + identifier, + onEdit, + onDelete, + showKebab, +}) => ( + + + + + + + {!showKebab && ( + + )} + +); + +export default NodeResourceTableRow; diff --git a/frontend/src/pages/hardwareProfiles/nodeResource/__tests__/utils.spec.ts b/frontend/src/pages/hardwareProfiles/nodeResource/__tests__/utils.spec.ts new file mode 100644 index 0000000000..dcb4fd2925 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/nodeResource/__tests__/utils.spec.ts @@ -0,0 +1,65 @@ +import { Identifier } from '~/types'; +import { + validateDefaultCount, + validateMinCount, +} from '~/pages/hardwareProfiles/nodeResource/utils'; +import { MEMORY_UNITS_FOR_SELECTION } from '~/utilities/valueUnits'; + +const identifier: Identifier = { + displayName: 'test', + identifier: 'test', + defaultCount: '2Gi', + minCount: '1Gi', + maxCount: '4Gi', +}; + +describe('validateDefaultCount', () => { + it('should return true if defaultCount is between minCount and maxCount', () => { + const result = validateDefaultCount(identifier, MEMORY_UNITS_FOR_SELECTION); + expect(result).toBe(true); + }); + + it('should return false if defaultCount is less than minCount', () => { + const result = validateDefaultCount( + { + ...identifier, + defaultCount: '512Mi', + }, + MEMORY_UNITS_FOR_SELECTION, + ); + + expect(result).toBe(false); + }); + + it('should return false if defaultCount is greater than maxCount', () => { + const result = validateDefaultCount( + { + ...identifier, + defaultCount: '8Gi', + }, + MEMORY_UNITS_FOR_SELECTION, + ); + + expect(result).toBe(false); + }); +}); + +describe('validateMinCount', () => { + it('should return true if minCount is less than maxCount', () => { + const result = validateMinCount(identifier, MEMORY_UNITS_FOR_SELECTION); + expect(result).toBe(true); + }); + + it('should return false if minCount is greater than maxCount', () => { + const result = validateMinCount( + { + ...identifier, + minCount: '8Gi', + maxCount: '4Gi', + }, + MEMORY_UNITS_FOR_SELECTION, + ); + + expect(result).toBe(false); + }); +}); diff --git a/frontend/src/pages/hardwareProfiles/nodeResource/const.ts b/frontend/src/pages/hardwareProfiles/nodeResource/const.ts new file mode 100644 index 0000000000..b5d7c13c07 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/nodeResource/const.ts @@ -0,0 +1,43 @@ +import { SortableData } from '~/components/table'; +import { Identifier } from '~/types'; + +export const nodeResourceColumns: SortableData[] = [ + { + field: 'resourceLabel', + label: 'Resource label', + sortable: false, + }, + { + field: 'identifier', + label: 'Resource identifier', + sortable: false, + }, + { + field: 'defaultCount', + label: 'Default', + sortable: false, + }, + { + field: 'minCount', + label: 'Minimum allowed', + sortable: false, + }, + { + field: 'minCount', + label: 'Maximum allowed', + sortable: false, + }, + { + field: 'kebab', + label: '', + sortable: false, + }, +]; + +export const EMPTY_IDENTIFIER: Identifier = { + displayName: '', + identifier: '', + minCount: 1, + maxCount: 1, + defaultCount: 1, +}; diff --git a/frontend/src/pages/hardwareProfiles/nodeResource/utils.ts b/frontend/src/pages/hardwareProfiles/nodeResource/utils.ts new file mode 100644 index 0000000000..563f59b059 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/nodeResource/utils.ts @@ -0,0 +1,9 @@ +import { Identifier } from '~/types'; +import { isLarger, UnitOption } from '~/utilities/valueUnits'; + +export const validateDefaultCount = (identifier: Identifier, unitOption: UnitOption[]): boolean => + isLarger(String(identifier.defaultCount), String(identifier.minCount), unitOption, true) && + isLarger(String(identifier.maxCount), String(identifier.defaultCount), unitOption, true); + +export const validateMinCount = (identifier: Identifier, unitOption: UnitOption[]): boolean => + isLarger(String(identifier.maxCount), String(identifier.minCount), unitOption, true);
{identifier.displayName}{identifier.identifier}{identifier.defaultCount}{identifier.minCount}{identifier.maxCount} + onEdit(identifier), + }, + { isSeparator: true }, + { + title: 'Delete', + onClick: () => onDelete(identifier), + isAriaDisabled: identifier.identifier === 'cpu' || identifier.identifier === 'memory', + }, + ]} + /> +