diff --git a/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts b/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts new file mode 100644 index 0000000000..0b4d98a250 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts @@ -0,0 +1,69 @@ +import { TableRow } from './components/table'; + +class CreateConnectionTypeTableRow extends TableRow { + findSectionHeading() { + return this.find().findByTestId('section-heading'); + } + + findName() { + return this.find().findByTestId('field-name'); + } + + findType() { + return this.find().findByTestId('field-type'); + } + + findDefault() { + return this.find().findByTestId('field-default'); + } + + findEnvVar() { + return this.find().findByTestId('field-env'); + } + + findRequired() { + return this.find().findByTestId('field-required'); + } +} + +class CreateConnectionTypePage { + visitCreatePage() { + cy.visitWithLogin('/connectionTypes/create'); + cy.findAllByText('Create connection type').should('exist'); + } + + visitDuplicatePage(name = 'existing') { + cy.visitWithLogin(`/connectionTypes/duplicate/${name}`); + cy.findAllByText('Create connection type').should('exist'); + } + + connectionTypeName() { + return cy.findByTestId('connection-type-name'); + } + + connectionTypeDesc() { + return cy.findByTestId('connection-type-description'); + } + + connectionTypeEnable() { + return cy.findByTestId('connection-type-enable'); + } + + findFieldsTable() { + return cy.findByTestId('connection-type-fields-table'); + } + + findAllFieldsTableRows() { + return this.findFieldsTable().findAllByTestId('row'); + } + + getFieldsTableRow(index: number) { + return new CreateConnectionTypeTableRow(() => this.findAllFieldsTableRows().eq(index)); + } + + findSubmitButton() { + return cy.findByTestId('submit-button'); + } +} + +export const createConnectionTypePage = new CreateConnectionTypePage(); diff --git a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts index 51838eb9a3..452ac6275c 100644 --- a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts +++ b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts @@ -50,6 +50,7 @@ import type { } from '~/concepts/pipelines/kfTypes'; import type { GrpcResponse } from '~/__mocks__/mlmd/utils'; import type { BuildMockPipelinveVersionsType } from '~/__mocks__'; +import type { ConnectionTypeConfigMap } from '~/concepts/connectionTypes/types'; type SuccessErrorResponse = { success: boolean; @@ -581,6 +582,24 @@ declare global { path: { namespace: string }; }, response: OdhResponse, + ) => Cypress.Chainable) & + (( + type: 'GET /api/connection-types', + response: ConnectionTypeConfigMap[], + ) => Cypress.Chainable) & + (( + type: 'PATCH /api/connection-types/:name', + options: { + path: { name: string }; + }, + response: { success: boolean; error: string }, + ) => Cypress.Chainable) & + (( + type: 'GET /api/connection-types/:name', + options: { + path: { name: string }; + }, + response: ConnectionTypeConfigMap, ) => Cypress.Chainable); } } diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts new file mode 100644 index 0000000000..2478475491 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts @@ -0,0 +1,84 @@ +import { + mockConnectionTypeConfigMap, + mockConnectionTypeConfigMapObj, +} from '~/__mocks__/mockConnectionType'; +import { createConnectionTypePage } from '~/__tests__/cypress/cypress/pages/connectionTypes'; +import { asClusterAdminUser } from '~/__tests__/cypress/cypress/utils/mockUsers'; + +describe('create', () => { + it('Display base page', () => { + asClusterAdminUser(); + createConnectionTypePage.visitCreatePage(); + + createConnectionTypePage.connectionTypeName().should('exist'); + createConnectionTypePage.connectionTypeDesc().should('exist'); + createConnectionTypePage.connectionTypeEnable().should('exist'); + createConnectionTypePage.findFieldsTable().should('exist'); + }); + + it('Allows create button with valid name', () => { + asClusterAdminUser(); + createConnectionTypePage.visitCreatePage(); + + createConnectionTypePage.connectionTypeName().should('have.value', ''); + createConnectionTypePage.findSubmitButton().should('be.disabled'); + + createConnectionTypePage.connectionTypeName().type('hello'); + createConnectionTypePage.findSubmitButton().should('be.enabled'); + }); +}); + +describe('duplicate', () => { + const existing = mockConnectionTypeConfigMapObj({ name: 'existing' }); + + beforeEach(() => { + asClusterAdminUser(); + cy.interceptOdh( + 'GET /api/connection-types/:name', + { path: { name: 'existing' } }, + mockConnectionTypeConfigMap({ name: 'existing' }), + ); + }); + + it('Prefill details from existing connection', () => { + createConnectionTypePage.visitDuplicatePage('existing'); + + createConnectionTypePage + .connectionTypeName() + .should( + 'have.value', + `Duplicate of ${existing.metadata.annotations['openshift.io/display-name']}`, + ); + createConnectionTypePage + .connectionTypeDesc() + .should('have.value', existing.metadata.annotations['openshift.io/description']); + createConnectionTypePage.connectionTypeEnable().should('be.checked'); + }); + + it('Prefill fields table from existing connection', () => { + createConnectionTypePage.visitDuplicatePage('existing'); + + createConnectionTypePage + .findAllFieldsTableRows() + .should('have.length', existing.data?.fields?.length); + + // Row 0 - Section + const row0 = createConnectionTypePage.getFieldsTableRow(0); + row0.findName().should('contain.text', 'Short text'); + row0.findSectionHeading().should('exist'); + + // Row 1 - Short text field + const row1 = createConnectionTypePage.getFieldsTableRow(1); + row1.findName().should('contain.text', 'Short text 1'); + row1.findType().should('have.text', 'Short text'); + row1.findDefault().should('have.text', '-'); + row1.findRequired().not('be.checked'); + + // Row 2 - Short text field + const row2 = createConnectionTypePage.getFieldsTableRow(2); + row2.findName().should('contain.text', 'Short text 2'); + row2.findType().should('have.text', 'Short text'); + row2.findDefault().should('have.text', 'This is the default value'); + row2.findRequired().should('be.checked'); + }); +}); diff --git a/frontend/src/app/AppRoutes.tsx b/frontend/src/app/AppRoutes.tsx index cc6ee4516d..bd227c0b21 100644 --- a/frontend/src/app/AppRoutes.tsx +++ b/frontend/src/app/AppRoutes.tsx @@ -14,6 +14,7 @@ import { useCheckJupyterEnabled } from '~/utilities/notebookControllerUtils'; import { SupportedArea } from '~/concepts/areas'; import useIsAreaAvailable from '~/concepts/areas/useIsAreaAvailable'; import ModelRegistrySettingsRoutes from '~/pages/modelRegistrySettings/ModelRegistrySettingsRoutes'; +import ConnectionTypeRoutes from '~/pages/connectionTypes/ConnectionTypeRoutes'; const HomePage = React.lazy(() => import('../pages/home/Home')); @@ -125,6 +126,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> )} diff --git a/frontend/src/concepts/connectionTypes/__tests__/utils.spec.ts b/frontend/src/concepts/connectionTypes/__tests__/utils.spec.ts index 3cabf41ca8..cf93a5b726 100644 --- a/frontend/src/concepts/connectionTypes/__tests__/utils.spec.ts +++ b/frontend/src/concepts/connectionTypes/__tests__/utils.spec.ts @@ -1,7 +1,8 @@ import { mockConnectionTypeConfigMapObj } from '~/__mocks__/mockConnectionType'; -import { DropdownField, HiddenField, TextField } from '~/concepts/connectionTypes/types'; +import { DropdownField, HiddenField, TextField, UriField } from '~/concepts/connectionTypes/types'; import { defaultValueToString, + fieldTypeToString, toConnectionTypeConfigMap, toConnectionTypeConfigMapObj, } from '~/concepts/connectionTypes/utils'; @@ -201,3 +202,26 @@ describe('defaultValueToString', () => { ).toBe('Two, Three'); }); }); + +describe('fieldTypeToString', () => { + it('should return default value as string', () => { + expect( + fieldTypeToString({ + type: 'text', + name: 'test', + envVar: 'test', + properties: {}, + } satisfies TextField), + ).toBe('Text'); + expect( + fieldTypeToString({ + type: 'uri', + name: 'test', + envVar: 'test', + properties: { + defaultValue: '', + }, + } satisfies UriField), + ).toBe('URI'); + }); +}); diff --git a/frontend/src/concepts/connectionTypes/useConnectionType.ts b/frontend/src/concepts/connectionTypes/useConnectionType.ts new file mode 100644 index 0000000000..49462210e6 --- /dev/null +++ b/frontend/src/concepts/connectionTypes/useConnectionType.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { ConnectionTypeConfigMapObj } from '~/concepts/connectionTypes/types'; +import { fetchConnectionType } from '~/services/connectionTypesService'; + +export const useConnectionType = ( + name?: string, +): [boolean, Error | undefined, ConnectionTypeConfigMapObj | undefined] => { + const [loaded, setLoaded] = React.useState(false); + const [error, setError] = React.useState(); + const [connectionType, setConnectionType] = React.useState(); + + React.useEffect(() => { + if (name) { + fetchConnectionType(name) + .then((res) => { + setLoaded(true); + setConnectionType(res); + }) + .catch((err) => { + setLoaded(true); + setError(err); + }); + } + }, [name]); + + return [loaded, error, connectionType]; +}; diff --git a/frontend/src/concepts/connectionTypes/utils.ts b/frontend/src/concepts/connectionTypes/utils.ts index 6ed8b7b535..473fc7cac8 100644 --- a/frontend/src/concepts/connectionTypes/utils.ts +++ b/frontend/src/concepts/connectionTypes/utils.ts @@ -46,3 +46,14 @@ export const defaultValueToString = ( } return defaultValue == null ? defaultValue : `${defaultValue}`; }; + +export const fieldTypeToString = (field: T): string => { + if (field.type === ConnectionTypeFieldType.URI) { + return field.type.toUpperCase(); + } + + const withSpaces = field.type.replace(/-/g, ' '); + const withCapitalized = withSpaces[0].toUpperCase() + withSpaces.slice(1); + + return withCapitalized; +}; diff --git a/frontend/src/concepts/k8s/NameDescriptionField.tsx b/frontend/src/concepts/k8s/NameDescriptionField.tsx index e8affd3f58..53d07703e5 100644 --- a/frontend/src/concepts/k8s/NameDescriptionField.tsx +++ b/frontend/src/concepts/k8s/NameDescriptionField.tsx @@ -16,10 +16,13 @@ import { isValidK8sName, translateDisplayNameForK8s } from '~/concepts/k8s/utils type NameDescriptionFieldProps = { nameFieldId: string; + nameFieldLabel?: string; descriptionFieldId: string; + descriptionFieldLabel?: string; data: NameDescType; setData?: (data: NameDescType) => void; autoFocusName?: boolean; + K8sLabelName?: string; showK8sName?: boolean; disableK8sName?: boolean; maxLength?: number; @@ -29,10 +32,13 @@ type NameDescriptionFieldProps = { const NameDescriptionField: React.FC = ({ nameFieldId, + nameFieldLabel = 'Name', descriptionFieldId, + descriptionFieldLabel = 'Description', data, setData, autoFocusName, + K8sLabelName = 'Resource name', showK8sName, disableK8sName, maxLength, @@ -58,7 +64,7 @@ const NameDescriptionField: React.FC = ({ return ( - + = ({ {showK8sName && ( = ({ )} - +