diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/registerModelPage.ts b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/registerModelPage.ts index d420cdff09..41f007d7e3 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/registerModelPage.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/registerModelPage.ts @@ -33,6 +33,26 @@ class RegisterModelPage { return cy.get(selector); } + findObjectStorageAutofillButton() { + return cy.findByTestId('object-storage-autofill-button'); + } + + findConnectionAutofillModal() { + return cy.findByTestId('connection-autofill-modal'); + } + + findProjectSelector() { + return this.findConnectionAutofillModal().findByTestId('project-selector-dropdown'); + } + + findConnectionSelector() { + return this.findConnectionAutofillModal().findByTestId('select-data-connection'); + } + + findAutofillButton() { + return cy.findByTestId('autofill-modal-button'); + } + findSubmitButton() { return cy.findByTestId('create-button'); } diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerModel.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerModel.cy.ts index 594f974120..92894b025a 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerModel.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerModel.cy.ts @@ -1,7 +1,13 @@ -import { mockDashboardConfig, mockDscStatus, mockK8sResourceList } from '~/__mocks__'; +import { + mockDashboardConfig, + mockDscStatus, + mockK8sResourceList, + mockProjectK8sResource, + mockSecretK8sResource, +} from '~/__mocks__'; import { mockDsciStatus } from '~/__mocks__/mockDsciStatus'; import { StackCapability, StackComponent } from '~/concepts/areas/types'; -import { ServiceModel } from '~/__tests__/cypress/cypress/utils/models'; +import { ProjectModel, SecretModel, ServiceModel } from '~/__tests__/cypress/cypress/utils/models'; import { FormFieldSelector, registerModelPage, @@ -42,7 +48,13 @@ const initIntercepts = () => { requiredCapabilities: [StackCapability.SERVICE_MESH, StackCapability.SERVICE_MESH_AUTHZ], }), ); - + cy.interceptK8sList( + ProjectModel, + mockK8sResourceList([ + mockProjectK8sResource({ k8sName: 'test-project', displayName: 'Test Project' }), + mockProjectK8sResource({ k8sName: 'test-project-2', displayName: 'Test Project 2' }), + ]), + ); cy.interceptK8sList( ServiceModel, mockK8sResourceList([ @@ -93,6 +105,67 @@ describe('Register model page', () => { registerModelPage.visit(); }); + it('Has Object storage autofill button if Object storage is selected', () => { + registerModelPage.findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE).click(); + registerModelPage + .findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE) + .should('be.checked'); + registerModelPage.findObjectStorageAutofillButton().should('be.visible'); + }); + + it('Does not have Object storage autofill button if Object storage is not selected', () => { + registerModelPage.findFormField(FormFieldSelector.LOCATION_TYPE_URI).click(); + registerModelPage + .findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE) + .should('not.be.checked'); + registerModelPage.findObjectStorageAutofillButton().should('not.exist'); + }); + + it('Can open Object storage autofill modal', () => { + registerModelPage.findConnectionAutofillModal().should('not.exist'); + registerModelPage.findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE).click(); + registerModelPage.findObjectStorageAutofillButton().click(); + registerModelPage.findConnectionAutofillModal().should('exist'); + }); + + it('Project selection with no connections displays message stating no connections available', () => { + registerModelPage.findConnectionAutofillModal().should('not.exist'); + registerModelPage.findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE).click(); + registerModelPage.findObjectStorageAutofillButton().click(); + registerModelPage + .findConnectionSelector() + .contains('Select a project to view its available data connections'); + registerModelPage.findProjectSelector().findDropdownItem('Test Project').click(); + registerModelPage.findConnectionSelector().contains('No available data connections'); + }); + + it('Project selection with connections displays connections and fills form', () => { + cy.interceptK8sList( + SecretModel, + mockK8sResourceList([mockSecretK8sResource({ s3Bucket: 'cmhvZHMtcHVibGlj' })]), + ); + registerModelPage.findConnectionAutofillModal().should('not.exist'); + registerModelPage.findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE).click(); + registerModelPage.findObjectStorageAutofillButton().click(); + registerModelPage + .findConnectionSelector() + .contains('Select a project to view its available data connections'); + registerModelPage.findProjectSelector().findDropdownItem('Test Project').click(); + registerModelPage.findConnectionSelector().contains('Select data connection'); + registerModelPage.findConnectionSelector().findDropdownItem('Test Secret').click(); + registerModelPage.findAutofillButton().click(); + registerModelPage.findConnectionAutofillModal().should('not.exist'); + registerModelPage + .findFormField(FormFieldSelector.LOCATION_ENDPOINT) + .should('have.value', 'https://s3.amazonaws.com/'); + registerModelPage + .findFormField(FormFieldSelector.LOCATION_BUCKET) + .should('have.value', 'rhods-public'); + registerModelPage + .findFormField(FormFieldSelector.LOCATION_REGION) + .should('have.value', 'us-east-1'); + }); + it('Disables submit until required fields are filled in object storage mode', () => { registerModelPage.findSubmitButton().should('be.disabled'); registerModelPage.findFormField(FormFieldSelector.MODEL_NAME).type('Test model name'); diff --git a/frontend/src/concepts/projects/ProjectSelector.tsx b/frontend/src/concepts/projects/ProjectSelector.tsx index e50cf7eddb..153a498f52 100644 --- a/frontend/src/concepts/projects/ProjectSelector.tsx +++ b/frontend/src/concepts/projects/ProjectSelector.tsx @@ -22,6 +22,7 @@ type ProjectSelectorProps = { filterLabel?: string; showTitle?: boolean; selectorLabel?: string; + isFullWidth?: boolean; }; const ProjectSelector: React.FC = ({ @@ -33,6 +34,7 @@ const ProjectSelector: React.FC = ({ filterLabel, showTitle = false, selectorLabel = 'Project', + isFullWidth = false, }) => { const { projects, updatePreferredProject } = React.useContext(ProjectsContext); const selection = projects.find(byName(namespace)); @@ -60,6 +62,7 @@ const ProjectSelector: React.FC = ({ variant={primary ? 'primary' : undefined} onClick={() => setDropdownOpen(!dropdownOpen)} isExpanded={dropdownOpen} + isFullWidth={isFullWidth} data-testid="project-selector-dropdown" > {toggleLabel} diff --git a/frontend/src/pages/modelRegistry/screens/RegisterModel/ConnectionDropdown.tsx b/frontend/src/pages/modelRegistry/screens/RegisterModel/ConnectionDropdown.tsx new file mode 100644 index 0000000000..4828524782 --- /dev/null +++ b/frontend/src/pages/modelRegistry/screens/RegisterModel/ConnectionDropdown.tsx @@ -0,0 +1,101 @@ +import { + Menu, + MenuContent, + MenuItem, + Dropdown, + MenuList, + MenuToggle, + Spinner, +} from '@patternfly/react-core'; +import React from 'react'; +import { DataConnection } from '~/pages/projects/types'; +import { getDataConnectionDisplayName } from '~/pages/projects/screens/detail/data-connections/utils'; +// TODO: temporarily importing across pages so not to interfere with ongoing dataconnection work +import useDataConnections from '~/pages/projects/screens/detail/data-connections/useDataConnections'; + +type ConnectionDropdownProps = { + onSelect: (connectionInfo: DataConnection) => void; + project?: string; + selectedConnection?: DataConnection; +}; +export const ConnectionDropdown = ({ + onSelect, + project, + selectedConnection, +}: ConnectionDropdownProps): React.JSX.Element => { + const [isOpen, setIsOpen] = React.useState(false); + const [connections, connectionsLoaded, connectionsLoadError] = useDataConnections(project); + + const onToggle = () => { + setIsOpen(!isOpen); + }; + + const filteredConnections = connections.filter((c) => c.data.data?.AWS_S3_BUCKET); + + const getToggleContent = () => { + if (!project) { + return 'Select a project to view its available data connections'; + } + if (connectionsLoadError) { + return 'Error loading connections'; + } + if (!connectionsLoaded) { + return ( + <> + Loading Data Connections for the selected project... + + ); + } + if (!filteredConnections.length) { + return 'No available data connections'; + } + if (selectedConnection) { + return getDataConnectionDisplayName(selectedConnection); + } + return 'Select data connection'; + }; + + const onSelectConnection = ( + _event: React.MouseEvent | undefined, + option?: string | number | null, + ) => { + setIsOpen(false); + if (typeof option === 'string') { + const value = connections.find((d) => d.data.metadata.name === option); + if (!value) { + return; + } + onSelect(value); + } + }; + return ( + setIsOpen(isOpened)} + toggle={(toggleRef) => ( + + {getToggleContent()} + + )} + isOpen={isOpen} + > + + + + {filteredConnections.map((dataItem) => ( + + {getDataConnectionDisplayName(dataItem)} + + ))} + + + + + ); +}; diff --git a/frontend/src/pages/modelRegistry/screens/RegisterModel/ConnectionModal.tsx b/frontend/src/pages/modelRegistry/screens/RegisterModel/ConnectionModal.tsx new file mode 100644 index 0000000000..f9f65694f9 --- /dev/null +++ b/frontend/src/pages/modelRegistry/screens/RegisterModel/ConnectionModal.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { Modal, Button, FormGroup, HelperText, Form, FormHelperText } from '@patternfly/react-core'; +import ProjectSelector from '~/concepts/projects/ProjectSelector'; +import { DataConnection } from '~/pages/projects/types'; +import { ConnectionDropdown } from './ConnectionDropdown'; + +export const ConnectionModal: React.FC<{ + isOpen: boolean; + onClose: () => void; + onSubmit: (connection: DataConnection) => void; +}> = ({ isOpen = false, onClose, onSubmit }) => { + const [project, setProject] = React.useState(undefined); + const [connection, setConnection] = React.useState(undefined); + + return ( + { + setProject(undefined); + setConnection(undefined); + onClose(); + }} + actions={[ + , + , + ]} + > +
+ + { + setProject(projectName); + setConnection(undefined); + }} + namespace={project || ''} + invalidDropdownPlaceholder="Select project" + /> + + + + + + Data connection list includes only object storage types that contain a bucket. + + + +
+
+ ); +}; diff --git a/frontend/src/pages/modelRegistry/screens/RegisterModel/RegisterModel.tsx b/frontend/src/pages/modelRegistry/screens/RegisterModel/RegisterModel.tsx index 99d41362ee..6d385c37aa 100644 --- a/frontend/src/pages/modelRegistry/screens/RegisterModel/RegisterModel.tsx +++ b/frontend/src/pages/modelRegistry/screens/RegisterModel/RegisterModel.tsx @@ -24,12 +24,20 @@ import { import spacing from '@patternfly/react-styles/css/utilities/Spacing/spacing'; import { useParams, useNavigate } from 'react-router'; import { Link } from 'react-router-dom'; +import { OptimizeIcon } from '@patternfly/react-icons'; import FormSection from '~/components/pf-overrides/FormSection'; import ApplicationsPage from '~/pages/ApplicationsPage'; import { ModelRegistryContext } from '~/concepts/modelRegistry/context/ModelRegistryContext'; import { useAppSelector } from '~/redux/hooks'; -import { useRegisterModelData, ModelLocationType } from './useRegisterModelData'; +import { DataConnection } from '~/pages/projects/types'; +import { convertAWSSecretData } from '~/pages/projects/screens/detail/data-connections/utils'; +import { + useRegisterModelData, + ModelLocationType, + RegisterVersionFormData, +} from './useRegisterModelData'; import { registerModel } from './utils'; +import { ConnectionModal } from './ConnectionModal'; const RegisterModel: React.FC = () => { const { modelRegistry: mrName } = useParams(); @@ -50,11 +58,11 @@ const RegisterModel: React.FC = () => { modelLocationURI, } = formData; const [loading, setIsLoading] = React.useState(false); - const [error, setError] = React.useState(undefined); + const [formError, setFormError] = React.useState(undefined); + const [isAutofillModalOpen, setAutofillModalOpen] = React.useState(false); const { apiState } = React.useContext(ModelRegistryContext); const author = useAppSelector((state) => state.user || ''); - const isSubmitDisabled = !modelName || !versionName || @@ -65,7 +73,7 @@ const RegisterModel: React.FC = () => { const handleSubmit = () => { setIsLoading(true); - setError(undefined); + setFormError(undefined); registerModel(apiState, formData, author) .then(({ registeredModel }) => { @@ -73,10 +81,22 @@ const RegisterModel: React.FC = () => { }) .catch((e: Error) => { setIsLoading(false); - setError(e); + setFormError(e); }); }; + const connectionDataMap: Record = { + AWS_S3_ENDPOINT: 'modelLocationEndpoint', + AWS_S3_BUCKET: 'modelLocationBucket', + AWS_DEFAULT_REGION: 'modelLocationRegion', + }; + + const fillObjectStorageByConnection = (connection: DataConnection) => { + convertAWSSecretData(connection).forEach((dataItem) => { + setData(connectionDataMap[dataItem.key], dataItem.value); + }); + }; + return ( { title="Model location" description="Specify the model location by providing either the object storage details or the URI." > - { - setData('modelLocationType', ModelLocationType.ObjectStorage); - }} - label="Object storage" - id="location-type-object-storage" - body={ - modelLocationType === ModelLocationType.ObjectStorage && ( -
- - setData('modelLocationEndpoint', value)} - /> - - - setData('modelLocationBucket', value)} - /> - - - setData('modelLocationRegion', value)} - /> - - - - - / - - - - setData('modelLocationPath', value)} - /> - - - - - - Enter a path to a model or folder. This path cannot point to a root - folder. - - - -
- ) - } - /> + + + { + setData('modelLocationType', ModelLocationType.ObjectStorage); + }} + label="Object storage" + id="location-type-object-storage" + /> + + {modelLocationType === ModelLocationType.ObjectStorage && ( + + + + )} + + {modelLocationType === ModelLocationType.ObjectStorage && ( + <> + + setData('modelLocationEndpoint', value)} + /> + + + setData('modelLocationBucket', value)} + /> + + + setData('modelLocationRegion', value)} + /> + + + + + / + + + + setData('modelLocationPath', value)} + /> + + + + + + Enter a path to a model or folder. This path cannot point to a root + folder. + + + + + )} { - {error && ( + {formError && ( setError(undefined)} />} + title={formError.name} + actionClose={ setFormError(undefined)} />} > - {error.message} + {formError.message} )} @@ -325,6 +375,14 @@ const RegisterModel: React.FC = () => { + setAutofillModalOpen(false)} + onSubmit={(connection) => { + fillObjectStorageByConnection(connection); + setAutofillModalOpen(false); + }} + />
); };