diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json index 3880f59e2d3..e496f7453ab 100644 --- a/frontend/.vscode/settings.json +++ b/frontend/.vscode/settings.json @@ -22,7 +22,9 @@ "eslint.enable": true, "prettier.eslintIntegration": true, - "javascript.validate.enable": false + "javascript.validate.enable": false, + "debug.node.autoAttach": "on", + "typescript.tsdk": "node_modules/typescript/lib" // TODO support prettier + stylelint // "prettier.stylelintIntegration": true, diff --git a/frontend/package.json b/frontend/package.json index 893f45a4724..858821b2eaa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -70,8 +70,10 @@ "brace": "0.11.x", "classnames": "2.x", "core-js": "2.x", + "d3": "^5.9.2", "file-saver": "1.3.x", "font-awesome": "4.7.x", + "formik": "2.0.1-rc.1", "fuzzysearch": "1.0.x", "history": "4.x", "immutable": "3.x", @@ -95,6 +97,7 @@ "react-jsonschema-form": "^1.0.4", "react-lightweight-tooltip": "1.x", "react-linkify": "^0.2.2", + "react-measure": "^2.2.6", "react-modal": "3.x", "react-redux": "5.x", "react-router-dom": "4.3.x", @@ -111,7 +114,8 @@ "url-polyfill": "^1.1.5", "url-search-params-polyfill": "2.x", "whatwg-fetch": "2.x", - "xterm": "^3.12.2" + "xterm": "^3.12.2", + "yup": "^0.27.0" }, "devDependencies": { "@types/classnames": "^2.2.7", @@ -156,6 +160,7 @@ "protractor-fail-fast": "3.x", "protractor-jasmine2-screenshot-reporter": "0.5.x", "read-pkg": "5.x", + "redux-mock-store": "^1.5.3", "resolve-url-loader": "2.x", "sass-loader": "6.x", "thread-loader": "1.x", diff --git a/frontend/packages/console-app/package.json b/frontend/packages/console-app/package.json index 45f8cdfc370..d65fd8fe41c 100644 --- a/frontend/packages/console-app/package.json +++ b/frontend/packages/console-app/package.json @@ -8,6 +8,7 @@ "test": "yarn --cwd ../.. run test packages/console-app" }, "dependencies": { + "@console/dev-console": "0.0.0-fixed", "@console/internal": "0.0.0-fixed", "@console/plugin-sdk": "0.0.0-fixed", "@console/shared": "0.0.0-fixed" diff --git a/frontend/packages/console-app/src/plugin.tsx b/frontend/packages/console-app/src/plugin.tsx index 384a08b656c..9018c2796c2 100644 --- a/frontend/packages/console-app/src/plugin.tsx +++ b/frontend/packages/console-app/src/plugin.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { CogIcon } from '@patternfly/react-icons'; +import { CogsIcon } from '@patternfly/react-icons'; import { Plugin, Perspective } from '@console/plugin-sdk'; type ConsumedExtensions = Perspective; @@ -10,7 +10,7 @@ const plugin: Plugin = [ properties: { id: 'admin', name: 'Administrator', - icon: , + icon: , landingPageURL: '/', default: true, }, diff --git a/frontend/packages/console-plugin-sdk/src/typings/pages.ts b/frontend/packages/console-plugin-sdk/src/typings/pages.ts index 1f26afc4f0b..8adcbb0522c 100644 --- a/frontend/packages/console-plugin-sdk/src/typings/pages.ts +++ b/frontend/packages/console-plugin-sdk/src/typings/pages.ts @@ -1,9 +1,9 @@ import * as React from 'react'; -import { RouteProps, RouteComponentProps } from 'react-router'; +import { RouteProps, RouteComponentProps } from 'react-router-dom'; import { K8sKind, K8sResourceKindReference } from '@console/internal/module/k8s'; import { Extension } from './extension'; -type LazyLoader = () => Promise>; +type LazyLoader = () => Promise>>; namespace ExtensionProperties { export interface ResourcePage { @@ -15,26 +15,26 @@ namespace ExtensionProperties { export type ResourceListPage = ResourcePage<{ /** See https://reacttraining.com/react-router/web/api/match */ - match?: RouteComponentProps['match']; + match: RouteComponentProps['match']; /** The resource kind scope. */ - kind?: K8sResourceKindReference; + kind: K8sResourceKindReference; /** Whether the page should assign focus when loaded. */ - autoFocus?: boolean; + autoFocus: boolean; /** Whether the page should mock the UI empty state. */ - mock?: boolean; + mock: boolean; /** The namespace scope. */ - namespace?: string; + namespace: string; }>; export type ResourceDetailsPage = ResourcePage<{ /** See https://reacttraining.com/react-router/web/api/match */ - match?: RouteComponentProps['match']; + match: RouteComponentProps['match']; /** The resource kind scope. */ - kind?: K8sResourceKindReference; + kind: K8sResourceKindReference; /** The namespace scope. */ - namespace?: string; + namespace: string; /** The page name. */ - name?: string; + name: string; }>; // Maps to react-router#https://reacttraining.com/react-router/web/api/Route diff --git a/frontend/packages/dev-console/package.json b/frontend/packages/dev-console/package.json index ca43fcb5a2c..c2d06821e9b 100644 --- a/frontend/packages/dev-console/package.json +++ b/frontend/packages/dev-console/package.json @@ -5,5 +5,8 @@ "private": true, "dependencies": { "@console/plugin-sdk": "0.0.0-fixed" + }, + "consolePlugin": { + "entry": "src/plugin.tsx" } } diff --git a/frontend/packages/dev-console/src/components/AddPage.tsx b/frontend/packages/dev-console/src/components/AddPage.tsx new file mode 100644 index 00000000000..653f0bc59e8 --- /dev/null +++ b/frontend/packages/dev-console/src/components/AddPage.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { Helmet } from 'react-helmet'; +import ODCEmptyState from './EmptyState'; +import NamespacedPage from './NamespacedPage'; + +const AddPage: React.FC = () => ( + + + +Add + + + + + +); + +export default AddPage; diff --git a/frontend/packages/dev-console/src/components/EmptyState.scss b/frontend/packages/dev-console/src/components/EmptyState.scss new file mode 100644 index 00000000000..35ec4f1dd45 --- /dev/null +++ b/frontend/packages/dev-console/src/components/EmptyState.scss @@ -0,0 +1,18 @@ +.odc-empty-state { + // work around status-box injecting intermediate node preventing pages from controling the height of their content + &__title { + background-color: var(--pf-global--BackgroundColor--light-100); + display: flex; + flex-direction: column; + } + + &__content { + padding: var(--pf-global--spacer--lg); + flex: 1; + } + + &__card { + height: 100%; + text-align: center; + } +} diff --git a/frontend/packages/dev-console/src/components/EmptyState.tsx b/frontend/packages/dev-console/src/components/EmptyState.tsx new file mode 100644 index 00000000000..8a83e547e14 --- /dev/null +++ b/frontend/packages/dev-console/src/components/EmptyState.tsx @@ -0,0 +1,100 @@ +import * as React from 'react'; +import { Card, CardBody, CardHeader, CardFooter, Grid, GridItem } from '@patternfly/react-core'; +import { connect } from 'react-redux'; +import { Link } from 'react-router-dom'; +import { formatNamespacedRouteForResource } from '@console/internal/actions/ui'; +import { PageHeading } from '@console/internal/components/utils'; +import './EmptyState.scss'; + +interface StateProps { + activeNamespace: string; +} + +export interface EmptySProps { + title: string; +} + +type Props = EmptySProps & StateProps; + +const ODCEmptyState: React.FunctionComponent = ({ title, activeNamespace }) => ( + +
+ +
+
+ + + + Import from Git + Import code from your git repository to be built and deployed + + + Import from Git + + + + + + + Browse Catalog + Browse the catalog to discover, deploy and connect to services + + + Browse Catalog + + + + + + + Deploy Image + Deploy an existing image from an image registry or image stream tag + + + Deploy Image + + + + + + + Import YAML + Create or replace resources from their YAML or JSON definitions. + + + Import YAML + + + + + + + Add Database + + Browse the catalog to discover database services to add to your application + + + + Add Database + + + + + +
+
+); + +const mapStateToProps = (state): StateProps => { + return { + activeNamespace: state.UI.get('activeNamespace'), + }; +}; + +export default connect(mapStateToProps)(ODCEmptyState); diff --git a/frontend/packages/dev-console/src/components/NamespacedPage.scss b/frontend/packages/dev-console/src/components/NamespacedPage.scss new file mode 100644 index 00000000000..20859b68b76 --- /dev/null +++ b/frontend/packages/dev-console/src/components/NamespacedPage.scss @@ -0,0 +1,23 @@ +.odc-namespaced-page { + height: 100%; + max-height: 100%; + display: flex; + flex-direction: column; + + &__content { + flex-grow: 1; + position: relative; + overflow: auto; + background-color: var(--pf-global--Color--light-200); + } + + // Override styles of namespace bar to support the addition of the application selector on mobile + & .co-namespace { + &-selector { + max-width: 100%; + } + &-bar__items { + flex-wrap: wrap; + } + } +} diff --git a/frontend/packages/dev-console/src/components/NamespacedPage.tsx b/frontend/packages/dev-console/src/components/NamespacedPage.tsx new file mode 100644 index 00000000000..865c557015b --- /dev/null +++ b/frontend/packages/dev-console/src/components/NamespacedPage.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { NamespaceBar } from '@console/internal/components/namespace'; +import ApplicationSelector from './dropdown/ApplicationSelector'; + +import './NamespacedPage.scss'; + +const NamespacedPage: React.FC = ({ children }) => ( +
+ + + +
{children}
+
+); + +export default NamespacedPage; diff --git a/frontend/packages/dev-console/src/components/__tests__/EmptyState.spec.tsx b/frontend/packages/dev-console/src/components/__tests__/EmptyState.spec.tsx new file mode 100644 index 00000000000..79179cc2374 --- /dev/null +++ b/frontend/packages/dev-console/src/components/__tests__/EmptyState.spec.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { Map as ImmutableMap } from 'immutable'; +import { shallow } from 'enzyme'; +import ConnectedEmptyStateComponent from '../EmptyState'; +import { getStoreTypedComponent } from '../../test/test-utils'; + +describe('EmptyState', () => { + const mockStore = configureMockStore(); + const ConnectedComponent = getStoreTypedComponent(ConnectedEmptyStateComponent); + + it('should pass activeNamespace from state as prop', () => { + const store = mockStore({ + UI: ImmutableMap({ + activeNamespace: 'project', + }), + }); + + const topologyWrapper = shallow(); + + expect(topologyWrapper.props().activeNamespace).toEqual('project'); + }); +}); diff --git a/frontend/packages/dev-console/src/components/dropdown/AppNameSelector.tsx b/frontend/packages/dev-console/src/components/dropdown/AppNameSelector.tsx new file mode 100644 index 00000000000..c30f32d0829 --- /dev/null +++ b/frontend/packages/dev-console/src/components/dropdown/AppNameSelector.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { FormGroup, ControlLabel, FormControl, HelpBlock } from 'patternfly-react'; +import ApplicationDropdown from './ApplicationDropdown'; + +const CREATE_APPLICATION_KEY = 'create-application-key'; + +interface AppNameSelectorProps { + namespace?: string; + application: string; + selectedKey: string; + onChange?: (name: string, key: string) => void; +} + +const AppNameSelector: React.FC = ({ + application, + namespace, + selectedKey, + onChange, +}) => { + const onDropdownChange = (appName: string, key: string) => { + if (key === CREATE_APPLICATION_KEY) { + onChange('', key); + } else { + onChange(appName, key); + } + }; + + const onInputChange: React.ReactEventHandler = (event) => { + onChange(event.currentTarget.value, selectedKey); + }; + + return ( + + + Application + + + {selectedKey === CREATE_APPLICATION_KEY ? ( + + Application Name + + Names the application. + + ) : null} + + ); +}; + +export default AppNameSelector; diff --git a/frontend/packages/dev-console/src/components/dropdown/ApplicationDropdown.tsx b/frontend/packages/dev-console/src/components/dropdown/ApplicationDropdown.tsx new file mode 100644 index 00000000000..ddf3a585c3c --- /dev/null +++ b/frontend/packages/dev-console/src/components/dropdown/ApplicationDropdown.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { Firehose } from '@console/internal/components/utils'; +import ResourceDropdown from './ResourceDropdown'; + +interface ApplicationDropdownProps { + className?: string; + dropDownClassName?: string; + menuClassName?: string; + buttonClassName?: string; + title?: React.ReactNode; + titlePrefix?: string; + allApplicationsKey?: string; + storageKey?: string; + disabled?: boolean; + allSelectorItem?: { + allSelectorKey?: string; + allSelectorTitle?: string; + }; + namespace?: string; + actionItem?: { + actionTitle: string; + actionKey: string; + }; + selectedKey: string; + onChange?: (name: string, key: string) => void; +} + +const ApplicationDropdown: React.FC = ({ namespace, ...props }) => { + const resources = [ + { + isList: true, + namespace, + kind: 'DeploymentConfig', + prop: 'deploymentConfigs', + }, + { + isList: true, + namespace, + kind: 'Deployment', + prop: 'deployments', + }, + ]; + return ( + + + + ); +}; + +export default ApplicationDropdown; diff --git a/frontend/packages/dev-console/src/components/dropdown/ApplicationSelector.tsx b/frontend/packages/dev-console/src/components/dropdown/ApplicationSelector.tsx new file mode 100644 index 00000000000..f688715b03f --- /dev/null +++ b/frontend/packages/dev-console/src/components/dropdown/ApplicationSelector.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { connect, Dispatch } from 'react-redux'; +import { + ALL_NAMESPACES_KEY, + ALL_APPLICATIONS_KEY, + APPLICATION_LOCAL_STORAGE_KEY, +} from '@console/internal/const'; +import { setActiveApplication } from '@console/internal/actions/ui'; +import { RootState } from '@console/internal/redux'; +import { getActiveNamespace, getActiveApplication } from '@console/internal/reducers/ui'; +import ApplicationDropdown from './ApplicationDropdown'; + +interface ApplicationSelectorProps { + namespace: string; + application: string; + onChange: (name: string) => void; +} + +const ApplicationSelector: React.FC = ({ + namespace, + application, + onChange, +}) => { + const onApplicationChange = (newApplication: string, key: string) => { + key === ALL_APPLICATIONS_KEY ? onChange(key) : onChange(newApplication); + }; + const allApplicationsTitle = 'all applications'; + + const disabled = namespace === ALL_NAMESPACES_KEY; + + let title: string = application; + if (disabled) { + title = 'No applications'; + } else if (title === ALL_APPLICATIONS_KEY) { + title = allApplicationsTitle; + } + + return ( + {title}} + titlePrefix="Application" + allSelectorItem={{ + allSelectorKey: ALL_APPLICATIONS_KEY, + allSelectorTitle: allApplicationsTitle, + }} + selectedKey={application || ALL_APPLICATIONS_KEY} + onChange={onApplicationChange} + storageKey={APPLICATION_LOCAL_STORAGE_KEY} + disabled={disabled} + /> + ); +}; + +const mapStateToProps = (state: RootState) => ({ + namespace: getActiveNamespace(state), + application: getActiveApplication(state), +}); + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + onChange: (app: string) => { + dispatch(setActiveApplication(app)); + }, +}); + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(ApplicationSelector); diff --git a/frontend/packages/dev-console/src/components/dropdown/ResourceDropdown.tsx b/frontend/packages/dev-console/src/components/dropdown/ResourceDropdown.tsx new file mode 100644 index 00000000000..f48f4ff01e5 --- /dev/null +++ b/frontend/packages/dev-console/src/components/dropdown/ResourceDropdown.tsx @@ -0,0 +1,157 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import * as fuzzy from 'fuzzysearch'; +import { Dropdown, LoadingInline } from '@console/internal/components/utils'; +import { K8sResourceKind } from '@console/internal/module/k8s'; + +type FirehoseList = { + data?: K8sResourceKind[]; + [key: string]: any; +}; + +interface State { + items: {}; + title: React.ReactNode; +} + +interface ResourceDropdownProps { + className?: string; + dropDownClassName?: string; + menuClassName?: string; + buttonClassName?: string; + title?: React.ReactNode; + titlePrefix?: string; + allApplicationsKey?: string; + storageKey?: string; + disabled?: boolean; + allSelectorItem?: { + allSelectorKey?: string; + allSelectorTitle?: string; + }; + actionItem?: { + actionTitle: string; + actionKey: string; + }; + dataSelector: string[] | number[] | symbol[]; + loaded?: boolean; + loadError?: string; + placeholder?: string; + resources?: FirehoseList[]; + selectedKey: string; + resourceFilter?: (resource: any) => boolean; + onChange?: (name: string, key: string) => void; +} + +class ResourceDropdown extends React.Component { + constructor(props) { + super(props); + this.state = { + items: {}, + title: this.props.loaded ? ( + {this.props.placeholder} + ) : ( + + ), + }; + } + + componentWillReceiveProps(nextProps: ResourceDropdownProps) { + const { + resources, + loaded, + loadError, + placeholder, + allSelectorItem, + resourceFilter, + dataSelector, + } = nextProps; + + if (!loaded) { + this.setState({ title: }); + return; + } + if (!this.props.loaded) { + this.setState({ + title: {placeholder}, + }); + } + + if (loadError) { + this.setState({ + title: Error Loading - {placeholder}, + }); + } + + const unsortedList = {}; + _.each(resources, ({ data }) => { + _.reduce( + data, + (acc, resource) => { + let dataValue; + if (resourceFilter && resourceFilter(resource)) { + dataValue = _.get(resource, dataSelector); + } else if (!resourceFilter) { + dataValue = _.get(resource, dataSelector); + } + if (dataValue) { + acc[dataValue] = dataValue; + } + return acc; + }, + unsortedList, + ); + }); + + const sortedList = {}; + + if (allSelectorItem && !_.isEmpty(unsortedList)) { + sortedList[allSelectorItem.allSelectorKey] = allSelectorItem.allSelectorTitle; + } + + _.keys(unsortedList) + .sort() + .forEach((key) => { + sortedList[key] = unsortedList[key]; + }); + + this.setState({ items: sortedList }); + } + + shouldComponentUpdate(nextProps, nextState) { + if (_.isEqual(this.state, nextState)) { + return false; + } + return true; + } + + private onChange = (key: string) => { + const name = this.state.items[key]; + const { actionItem, onChange } = this.props; + const title = actionItem && key === actionItem.actionKey ? actionItem.actionTitle : name; + onChange && this.props.onChange(name, key); + this.setState({ title }); + }; + + render() { + return ( + + ); + } +} + +export default ResourceDropdown; diff --git a/frontend/packages/dev-console/src/components/dropdown/SourceSecretDropdown.tsx b/frontend/packages/dev-console/src/components/dropdown/SourceSecretDropdown.tsx new file mode 100644 index 00000000000..ca433835169 --- /dev/null +++ b/frontend/packages/dev-console/src/components/dropdown/SourceSecretDropdown.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { SecretModel } from '@console/internal/models'; +import { Firehose } from '@console/internal/components/utils'; +import ResourceDropdown from './ResourceDropdown'; + +interface SourceSecretDropdownProps { + dropDownClassName?: string; + menuClassName?: string; + namespace?: string; + actionItem?: { + actionTitle: string; + actionKey: string; + }; + selectedKey: string; + onChange?: (name: string, key: string) => void; +} + +const SourceSecretDropdown: React.FC = (props) => { + const filterData = (item) => { + return item.type === 'kubernetes.io/basic-auth' || item.type === 'kubernetes.io/ssh-auth'; + }; + const resources = [ + { + isList: true, + namespace: props.namespace, + kind: SecretModel.kind, + prop: 'secrets', + }, + ]; + return ( + + + + ); +}; + +export default SourceSecretDropdown; diff --git a/frontend/packages/dev-console/src/components/formik-fields/CheckboxField.tsx b/frontend/packages/dev-console/src/components/formik-fields/CheckboxField.tsx new file mode 100644 index 00000000000..cdbc070f4db --- /dev/null +++ b/frontend/packages/dev-console/src/components/formik-fields/CheckboxField.tsx @@ -0,0 +1,29 @@ +/* eslint-disable no-unused-vars, no-undef */ +import * as React from 'react'; +import { useField } from 'formik'; +import { FormGroup, HelpBlock, Checkbox } from 'patternfly-react'; +import { InputFieldProps } from './field-types'; +import { getValidationState } from './field-utils'; + +const CheckboxField: React.FC = ({ label, helpText, ...props }) => { + const [field, { touched, error }] = useField(props.name); + return ( + + + {label} + + {helpText && {helpText}} + {touched && error && {error}} + + ); +}; + +export default CheckboxField; diff --git a/frontend/packages/dev-console/src/components/formik-fields/DropdownField.tsx b/frontend/packages/dev-console/src/components/formik-fields/DropdownField.tsx new file mode 100644 index 00000000000..69b2521be56 --- /dev/null +++ b/frontend/packages/dev-console/src/components/formik-fields/DropdownField.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import cx from 'classnames'; +import { useField, useFormikContext, FormikValues } from 'formik'; +import { FormGroup, ControlLabel, HelpBlock } from 'patternfly-react'; +import { Dropdown } from '@console/internal/components/utils'; +import { DropdownFieldProps } from './field-types'; +import { getValidationState } from './field-utils'; + +const DropdownField: React.FC = ({ label, helpText, ...props }) => { + const [field, { touched, error }] = useField(props.name); + const { setFieldValue, setFieldTouched } = useFormikContext(); + return ( + + {label} + setFieldValue(props.name, value)} + onBlur={() => setFieldTouched(props.name, true)} + /> + {helpText && {helpText}} + {touched && error && {error}} + + ); +}; + +export default DropdownField; diff --git a/frontend/packages/dev-console/src/components/formik-fields/EnvironmentField.tsx b/frontend/packages/dev-console/src/components/formik-fields/EnvironmentField.tsx new file mode 100644 index 00000000000..df8671ed29d --- /dev/null +++ b/frontend/packages/dev-console/src/components/formik-fields/EnvironmentField.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import cx from 'classnames'; +import { useFormikContext, FormikValues } from 'formik'; +import { FormGroup, ControlLabel, HelpBlock } from 'patternfly-react'; +import { EnvironmentPage } from '@console/internal/components/environment'; +import { EnvironmentFieldProps, NameValuePair, NameValueFromPair } from './field-types'; + +const EnvironmentField: React.FC = ({ label, helpText, ...props }) => { + const { setFieldValue } = useFormikContext(); + return ( + + {label} + setFieldValue(props.name, obj)} + addConfigMapSecret + useLoadingInline + /> + {helpText && {helpText}} + + ); +}; + +export default EnvironmentField; diff --git a/frontend/packages/dev-console/src/components/formik-fields/InputField.tsx b/frontend/packages/dev-console/src/components/formik-fields/InputField.tsx new file mode 100644 index 00000000000..e27fdf8f06d --- /dev/null +++ b/frontend/packages/dev-console/src/components/formik-fields/InputField.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import cx from 'classnames'; +import { useField } from 'formik'; +import { FormGroup, ControlLabel, FormControl, HelpBlock } from 'patternfly-react'; +import { InputFieldProps } from './field-types'; +import { getValidationState } from './field-utils'; + +const InputField: React.FC = ({ label, helpText, ...props }) => { + const [field, { touched, error }] = useField(props.name); + return ( + + {label} + + {helpText && {helpText}} + {touched && error && {error}} + + ); +}; + +export default InputField; diff --git a/frontend/packages/dev-console/src/components/formik-fields/NSDropdownField.tsx b/frontend/packages/dev-console/src/components/formik-fields/NSDropdownField.tsx new file mode 100644 index 00000000000..48615b6e237 --- /dev/null +++ b/frontend/packages/dev-console/src/components/formik-fields/NSDropdownField.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import cx from 'classnames'; +import { useField, useFormikContext, FormikValues } from 'formik'; +import { FormGroup, ControlLabel, HelpBlock } from 'patternfly-react'; +import { NsDropdown } from '@console/internal/components/utils'; +import { DropdownFieldProps } from './field-types'; +import { getValidationState } from './field-utils'; + +const NSDropdownField: React.FC = ({ label, helpText, ...props }) => { + const [field, { touched, error }] = useField(props.name); + const { setFieldValue, setFieldTouched } = useFormikContext(); + return ( + + {label} + setFieldValue(props.name, value)} + onBlur={() => setFieldTouched(props.name, true)} + /> + {helpText && {helpText}} + {touched && error && {error}} + + ); +}; + +export default NSDropdownField; diff --git a/frontend/packages/dev-console/src/components/formik-fields/NumberSpinnerField.tsx b/frontend/packages/dev-console/src/components/formik-fields/NumberSpinnerField.tsx new file mode 100644 index 00000000000..d694d0bebbe --- /dev/null +++ b/frontend/packages/dev-console/src/components/formik-fields/NumberSpinnerField.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import cx from 'classnames'; +import { useField, useFormikContext, FormikValues } from 'formik'; +import { FormGroup, ControlLabel, HelpBlock } from 'patternfly-react'; +import { NumberSpinner } from '@console/internal/components/utils'; +import { InputFieldProps } from './field-types'; +import { getValidationState } from './field-utils'; + +const NumberSpinnerField: React.FC = ({ label, helpText, ...props }) => { + const [field, { touched, error }] = useField(props.name); + const { setFieldValue, setFieldTouched } = useFormikContext(); + return ( + + {label} + { + setFieldValue(props.name, _.toInteger(field.value) + operation); + setFieldTouched(props.name, true); + }} + aria-describedby={helpText && `${props.name}-help`} + /> + {helpText && {helpText}} + {touched && error && {error}} + + ); +}; + +export default NumberSpinnerField; diff --git a/frontend/packages/dev-console/src/components/formik-fields/field-types.ts b/frontend/packages/dev-console/src/components/formik-fields/field-types.ts new file mode 100644 index 00000000000..cc8f04551f4 --- /dev/null +++ b/frontend/packages/dev-console/src/components/formik-fields/field-types.ts @@ -0,0 +1,45 @@ +export interface InputFieldProps { + type?: string; + name: string; + label: string; + helpText?: string; + required?: boolean; + onChange?: (event) => void; + onBlur?: (event) => void; +} + +export interface DropdownFieldProps extends InputFieldProps { + items?: object; + selectedKey: string; + title?: React.ReactNode; + fullWidth?: boolean; +} + +export interface EnvironmentFieldProps extends InputFieldProps { + obj?: object; + envPath: string[]; +} + +export interface NameValuePair { + name: string; + value: string; +} + +export interface NameValueFromPair { + name: string; + valueForm: ConfigMapKeyRef | SecretKeyRef; +} + +export interface ConfigMapKeyRef { + configMapKeyRef: { + key: string; + name: string; + }; +} + +export interface SecretKeyRef { + secretKeyRef: { + key: string; + name: string; + }; +} diff --git a/frontend/packages/dev-console/src/components/formik-fields/field-utils.ts b/frontend/packages/dev-console/src/components/formik-fields/field-utils.ts new file mode 100644 index 00000000000..71dea3550c1 --- /dev/null +++ b/frontend/packages/dev-console/src/components/formik-fields/field-utils.ts @@ -0,0 +1,10 @@ +import cx from 'classnames'; + +export const getValidationState = (error: string, touched: boolean, warning?: string) => { + const state = cx({ + success: touched && !error, + error: touched && error, + warning: touched && warning, + }); + return state || null; +}; diff --git a/frontend/packages/dev-console/src/components/formik-fields/index.ts b/frontend/packages/dev-console/src/components/formik-fields/index.ts new file mode 100644 index 00000000000..f1811da3733 --- /dev/null +++ b/frontend/packages/dev-console/src/components/formik-fields/index.ts @@ -0,0 +1,6 @@ +export { default as InputField } from './InputField'; +export { default as DropdownField } from './DropdownField'; +export { default as NSDropdownField } from './NSDropdownField'; +export { default as CheckboxField } from './CheckboxField'; +export { default as NumberSpinnerField } from './NumberSpinnerField'; +export { default as EnvironmentField } from './EnvironmentField'; diff --git a/frontend/packages/dev-console/src/components/import/GitImport.tsx b/frontend/packages/dev-console/src/components/import/GitImport.tsx new file mode 100644 index 00000000000..f429029cac9 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/GitImport.tsx @@ -0,0 +1,116 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { Formik } from 'formik'; +import { history } from '@console/internal/components/utils'; +import { GitImportFormData, FirehoseList } from './import-types'; +import { NormalizedBuilderImages, normalizeBuilderImages } from '../../utils/imagestream-utils'; +import { + createDeploymentConfig, + createImageStream, + createBuildConfig, + createService, + createRoute, +} from './import-submit-utils'; +import { validationSchema } from './import-validation-utils'; +import GitImportForm from './GitImportForm'; + +export interface GitImportProps { + namespace: string; + imageStreams?: FirehoseList; +} + +const GitImport: React.FC = ({ namespace, imageStreams }) => { + const initialValues: GitImportFormData = { + name: '', + project: { + name: namespace || '', + }, + application: { + name: '', + selectedKey: '', + }, + git: { + url: '', + type: '', + ref: '', + dir: '/', + showGitType: false, + }, + image: { + selected: '', + recommended: '', + tag: '', + ports: [], + }, + route: { + create: true, + }, + build: { + env: [], + triggers: { + webhook: true, + image: true, + config: true, + }, + }, + deployment: { + env: [], + triggers: { + image: true, + config: true, + }, + replicas: 1, + }, + labels: {}, + }; + + const builderImages: NormalizedBuilderImages = + imageStreams && imageStreams.loaded && normalizeBuilderImages(imageStreams.data); + + const handleSubmit = (values, actions) => { + const imageStream = builderImages[values.image.selected].obj; + + const { + project: { name: projectName }, + route: { create: canCreateRoute }, + image: { ports }, + } = values; + + const requests = [ + createDeploymentConfig(values, imageStream), + createImageStream(values, imageStream), + createBuildConfig(values, imageStream), + ]; + + // Only create a service or route if the builder image has ports. + if (!_.isEmpty(ports)) { + requests.push(createService(values, imageStream)); + if (canCreateRoute) { + requests.push(createRoute(values, imageStream)); + } + } + + requests.forEach((r) => r.catch((err) => actions.setStatus({ submitError: err.message }))); + Promise.all(requests) + .then(() => { + actions.setSubmitting(false); + history.push(`/topology/ns/${projectName}`); + }) + .catch((err) => { + actions.setSubmitting(false); + actions.setStatus({ submitError: err.message }); + }); + }; + + return ( + } + /> + ); +}; + +export default GitImport; diff --git a/frontend/packages/dev-console/src/components/import/GitImportForm.tsx b/frontend/packages/dev-console/src/components/import/GitImportForm.tsx new file mode 100644 index 00000000000..29fc3603638 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/GitImportForm.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { Form, Button, ExpandCollapse } from 'patternfly-react'; +import { FormikProps, FormikValues } from 'formik'; +import { ButtonBar } from '@console/internal/components/utils'; +import { NormalizedBuilderImages } from '../../utils/imagestream-utils'; +import GitSection from './git/GitSection'; +import BuilderSection from './builder/BuilderSection'; +import AppSection from './app/AppSection'; +import RouteSection from './route/RouteSection'; +import ScalingSection from './advanced/ScalingSection'; +import BuildConfigSection from './advanced/BuildConfigSection'; +import DeploymentConfigSection from './advanced/DeploymentConfigSection'; +import LabelSection from './advanced/LabelSection'; + +export interface GitImportFormProps { + builderImages?: NormalizedBuilderImages; +} + +const GitImportForm: React.FC & GitImportFormProps> = ({ + values, + errors, + handleSubmit, + handleReset, + builderImages, + status, + isSubmitting, + dirty, +}) => ( +
+
+ + + + + + + + + + +
+
+ + + + +
+); + +export default GitImportForm; diff --git a/frontend/packages/dev-console/src/components/import/ImportPage.tsx b/frontend/packages/dev-console/src/components/import/ImportPage.tsx new file mode 100644 index 00000000000..741086a77b9 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/ImportPage.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { match as RMatch } from 'react-router-dom'; +import { Helmet } from 'react-helmet'; +import { PageHeading, Firehose } from '@console/internal/components/utils'; +import { ImageStreamModel } from '@console/internal/models'; +import GitImport from './GitImport'; + +export interface ImportPageProps { + match: RMatch<{ ns?: string }>; +} + +const ImportPage: React.FunctionComponent = ({ match }) => { + const namespace = match.params.ns; + return ( + + + Import from Git + + +
+ + + +
+
+ ); +}; + +export default ImportPage; diff --git a/frontend/packages/dev-console/src/components/import/__tests__/import-validation-utils.spec.ts b/frontend/packages/dev-console/src/components/import/__tests__/import-validation-utils.spec.ts new file mode 100644 index 00000000000..8cc66613362 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/__tests__/import-validation-utils.spec.ts @@ -0,0 +1,96 @@ +import { validationSchema, detectGitType } from '../import-validation-utils'; +import { GitImportFormData } from '../import-types'; + +describe('ValidationUtils', () => { + describe('Detect Git Type', () => { + it('should return undefined for invalid git url', () => { + const gitType = detectGitType('test'); + expect(gitType).toEqual(undefined); + }); + it('should return empty string for valid but unknown git url ', () => { + const gitType = detectGitType('https://svnsource.test.com'); + expect(gitType).toEqual(''); + }); + it('should return proper git type for valid known git url', () => { + const gitType = detectGitType('https://github.com/test/repo'); + expect(gitType).toEqual('github'); + }); + }); + + describe('Validation Schema', () => { + const mockFormData: GitImportFormData = { + name: 'test-app', + project: { + name: 'mock-project', + }, + application: { + name: 'mock-app', + selectedKey: 'mock-app', + }, + git: { + url: 'https://github.com/test/repo', + type: 'github', + ref: '', + dir: '', + showGitType: false, + }, + image: { + selected: 'nodejs', + recommended: '', + tag: 'latest', + ports: [], + }, + route: { + create: false, + }, + build: { + env: [], + triggers: { + webhook: true, + image: true, + config: true, + }, + }, + deployment: { + env: [], + triggers: { + image: true, + config: true, + }, + replicas: 1, + }, + labels: {}, + }; + + it('should validate the form data', async () => { + await validationSchema.isValid(mockFormData).then((valid) => expect(valid).toEqual(true)); + }); + + it('should throw an error if url is invalid', async () => { + mockFormData.git.url = 'something.com'; + await validationSchema.isValid(mockFormData).then((valid) => expect(valid).toEqual(false)); + await validationSchema + .validate(mockFormData) + .catch((err) => expect(err.message).toBe('Invalid Git URL')); + }); + + it('should throw an error if url is valid but git type is not valid', async () => { + mockFormData.git.url = 'https://something.com/test/repo'; + mockFormData.git.type = ''; + await validationSchema.isValid(mockFormData).then((valid) => expect(valid).toEqual(true)); + mockFormData.git.showGitType = true; + await validationSchema.validate(mockFormData).catch((err) => { + expect(err.message).toBe('We failed to detect the git type. Please choose a git type.'); + }); + }); + + it('should throw an error for required fields if empty', async () => { + mockFormData.name = ''; + await validationSchema.isValid(mockFormData).then((valid) => expect(valid).toEqual(false)); + await validationSchema.validate(mockFormData).catch((err) => { + expect(err.message).toBe('Required'); + expect(err.type).toBe('required'); + }); + }); + }); +}); diff --git a/frontend/packages/dev-console/src/components/import/advanced/BuildConfigSection.tsx b/frontend/packages/dev-console/src/components/import/advanced/BuildConfigSection.tsx new file mode 100644 index 00000000000..8607f49e27e --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/advanced/BuildConfigSection.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { CheckboxField, EnvironmentField } from '../../formik-fields'; +import FormSection from '../section/FormSection'; + +export interface BuildConfigSectionProps { + namespace: string; +} + +const BuildConfigSection: React.FC = ({ namespace }) => { + const buildConfigObj = { + kind: 'BuildConfig', + metadata: { + namespace, + }, + }; + + return ( + + + + + + + ); +}; + +export default BuildConfigSection; diff --git a/frontend/packages/dev-console/src/components/import/advanced/DeploymentConfigSection.tsx b/frontend/packages/dev-console/src/components/import/advanced/DeploymentConfigSection.tsx new file mode 100644 index 00000000000..c79abd1bb1f --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/advanced/DeploymentConfigSection.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { CheckboxField, EnvironmentField } from '../../formik-fields'; +import FormSection from '../section/FormSection'; + +export interface DeploymentConfigSectionProps { + namespace: string; +} + +const DeploymentConfigSection: React.FC = ({ namespace }) => { + const deploymentConfigObj = { + kind: 'DeploymentConfig', + metadata: { + namespace, + }, + }; + + return ( + + + + + + ); +}; + +export default DeploymentConfigSection; diff --git a/frontend/packages/dev-console/src/components/import/advanced/LabelSection.tsx b/frontend/packages/dev-console/src/components/import/advanced/LabelSection.tsx new file mode 100644 index 00000000000..6a33ba3eb7d --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/advanced/LabelSection.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { NameValueEditor } from '@console/internal/components/utils/name-value-editor'; +import { useFormikContext, FormikValues } from 'formik'; +import FormSection from '../section/FormSection'; + +const LabelSection: React.FC = () => { + const { values, setFieldValue } = useFormikContext(); + const labels = _.isEmpty(values.labels) ? [['', '']] : _.toPairs(values.labels); + return ( + + setFieldValue('labels', _.fromPairs(obj.nameValuePairs))} + useLoadingInline + readOnly={false} + /> + + ); +}; + +export default LabelSection; diff --git a/frontend/packages/dev-console/src/components/import/advanced/ScalingSection.tsx b/frontend/packages/dev-console/src/components/import/advanced/ScalingSection.tsx new file mode 100644 index 00000000000..98b76a963d2 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/advanced/ScalingSection.tsx @@ -0,0 +1,22 @@ +/* eslint-disable no-unused-vars, no-undef */ +import * as React from 'react'; +import { NumberSpinnerField } from '../../formik-fields'; +import FormSection from '../section/FormSection'; + +const ScalingSection: React.FC = () => { + return ( + + + + ); +}; + +export default ScalingSection; diff --git a/frontend/packages/dev-console/src/components/import/app/AppSection.tsx b/frontend/packages/dev-console/src/components/import/app/AppSection.tsx new file mode 100644 index 00000000000..3c4fbdca1fc --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/app/AppSection.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { InputField, NSDropdownField } from '../../formik-fields'; +import { ProjectData } from '../import-types'; +import FormSection from '../section/FormSection'; +import ApplicationSelector from './ApplicationSelector'; + +export interface AppSectionProps { + project: ProjectData; +} + +const AppSection: React.FC = ({ project }) => { + return ( + + + + + + ); +}; + +export default AppSection; diff --git a/frontend/packages/dev-console/src/components/import/app/ApplicationSelector.tsx b/frontend/packages/dev-console/src/components/import/app/ApplicationSelector.tsx new file mode 100644 index 00000000000..7275faa0e98 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/app/ApplicationSelector.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { FormGroup, ControlLabel, HelpBlock } from 'patternfly-react'; +import { useFormikContext, FormikValues, useField } from 'formik'; +import { InputField } from '../../formik-fields'; +import { getValidationState } from '../../formik-fields/field-utils'; +import ApplicationDropdown from '../../dropdown/ApplicationDropdown'; + +export const CREATE_APPLICATION_KEY = 'create-application-key'; + +export interface ApplicationSelectorProps { + namespace?: string; +} + +const ApplicationSelector: React.FC = ({ namespace }) => { + const [selectedKey, { touched, error }] = useField('application.selectedKey'); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const onDropdownChange = (application: string, key: string) => { + setFieldTouched('application.selectedKey', true); + if (key === CREATE_APPLICATION_KEY) { + setFieldValue('application.name', ''); + setFieldValue('application.selectedKey', key); + } else { + setFieldValue('application.name', application); + setFieldValue('application.selectedKey', key); + } + }; + + return ( + + + Application + + {touched && error && {error}} + + {selectedKey.value === CREATE_APPLICATION_KEY && ( + + )} + + ); +}; + +export default ApplicationSelector; diff --git a/frontend/packages/dev-console/src/components/import/builder/BuilderImageCard.scss b/frontend/packages/dev-console/src/components/import/builder/BuilderImageCard.scss new file mode 100644 index 00000000000..80b25e64382 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/builder/BuilderImageCard.scss @@ -0,0 +1,40 @@ +.odc-builder-image-card { + position: relative; + height: 112px; + width: 136px; + margin: 4px; + align-items: center; + border: 0; + + &__icon { + height: 32px; + max-width: 80px; + min-width: 40px; + } + + &__title { + font-size: var(--pf-global--FontSize--sm); + font-weight: var(--pf-global--FontWeight--bold); + line-height: var(--pf-global--LineHeight--sm); + } + + &__recommended { + position: absolute; + color: var(--pf-global--success-color--100); + font-size: var(--pf-global--FontSize--md); + top: 0px; + right: 3px; + } + + &.is-selected, + &:hover, + &:focus, + &:active { + outline: var(--pf-global--active-color--100) 3px solid; + outline-offset: -3px; + } + + .pf-c-card__body { + padding: 5px !important; + } +} diff --git a/frontend/packages/dev-console/src/components/import/builder/BuilderImageCard.tsx b/frontend/packages/dev-console/src/components/import/builder/BuilderImageCard.tsx new file mode 100644 index 00000000000..0c9938c7023 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/builder/BuilderImageCard.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import * as classNames from 'classnames'; +import { Card, CardHeader, CardBody } from '@patternfly/react-core'; +import { StarIcon } from '@patternfly/react-icons'; +import { BuilderImage } from '../../../utils/imagestream-utils'; + +import './BuilderImageCard.scss'; + +export interface BuilderImageCardProps { + image: BuilderImage; + selected: boolean; + recommended: boolean; + onChange: (name: string) => void; +} + +const BuilderImageCard: React.FC = ({ + image, + selected, + recommended, + onChange, +}) => { + const classes = classNames('odc-builder-image-card', { 'is-selected': selected }); + return ( + onChange(image.name)} + > + + {image.displayName} + + + {image.title} + + {recommended && ( + + + + )} + + ); +}; + +export default BuilderImageCard; diff --git a/frontend/packages/dev-console/src/components/import/builder/BuilderImageSelector.scss b/frontend/packages/dev-console/src/components/import/builder/BuilderImageSelector.scss new file mode 100644 index 00000000000..3069d28dc72 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/builder/BuilderImageSelector.scss @@ -0,0 +1,13 @@ +.odc-builder-image-selector { + display: flex; + flex-direction: column; + flex-flow: wrap; + background: var(--pf-global--Color--light-200); + padding: 4px; + + &__success-icon { + color: var(--pf-global--success-color--100); + font-size: var(--pf-global--FontSize--md); + margin-left: var(--pf-global--spacer--sm); + } +} diff --git a/frontend/packages/dev-console/src/components/import/builder/BuilderImageSelector.tsx b/frontend/packages/dev-console/src/components/import/builder/BuilderImageSelector.tsx new file mode 100644 index 00000000000..e8d27b4bbb8 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/builder/BuilderImageSelector.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { useField, useFormikContext, FormikValues } from 'formik'; +import { LoadingInline } from '@console/internal/components/utils'; +import { FormGroup, ControlLabel, HelpBlock } from 'patternfly-react'; +import { CheckCircleIcon, StarIcon } from '@patternfly/react-icons'; +import { NormalizedBuilderImages } from '../../../utils/imagestream-utils'; +import { getValidationState } from '../../formik-fields/field-utils'; +import BuilderImageCard from './BuilderImageCard'; + +import './BuilderImageSelector.scss'; + +export interface BuilderImageSelectorProps { + loadingImageStream: boolean; + loadingRecommendedImage?: boolean; + builderImages: NormalizedBuilderImages; +} + +const BuilderImageSelector: React.FC = ({ + loadingImageStream, + loadingRecommendedImage, + builderImages, +}) => { + const [selected, { error: selectedError, touched: selectedTouched }] = useField('image.selected'); + const [recommended] = useField('image.recommended'); + const { values, setValues, setFieldTouched } = useFormikContext(); + + const handleImageChange = (image: string) => { + const newValues = { + ...values, + image: { + ...values.image, + selected: image, + tag: builderImages[image].recentTag.name, + }, + }; + setValues(newValues); + setFieldTouched('image.selected', true); + setFieldTouched('image.tag', true); + }; + + return ( + + Builder Image + {loadingRecommendedImage && } + {recommended.value && ( + + + + Recommended builder images are represented by{' '} + icon + + + )} + {loadingImageStream ? ( + + ) : ( +
+ {_.values(builderImages).map((image) => ( + + ))} +
+ )} + {selectedTouched && selectedError && {selectedError}} +
+ ); +}; + +export default BuilderImageSelector; diff --git a/frontend/packages/dev-console/src/components/import/builder/BuilderImageTagSelector.tsx b/frontend/packages/dev-console/src/components/import/builder/BuilderImageTagSelector.tsx new file mode 100644 index 00000000000..32d0072851f --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/builder/BuilderImageTagSelector.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { useFormikContext, FormikValues } from 'formik'; +import { ResourceName } from '@console/internal/components/utils'; +import { getPorts } from '@console/internal/components/source-to-image'; +import { K8sResourceKind, k8sGet } from '@console/internal/module/k8s'; +import { ImageStreamTagModel } from '@console/internal/models'; +import ImageStreamInfo from '../../source-to-image/ImageStreamInfo'; +import { BuilderImage, getTagDataWithDisplayName } from '../../../utils/imagestream-utils'; +import { DropdownField } from '../../formik-fields'; + +export interface BuilderImageTagSelectorProps { + selectedBuilderImage: BuilderImage; + selectedImageTag: string; +} + +const BuilderImageTagSelector: React.FC = ({ + selectedBuilderImage, + selectedImageTag, +}) => { + const { setFieldValue } = useFormikContext(); + const { + name: imageName, + tags: imageTags, + displayName: imageDisplayName, + imageStreamNamespace, + } = selectedBuilderImage; + + const tagItems = {}; + _.each( + imageTags, + ({ name }) => (tagItems[name] = ), + ); + + const [imageTag, displayName] = getTagDataWithDisplayName( + imageTags, + selectedImageTag, + imageDisplayName, + ); + + React.useEffect(() => { + // eslint-disable-next-line promise/catch-or-return + k8sGet(ImageStreamTagModel, `${imageName}:${selectedImageTag}`, imageStreamNamespace).then( + (imageStreamTag: K8sResourceKind) => { + const ports = getPorts(imageStreamTag); + setFieldValue('image.ports', ports); + }, + ); + }, [imageName, imageStreamNamespace, selectedImageTag, setFieldValue]); + + return ( + + + {imageTag && } + + ); +}; + +export default BuilderImageTagSelector; diff --git a/frontend/packages/dev-console/src/components/import/builder/BuilderSection.tsx b/frontend/packages/dev-console/src/components/import/builder/BuilderSection.tsx new file mode 100644 index 00000000000..7081fcb5c36 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/builder/BuilderSection.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { NormalizedBuilderImages } from '../../../utils/imagestream-utils'; +import { ImageData } from '../import-types'; +import FormSection from '../section/FormSection'; +import BuilderImageSelector from './BuilderImageSelector'; +import BuilderImageTagSelector from './BuilderImageTagSelector'; + +export interface ImageSectionProps { + image: ImageData; + builderImages: NormalizedBuilderImages; +} + +const BuilderSection: React.FC = ({ image, builderImages }) => { + return ( + + + {image.tag && ( + + )} + + ); +}; + +export default BuilderSection; diff --git a/frontend/packages/dev-console/src/components/import/git/CreateSourceSecret.tsx b/frontend/packages/dev-console/src/components/import/git/CreateSourceSecret.tsx new file mode 100644 index 00000000000..1a9469c2df1 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/git/CreateSourceSecret.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; +import { FormGroup, ControlLabel } from 'patternfly-react'; +import { Dropdown } from '@console/internal/components/utils'; +import { + BasicAuthSubform, + SSHAuthSubform, +} from '@console/internal/components/secrets/create-secret'; + +export enum SecretType { + basicAuth = 'kubernetes.io/basic-auth', + sshAuth = 'kubernetes.io/ssh-auth', +} + +export type CreateSourceSecretState = { + type: SecretType; + stringData: { + [key: string]: string; + }; + authType: SecretType.basicAuth | SecretType.sshAuth; +}; + +export type CreateSourceSecretProps = { + onChange: Function; + selectedKey: string; + secretType: SecretType; + secretName: string; + secretCredentials: { + [key: string]: string; + }; +}; + +export default class CreateSourceSecret extends React.Component< + CreateSourceSecretProps, + CreateSourceSecretState +> { + constructor(props) { + super(props); + this.state = { + type: this.props.secretType, + stringData: this.props.secretCredentials, + authType: SecretType.basicAuth, + }; + } + + onDataChange = (secretsData) => { + this.setState( + { + stringData: { ...secretsData }, + }, + () => + this.props.onChange( + this.props.secretName, + this.props.selectedKey, + this.state.type, + this.state.stringData, + ), + ); + }; + + changeAuthenticationType = (type: SecretType) => { + this.setState({ + type, + stringData: {}, + }); + }; + + render() { + const authTypes = { + [SecretType.basicAuth]: 'Basic Authentication', + [SecretType.sshAuth]: 'SSH Key', + }; + return ( + + + Authentication Type + + + + {this.state.type === SecretType.basicAuth ? ( + + ) : ( + + )} + + ); + } +} diff --git a/frontend/packages/dev-console/src/components/import/git/GitSection.tsx b/frontend/packages/dev-console/src/components/import/git/GitSection.tsx new file mode 100644 index 00000000000..207438edf19 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/git/GitSection.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { useFormikContext, FormikValues } from 'formik'; +import { ExpandCollapse } from 'patternfly-react'; +import { InputField, DropdownField } from '../../formik-fields'; +import FormSection from '../section/FormSection'; +import { GitTypes } from '../import-types'; +import { detectGitType } from '../import-validation-utils'; + +const GitSection: React.FC = () => { + const { values, setValues, setFieldTouched } = useFormikContext(); + const handleGitUrlBlur = () => { + const gitType = detectGitType(values.git.url); + const showGitType = gitType === ''; + const newValues = { + ...values, + git: { + ...values.git, + type: gitType, + showGitType, + }, + }; + setValues(newValues); + setFieldTouched('git.url', true); + setFieldTouched('git.type', showGitType); + }; + + return ( + + + {values.git.showGitType && ( + + )} + + + + + + ); +}; + +export default GitSection; diff --git a/frontend/packages/dev-console/src/components/import/git/SourceSecretSelector.tsx b/frontend/packages/dev-console/src/components/import/git/SourceSecretSelector.tsx new file mode 100644 index 00000000000..cf3c98afc02 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/git/SourceSecretSelector.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import { FormGroup, ControlLabel, FormControl, HelpBlock } from 'patternfly-react'; +import SourceSecretDropdown from '../../dropdown/SourceSecretDropdown'; +import CreateSourceSecret, { SecretType } from './CreateSourceSecret'; + +const CREATE_SOURCE_SECRET = 'create-source-secret'; + +interface SourceSecretSelectorProps { + namespace?: string; + sourceSecret: string; + selectedKey: string; + secretCredentials: { [key: string]: string }; + onChange: ( + name: string, + key: string, + authType?: string, + credentials?: { [key: string]: string }, + ) => void; +} + +const SourceSecretSelector: React.FC = ({ + sourceSecret, + namespace, + secretCredentials, + selectedKey, + onChange, +}) => { + const onDropdownChange = (secretName: string, key: string) => { + if (key === CREATE_SOURCE_SECRET) { + onChange('', key, SecretType.basicAuth, {}); + } else { + onChange(secretName, key); + } + }; + + const onInputChange: React.ReactEventHandler = (event) => { + onChange(event.currentTarget.value, selectedKey, SecretType.basicAuth, secretCredentials); + }; + + return ( + + + Source Secret + + + {selectedKey === CREATE_SOURCE_SECRET ? ( + + + Secret Name + + Unique name of the new secret. + + + + + + ) : null} + + ); +}; + +export default SourceSecretSelector; diff --git a/frontend/packages/dev-console/src/components/import/import-submit-utils.ts b/frontend/packages/dev-console/src/components/import/import-submit-utils.ts new file mode 100644 index 00000000000..c6f30902111 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/import-submit-utils.ts @@ -0,0 +1,239 @@ +import * as _ from 'lodash'; +import { + ImageStreamModel, + BuildConfigModel, + DeploymentConfigModel, + ServiceModel, + RouteModel, +} from '@console/internal/models'; +import { k8sCreate, K8sResourceKind } from '@console/internal/module/k8s'; +import { makePortName } from '../../utils/imagestream-utils'; +import { getAppLabels, getPodLabels } from '../../utils/resource-label-utils'; +import { GitImportFormData } from './import-types'; + +export const createImageStream = ( + formData: GitImportFormData, + { metadata: { name: imageStreamName } }: K8sResourceKind, +): Promise => { + const { + name, + project: { name: namespace }, + application: { name: application }, + labels: userLabels, + } = formData; + const defaultLabels = getAppLabels(name, application, imageStreamName); + const imageStream = { + apiVersion: 'image.openshift.io/v1', + kind: 'ImageStream', + metadata: { + name, + namespace, + labels: { ...defaultLabels, ...userLabels }, + }, + }; + + return k8sCreate(ImageStreamModel, imageStream); +}; + +export const createBuildConfig = ( + formData: GitImportFormData, + imageStream: K8sResourceKind, +): Promise => { + const { + name, + project: { name: namespace }, + application: { name: application }, + git: { url: repository, ref = 'master', dir: contextDir }, + image: { tag: selectedTag }, + build: { env, triggers }, + labels: userLabels, + } = formData; + + const defaultLabels = getAppLabels(name, application, imageStream.metadata.name); + const buildConfig = { + apiVersion: 'build.openshift.io/v1', + kind: 'BuildConfig', + metadata: { + name, + namespace, + labels: { ...defaultLabels, ...userLabels }, + }, + spec: { + output: { + to: { + kind: 'ImageStreamTag', + name: `${name}:latest`, + }, + }, + source: { + contextDir, + git: { + uri: repository, + ref, + type: 'Git', + }, + }, + strategy: { + type: 'Source', + sourceStrategy: { + env, + from: { + kind: 'ImageStreamTag', + name: `${imageStream.metadata.name}:${selectedTag}`, + namespace: imageStream.metadata.namespace, + }, + }, + }, + triggers: [ + ...(triggers.image ? [{ type: 'ImageChange', imageChange: {} }] : []), + ...(triggers.config ? [{ type: 'ConfigChange' }] : []), + ], + }, + }; + + return k8sCreate(BuildConfigModel, buildConfig); +}; + +export const createDeploymentConfig = ( + formData: GitImportFormData, + imageStream: K8sResourceKind, +): Promise => { + const { + name, + project: { name: namespace }, + application: { name: application }, + image: { ports }, + deployment: { env, replicas, triggers }, + labels: userLabels, + } = formData; + + const defaultLabels = getAppLabels(name, application, imageStream.metadata.name); + const podLabels = getPodLabels(name); + + const deploymentConfig = { + apiVersion: 'apps.openshift.io/v1', + kind: 'DeploymentConfig', + metadata: { + name, + namespace, + labels: { ...defaultLabels, ...userLabels }, + }, + spec: { + selector: podLabels, + replicas, + template: { + metadata: { + labels: podLabels, + }, + spec: { + containers: [ + { + name, + image: `${name}:latest`, + ports, + env, + }, + ], + }, + }, + triggers: [ + { + type: 'ImageChange', + imageChangeParams: { + automatic: triggers.image, + containerNames: [name], + from: { + kind: 'ImageStreamTag', + name: `${name}:latest`, + }, + }, + }, + ...(triggers.config ? [{ type: 'ConfigChange' }] : []), + ], + }, + }; + + return k8sCreate(DeploymentConfigModel, deploymentConfig); +}; + +export const createService = ( + formData: GitImportFormData, + imageStream: K8sResourceKind, +): Promise => { + const { + name, + project: { name: namespace }, + application: { name: application }, + image: { ports }, + labels: userLabels, + } = formData; + + const firstPort = _.head(ports); + const defaultLabels = getAppLabels(name, application, imageStream.metadata.name); + const podLabels = getPodLabels(name); + const service = { + kind: 'Service', + apiVersion: 'v1', + metadata: { + name, + namespace, + labels: { ...defaultLabels, ...userLabels }, + }, + spec: { + selector: podLabels, + ports: [ + { + port: firstPort.containerPort, + targetPort: firstPort.containerPort, + protocol: firstPort.protocol, + // Use the same naming convention as the CLI. + name: makePortName(firstPort), + }, + ], + }, + }; + + return k8sCreate(ServiceModel, service); +}; + +export const createRoute = ( + formData: GitImportFormData, + imageStream: K8sResourceKind, +): Promise => { + const { + name, + project: { name: namespace }, + application: { name: application }, + image: { ports }, + labels: userLabels, + } = formData; + + const firstPort = _.head(ports); + const defaultLabels = getAppLabels(name, application, imageStream.metadata.name); + const route = { + kind: 'Route', + apiVersion: 'route.openshift.io/v1', + metadata: { + name, + namespace, + labels: { ...defaultLabels, ...userLabels }, + }, + spec: { + to: { + kind: 'Service', + name, + }, + // The service created by `createService` uses the same port as the container port. + port: { + // Use the port name, not the number for targetPort. The router looks + // at endpoints, not services, when resolving ports, so port numbers + // will not resolve correctly if the service port and container port + // numbers don't match. + targetPort: makePortName(firstPort), + }, + wildcardPolicy: 'None', + }, + }; + + return k8sCreate(RouteModel, route); +}; diff --git a/frontend/packages/dev-console/src/components/import/import-types.ts b/frontend/packages/dev-console/src/components/import/import-types.ts new file mode 100644 index 00000000000..0b62c6b3fb3 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/import-types.ts @@ -0,0 +1,72 @@ +import { K8sResourceKind, ContainerPort } from '@console/internal/module/k8s'; +import { NameValuePair, NameValueFromPair } from '../formik-fields/field-types'; + +export interface FirehoseList { + data?: K8sResourceKind[]; + [key: string]: any; +} + +export interface GitImportFormData { + name: string; + project: ProjectData; + application: ApplicationData; + git: GitData; + image: ImageData; + route: RouteData; + build: BuildData; + deployment: DeploymentData; + labels: { [name: string]: string }; +} + +export interface ApplicationData { + name: string; + selectedKey: string; +} + +export interface ImageData { + selected: string; + recommended: string; + tag: string; + ports: ContainerPort[]; +} + +export interface ProjectData { + name: string; +} + +export interface GitData { + url: string; + type: string; + ref: string; + dir: string; + showGitType: boolean; +} + +export interface RouteData { + create: boolean; +} + +export interface BuildData { + triggers: { + webhook: boolean; + image: boolean; + config: boolean; + }; + env: (NameValuePair | NameValueFromPair)[]; +} + +export interface DeploymentData { + triggers: { + image: boolean; + config: boolean; + }; + replicas: number; + env: (NameValuePair | NameValueFromPair)[]; +} + +export enum GitTypes { + '' = 'Please choose Git type', + github = 'GitHub', + gitlab = 'GitLab', + bitbucket = 'Bitbucket', +} diff --git a/frontend/packages/dev-console/src/components/import/import-validation-utils.ts b/frontend/packages/dev-console/src/components/import/import-validation-utils.ts new file mode 100644 index 00000000000..12d583c6528 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/import-validation-utils.ts @@ -0,0 +1,56 @@ +import * as yup from 'yup'; + +const urlRegex = /^(((ssh|git|https?):\/\/[\w]+)|(git@[\w]+.[\w]+:))([\w\-._~/?#[\]!$&'()*+,;=])+$/; + +export const validationSchema = yup.object().shape({ + name: yup.string().required('Required'), + project: yup.object().shape({ + name: yup.string().required('Required'), + }), + application: yup.object().shape({ + name: yup.string().required('Required'), + selectedKey: yup.string().required('Required'), + }), + image: yup.object().shape({ + selected: yup.string().required('Required'), + tag: yup.string().required('Required'), + }), + git: yup.object().shape({ + url: yup + .string() + .matches(urlRegex, 'Invalid Git URL') + .required('Required'), + type: yup.string().when('showGitType', { + is: true, + then: yup.string().required('We failed to detect the git type. Please choose a git type.'), + }), + showGitType: yup.boolean(), + }), + deployment: yup.object().shape({ + replicas: yup + .number() + .integer('Replicas must be an Integer') + .min(0, 'Replicas must be greater than or equal to 0.') + .test({ + name: 'isEmpty', + test: (value) => value !== undefined, + message: 'This field cannot be empty', + }), + }), +}); + +export const detectGitType = (url: string): string => { + if (!urlRegex.test(url)) { + return undefined; + } + if (url.includes('github.com')) { + return 'github'; + } + if (url.includes('bitbucket.org')) { + return 'bitbucket'; + } + if (url.includes('gitlab.com')) { + return 'gitlab'; + } + return ''; +}; diff --git a/frontend/packages/dev-console/src/components/import/route/RouteSection.tsx b/frontend/packages/dev-console/src/components/import/route/RouteSection.tsx new file mode 100644 index 00000000000..bda90465dcf --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/route/RouteSection.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { CheckboxField } from '../../formik-fields'; +import FormSection from '../section/FormSection'; + +const RouteSection: React.FC = () => { + return ( + + + + ); +}; + +export default RouteSection; diff --git a/frontend/packages/dev-console/src/components/import/section/FormSection.tsx b/frontend/packages/dev-console/src/components/import/section/FormSection.tsx new file mode 100644 index 00000000000..5dea7cf65ba --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/section/FormSection.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import FormSectionHeading from './FormSectionHeading'; +import FormSectionDivider from './FormSectionDivider'; +import FormSectionSubHeading from './FormSectionSubHeading'; + +export interface FormSectionProps { + title: string; + subTitle?: string; + divider?: boolean; + children: React.ReactNode; +} + +const FormSection: React.FC = ({ title, subTitle, divider, children }) => ( + + + {subTitle && } + {children} + {divider && } + +); + +export default FormSection; diff --git a/frontend/packages/dev-console/src/components/import/section/FormSectionDivider.tsx b/frontend/packages/dev-console/src/components/import/section/FormSectionDivider.tsx new file mode 100644 index 00000000000..965d47220ae --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/section/FormSectionDivider.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; + +const FormSectionDivider: React.FC = () => ( +
+); + +export default FormSectionDivider; diff --git a/frontend/packages/dev-console/src/components/import/section/FormSectionHeading.tsx b/frontend/packages/dev-console/src/components/import/section/FormSectionHeading.tsx new file mode 100644 index 00000000000..d2751b7065b --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/section/FormSectionHeading.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { SectionHeading } from '@console/internal/components/utils'; + +export interface FormSectionHeadingProps { + title: string; +} + +const FormSectionHeading: React.FC = ({ title }) => ( + +); + +export default FormSectionHeading; diff --git a/frontend/packages/dev-console/src/components/import/section/FormSectionSubHeading.tsx b/frontend/packages/dev-console/src/components/import/section/FormSectionSubHeading.tsx new file mode 100644 index 00000000000..1a660ad128d --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/section/FormSectionSubHeading.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { HelpBlock } from 'patternfly-react'; + +export interface FormSectionHeadingProps { + title: string; +} + +const FormSectionSubHeading = ({ subTitle }) => ( + {subTitle} +); + +export default FormSectionSubHeading; diff --git a/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunDetails.tsx b/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunDetails.tsx new file mode 100644 index 00000000000..1491eabd5fd --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunDetails.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { SectionHeading, ResourceSummary } from '@console/internal/components/utils'; +import { K8sResourceKind } from '@console/internal/module/k8s'; +import { PipelineRunVisualization } from './PipelineRunVisualization'; + +export interface PipelineRunDetailsProps { + obj: K8sResourceKind; +} + +export const PipelineRunDetails: React.FC = ({ obj: pipelineRun }) => { + return ( +
+ + +
+
+ +
+
+
+ ); +}; diff --git a/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunDetailsPage.tsx b/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunDetailsPage.tsx new file mode 100644 index 00000000000..4cee98e7c28 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunDetailsPage.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { DetailsPage, DetailsPageProps } from '@console/internal/components/factory'; +import { navFactory } from '@console/internal/components/utils'; +import { PipelineRunDetails } from './PipelineRunDetails'; + +const PipelineRunDetailsPage: React.FC = (props) => ( + +); + +export default PipelineRunDetailsPage; diff --git a/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunHeader.tsx b/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunHeader.tsx new file mode 100644 index 00000000000..b2f3b7eef24 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunHeader.tsx @@ -0,0 +1,49 @@ +import { sortable } from '@patternfly/react-table'; +import { tableColumnClasses } from './pipelinerun-table'; + +const PipelineRunHeader = () => { + return [ + { + title: 'Name', + sortField: 'metadata.name', + transforms: [sortable], + props: { className: tableColumnClasses[0] }, + }, + { + title: 'Started', + sortField: 'metadata.labels', + transforms: [sortable], + props: { className: tableColumnClasses[1] }, + }, + { + title: 'Status', + sortField: 'podPhase', + transforms: [sortable], + props: { className: tableColumnClasses[2] }, + }, + { + title: 'Task Progress', + sortField: 'podReadiness', + transforms: [sortable], + props: { className: tableColumnClasses[3] }, + }, + { + title: 'Duration', + sortField: 'podPhase', + transforms: [sortable], + props: { className: tableColumnClasses[4] }, + }, + { + title: 'Trigger', + sortField: 'podReadiness', + transforms: [sortable], + props: { className: tableColumnClasses[5] }, + }, + { + title: '', + props: { className: tableColumnClasses[6] }, + }, + ]; +}; + +export default PipelineRunHeader; diff --git a/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunList.tsx b/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunList.tsx new file mode 100644 index 00000000000..4581a158552 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunList.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { Table } from '@console/internal/components/factory'; +import PipelineRunHeader from './PipelineRunHeader'; +import PipelineRunRow from './PipelineRunRow'; +import { PipelineRunModel } from '../../models'; + +export const PipelineRunList: React.FC = (props) => ( + +); + +export default PipelineRunList; diff --git a/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunRow.tsx b/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunRow.tsx new file mode 100644 index 00000000000..28cb28cb064 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunRow.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { TableRow, TableData } from '@console/internal/components/factory'; +import { + Kebab, + ResourceLink, + StatusIcon, + Timestamp, + ResourceKebab, +} from '@console/internal/components/utils'; +import { referenceForModel } from '@console/internal/module/k8s'; +import { pipelineRunFilterReducer } from '../../utils/pipeline-filter-reducer'; +import { reRunPipelineRun, stopPipelineRun } from '../../utils/pipeline-actions'; +import { PipelineRun } from '../../utils/pipeline-augment'; +import { tableColumnClasses } from './pipelinerun-table'; +import { PipelineRunModel } from '../../models'; + +const pipelinerunReference = referenceForModel(PipelineRunModel); + +interface PipelineRunRowProps { + obj: PipelineRun; + index: number; + key?: string; + style: object; +} + +const PipelineRunRow: React.FC = ({ obj, index, key, style }) => { + const menuActions = [reRunPipelineRun(obj), stopPipelineRun(obj), ...Kebab.factory.common]; + return ( + + + + + + + + + + + + - + - + + {obj.spec && obj.spec.trigger && obj.spec.trigger.type ? obj.spec.trigger.type : '-'} + + + + + + ); +}; + +export default PipelineRunRow; diff --git a/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunVisualization.tsx b/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunVisualization.tsx new file mode 100644 index 00000000000..55c9561ae57 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelineruns/PipelineRunVisualization.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { K8sResourceKind, k8sGet } from '@console/internal/module/k8s'; +import { PipelineVisualizationGraph } from '../pipelines/PipelineVisualizationGraph'; +import { getPipelineTasks } from '../../utils/pipeline-utils'; + +import { PipelineModel } from '../../models'; + +export interface PipelineRunVisualizationProps { + pipelineRun: K8sResourceKind; +} + +export interface PipelineVisualizationRunState { + pipeline: K8sResourceKind; +} + +export class PipelineRunVisualization extends React.Component< + PipelineRunVisualizationProps, + PipelineVisualizationRunState +> { + constructor(props) { + super(props); + this.state = { pipeline: { apiVersion: '', metadata: {}, kind: 'PipelineRun' } }; + } + + componentDidMount() { + // eslint-disable-next-line promise/catch-or-return + k8sGet( + PipelineModel, + this.props.pipelineRun.spec.pipelineRef.name, + this.props.pipelineRun.metadata.namespace, + ).then((res) => { + this.setState({ + pipeline: res, + }); + }); + } + + render() { + return ( + + ); + } +} diff --git a/frontend/packages/dev-console/src/components/pipelineruns/pipelinerun-table.ts b/frontend/packages/dev-console/src/components/pipelineruns/pipelinerun-table.ts new file mode 100644 index 00000000000..2e76442c552 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelineruns/pipelinerun-table.ts @@ -0,0 +1,11 @@ +import { Kebab } from '@console/internal/components/utils'; + +export const tableColumnClasses = [ + 'col-lg-2 col-md-2 col-sm-4 col-xs-4', + 'col-lg-2 col-md-2 col-sm-4 col-xs-4', + 'col-lg-2 col-md-2 col-sm-2 col-xs-2', + 'col-lg-2 col-md-2 hidden-sm hidden-xs', + 'col-lg-2 col-md-2 hidden-sm hidden-xs', + 'col-lg-1 col-md-1 hidden-sm hidden-xs', + Kebab.columnClass, +]; diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineAugmentRuns.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelineAugmentRuns.tsx new file mode 100644 index 00000000000..95a1a86dd21 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineAugmentRuns.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { Table } from '@console/internal/components/factory'; +import PipelineHeader from './PipelineHeader'; +import PipelineRow from './PipelineRow'; +import { augmentRunsToData, PropPipelineData, KeyedRuns } from '../../utils/pipeline-augment'; +import { PipelineModel } from '../../models'; + +export type PipelineAugmentRunsProps = { + data?: PropPipelineData[]; + propsReferenceForRuns?: string[]; +}; + +// Firehose injects a lot of props and some of those are considered the KeyedRuns +const PipelineAugmentRuns: React.FC = ({ + data, + propsReferenceForRuns, + ...props +}) => ( +
+); + +export default PipelineAugmentRuns; diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineDetails.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelineDetails.tsx new file mode 100644 index 00000000000..9d2fce9a881 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineDetails.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { SectionHeading, ResourceSummary, ResourceLink } from '@console/internal/components/utils'; +import { referenceForModel } from '@console/internal/module/k8s'; +import { PipelineVisualization } from './PipelineVisualization'; +import { TaskModel } from '../../models'; + +const PipelineDetails = ({ obj: pipeline }) => ( +
+ + +
+
+ +
+
+ +
+ {pipeline.spec.tasks.map((task) => { + return ( + +
Name: {task.name}
+
+ Ref:{' '} + +
+
+ ); + })} +
+
+
+
+); + +export default PipelineDetails; diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineDetailsPage.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelineDetailsPage.tsx new file mode 100644 index 00000000000..9779356640a --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineDetailsPage.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { DetailsPage, DetailsPageProps } from '@console/internal/components/factory'; +import { Kebab, navFactory } from '@console/internal/components/utils'; +import { k8sGet, k8sList } from '@console/internal/module/k8s'; +import PipelinEnvironmentComponent from './PipelineEnvironment'; +import PipelineDetails from './PipelineDetails'; +import PipelineRuns from './PipelineRuns'; +import { triggerPipeline, rerunPipeline } from '../../utils/pipeline-actions'; +import { getLatestRun } from '../../utils/pipeline-augment'; +import { PipelineRunModel, PipelineModel } from '../../models'; + +interface PipelineDetailsPageStates { + menuActions: Function[]; +} + +class PipelineDetailsPage extends React.Component { + constructor(props) { + super(props); + this.state = { menuActions: [] }; + } + + componentDidMount() { + // eslint-disable-next-line promise/catch-or-return + k8sGet(PipelineModel, this.props.name, this.props.namespace).then((res) => { + // eslint-disable-next-line promise/no-nesting, promise/catch-or-return + k8sList(PipelineRunModel, { + labelSelector: { 'tekton.dev/pipeline': res.metadata.name }, + }).then((listres) => { + this.setState({ + menuActions: [ + triggerPipeline(res, getLatestRun({ data: listres }, 'creationTimestamp'), 'pipelines'), + rerunPipeline(res, getLatestRun({ data: listres }, 'creationTimestamp'), 'pipelines'), + ...Kebab.factory.common, + ], + }); + }); + }); + } + + render() { + return ( + + ); + } +} + +export default PipelineDetailsPage; diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineEnvironment.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelineEnvironment.tsx new file mode 100644 index 00000000000..83318f1fcbb --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineEnvironment.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { AsyncComponent } from '@console/internal/components/utils'; + +const PipelineEnvironmentTab = (props) => ( + + import('@console/internal/components/environment.jsx').then((c) => c.EnvironmentPage) + } + {...props} + /> +); + +const envPath = ['spec', 'containers']; +const PipelinenvironmentComponent = (props) => ( + +); + +export default PipelinenvironmentComponent; diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineHeader.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelineHeader.tsx new file mode 100644 index 00000000000..24d2a0f2ea3 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineHeader.tsx @@ -0,0 +1,43 @@ +import { sortable } from '@patternfly/react-table'; +import { tableColumnClasses } from './pipeline-table'; + +const PipelineHeader = () => { + return [ + { + title: 'Name', + sortField: 'metadata.name', + transforms: [sortable], + props: { className: tableColumnClasses[0] }, + }, + { + title: 'Last Run', + sortField: 'lastRun.metadata.name', + transforms: [sortable], + props: { className: tableColumnClasses[1] }, + }, + { + title: 'Last Run Status', + sortField: 'latestRun.status.succeededCondition', + transforms: [sortable], + props: { className: tableColumnClasses[2] }, + }, + { + title: 'Task Status', + sortField: 'latestRun.status.completionTime', + transforms: [sortable], + props: { className: tableColumnClasses[3] }, + }, + { + title: 'Last Run Time', + sortField: 'latestRun.status.completionTime', + transforms: [sortable], + props: { className: tableColumnClasses[4] }, + }, + { + title: '', + props: { className: tableColumnClasses[5] }, + }, + ]; +}; + +export default PipelineHeader; diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineList.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelineList.tsx new file mode 100644 index 00000000000..7187a3da5fc --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineList.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { Firehose } from '@console/internal/components/utils'; +import { Table } from '@console/internal/components/factory'; +import PipelineHeader from './PipelineHeader'; +import PipelineRow from './PipelineRow'; +import PipelineAugmentRuns from './PipelineAugmentRuns'; +import { getResources, PropPipelineData, Resource } from '../../utils/pipeline-augment'; +import { PipelineModel } from '../../models'; + +export interface PipelineListProps { + data?: PropPipelineData[]; +} + +const PipelineList: React.FC = (props) => { + const { propsReferenceForRuns, resources }: Resource = getResources(props.data); + return resources && resources.length > 0 ? ( + + + + ) : ( +
+ ); +}; + +export default PipelineList; diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineRow.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelineRow.tsx new file mode 100644 index 00000000000..38ee085da9a --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineRow.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { TableRow, TableData } from '@console/internal/components/factory'; +import { + Kebab, + ResourceLink, + StatusIcon, + Timestamp, + ResourceKebab, +} from '@console/internal/components/utils'; +import { referenceForModel } from '@console/internal/module/k8s'; +import { pipelineFilterReducer } from '../../utils/pipeline-filter-reducer'; +import { Pipeline } from '../../utils/pipeline-augment'; +import { tableColumnClasses } from './pipeline-table'; +import { PipelineModel, PipelineRunModel } from '../../models'; +import { triggerPipeline, rerunPipeline } from '../../utils/pipeline-actions'; + +const pipelineReference = referenceForModel(PipelineModel); +const pipelinerunReference = referenceForModel(PipelineRunModel); + +interface PipelineRowProps { + obj: Pipeline; + index: number; + key?: string; + style: object; +} + +const PipelineRow: React.FC = ({ obj, index, key, style }) => { + const menuActions = [ + triggerPipeline(obj, obj.latestRun, ''), + rerunPipeline(obj, obj.latestRun, ''), + ...Kebab.factory.common, + ]; + return ( + + + + + + {obj.latestRun && obj.latestRun.metadata && obj.latestRun.metadata.name ? ( + + ) : ( + '-' + )} + + + + + - + + + + + + + + ); +}; + +export default PipelineRow; diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineRuns.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelineRuns.tsx new file mode 100644 index 00000000000..7f0d39c2a29 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineRuns.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { ListPage } from '@console/internal/components/factory'; +import PipelineRunsList from '../pipelineruns/PipelineRunList'; +import { + pipelineRunFilterReducer, + pipelineRunStatusFilter, +} from '../../utils/pipeline-filter-reducer'; +import { PipelineRunModel } from '../../models'; + +const filters = [ + { + type: 'pipelinerun-status', + selected: ['Succeeded'], + reducer: pipelineRunFilterReducer, + items: [ + { id: 'Succeeded', title: 'Complete' }, + { id: 'Failed', title: 'Failed' }, + { id: 'Running', title: 'Running' }, + ], + filter: pipelineRunStatusFilter, + }, +]; + +interface PipelineRunsProps { + obj: any; +} + +const PipelineRuns: React.FC = ({ obj }) => ( + +); + +export default PipelineRuns; diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineVisualization.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelineVisualization.tsx new file mode 100644 index 00000000000..657f728af99 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineVisualization.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import { K8sResourceKind } from '@console/internal/module/k8s'; +import { getPipelineTasks } from '../../utils/pipeline-utils'; +import { PipelineVisualizationGraph } from './PipelineVisualizationGraph'; + +export interface PipelineVisualizationProps { + pipeline?: K8sResourceKind; +} + +export const PipelineVisualization: React.FC = ({ pipeline }) => ( + +); diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationGraph.scss b/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationGraph.scss new file mode 100644 index 00000000000..5adf7944de6 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationGraph.scss @@ -0,0 +1,81 @@ +$border-color: var(--pf-global--BorderColor--light-100); +$gutter: 1.8em; + +.odc-pipeline-vis-graph { + background: var(--pf-global--BackgroundColor--100); + padding: var(--pf-global--spacer--md); + margin: var(--pf-global--spacer--md) 0; + height: 20em; + overflow: auto; + position: relative; + -webkit-overflow-scrolling: touch; + + // reset + &__stage-column { + list-style: none; + padding: 0; + margin: 0; + } + + &__stages { + display: inline-flex; + } + + &__stage { + margin: 0 2em; + + &:not(:first-child) { + & .odc-pipeline-vis-task:first-child { + &::before { + content: ''; + position: absolute; + top: 1em; + + border-top: 1px solid $border-color; + width: $gutter * 1.7 + 1; + height: 0; + } + } + } + } + + &__stage.is-parallel { + & .odc-pipeline-vis-task { + &:first-child { + &::before, + &::after { + content: ''; + position: absolute; + top: 1em; + border-top: 1px solid $border-color; + width: $gutter * 1.7 + 1; + height: 0; + } + &::before { + left: 0; + transform: translateX(-100%); + } + &::after { + right: 0; + transform: translateX(100%); + } + } + } + &:last-child { + & .odc-pipeline-vis-task:first-child { + &::after { + content: ''; + width: $gutter / 2 + 0.05; + } + } + } + } + &__stage:not(.is-parallel) { + & .odc-pipeline-vis-task:first-child { + &::before { + left: 0; + transform: translateX(-100%); + } + } + } +} diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationGraph.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationGraph.tsx new file mode 100644 index 00000000000..6f0f46dc8ee --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationGraph.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import * as cx from 'classnames'; +import { ChevronCircleRightIcon } from '@patternfly/react-icons'; +import { PipelineVisualizationTask } from './PipelineVisualizationTask'; +import { PipelineVisualizationTaskItem } from '../../utils/pipeline-utils'; + +import './PipelineVisualizationGraph.scss'; + +export interface PipelineVisualizationGraphProps { + graph: PipelineVisualizationTaskItem[][]; + namespace: string; +} + +export const PipelineVisualizationGraph: React.FC = ({ + graph, + namespace, +}) => { + return ( +
+
+
+
+ +
+
+ {graph.map((stage) => { + return ( +
1 })} + key={stage.map((t) => t.taskRef.name).join(',')} + > +
    + {stage.map((task) => { + return ( + + ); + })} +
+
+ ); + })} +
+
+ ); +}; diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationStepList.scss b/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationStepList.scss new file mode 100644 index 00000000000..0997a79c36e --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationStepList.scss @@ -0,0 +1,20 @@ +.odc-pipeline-vis-steps-list { + list-style: none; + padding: 0; + width: 10em; + &__item { + padding: 5px; + border-radius: 10px; + margin-bottom: 10px; + border: 1px solid var(--pf-global--BorderColor--light-100); + background: var(--pf-global--BackgroundColor--200); + color: var(--pf-global--Color--dark-100); + justify-content: center; + align-items: center; + display: flex; + text-align: center; + &:last-child { + margin-bottom: 0px; + } + } +} diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationStepList.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationStepList.tsx new file mode 100644 index 00000000000..946e7b896a4 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationStepList.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; + +import './PipelineVisualizationStepList.scss'; + +export interface PipelineVisualizationStepListProps { + steps: { name: string }[]; +} +export const PipelineVisualizationStepList: React.FC = ({ + steps, +}) => ( +
    + {steps.map((step) => { + return ( +
  • + {step.name} +
  • + ); + })} +
+); diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationTask.scss b/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationTask.scss new file mode 100644 index 00000000000..230aeb89603 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationTask.scss @@ -0,0 +1,116 @@ +$border-color: var(--pf-global--BorderColor--light-100); +$gutter: 1.8em; +.odc-pipeline-vis-task { + position: relative; + width: 13em; + height: 2em; + white-space: normal; + border: 1px solid $border-color; + border-radius: 5px; + background-color: var(--pf-global--BackgroundColor--200); + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + margin-top: 1.5em; + padding: 0.5em; + &__content { + width: inherit; + display: flex; + position: relative; + top: 1px; + } + + &.is-done { + color: var(--pf-global--success-color--200); + } + + &.is-running { + color: var(--pf-global--primary-color--dark-100); + } + + &.is-error { + color: var(--pf-global--danger-color--100); + } + + &.is-idle { + color: var(--pf-global--disabled-color--100); + } + + &.is-input-node { + width: 2em; + height: 2em; + border-radius: 2em; + color: var(--pf-global--BackgroundColor--200); + background: $border-color; + &::after { + content: ''; + position: absolute; + transform: translateY(-100%); + left: 2em; + bottom: -1em; + } + } + + &:not(:first-child) { + margin-top: 3em; + } + + &__stepcount { + margin: 0 4px; + white-space: nowrap; + } + + &__status { + margin: 0 4px; + width: 1.5em; + flex-grow: 0; + flex-shrink: 0; + order: -1; + display: flex; + justify-content: center; + align-items: center; + font-size: 1.1em; + } + + &__title { + flex-grow: 1; + flex-shrink: 1; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + margin: 0px 4px; + &:hover { + cursor: pointer; + } + &.is-text-center { + text-align: center; + } + } + // Connect each task + &:not(:first-child) { + &::after, + &::before { + content: ''; + top: -4em; + position: absolute; + border-bottom: 1px solid $border-color; + width: $gutter / 2; + height: calc(5em + 1px); + } + + // Right connecting lines + &::after { + right: 0; + transform: translateX(100%); + border-right: 1px solid $border-color; + } + + // Left connecting lines + &::before { + left: 0; + transform: translateX(-100%); + border-left: 1px solid $border-color; + } + } +} diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationTask.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationTask.tsx new file mode 100644 index 00000000000..0b6a561f8df --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineVisualizationTask.tsx @@ -0,0 +1,108 @@ +import * as React from 'react'; +import * as cx from 'classnames'; +import { Tooltip } from '@patternfly/react-core'; +import { + SyncAltIcon, + CheckCircleIcon, + ErrorCircleOIcon, + CircleIcon, +} from '@patternfly/react-icons'; +import { K8sResourceKind } from '@console/internal/module/k8s'; +import { Firehose } from '@console/internal/components/utils'; +import { PipelineVisualizationStepList } from './PipelineVisualizationStepList'; +import { TaskStatusClassNameMap } from '../../utils/pipeline-utils'; + +import './PipelineVisualizationTask.scss'; + +interface TaskProps { + loaded?: boolean; + task?: { + data: K8sResourceKind; + }; + status?: { + reason: string; + duration: string; + }; + namespace: string; +} + +interface PipelineVisualizationTaskProp { + namespace: string; + task: { + taskRef: { + name: string; + }; + status?: { + reason: string; + duration: string; + }; + }; + taskRun?: string; +} + +interface StatusIconProps { + status: string; +} +export const StatusIcon: React.FC = ({ status }) => { + switch (status) { + case 'In Progress': + return ; + + case 'Succeeded': + return ; + + case 'Failed': + return ; + + default: + return ; + } +}; + +export const PipelineVisualizationTask: React.FC = (props) => { + return ( + + + + ); +}; +const TaskComponent: React.FC = (props) => { + const task = props.task.data; + const { status } = props; + + const getTaskStatusClass = (taskStatus = 'Idle') => { + return TaskStatusClassNameMap[taskStatus]; + }; + return ( +
  • + } + > +
    +
    + {task.metadata ? task.metadata.name : ''} +
    + {status && status.reason && ( +
    + +
    + )} + {status && status.duration && ( +
    ({status.duration})
    + )} +
    +
    +
  • + ); +}; diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelinesPage.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelinesPage.tsx new file mode 100644 index 00000000000..b6b3aa3ce0d --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/PipelinesPage.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { ListPage } from '@console/internal/components/factory'; +import PipelineList from './PipelineList'; +import { pipelineFilterReducer, pipelineStatusFilter } from '../../utils/pipeline-filter-reducer'; +import { PipelineModel } from '../../models'; + +const filters = [ + { + type: 'pipeline-status', + selected: ['Running', 'Failed', 'Complete'], + reducer: pipelineFilterReducer, + items: [ + { id: 'Running', title: 'Running' }, + { id: 'Failed', title: 'Failed' }, + { id: 'Complete', title: 'Complete' }, + ], + filter: pipelineStatusFilter, + }, +]; + +const PipelinesPage: React.FC = (props) => ( + +); + +export default PipelinesPage; diff --git a/frontend/packages/dev-console/src/components/pipelines/__mocks__/PipelineVisualizationTask.tsx b/frontend/packages/dev-console/src/components/pipelines/__mocks__/PipelineVisualizationTask.tsx new file mode 100644 index 00000000000..ef30a84db77 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/__mocks__/PipelineVisualizationTask.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { TaskStatusClassNameMap } from '../../../utils/pipeline-utils'; + +export const PipelineVisualizationTask = ({ task }) => ( +
  • + {task.name} + {task.status && task.status.reason && ( + {task.status.reason} + )} + {task.status && task.status.duration && {task.status.duration}} +
  • +); diff --git a/frontend/packages/dev-console/src/components/pipelines/__tests__/Pipeline.spec.tsx b/frontend/packages/dev-console/src/components/pipelines/__tests__/Pipeline.spec.tsx new file mode 100644 index 00000000000..3a602a22a0a --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/__tests__/Pipeline.spec.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { ListPage } from '@console/internal/components/factory'; +import PipelinePage from '../PipelinesPage'; +import PipelineRuns from '../PipelineRuns'; + +const pipelinePageProps = { + namespace: 'all-namespaces', +}; + +const pipelineRunProps = { + obj: { + metadata: { + name: 'pipeline-a', + }, + }, +}; + +const pipelineWrapper = shallow(); +const pipelineRunWrapper = shallow(); + +describe('Pipeline List', () => { + it('Renders a list', () => { + expect(pipelineWrapper.exists()).toBe(true); + expect(pipelineWrapper.find(ListPage).exists()); + }); + + it('List renders Pipeline resources', () => { + expect(pipelineWrapper.exists()).toBe(true); + expect(pipelineWrapper.find(ListPage).prop('kind')).toMatch('Pipeline'); + }); + + it('List renders Pipeline namespace with all-namespaces', () => { + expect(pipelineWrapper.find(ListPage).prop('namespace')).toBe('all-namespaces'); + }); + + it('List renders Pipeline with canCreate false', () => { + expect(pipelineWrapper.find(ListPage).prop('canCreate')).toBeFalsy(); + }); + + it('List renders Pipeline with default filter', () => { + expect(pipelineWrapper.find(ListPage).prop('rowFilters')[0].type).toEqual('pipeline-status'); + expect(pipelineWrapper.find(ListPage).prop('rowFilters')[0].items).toHaveLength(3); + }); +}); + +describe('Pipeline Run List', () => { + it('Renders a list', () => { + expect(pipelineRunWrapper.exists()).toBe(true); + expect(pipelineRunWrapper.find(ListPage).exists()); + }); + + it('List renders PipelineRun resources', () => { + expect(pipelineRunWrapper.exists()).toBe(true); + expect(pipelineRunWrapper.find(ListPage).prop('kind')).toMatch('PipelineRun'); + }); +}); diff --git a/frontend/packages/dev-console/src/components/pipelines/__tests__/PipelineVisualizationGraph.spec.tsx b/frontend/packages/dev-console/src/components/pipelines/__tests__/PipelineVisualizationGraph.spec.tsx new file mode 100644 index 00000000000..d0513cb85ec --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/__tests__/PipelineVisualizationGraph.spec.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import * as Renderer from 'react-test-renderer'; +import { PipelineVisualizationGraph } from '../PipelineVisualizationGraph'; +import { PipelineVisualizationProps } from '../PipelineVisualization'; +import { mockPipelineGraph } from './pipeline-visualization-test-data'; +import { getPipelineTasks } from '../../../utils/pipeline-utils'; +import { mockPipeline } from './pipeline-mock'; +import { mockPipelineRun } from './pipelinerun-mock'; + +jest.mock('react-dom', () => ({ + findDOMNode: () => ({}), + createPortal: (node) => node, +})); + +jest.mock('../PipelineVisualizationTask'); +describe('PipelineVisualizationGraph', () => { + const props = { + namespace: 'test', + graph: mockPipelineGraph, + }; + let wrapper: ShallowWrapper; + beforeEach(() => { + wrapper = shallow(); + }); + + it('renders a Pipeline visualization graph', () => { + expect(wrapper.exists()).toBeTruthy(); + }); + + it('should contain right number of stages', () => { + const noOfStages = wrapper.find('.odc-pipeline-vis-graph__stage-column').length; + + expect(noOfStages).toEqual(mockPipelineGraph.length); + }); + + it('should match the previous pipeline snapshot', () => { + const tree = Renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should match the previous pipelineRun snapshot', () => { + const graph = getPipelineTasks(mockPipeline, mockPipelineRun); + const tree = Renderer.create( + , + ).toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/frontend/packages/dev-console/src/components/pipelines/__tests__/__snapshots__/PipelineVisualizationGraph.spec.tsx.snap b/frontend/packages/dev-console/src/components/pipelines/__tests__/__snapshots__/PipelineVisualizationGraph.spec.tsx.snap new file mode 100644 index 00000000000..bb03f0a5061 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/__tests__/__snapshots__/PipelineVisualizationGraph.spec.tsx.snap @@ -0,0 +1,197 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PipelineVisualizationGraph should match the previous pipeline snapshot 1`] = ` +
    +
    +
    +
    + + + +
    +
    +
    +
      +
    • + + start-app + +
    • +
    +
    +
    +
      +
    • + + test-app-1 + +
    • +
    • + + test-app-2 + +
    • +
    +
    +
    +
      +
    • + + build-image-1 + +
    • +
    • + + build-image-2 + +
    • +
    +
    +
    +
      +
    • + + deploy + +
    • +
    +
    +
    +
    +`; + +exports[`PipelineVisualizationGraph should match the previous pipelineRun snapshot 1`] = ` +
    +
    +
    +
    + + + +
    +
    +
    +
      +
    • + + build-skaffold-web + + + Succeeded + + + 32s + +
    • +
    +
    +
    +
      +
    • + + deploy-web + + + Succeeded + + + 32s + +
    • +
    +
    +
    +
    +`; diff --git a/frontend/packages/dev-console/src/components/pipelines/__tests__/pipeline-mock.ts b/frontend/packages/dev-console/src/components/pipelines/__tests__/pipeline-mock.ts new file mode 100644 index 00000000000..41808412786 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/__tests__/pipeline-mock.ts @@ -0,0 +1,94 @@ +export const mockPipeline = { + apiVersion: 'tekton.dev/v1alpha1', + kind: 'Pipeline', + metadata: { + annotations: { + 'kubectl.kubernetes.io/last-applied-configuration': + '{"apiVersion":"tekton.dev/v1alpha1","kind":"Pipeline","metadata":{"annotations":{},"name":"tutorial-pipeline","namespace":"tekton-pipelines"},"spec":{"resources":[{"name":"source-repo","type":"git"},{"name":"web-image","type":"image"}],"tasks":[{"name":"build-skaffold-web","params":[{"name":"pathToDockerFile","value":"Dockerfile"},{"name":"pathToContext","value":"/workspace/docker-source/examples/microservices/leeroy-web"}],"resources":{"inputs":[{"name":"docker-source","resource":"source-repo"}],"outputs":[{"name":"builtImage","resource":"web-image"}]},"taskRef":{"name":"build-docker-image-from-git-source"}},{"name":"deploy-web","params":[{"name":"path","value":"/workspace/source/examples/microservices/leeroy-web/kubernetes/deployment.yaml"},{"name":"yqArg","value":"-d1"},{"name":"yamlPathToImage","value":"spec.template.spec.containers[0].image"}],"resources":{"inputs":[{"name":"source","resource":"source-repo"},{"from":["build-skaffold-web"],"name":"image","resource":"web-image"}]},"taskRef":{"name":"deploy-using-kubectl"}}]}}\n', + }, + creationTimestamp: '2019-06-08T17:22:54Z', + generation: 1, + name: 'tutorial-pipeline', + namespace: 'tekton-pipelines', + resourceVersion: '517078', + selfLink: '/apis/tekton.dev/v1alpha1/namespaces/tekton-pipelines/pipelines/tutorial-pipeline', + uid: '0d4a9e56-8a12-11e9-8eab-52fdfc072182', + }, + spec: { + resources: [ + { + name: 'source-repo', + type: 'git', + }, + { + name: 'web-image', + type: 'image', + }, + ], + tasks: [ + { + name: 'build-skaffold-web', + params: [ + { + name: 'pathToDockerFile', + value: 'Dockerfile', + }, + { + name: 'pathToContext', + value: '/workspace/docker-source/examples/microservices/leeroy-web', + }, + ], + resources: { + inputs: [ + { + name: 'docker-source', + resource: 'source-repo', + }, + ], + outputs: [ + { + name: 'builtImage', + resource: 'web-image', + }, + ], + }, + taskRef: { + name: 'build-docker-image-from-git-source', + }, + }, + { + name: 'deploy-web', + params: [ + { + name: 'path', + value: '/workspace/source/examples/microservices/leeroy-web/kubernetes/deployment.yaml', + }, + { + name: 'yqArg', + value: '-d1', + }, + { + name: 'yamlPathToImage', + value: 'spec.template.spec.containers[0].image', + }, + ], + resources: { + inputs: [ + { + name: 'source', + resource: 'source-repo', + }, + { + from: ['build-skaffold-web'], + name: 'image', + resource: 'web-image', + }, + ], + }, + taskRef: { + name: 'deploy-using-kubectl', + }, + }, + ], + }, +}; diff --git a/frontend/packages/dev-console/src/components/pipelines/__tests__/pipeline-visualization-test-data.ts b/frontend/packages/dev-console/src/components/pipelines/__tests__/pipeline-visualization-test-data.ts new file mode 100644 index 00000000000..00f5d40497b --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/__tests__/pipeline-visualization-test-data.ts @@ -0,0 +1,133 @@ +import { PipelineVisualizationTaskItem } from '../../../utils/pipeline-utils'; + +export const mockPipelineGraph: PipelineVisualizationTaskItem[][] = [ + [ + { + name: 'start-app', + resources: { + inputs: [ + { + name: 'workspace-git', + resource: 'mapit-git', + }, + ], + outputs: [ + { + name: 'workspace-git', + resource: 'mapit-git', + }, + ], + }, + taskRef: { + name: 'start-app', + }, + }, + ], + [ + { + name: 'test-app-1', + resources: { + inputs: [ + { + from: ['start-app'], + name: 'workspace-git', + resource: 'mapit-git', + }, + ], + }, + taskRef: { + name: 'test-app-1', + }, + }, + { + name: 'test-app-2', + resources: { + inputs: [ + { + from: ['start-app'], + name: 'workspace-git', + resource: 'mapit-git', + }, + ], + }, + taskRef: { + name: 'test-app-2', + }, + }, + ], + [ + { + name: 'build-image-1', + params: [ + { + name: 'dockerfile', + value: 'Dockerfile.openjdk', + }, + { + name: 'verifyTLS', + value: 'false', + }, + ], + resources: { + inputs: [ + { + from: ['build-app'], + name: 'workspace-git', + resource: 'mapit-git', + }, + ], + outputs: [ + { + name: 'image', + resource: 'mapit-image', + }, + ], + }, + runAfter: ['test-app-1', 'test-app-2'], + taskRef: { + name: 'build-image-1', + }, + }, + { + name: 'build-image-2', + params: [ + { + name: 'dockerfile', + value: 'Dockerfile.openjdk', + }, + { + name: 'verifyTLS', + value: 'false', + }, + ], + resources: { + inputs: [ + { + from: ['build-app'], + name: 'workspace-git', + resource: 'mapit-git', + }, + ], + outputs: [ + { + name: 'image', + resource: 'mapit-image', + }, + ], + }, + runAfter: ['test-app-1', 'test-app-2'], + taskRef: { + name: 'build-image-2', + }, + }, + ], + [ + { + name: 'deploy', + runAfter: ['build-image-1', 'build-image-2'], + taskRef: { + name: 'openshift-cli-deploy-mapit', + }, + }, + ], +]; diff --git a/frontend/packages/dev-console/src/components/pipelines/__tests__/pipelinerun-mock.ts b/frontend/packages/dev-console/src/components/pipelines/__tests__/pipelinerun-mock.ts new file mode 100644 index 00000000000..b1c22af9cf9 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/__tests__/pipelinerun-mock.ts @@ -0,0 +1,170 @@ +export const mockPipelineRun = { + apiVersion: 'tekton.dev/v1alpha1', + kind: 'PipelineRun', + metadata: { + annotations: { + 'kubectl.kubernetes.io/last-applied-configuration': + '{"apiVersion":"tekton.dev/v1alpha1","kind":"PipelineRun","metadata":{"annotations":{},"name":"tutorial-pipeline-run-1","namespace":"tekton-pipelines"},"spec":{"pipelineRef":{"name":"tutorial-pipeline"},"resources":[{"name":"source-repo","resourceRef":{"name":"skaffold-git"}},{"name":"web-image","resourceRef":{"name":"skaffold-image-leeroy-web"}}],"trigger":{"type":"manual"}}}\n', + }, + selfLink: + '/apis/tekton.dev/v1alpha1/namespaces/tekton-pipelines/pipelineruns/tutorial-pipeline-run-1', + resourceVersion: '531347', + name: 'tutorial-pipeline-run-1', + uid: 'ebff21e8-8a1a-11e9-8eab-52fdfc072182', + creationTimestamp: '2019-06-08T18:26:24Z', + generation: 1, + namespace: 'tekton-pipelines', + labels: { + 'tekton.dev/pipeline': 'tutorial-pipeline', + }, + }, + spec: { + pipelineRef: { + name: 'tutorial-pipeline', + }, + resources: [ + { + name: 'source-repo', + resourceRef: { + name: 'skaffold-git', + }, + }, + { + name: 'web-image', + resourceRef: { + name: 'skaffold-image-leeroy-web', + }, + }, + ], + serviceAccount: '', + trigger: { + type: 'manual', + }, + }, + status: { + completionTime: '2019-06-08T18:27:28Z', + conditions: [ + { + lastTransitionTime: '2019-06-08T18:27:28Z', + message: 'All Tasks have completed executing', + reason: 'Succeeded', + status: 'True', + type: 'Succeeded', + }, + ], + startTime: '2019-06-08T18:26:24Z', + taskRuns: { + 'tutorial-pipeline-run-1-build-skaffold-web-s98hr': { + pipelineTaskName: 'build-skaffold-web', + status: { + completionTime: '2019-06-08T18:26:56Z', + conditions: [ + { + lastTransitionTime: '2019-06-08T18:26:56Z', + status: 'True', + type: 'Succeeded', + }, + ], + podName: 'tutorial-pipeline-run-1-build-skaffold-web-s98hr-pod-b0fc45', + startTime: '2019-06-08T18:26:24Z', + steps: [ + { + name: 'build-and-push', + terminated: { + containerID: + 'cri-o://dc7cdc11f878fe5d6c95ce7d3aa5a31b3586f3bbdb1fb001a0e13394f622aec2', + exitCode: 0, + finishedAt: '2019-06-08T18:26:55Z', + reason: 'Completed', + startedAt: '2019-06-08T18:26:55Z', + }, + }, + { + name: 'git-source-skaffold-git-prxcd', + terminated: { + containerID: + 'cri-o://3ba8f0645ad3160778d335bd11940ec105e8a26408abe00bf1ca7d48616e0d59', + exitCode: 0, + finishedAt: '2019-06-08T18:26:54Z', + reason: 'Completed', + startedAt: '2019-06-08T18:26:35Z', + }, + }, + { + name: 'nop', + terminated: { + containerID: + 'cri-o://64b61cac91394becb93b65e77c236d9d9c631905854a8dd16a1dbae4803faf8d', + exitCode: 0, + finishedAt: '2019-06-08T18:26:56Z', + reason: 'Completed', + startedAt: '2019-06-08T18:26:56Z', + }, + }, + ], + }, + }, + 'tutorial-pipeline-run-1-deploy-web-9sgh6': { + pipelineTaskName: 'deploy-web', + status: { + completionTime: '2019-06-08T18:27:28Z', + conditions: [ + { + lastTransitionTime: '2019-06-08T18:27:28Z', + status: 'True', + type: 'Succeeded', + }, + ], + podName: 'tutorial-pipeline-run-1-deploy-web-9sgh6-pod-a30845', + startTime: '2019-06-08T18:26:56Z', + steps: [ + { + name: 'git-source-skaffold-git-k997z', + terminated: { + containerID: + 'cri-o://354d7f3b3c543f98c6ac1fdb118777505a57d1c9d1a38520f4c82737c853053c', + exitCode: 0, + finishedAt: '2019-06-08T18:27:26Z', + reason: 'Completed', + startedAt: '2019-06-08T18:27:07Z', + }, + }, + { + name: 'post-deploy', + terminated: { + containerID: + 'cri-o://e0a755bcda299370d9886825cde7d27682644e8796a2f0d019d2d8819c068e07', + exitCode: 0, + finishedAt: '2019-06-08T18:27:27Z', + reason: 'Completed', + startedAt: '2019-06-08T18:27:25Z', + }, + }, + { + name: 'replace-image', + terminated: { + containerID: + 'cri-o://5596e313340dfeaf2b7fe53f7b13cfa2ddb293d731e5a185decf1defe838bada', + exitCode: 0, + finishedAt: '2019-06-08T18:27:26Z', + reason: 'Completed', + startedAt: '2019-06-08T18:27:15Z', + }, + }, + { + name: 'nop', + terminated: { + containerID: + 'cri-o://ae947df276fb75dbc59edcaa68f561674b8163f08156dd255a71405523a9d8d4', + exitCode: 0, + finishedAt: '2019-06-08T18:27:28Z', + reason: 'Completed', + startedAt: '2019-06-08T18:27:26Z', + }, + }, + ], + }, + }, + }, + }, +}; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-table.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-table.ts new file mode 100644 index 00000000000..43036dcef42 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-table.ts @@ -0,0 +1,10 @@ +import { Kebab } from '@console/internal/components/utils'; + +export const tableColumnClasses = [ + 'col-lg-2 col-md-2 col-sm-3 col-xs-5', + 'col-lg-2 col-md-2 col-sm-4 col-xs-5', + 'col-lg-2 col-md-2 col-sm-3 hidden-xs', + 'col-lg-2 col-md-2 hidden-sm hidden-xs', + 'col-lg-2 col-md-2 hidden-sm hidden-xs', + Kebab.columnClass, +]; diff --git a/frontend/packages/dev-console/src/components/source-to-image/ImageStreamInfo.tsx b/frontend/packages/dev-console/src/components/source-to-image/ImageStreamInfo.tsx new file mode 100644 index 00000000000..6145bd9fb09 --- /dev/null +++ b/frontend/packages/dev-console/src/components/source-to-image/ImageStreamInfo.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { getAnnotationTags } from '@console/internal/components/image-stream'; +import { ImageStreamIcon } from '@console/internal/components/catalog/catalog-item-icon'; +import { ExternalLink } from '@console/internal/components/utils'; +import { getSampleRepo } from '../../utils/imagestream-utils'; + +export type ImageStreamInfoProps = { + displayName: string; + tag: object; +}; + +const ImageStreamInfo: React.FC = ({ displayName, tag }) => { + const annotationTags = getAnnotationTags(tag); + const description = _.get(tag, 'annotations.description'); + const sampleRepo = getSampleRepo(tag); + + return ( +
    +
    + +
    +

    {displayName}

    + {annotationTags && ( +

    + {_.map(annotationTags, (annotationTag, i) => ( + + {annotationTag} + + ))} +

    + )} +
    +
    + {description &&

    {description}

    } + {sampleRepo && ( +

    + Sample repository: +

    + )} +
    + ); +}; + +export default ImageStreamInfo; diff --git a/frontend/packages/dev-console/src/components/source-to-image/SourceToImage.tsx b/frontend/packages/dev-console/src/components/source-to-image/SourceToImage.tsx new file mode 100644 index 00000000000..3d663ba5959 --- /dev/null +++ b/frontend/packages/dev-console/src/components/source-to-image/SourceToImage.tsx @@ -0,0 +1,403 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { connect } from 'react-redux'; +import { Link } from 'react-router-dom'; +import { LoadingBox, LoadError } from '@console/internal/components/utils/status-box'; +import { + Dropdown, + history, + MsgBox, + NsDropdown, + ResourceName, +} from '@console/internal/components/utils'; +import { ImageStreamTagModel } from '@console/internal/models'; +import { ContainerPort, k8sGet, K8sResourceKind } from '@console/internal/module/k8s'; +import { getBuilderTagsSortedByVersion } from '@console/internal/components/image-stream'; +import { ButtonBar } from '@console/internal/components/utils/button-bar'; +import { getActivePerspective } from '@console/internal/reducers/ui'; +import { + getPorts, + getSampleRepo, + getSampleRef, + getSampleContextDir, + getTagDataWithDisplayName, +} from '../../utils/imagestream-utils'; +import ImageStreamInfo from './ImageStreamInfo'; +import { + createDeploymentConfig, + createImageStream, + createBuildConfig, + createService, + createRoute, +} from '../../utils/create-resource-utils'; +import AppNameSelector from '../dropdown/AppNameSelector'; +import SourceToImageResourceDetails from './SourceToImageResourceDetails'; + +const mapBuildSourceStateToProps = (state) => { + return { + activePerspective: getActivePerspective(state), + }; +}; + +class BuildSource extends React.Component< + BuildSourceStateProps & BuildSourceProps, + BuildSourceState +> { + constructor(props) { + super(props); + + const { preselectedNamespace: namespace = '' } = this.props; + this.state = { + tags: [], + namespace, + application: '', + selectedApplicationKey: '', + selectedTag: '', + name: '', + repository: '', + ref: '', + contextDir: '', + createRoute: false, + ports: [], + inProgress: false, + }; + } + + static getDerivedStateFromProps(props, state) { + if (_.isEmpty(props.obj.data)) { + return null; + } + const previousTag = state.selectedTag; + // Sort tags in reverse order by semver, falling back to a string comparison if not a valid version. + const tags = getBuilderTagsSortedByVersion(props.obj.data); + // Select the first tag if the current tag is missing or empty. + const selectedTag = + previousTag && _.includes(tags, previousTag) ? previousTag : _.get(_.head(tags), 'name'); + + return { tags, selectedTag }; + } + + componentDidUpdate(prevProps, prevState) { + if (prevState.selectedTag !== this.state.selectedTag) { + this.getImageStreamImage(); + } + } + + onNamespaceChange = (namespace: string) => { + this.setState({ namespace }); + }; + + onApplicationChange = (application: string, selectedKey: string) => { + this.setState({ application, selectedApplicationKey: selectedKey }); + }; + + onTagChange = (selectedTag: any) => { + this.setState({ selectedTag }, this.getImageStreamImage); + }; + + onNameChange: React.ReactEventHandler = (event) => { + this.setState({ name: event.currentTarget.value }); + }; + + onRepositoryChange: React.ReactEventHandler = (event) => { + // Reset ref and context dir if previously set from filling in a sample. + this.setState({ repository: event.currentTarget.value, ref: '', contextDir: '' }); + }; + + onCreateRouteChange: React.ReactEventHandler = (event) => { + this.setState({ createRoute: event.currentTarget.checked }); + }; + + fillSample: React.ReactEventHandler = () => { + const { + obj: { data: imageStream }, + } = this.props; + const { name: currentName, selectedTag } = this.state; + const tag = _.find(imageStream.spec.tags, { name: selectedTag }); + const repository = getSampleRepo(tag); + const ref = getSampleRef(tag); + const contextDir = getSampleContextDir(tag); + const name = currentName || imageStream.metadata.name; + this.setState({ name, repository, ref, contextDir }); + }; + + getImageStreamImage = () => { + const { selectedTag } = this.state; + if (!selectedTag) { + return; + } + + const { + obj: { data: imageStream }, + } = this.props; + const imageStreamTagName = `${imageStream.metadata.name}:${selectedTag}`; + this.setState({ inProgress: true }); + k8sGet(ImageStreamTagModel, imageStreamTagName, imageStream.metadata.namespace) + .then((imageStreamImage: K8sResourceKind) => { + const ports = getPorts(imageStreamImage); + this.setState({ ports, inProgress: false }); + return null; + }) + .catch((err) => this.setState({ error: err.message, inProgress: false })); + }; + + handleError = (err) => { + this.setState((state) => ({ + error: state.error ? `${state.error}; ${err.message}` : err.message, + })); + }; + + save = (event: React.FormEvent) => { + event.preventDefault(); + const { + activePerspective, + obj: { data: imageStream }, + } = this.props; + const { + name, + namespace, + application, + selectedTag, + repository, + createRoute: canCreateRoute, + ports, + ref, + contextDir, + } = this.state; + if (!name || !selectedTag || !namespace || !application || !repository) { + this.setState({ error: 'Please complete all required fields.' }); + return; + } + + const requests = [ + createDeploymentConfig(name, namespace, application, ports, imageStream), + createImageStream(name, namespace, application, imageStream), + createBuildConfig( + name, + namespace, + application, + repository, + ref, + contextDir, + selectedTag, + imageStream, + ), + ]; + + // Only create a service or route if the builder image has ports. + if (!_.isEmpty(ports)) { + requests.push(createService(name, namespace, application, ports, imageStream)); + if (canCreateRoute) { + requests.push(createRoute(name, namespace, application, ports, imageStream)); + } + } + + requests.forEach((r) => r.catch(this.handleError)); + this.setState({ inProgress: true, error: null }); + Promise.all(requests) + .then(() => { + this.setState({ inProgress: false }); + if (!this.state.error) { + switch (activePerspective) { + case 'dev': + history.push(`/dev/topology/ns/${this.state.namespace}`); + break; + default: + history.push(`/k8s/cluster/projects/${this.state.namespace}/workloads`); + } + } + }) + .catch(() => this.setState({ inProgress: false })); + }; + + render() { + const { obj } = this.props; + const { selectedTag, tags, ports } = this.state; + if (obj.loadError) { + return ( + + ); + } + + if (!obj.loaded) { + return ; + } + + const imageStream = obj.data; + if (_.isEmpty(tags)) { + return ( + + ); + } + + const [tag, displayName] = getTagDataWithDisplayName( + imageStream.spec.tags, + selectedTag, + imageStream.metadata.name, + ); + const sampleRepo = getSampleRepo(tag); + + const tagOptions = {}; + _.each( + tags, + ({ name }) => + (tagOptions[name] = ( + + )), + ); + return ( +
    +
    + + +
    +
    +
    +
    + + +
    + +
    + + +
    +
    + + +
    + Names the resources created for this application. +
    +
    +
    + + + {sampleRepo && ( +
    +