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/acceleratorProfiles/screens/manage/ManageAcceleratorProfile.tsx b/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfile.tsx index 4b77ccff1b..bf9d9050e3 100644 --- a/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfile.tsx +++ b/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfile.tsx @@ -10,6 +10,8 @@ import K8sNameDescriptionField, { useK8sNameDescriptionFieldData, } from '~/concepts/k8s/K8sNameDescriptionField/K8sNameDescriptionField'; import { isK8sNameDescriptionDataValid } from '~/concepts/k8s/K8sNameDescriptionField/utils'; +import { Identifier } from '~/types'; +import { ManageNodeResourceSection } from '~/pages/hardwareProfiles/ManageNodeResourceSection'; import { ManageAcceleratorProfileFooter } from './ManageAcceleratorProfileFooter'; import { ManageAcceleratorProfileTolerationsSection } from './ManageAcceleratorProfileTolerationsSection'; import { AcceleratorProfileFormData, ManageAcceleratorProfileSectionID } from './types'; @@ -62,6 +64,16 @@ const ManageAcceleratorProfile: React.FC = ({ const validFormData = isK8sNameDescriptionDataValid(profileNameDesc) && !!state.identifier; + const [identifiers, setIdentifiers] = React.useState([ + { + displayName: 'test', + identifier: 'test', + minCount: '1Gi', + maxCount: '10Gi', + defaultCount: '2Gi', + }, + ]); + return ( = ({ /> + setState('tolerations', tolerations)} diff --git a/frontend/src/pages/hardwareProfiles/ManageNodeResourceSection.tsx b/frontend/src/pages/hardwareProfiles/ManageNodeResourceSection.tsx new file mode 100644 index 0000000000..c0dae594ff --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/ManageNodeResourceSection.tsx @@ -0,0 +1,48 @@ +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])} + /> + ) : 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..f975e5b245 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/nodeResource/CountFormField.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { FormGroup, FormHelperText, HelperText, HelperTextItem } from '@patternfly/react-core'; +import ValueUnitField from '~/components/ValueUnitField'; +import { MEMORY_UNITS_FOR_SELECTION } from '~/utilities/valueUnits'; + +type CountFormFielddProps = { + label: string; + fieldId: string; + size: string; + setSize: (val: string) => void; + errorMessage?: string; + isValid?: boolean; +}; + +const CountFormField: React.FC = ({ + label, + fieldId, + size, + setSize, + errorMessage, + isValid = true, +}) => ( + + setSize(value)} + onChange={(value) => setSize(value)} + options={MEMORY_UNITS_FOR_SELECTION} + value={size} + /> + {!isValid && ( + + + + {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..93c43244fa --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/nodeResource/ManageNodeResourceModal.tsx @@ -0,0 +1,68 @@ +import React, { useEffect, useState } from 'react'; +import { Modal } from '@patternfly/react-core/deprecated'; +import DashboardModalFooter from '~/concepts/dashboard/DashboardModalFooter'; +import { Identifier } from '~/types'; +import { EMPTY_IDENTIFIER } from './const'; +import NodeResourceForm from './NodeResourceForm'; +import { validateDefaultCount, validateMinCount } from './utils'; + +type ManageNodeResourceModalProps = { + onClose: () => void; + existingIdentifier?: Identifier; + onSave: (identifier: Identifier) => void; +}; + +const ManageNodeResourceModal: React.FC = ({ + onClose, + existingIdentifier, + onSave, +}) => { + const [identifier, setIdentifier] = useState(EMPTY_IDENTIFIER); + const isButtonDisabled = + !identifier.displayName || + !identifier.identifier || + !identifier.maxCount || + !validateDefaultCount(identifier) || + !validateMinCount(identifier); + useEffect(() => { + if (existingIdentifier) { + setIdentifier(existingIdentifier); + } + }, [existingIdentifier]); + + const handleUpdate = (updatedIdentifier: Identifier) => { + setIdentifier(updatedIdentifier); + }; + + const onBeforeClose = () => { + setIdentifier(EMPTY_IDENTIFIER); + onClose(); + }; + + return ( + { + onBeforeClose(); + }} + footer={ + { + onSave(identifier); + onBeforeClose(); + }} + onCancel={() => onBeforeClose()} + isSubmitDisabled={isButtonDisabled} + alertTitle="Error saving resource" + /> + } + > + + + ); +}; + +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..3ce2e36e01 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceForm.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { TextInput, FormGroup, Form } from '@patternfly/react-core'; +import { Identifier } from '~/types'; +import CountFormField from './CountFormField'; +import { validateDefaultCount, validateMinCount } from './utils'; + +type NodeResourceFormProps = { + identifier: Identifier; + onUpdate: (data: Identifier) => void; +}; + +const NodeResourceForm: React.FC = ({ identifier, onUpdate }) => { + const handleFieldUpdate = (field: keyof Identifier, value: unknown) => { + onUpdate({ ...identifier, [field]: value }); + }; + + return ( +
+ + handleFieldUpdate('displayName', value)} + /> + + + + handleFieldUpdate('identifier', value)} + /> + + + handleFieldUpdate('defaultCount', value)} + label="Default" + fieldId="default" + errorMessage="Default must be equal to or between the minimum and maximum allowed limits." + isValid={validateDefaultCount(identifier)} + /> + + handleFieldUpdate('minCount', value)} + label="Minimum allowed" + fieldId="minimum-allowed" + isValid={validateMinCount(identifier)} + errorMessage="Minimum allowed value cannot exceed the maximum allowed value" + /> + + handleFieldUpdate('maxCount', value)} + label="Maximum allowed" + fieldId="maximum-allowed" + /> + + ); +}; + +export default NodeResourceForm; diff --git a/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTable.tsx b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTable.tsx new file mode 100644 index 0000000000..e21306e7bf --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTable.tsx @@ -0,0 +1,88 @@ +import { EmptyState, EmptyStateBody, Title } from '@patternfly/react-core'; +import React from 'react'; +import { PlusCircleIcon } from '@patternfly/react-icons'; +import { Table } from '~/components/table'; +import { Identifier } from '~/types'; +import { nodeResourceColumns } from './const'; +import NodeResourceTableRow from './NodeResourceTableRow'; +import ManageNodeResourceModal from './ManageNodeResourceModal'; + +type NodeResourceTableProps = { + identifiers: Identifier[]; + onUpdate: (identifiers: Identifier[]) => void; + viewOnly?: boolean; +}; + +const NodeResourceTable: React.FC = ({ + identifiers, + onUpdate, + viewOnly, +}) => { + const [editIdentifier, setEditIdentifier] = React.useState(); + const [currentIndex, setCurrentIndex] = React.useState(); + + if (identifiers.length === 0) { + return ( + + No node resource + + } + icon={PlusCircleIcon} + variant="xs" + data-testid="node-resource-empty-state" + > + No node resource body + + ); + } + + return ( + <> + column.field !== 'kebab') + : nodeResourceColumns + } + data-testid="node-resource-table" + 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); + } + }} + /> + ) : 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..6faf40da4e --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTableRow.tsx @@ -0,0 +1,44 @@ +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..48c821fb5b --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/nodeResource/__tests__/utils.spec.ts @@ -0,0 +1,55 @@ +import { Identifier } from '~/types'; +import { + validateDefaultCount, + validateMinCount, +} from '~/pages/hardwareProfiles/nodeResource/utils'; + +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); + expect(result).toBe(true); + }); + + it('should return false if defaultCount is less than minCount', () => { + const result = validateDefaultCount({ + ...identifier, + defaultCount: '512Mi', + }); + + expect(result).toBe(false); + }); + + it('should return false if defaultCount is greater than maxCount', () => { + const result = validateDefaultCount({ + ...identifier, + defaultCount: '8Gi', + }); + + expect(result).toBe(false); + }); +}); + +describe('validateMinCount', () => { + it('should return true if minCount is less than maxCount', () => { + const result = validateMinCount(identifier); + expect(result).toBe(true); + }); + + it('should return false if minCount is greater than maxCount', () => { + const result = validateMinCount({ + ...identifier, + minCount: '8Gi', + maxCount: '4Gi', + }); + + 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..49c2d99d52 --- /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: '', + maxCount: '', + defaultCount: '', +}; diff --git a/frontend/src/pages/hardwareProfiles/nodeResource/utils.ts b/frontend/src/pages/hardwareProfiles/nodeResource/utils.ts new file mode 100644 index 0000000000..044da76444 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/nodeResource/utils.ts @@ -0,0 +1,9 @@ +import { Identifier } from '~/types'; +import { isLarger, MEMORY_UNITS_FOR_SELECTION } from '~/utilities/valueUnits'; + +export const validateDefaultCount = (identifier: Identifier): boolean => + isLarger(identifier.defaultCount, identifier.minCount, MEMORY_UNITS_FOR_SELECTION, true) && + isLarger(identifier.maxCount, identifier.defaultCount, MEMORY_UNITS_FOR_SELECTION, true); + +export const validateMinCount = (identifier: Identifier): boolean => + isLarger(identifier.maxCount, identifier.minCount, MEMORY_UNITS_FOR_SELECTION, true); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4289c9988d..cd2f423f5b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -307,9 +307,9 @@ export type Toleration = { export type Identifier = { displayName: string; identifier: string; - minCount: number | string; - maxCount: number | string; - defaultCount: number | string; + minCount: string; + maxCount: string; + defaultCount: string; }; export type NodeSelector = {
{identifier.displayName}{identifier.identifier}{identifier.defaultCount}{identifier.minCount}{identifier.maxCount} + onEdit(identifier), + }, + { isSeparator: true }, + { + title: 'Delete', + onClick: () => onDelete(identifier), + }, + ]} + /> +