diff --git a/frontend/integration-tests/tests/deploy-image.scenario.ts b/frontend/integration-tests/tests/deploy-image.scenario.ts index d0fb8b89102..fc7a9193d8d 100644 --- a/frontend/integration-tests/tests/deploy-image.scenario.ts +++ b/frontend/integration-tests/tests/deploy-image.scenario.ts @@ -1,4 +1,4 @@ -import { browser, $, element, by, ExpectedConditions as until } from 'protractor'; +import { browser, element, by, ExpectedConditions as until } from 'protractor'; import { appHost, checkLogs, checkErrors, testName } from '../protractor.conf'; @@ -35,15 +35,16 @@ describe('Deploy Image', () => { it('can be used to search for an image', async () => { // Put the search term in the search field - await element(by.css('[data-test-id="deploy-image-search-term"]')).sendKeys(imageName); - // Click the search button - await element(by.css('[data-test-id="input-search-field-btn"]')).click(); - // Wait for the results section to appear - await browser.wait(until.presenceOf($('.co-image-name-results__details'))); + await element(by.css('[data-test-id=deploy-image-search-term]')).sendKeys(imageName); + + //remove focus form image search field + await element(by.css('[data-test-id=application-form-app-name]')).click(); + + const helperText = 'form-input-searchTerm-field-helper'; + // Wait for the validation + await browser.wait(until.presenceOf(element(by.id(helperText)))); // Confirm the results appeared - expect( - element(by.cssContainingText('.co-image-name-results__heading', imageName)).isPresent(), - ).toBe(true); + expect(element(by.id(helperText)).isPresent()).toBe(true); }); it('should auto fill in the application', async () => { diff --git a/frontend/packages/console-shared/src/components/formik-fields/InputSearchField.tsx b/frontend/packages/console-shared/src/components/formik-fields/InputSearchField.tsx deleted file mode 100644 index 083c21d17d6..00000000000 --- a/frontend/packages/console-shared/src/components/formik-fields/InputSearchField.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import * as React from 'react'; -import { useField } from 'formik'; -import { FormGroup, InputGroup, TextInput, Button, ButtonVariant } from '@patternfly/react-core'; -import { SearchIcon } from '@patternfly/react-icons'; -import { SearchInputFieldProps } from './field-types'; -import { getFieldId } from './field-utils'; - -const InputSearchField: React.FC = ({ - label, - helpText, - onSearch, - required, - ...props -}) => { - const [field, { touched, error }] = useField(props.name); - const fieldId = getFieldId(props.name, 'input-search'); - const isValid = !(touched && error); - const errorMessage = !isValid ? error : ''; - return ( - - - field.onChange(event)} - onKeyDown={(e: React.KeyboardEvent) => { - if (e.keyCode === 13) { - e.preventDefault(); - e.stopPropagation(); - onSearch(field.value); - } - }} - /> - - - - ); -}; - -export default InputSearchField; diff --git a/frontend/packages/console-shared/src/components/formik-fields/index.ts b/frontend/packages/console-shared/src/components/formik-fields/index.ts index 563e761a2e1..ad37e5e2714 100644 --- a/frontend/packages/console-shared/src/components/formik-fields/index.ts +++ b/frontend/packages/console-shared/src/components/formik-fields/index.ts @@ -3,7 +3,6 @@ export { default as DropdownField } from './DropdownField'; export { default as DroppableFileInputField } from './DroppableFileInputField'; export { default as EnvironmentField } from './EnvironmentField'; export { default as InputField } from './InputField'; -export { default as InputSearchField } from './InputSearchField'; export { default as MultiColumnField } from './multi-column-field/MultiColumnField'; export { default as NSDropdownField } from './NSDropdownField'; export { default as NumberSpinnerField } from './NumberSpinnerField'; diff --git a/frontend/packages/dev-console/integration-tests/tests/dev-perspective.scenario.ts b/frontend/packages/dev-console/integration-tests/tests/dev-perspective.scenario.ts index 381e1548db8..261b46e4b0f 100644 --- a/frontend/packages/dev-console/integration-tests/tests/dev-perspective.scenario.ts +++ b/frontend/packages/dev-console/integration-tests/tests/dev-perspective.scenario.ts @@ -29,7 +29,7 @@ describe('Application Launcher Menu', () => { expect(pageSidebar.getText()).toContain('Topology'); expect(pageSidebar.getText()).toContain('+Add'); expect(pageSidebar.getText()).toContain('Builds'); - expect(pageSidebar.getText()).toContain('Advanced'); + expect(pageSidebar.getText()).toContain('More'); expect(pageSidebar.getText()).toContain('Project Details'); expect(pageSidebar.getText()).toContain('Project Access'); expect(pageSidebar.getText()).toContain('Search'); diff --git a/frontend/packages/dev-console/src/components/import/image-search/ImageSearch.tsx b/frontend/packages/dev-console/src/components/import/image-search/ImageSearch.tsx index eb25846f187..9c2eecdca93 100644 --- a/frontend/packages/dev-console/src/components/import/image-search/ImageSearch.tsx +++ b/frontend/packages/dev-console/src/components/import/image-search/ImageSearch.tsx @@ -3,9 +3,15 @@ import * as _ from 'lodash'; import { k8sCreate } from '@console/internal/module/k8s'; import { ImageStreamImportsModel } from '@console/internal/models'; import { useFormikContext, FormikValues } from 'formik'; -import { TextInputTypes, Alert, AlertActionCloseButton, Button } from '@patternfly/react-core'; +import { + TextInputTypes, + Alert, + AlertActionCloseButton, + Button, + ValidatedOptions, +} from '@patternfly/react-core'; import { SecretTypeAbstraction } from '@console/internal/components/secrets/create-secret'; -import { InputSearchField } from '@console/shared'; +import { InputField } from '@console/shared'; import { getSuggestedName, getPorts, makePortName } from '../../../utils/imagestream-utils'; import { secretModalLauncher } from '../CreateSecretModal'; @@ -13,87 +19,108 @@ const ImageSearch: React.FC = () => { const { values, setFieldValue, dirty } = useFormikContext(); const [newImageSecret, setNewImageSecret] = React.useState(''); const [alertVisible, shouldHideAlert] = React.useState(true); + const [validated, setValidated] = React.useState(ValidatedOptions.default); const namespace = values.project.name; - const handleSearch = React.useCallback( - (searchTerm: string) => { - const importImage = { - kind: 'ImageStreamImport', - apiVersion: 'image.openshift.io/v1', - metadata: { - name: 'newapp', - namespace: values.project.name, - }, - spec: { - import: false, - images: [ - { - from: { - kind: 'DockerImage', - name: _.trim(searchTerm), - }, + const handleSearch = React.useCallback(() => { + const searchTermImage = values.searchTerm; + setFieldValue('isSearchingForImage', true); + setValidated(ValidatedOptions.default); + const importImage = { + kind: 'ImageStreamImport', + apiVersion: 'image.openshift.io/v1', + metadata: { + name: 'newapp', + namespace: values.project.name, + }, + spec: { + import: false, + images: [ + { + from: { + kind: 'DockerImage', + name: _.trim(searchTermImage), }, - ], - }, - status: {}, - }; + }, + ], + }, + status: {}, + }; - k8sCreate(ImageStreamImportsModel, importImage) - .then((imageStreamImport) => { - const status = _.get(imageStreamImport, 'status.images[0].status'); - if (status.status === 'Success') { - const name = _.get(imageStreamImport, 'spec.images[0].from.name'); - const image = _.get(imageStreamImport, 'status.images[0].image'); - const tag = _.get(imageStreamImport, 'status.images[0].tag'); - const isi = { name, image, tag, status }; - const ports = getPorts(isi); - setFieldValue('isSearchingForImage', false); - setFieldValue('isi.name', name); - setFieldValue('isi.image', image); - setFieldValue('isi.tag', tag); - setFieldValue('isi.status', status); - setFieldValue('isi.ports', ports); - setFieldValue('image.ports', ports); - setFieldValue('image.tag', tag); - !values.name && setFieldValue('name', getSuggestedName(name)); - !values.application.name && - setFieldValue('application.name', `${getSuggestedName(name)}-app`); - // set default port value - const targetPort = _.head(ports); - targetPort && setFieldValue('route.targetPort', makePortName(targetPort)); - } else { - setFieldValue('isSearchingForImage', false); - setFieldValue('isi', {}); - setFieldValue('isi.status', status.message); - setFieldValue('route.targetPort', null); - } - }) - .catch((error) => { - setFieldValue('isi', {}); - setFieldValue('isi.status', error.message); + k8sCreate(ImageStreamImportsModel, importImage) + .then((imageStreamImport) => { + const status = _.get(imageStreamImport, 'status.images[0].status'); + if (status.status === 'Success') { + const name = _.get(imageStreamImport, 'spec.images[0].from.name'); + const image = _.get(imageStreamImport, 'status.images[0].image'); + const tag = _.get(imageStreamImport, 'status.images[0].tag'); + const isi = { name, image, tag, status }; + const ports = getPorts(isi); setFieldValue('isSearchingForImage', false); - }); - }, - [setFieldValue, values.application.name, values.name, values.project.name], - ); + setFieldValue('isi.name', name); + setFieldValue('isi.image', image); + setFieldValue('isi.tag', tag); + setFieldValue('isi.status', status); + setFieldValue('isi.ports', ports); + setFieldValue('image.ports', ports); + setFieldValue('image.tag', tag); + !values.name && setFieldValue('name', getSuggestedName(name)); + !values.application.name && + setFieldValue('application.name', `${getSuggestedName(name)}-app`); + // set default port value + const targetPort = _.head(ports); + targetPort && setFieldValue('route.targetPort', makePortName(targetPort)); + setValidated(ValidatedOptions.success); + } else { + setFieldValue('isSearchingForImage', false); + setFieldValue('isi', {}); + setFieldValue('isi.status', status.message); + setFieldValue('route.targetPort', null); + setValidated(ValidatedOptions.error); + } + }) + .catch((error) => { + setFieldValue('isi', {}); + setFieldValue('isi.status', error.message); + setFieldValue('isSearchingForImage', false); + setValidated(ValidatedOptions.error); + }); + }, [setFieldValue, values.application.name, values.name, values.project.name, values.searchTerm]); const handleSave = (name: string) => { setNewImageSecret(name); - values.searchTerm && handleSearch(values.searchTerm); + values.searchTerm && handleSearch(); }; + const getHelpText = () => { + if (values.isSearchingForImage) { + return 'Validating...'; + } + if (!values.isSearchingForImage && validated === ValidatedOptions.success) { + return 'Validated'; + } + return ''; + }; + + const helpTextInvalid = validated === ValidatedOptions.error && ( + {values.searchTerm === '' ? 'Required' : values.isi.status} + ); + React.useEffect(() => { - !dirty && values.searchTerm && handleSearch(values.searchTerm); + !dirty && values.searchTerm && handleSearch(); }, [dirty, handleSearch, values.searchTerm]); return ( <> -
diff --git a/frontend/packages/dev-console/src/components/import/image-search/ImageSearchSection.tsx b/frontend/packages/dev-console/src/components/import/image-search/ImageSearchSection.tsx index 38d4e13689e..e8e1b734b28 100644 --- a/frontend/packages/dev-console/src/components/import/image-search/ImageSearchSection.tsx +++ b/frontend/packages/dev-console/src/components/import/image-search/ImageSearchSection.tsx @@ -5,8 +5,6 @@ import FormSection from '../section/FormSection'; import { imageRegistryType } from '../../../utils/imagestream-utils'; import ImageStream from './ImageStream'; import ImageSearch from './ImageSearch'; -import SearchStatus from './SearchStatus'; -import SearchResults from './SearchResults'; const ImageSearchSection: React.FC = () => { const { values, setFieldValue, initialValues } = useFormikContext(); @@ -49,8 +47,6 @@ const ImageSearchSection: React.FC = () => { }, ]} /> - - ); }; diff --git a/frontend/packages/dev-console/src/components/import/image-search/ImageStream.tsx b/frontend/packages/dev-console/src/components/import/image-search/ImageStream.tsx index e94742b2e16..52b33af307c 100644 --- a/frontend/packages/dev-console/src/components/import/image-search/ImageStream.tsx +++ b/frontend/packages/dev-console/src/components/import/image-search/ImageStream.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import * as _ from 'lodash'; -import { Alert } from '@patternfly/react-core'; +import { Alert, FormGroup, ValidatedOptions } from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; import { useFormikContext, FormikValues } from 'formik'; import { CheckboxField } from '@console/shared'; import { K8sResourceKind } from '@console/internal/module/k8s'; @@ -45,9 +46,10 @@ export const ImageStreamReducer = (state: ImageStreamState, action: ImageStreamA const ImageStream: React.FC = () => { const { - values: { imageStream, project, registry }, + values: { imageStream, project, registry, isi }, setFieldValue, } = useFormikContext(); + const [validated, setValidated] = React.useState(ValidatedOptions.default); const [state, dispatch] = React.useReducer(ImageStreamReducer, initialState); const [hasImageStreams, setHasImageStreams] = React.useState(false); const { @@ -75,23 +77,37 @@ const ImageStream: React.FC = () => { registry === RegistryType.Internal && imageStream.namespace !== BuilderImagesNamespace.Openshift && project.name !== imageStream.namespace; + const helperTextInvalid = validated === ValidatedOptions.error && ( + <> + +  {isi.status} + + ); return ( <> - -
-
- -
-
- -
/
-
-
- -
:
+ + +
+
+ +
+
+ +
/
+
+
+ +
:
+
-
+ {isNamespaceSelected && isImageStreamSelected && !isTagsAvailable && hasCreateAccess && (
diff --git a/frontend/packages/dev-console/src/components/import/image-search/ImageStreamTagDropdown.tsx b/frontend/packages/dev-console/src/components/import/image-search/ImageStreamTagDropdown.tsx index a223e2aa3db..7cbee1ce6c1 100644 --- a/frontend/packages/dev-console/src/components/import/image-search/ImageStreamTagDropdown.tsx +++ b/frontend/packages/dev-console/src/components/import/image-search/ImageStreamTagDropdown.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import * as _ from 'lodash'; import { useFormikContext, FormikValues } from 'formik'; +import { ValidatedOptions } from '@patternfly/react-core'; import { DropdownField } from '@console/shared'; import { k8sGet, K8sResourceKind } from '@console/internal/module/k8s'; import { ImageStreamTagModel } from '@console/internal/models'; @@ -17,9 +18,8 @@ const ImageStreamTagDropdown: React.FC = () => { const { values: { imageStream, application, formType }, setFieldValue, - setFieldError, } = useFormikContext(); - const { state, hasImageStreams } = React.useContext(ImageStreamContext); + const { state, hasImageStreams, setValidated } = React.useContext(ImageStreamContext); const { selectedImageStream, accessLoading, loading } = state; imageStreamTagList = getImageStreamTags(selectedImageStream as K8sResourceKind); const isNamespaceSelected = imageStream.namespace !== '' && !accessLoading; @@ -47,11 +47,13 @@ const ImageStreamTagDropdown: React.FC = () => { // set default port value const targetPort = _.head(ports); targetPort && setFieldValue('route.targetPort', makePortName(targetPort)); + setValidated(ValidatedOptions.success); }) .catch((error) => { - setFieldError('isi.image', error.message); setFieldValue('isi', {}); + setFieldValue('isi.status', error.message); setFieldValue('isSearchingForImage', false); + setValidated(ValidatedOptions.error); }); }, [ @@ -60,7 +62,7 @@ const ImageStreamTagDropdown: React.FC = () => { imageStream.namespace, formType, application.name, - setFieldError, + setValidated, ], ); diff --git a/frontend/packages/dev-console/src/components/import/image-search/SearchResults.tsx b/frontend/packages/dev-console/src/components/import/image-search/SearchResults.tsx deleted file mode 100644 index d8d3326c395..00000000000 --- a/frontend/packages/dev-console/src/components/import/image-search/SearchResults.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import * as React from 'react'; -import * as _ from 'lodash'; -import { CubeIcon } from '@patternfly/react-icons'; -import { Timestamp, units } from '@console/internal/components/utils'; -import { Alert } from '@patternfly/react-core'; -import { useFormikContext, FormikValues } from 'formik'; - -const runsAsRoot = (image) => { - const user = _.get(image, 'dockerImageMetadata.Config.User'); - return !user || user === '0' || user === 'root'; -}; - -const SearchResults: React.FC = () => { - const { values } = useFormikContext(); - - const ImagePorts = ({ ports }) => ( - <> - {_.size(ports) > 1 ? 'Ports ' : 'Port '} - {_.map(ports, (port) => `${port.containerPort}/${port.protocol.toUpperCase()}`).join( - ', ', - )}{' '} - will be load balanced by Service {values.name || ''}. -
- Other containers can access this service through the hostname{' '} - {values.name || ''}. -
- - ); - - return !_.isEmpty(values.isi.image) ? ( -
-
- {runsAsRoot(values.isi.image) && ( - - Image {values.isi.name} runs as the root user which - might not be permitted by your cluster administrator. - - )} -
-
- -
-
-

- {values.isi.name} - - {_.get(values.isi, 'result.ref.registry') && ( - from {values.isi.result.ref.registry}, - )} - {_.get(values.isi, 'image.dockerImageMetadata.Created') && ( - - )} - {_.get(values.isi, 'image.dockerImageMetadata.Size') && ( - - { - units.humanize(values.isi.image.dockerImageMetadata.Size, 'binaryBytes', true) - .string - } - ,{' '} - - )} - {_.size(values.isi.image.dockerImageLayers)} layers - -

-
    - {!values.isi.namespace && ( -
  • - Image Stream{' '} - - {values.name || ''}:{values.isi.tag || 'latest'} - {' '} - will track this image. -
  • - )} -
  • - This image will be deployed in Deployment Config{' '} - {values.name || ''}. -
  • - {values.isi.ports && ( -
  • - -
  • - )} -
- {!_.isEmpty(_.get(values.isi, 'image.dockerImageMetadata.Config.Volumes')) && ( -

- This image declares volumes and will default to use non-persistent, host-local - storage. You can add persistent storage later to the deployment config. -

- )} -
-
-
-
- ) : null; -}; - -export default SearchResults; diff --git a/frontend/packages/dev-console/src/components/import/image-search/SearchStatus.tsx b/frontend/packages/dev-console/src/components/import/image-search/SearchStatus.tsx deleted file mode 100644 index b2248b44662..00000000000 --- a/frontend/packages/dev-console/src/components/import/image-search/SearchStatus.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from 'react'; -import * as _ from 'lodash'; -import { Loading } from '@console/internal/components/utils'; -import { useFormikContext, FormikValues } from 'formik'; -import { RedExclamationCircleIcon } from '@console/shared'; - -const SearchStatus: React.FC = () => { - const { values, errors } = useFormikContext(); - const isiError = _.get(errors, 'isi.image'); - const isiStatus = values?.isi?.status; - - return _.isEmpty(values.isi.image) ? ( -
-
- {values.isSearchingForImage && } - {!values.isSearchingForImage && !isiError && ( -

- Enter an image name OR select an image stream tag. -

- )} - {!values.isSearchingForImage && isiError && ( - <> -

- Could not load image metadata. -

-

{isiStatus || isiError}

- - )} -
-
- ) : null; -}; - -export default SearchStatus; diff --git a/frontend/packages/dev-console/src/components/import/import-types.ts b/frontend/packages/dev-console/src/components/import/import-types.ts index 33be3b5d0af..b4314d26c74 100644 --- a/frontend/packages/dev-console/src/components/import/import-types.ts +++ b/frontend/packages/dev-console/src/components/import/import-types.ts @@ -30,6 +30,7 @@ export interface ImageStreamContextProps { dispatch: React.Dispatch; hasImageStreams: boolean; setHasImageStreams: (value: boolean) => void; + setValidated: (validated: ValidatedOptions) => void; } export interface SourceToImageFormProps { builderImages?: NormalizedBuilderImages;