mirror of
https://github.com/ansible/awx.git
synced 2026-05-19 14:57:39 -02:30
Merge pull request #5933 from jlmitch5/credForm
Update form layout, Formik Field use to useField, and add credential form Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
@@ -4,6 +4,14 @@ class Credentials extends Base {
|
|||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/credentials/';
|
this.baseUrl = '/api/v2/credentials/';
|
||||||
|
|
||||||
|
this.readAccessList = this.readAccessList.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
readAccessList(id, params) {
|
||||||
|
return this.http.get(`${this.baseUrl}${id}/access_list/`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { string, bool } from 'prop-types';
|
import { string, bool } from 'prop-types';
|
||||||
import { Field, useFormikContext } from 'formik';
|
import { useField } from 'formik';
|
||||||
import { Split, SplitItem } from '@patternfly/react-core';
|
import { Split, SplitItem } from '@patternfly/react-core';
|
||||||
import { yamlToJson, jsonToYaml, isJson } from '@util/yaml';
|
import { yamlToJson, jsonToYaml, isJson } from '@util/yaml';
|
||||||
import CodeMirrorInput from './CodeMirrorInput';
|
import CodeMirrorInput from './CodeMirrorInput';
|
||||||
@@ -8,14 +8,11 @@ import YamlJsonToggle from './YamlJsonToggle';
|
|||||||
import { JSON_MODE, YAML_MODE } from './constants';
|
import { JSON_MODE, YAML_MODE } from './constants';
|
||||||
|
|
||||||
function VariablesField({ id, name, label, readOnly }) {
|
function VariablesField({ id, name, label, readOnly }) {
|
||||||
const { values } = useFormikContext();
|
const [field, meta, helpers] = useField(name);
|
||||||
const value = values[name];
|
const [mode, setMode] = useState(isJson(field.value) ? JSON_MODE : YAML_MODE);
|
||||||
const [mode, setMode] = useState(isJson(value) ? JSON_MODE : YAML_MODE);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Field name={name}>
|
<>
|
||||||
{({ field, form }) => (
|
|
||||||
<div className="pf-c-form__group">
|
|
||||||
<Split gutter="sm">
|
<Split gutter="sm">
|
||||||
<SplitItem>
|
<SplitItem>
|
||||||
<label htmlFor={id} className="pf-c-form__label">
|
<label htmlFor={id} className="pf-c-form__label">
|
||||||
@@ -31,10 +28,10 @@ function VariablesField({ id, name, label, readOnly }) {
|
|||||||
newMode === YAML_MODE
|
newMode === YAML_MODE
|
||||||
? jsonToYaml(field.value)
|
? jsonToYaml(field.value)
|
||||||
: yamlToJson(field.value);
|
: yamlToJson(field.value);
|
||||||
form.setFieldValue(name, newVal);
|
helpers.setValue(newVal);
|
||||||
setMode(newMode);
|
setMode(newMode);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
form.setFieldError(name, err.message);
|
helpers.setError(err.message);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -45,21 +42,16 @@ function VariablesField({ id, name, label, readOnly }) {
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={newVal => {
|
onChange={newVal => {
|
||||||
form.setFieldValue(name, newVal);
|
helpers.setValue(newVal);
|
||||||
}}
|
}}
|
||||||
hasErrors={!!form.errors[field.name]}
|
hasErrors={!!meta.error}
|
||||||
/>
|
/>
|
||||||
{form.errors[field.name] ? (
|
{meta.error ? (
|
||||||
<div
|
<div className="pf-c-form__helper-text pf-m-error" aria-live="polite">
|
||||||
className="pf-c-form__helper-text pf-m-error"
|
{meta.error}
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
{form.errors[field.name]}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</>
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
VariablesField.propTypes = {
|
VariablesField.propTypes = {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function ExpandingContainer({ isExpanded, children }) {
|
|||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setContentHeight(ref.current.scrollHeight);
|
setContentHeight(ref.current.scrollHeight);
|
||||||
}, [setContentHeight]);
|
}, [setContentHeight, children]);
|
||||||
const height = isExpanded ? contentHeight : '0';
|
const height = isExpanded ? contentHeight : '0';
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const DetailName = styled(({ fullWidth, ...props }) => (
|
|||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const DetailValue = styled(({ fullWidth, ...props }) => (
|
const DetailValue = styled(({ fullWidth, isEncrypted, ...props }) => (
|
||||||
<TextListItem {...props} />
|
<TextListItem {...props} />
|
||||||
))`
|
))`
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
@@ -23,6 +23,12 @@ const DetailValue = styled(({ fullWidth, ...props }) => (
|
|||||||
`
|
`
|
||||||
grid-column: 2 / -1;
|
grid-column: 2 / -1;
|
||||||
`}
|
`}
|
||||||
|
${props =>
|
||||||
|
props.isEncrypted &&
|
||||||
|
`
|
||||||
|
text-transform: uppercase
|
||||||
|
color: var(--pf-global--Color--400);
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Detail = ({
|
const Detail = ({
|
||||||
@@ -32,6 +38,7 @@ const Detail = ({
|
|||||||
className,
|
className,
|
||||||
dataCy,
|
dataCy,
|
||||||
alwaysVisible,
|
alwaysVisible,
|
||||||
|
isEncrypted,
|
||||||
}) => {
|
}) => {
|
||||||
if (!value && typeof value !== 'number' && !alwaysVisible) {
|
if (!value && typeof value !== 'number' && !alwaysVisible) {
|
||||||
return null;
|
return null;
|
||||||
@@ -55,6 +62,7 @@ const Detail = ({
|
|||||||
component={TextListItemVariants.dd}
|
component={TextListItemVariants.dd}
|
||||||
fullWidth={fullWidth}
|
fullWidth={fullWidth}
|
||||||
data-cy={valueCy}
|
data-cy={valueCy}
|
||||||
|
isEncrypted={isEncrypted}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</DetailValue>
|
</DetailValue>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const ActionGroup = styled(PFActionGroup)`
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
--pf-c-form__group--m-action--MarginTop: 0;
|
--pf-c-form__group--m-action--MarginTop: 0;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-right: calc(var(--pf-c-form__actions--MarginRight) * -1);
|
||||||
|
|
||||||
.pf-c-form__actions {
|
.pf-c-form__actions {
|
||||||
& > button {
|
& > button {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { string, func } from 'prop-types';
|
import { string, func } from 'prop-types';
|
||||||
import { Field } from 'formik';
|
import { useField } from 'formik';
|
||||||
import { Checkbox, Tooltip } from '@patternfly/react-core';
|
import { Checkbox, Tooltip } from '@patternfly/react-core';
|
||||||
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
@@ -10,9 +10,8 @@ const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
function CheckboxField({ id, name, label, tooltip, validate, ...rest }) {
|
function CheckboxField({ id, name, label, tooltip, validate, ...rest }) {
|
||||||
|
const [field] = useField({ name, validate });
|
||||||
return (
|
return (
|
||||||
<Field name={name} validate={validate}>
|
|
||||||
{({ field }) => (
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
label={
|
label={
|
||||||
@@ -34,8 +33,6 @@ function CheckboxField({ id, name, label, tooltip, validate, ...rest }) {
|
|||||||
field.onChange(event);
|
field.onChange(event);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
CheckboxField.propTypes = {
|
CheckboxField.propTypes = {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Field } from 'formik';
|
import { useField } from 'formik';
|
||||||
import { FormGroup, TextInput, Tooltip } from '@patternfly/react-core';
|
import {
|
||||||
|
FormGroup,
|
||||||
|
TextInput,
|
||||||
|
TextArea,
|
||||||
|
Tooltip,
|
||||||
|
} from '@patternfly/react-core';
|
||||||
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
@@ -18,19 +23,48 @@ function FormField(props) {
|
|||||||
tooltipMaxWidth,
|
tooltipMaxWidth,
|
||||||
validate,
|
validate,
|
||||||
isRequired,
|
isRequired,
|
||||||
|
type,
|
||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
return (
|
const [field, meta] = useField({ name, validate });
|
||||||
<Field name={name} validate={validate}>
|
const isValid = !(meta.touched && meta.error);
|
||||||
{({ field, form }) => {
|
|
||||||
const isValid =
|
|
||||||
form && (!form.touched[field.name] || !form.errors[field.name]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{(type === 'textarea' && (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
fieldId={id}
|
fieldId={id}
|
||||||
helperTextInvalid={form.errors[field.name]}
|
helperTextInvalid={meta.error}
|
||||||
|
isRequired={isRequired}
|
||||||
|
isValid={isValid}
|
||||||
|
label={label}
|
||||||
|
>
|
||||||
|
{tooltip && (
|
||||||
|
<Tooltip
|
||||||
|
content={tooltip}
|
||||||
|
maxWidth={tooltipMaxWidth}
|
||||||
|
position="right"
|
||||||
|
>
|
||||||
|
<QuestionCircleIcon />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<TextArea
|
||||||
|
id={id}
|
||||||
|
isRequired={isRequired}
|
||||||
|
isValid={isValid}
|
||||||
|
resizeOrientation="vertical"
|
||||||
|
{...rest}
|
||||||
|
{...field}
|
||||||
|
onChange={(value, event) => {
|
||||||
|
field.onChange(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)) || (
|
||||||
|
<FormGroup
|
||||||
|
fieldId={id}
|
||||||
|
helperTextInvalid={meta.error}
|
||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
label={label}
|
label={label}
|
||||||
@@ -55,16 +89,15 @@ function FormField(props) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
);
|
)}
|
||||||
}}
|
</>
|
||||||
</Field>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
FormField.propTypes = {
|
FormField.propTypes = {
|
||||||
id: PropTypes.string.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
label: PropTypes.string.isRequired,
|
label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
|
||||||
type: PropTypes.string,
|
type: PropTypes.string,
|
||||||
validate: PropTypes.func,
|
validate: PropTypes.func,
|
||||||
isRequired: PropTypes.bool,
|
isRequired: PropTypes.bool,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Field } from 'formik';
|
import { useField } from 'formik';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
@@ -16,29 +16,25 @@ import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
|
|||||||
function PasswordField(props) {
|
function PasswordField(props) {
|
||||||
const { id, name, label, validate, isRequired, i18n } = props;
|
const { id, name, label, validate, isRequired, i18n } = props;
|
||||||
const [inputType, setInputType] = useState('password');
|
const [inputType, setInputType] = useState('password');
|
||||||
|
const [field, meta] = useField({ name, validate });
|
||||||
|
|
||||||
|
const isValid = !(meta.touched && meta.error);
|
||||||
|
|
||||||
const handlePasswordToggle = () => {
|
const handlePasswordToggle = () => {
|
||||||
setInputType(inputType === 'text' ? 'password' : 'text');
|
setInputType(inputType === 'text' ? 'password' : 'text');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
|
||||||
<Field name={name} validate={validate}>
|
|
||||||
{({ field, form }) => {
|
|
||||||
const isValid =
|
|
||||||
form && (!form.touched[field.name] || !form.errors[field.name]);
|
|
||||||
return (
|
return (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
fieldId={id}
|
fieldId={id}
|
||||||
helperTextInvalid={form.errors[field.name]}
|
helperTextInvalid={meta.error}
|
||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
label={label}
|
label={label}
|
||||||
>
|
>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
content={inputType === 'password' ? i18n._(t`Show`) : i18n._(t`Hide`)}
|
||||||
inputType === 'password' ? i18n._(t`Show`) : i18n._(t`Hide`)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant={ButtonVariant.control}
|
variant={ButtonVariant.control}
|
||||||
@@ -51,20 +47,19 @@ function PasswordField(props) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<TextInput
|
<TextInput
|
||||||
id={id}
|
id={id}
|
||||||
|
placeholder={field.value === '$encrypted$' ? 'ENCRYPTED' : undefined}
|
||||||
|
{...field}
|
||||||
|
value={field.value === '$encrypted$' ? '' : field.value}
|
||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
type={inputType}
|
type={inputType}
|
||||||
{...field}
|
onChange={(_, event) => {
|
||||||
onChange={(value, event) => {
|
|
||||||
field.onChange(event);
|
field.onChange(event);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
);
|
);
|
||||||
}}
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PasswordField.propTypes = {
|
PasswordField.propTypes = {
|
||||||
|
|||||||
47
awx/ui_next/src/components/FormLayout/FormLayout.jsx
Normal file
47
awx/ui_next/src/components/FormLayout/FormLayout.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
export const FormColumnLayout = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-gap: var(--pf-c-form--GridGap);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
|
||||||
|
@media (min-width: 1210px) {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FormFullWidthLayout = styled.div`
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-gap: var(--pf-c-form--GridGap);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FormCheckboxLayout = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: -10px;
|
||||||
|
margin-right: -30px !important;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-right: 30px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SubFormLayout = styled.div`
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
margin-right: calc(var(--pf-c-card--child--PaddingRight) * -1);
|
||||||
|
margin-left: calc(var(--pf-c-card--child--PaddingLeft) * -1);
|
||||||
|
padding: var(--pf-c-card--child--PaddingRight);
|
||||||
|
|
||||||
|
& > .pf-c-title {
|
||||||
|
--pf-c-title--m-md--FontWeight: 700;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
6
awx/ui_next/src/components/FormLayout/index.js
Normal file
6
awx/ui_next/src/components/FormLayout/index.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
FormColumnLayout,
|
||||||
|
FormFullWidthLayout,
|
||||||
|
FormCheckboxLayout,
|
||||||
|
SubFormLayout,
|
||||||
|
} from './FormLayout';
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
const Row = styled.div`
|
|
||||||
display: grid;
|
|
||||||
grid-gap: 20px;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
`;
|
|
||||||
export default function FormRow({ children, className }) {
|
|
||||||
return <Row className={className}>{children}</Row>;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { mount } from 'enzyme';
|
|
||||||
|
|
||||||
import FormRow from './FormRow';
|
|
||||||
|
|
||||||
describe('FormRow', () => {
|
|
||||||
test('renders the expected content', () => {
|
|
||||||
const wrapper = mount(<FormRow />);
|
|
||||||
expect(wrapper).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from './FormRow';
|
|
||||||
@@ -482,10 +482,12 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
|||||||
"componentStyle": ComponentStyle {
|
"componentStyle": ComponentStyle {
|
||||||
"componentId": "Detail__DetailValue-sc-16ypsyv-1",
|
"componentId": "Detail__DetailValue-sc-16ypsyv-1",
|
||||||
"isStatic": false,
|
"isStatic": false,
|
||||||
"lastClassName": "yHlYM",
|
"lastClassName": "kCDjmZ",
|
||||||
"rules": Array [
|
"rules": Array [
|
||||||
"word-break:break-all;",
|
"word-break:break-all;",
|
||||||
[Function],
|
[Function],
|
||||||
|
" ",
|
||||||
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"displayName": "Detail__DetailValue",
|
"displayName": "Detail__DetailValue",
|
||||||
@@ -502,18 +504,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
|||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
>
|
>
|
||||||
<Component
|
<Component
|
||||||
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
|
className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
|
||||||
component="dd"
|
component="dd"
|
||||||
data-cy={null}
|
data-cy={null}
|
||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
>
|
>
|
||||||
<TextListItem
|
<TextListItem
|
||||||
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
|
className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
|
||||||
component="dd"
|
component="dd"
|
||||||
data-cy={null}
|
data-cy={null}
|
||||||
>
|
>
|
||||||
<dd
|
<dd
|
||||||
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
|
className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
|
||||||
data-cy={null}
|
data-cy={null}
|
||||||
data-pf-content={true}
|
data-pf-content={true}
|
||||||
>
|
>
|
||||||
@@ -672,10 +674,12 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
|||||||
"componentStyle": ComponentStyle {
|
"componentStyle": ComponentStyle {
|
||||||
"componentId": "Detail__DetailValue-sc-16ypsyv-1",
|
"componentId": "Detail__DetailValue-sc-16ypsyv-1",
|
||||||
"isStatic": false,
|
"isStatic": false,
|
||||||
"lastClassName": "yHlYM",
|
"lastClassName": "kCDjmZ",
|
||||||
"rules": Array [
|
"rules": Array [
|
||||||
"word-break:break-all;",
|
"word-break:break-all;",
|
||||||
[Function],
|
[Function],
|
||||||
|
" ",
|
||||||
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"displayName": "Detail__DetailValue",
|
"displayName": "Detail__DetailValue",
|
||||||
@@ -692,18 +696,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
|||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
>
|
>
|
||||||
<Component
|
<Component
|
||||||
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
|
className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
|
||||||
component="dd"
|
component="dd"
|
||||||
data-cy={null}
|
data-cy={null}
|
||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
>
|
>
|
||||||
<TextListItem
|
<TextListItem
|
||||||
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
|
className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
|
||||||
component="dd"
|
component="dd"
|
||||||
data-cy={null}
|
data-cy={null}
|
||||||
>
|
>
|
||||||
<dd
|
<dd
|
||||||
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
|
className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
|
||||||
data-cy={null}
|
data-cy={null}
|
||||||
data-pf-content={true}
|
data-pf-content={true}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,22 +6,28 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
useParams,
|
useParams,
|
||||||
useLocation,
|
useLocation,
|
||||||
|
useRouteMatch,
|
||||||
Route,
|
Route,
|
||||||
Redirect,
|
Redirect,
|
||||||
Link,
|
Link,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import { TabbedCardHeader } from '@components/Card';
|
import { TabbedCardHeader } from '@components/Card';
|
||||||
import CardCloseButton from '@components/CardCloseButton';
|
import CardCloseButton from '@components/CardCloseButton';
|
||||||
|
import { ResourceAccessList } from '@components/ResourceAccessList';
|
||||||
import ContentError from '@components/ContentError';
|
import ContentError from '@components/ContentError';
|
||||||
import RoutedTabs from '@components/RoutedTabs';
|
import RoutedTabs from '@components/RoutedTabs';
|
||||||
import CredentialDetail from './CredentialDetail';
|
import CredentialDetail from './CredentialDetail';
|
||||||
|
import CredentialEdit from './CredentialEdit';
|
||||||
import { CredentialsAPI } from '@api';
|
import { CredentialsAPI } from '@api';
|
||||||
|
|
||||||
function Credential({ i18n, setBreadcrumb }) {
|
function Credential({ i18n, setBreadcrumb }) {
|
||||||
const [credential, setCredential] = useState(null);
|
const [credential, setCredential] = useState(null);
|
||||||
const [contentError, setContentError] = useState(null);
|
const [contentError, setContentError] = useState(null);
|
||||||
const [hasContentLoading, setHasContentLoading] = useState(true);
|
const [hasContentLoading, setHasContentLoading] = useState(true);
|
||||||
const location = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
const match = useRouteMatch({
|
||||||
|
path: '/credentials/:id',
|
||||||
|
});
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,18 +43,20 @@ function Credential({ i18n, setBreadcrumb }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [id, setBreadcrumb]);
|
}, [id, pathname, setBreadcrumb]);
|
||||||
|
|
||||||
const tabsArray = [
|
const tabsArray = [
|
||||||
{ name: i18n._(t`Details`), link: `/credentials/${id}/details`, id: 0 },
|
{ name: i18n._(t`Details`), link: `/credentials/${id}/details`, id: 0 },
|
||||||
{ name: i18n._(t`Access`), link: `/credentials/${id}/access`, id: 1 },
|
|
||||||
{
|
|
||||||
name: i18n._(t`Notifications`),
|
|
||||||
link: `/credentials/${id}/notifications`,
|
|
||||||
id: 2,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (credential && credential.organization) {
|
||||||
|
tabsArray.push({
|
||||||
|
name: i18n._(t`Access`),
|
||||||
|
link: `/credentials/${id}/access`,
|
||||||
|
id: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let cardHeader = hasContentLoading ? null : (
|
let cardHeader = hasContentLoading ? null : (
|
||||||
<TabbedCardHeader>
|
<TabbedCardHeader>
|
||||||
<RoutedTabs tabsArray={tabsArray} />
|
<RoutedTabs tabsArray={tabsArray} />
|
||||||
@@ -56,7 +64,7 @@ function Credential({ i18n, setBreadcrumb }) {
|
|||||||
</TabbedCardHeader>
|
</TabbedCardHeader>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (location.pathname.endsWith('edit') || location.pathname.endsWith('add')) {
|
if (pathname.endsWith('edit') || pathname.endsWith('add')) {
|
||||||
cardHeader = null;
|
cardHeader = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,11 +95,45 @@ function Credential({ i18n, setBreadcrumb }) {
|
|||||||
to="/credentials/:id/details"
|
to="/credentials/:id/details"
|
||||||
exact
|
exact
|
||||||
/>
|
/>
|
||||||
{credential && (
|
{credential && [
|
||||||
<Route path="/credentials/:id/details">
|
<Route
|
||||||
<CredentialDetail credential={credential} />
|
key="details"
|
||||||
</Route>
|
path="/credentials/:id/details"
|
||||||
|
render={() => <CredentialDetail credential={credential} />}
|
||||||
|
/>,
|
||||||
|
<Route
|
||||||
|
key="edit"
|
||||||
|
path="/credentials/:id/edit"
|
||||||
|
render={() => <CredentialEdit credential={credential} />}
|
||||||
|
/>,
|
||||||
|
credential.organization && (
|
||||||
|
<Route
|
||||||
|
key="access"
|
||||||
|
path="/credentials/:id/access"
|
||||||
|
render={() => (
|
||||||
|
<ResourceAccessList
|
||||||
|
resource={credential}
|
||||||
|
apiModel={CredentialsAPI}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
<Route
|
||||||
|
key="not-found"
|
||||||
|
path="*"
|
||||||
|
render={() =>
|
||||||
|
!hasContentLoading && (
|
||||||
|
<ContentError isNotFound>
|
||||||
|
{match.params.id && (
|
||||||
|
<Link to={`/credentials/${match.params.id}/details`}>
|
||||||
|
{i18n._(`View Credential Details`)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</ContentError>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
<Route
|
<Route
|
||||||
key="not-found"
|
key="not-found"
|
||||||
path="*"
|
path="*"
|
||||||
|
|||||||
@@ -3,31 +3,50 @@ import { act } from 'react-dom/test-utils';
|
|||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { CredentialsAPI } from '@api';
|
import { CredentialsAPI } from '@api';
|
||||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
import { mockCredentials } from './shared';
|
import mockCredential from './shared/data.credential.json';
|
||||||
|
import mockOrgCredential from './shared/data.orgCredential.json';
|
||||||
import Credential from './Credential';
|
import Credential from './Credential';
|
||||||
|
|
||||||
jest.mock('@api');
|
jest.mock('@api');
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useRouteMatch: () => ({
|
||||||
|
url: '/credentials/2',
|
||||||
|
params: { id: 2 },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
CredentialsAPI.readDetail.mockResolvedValue({
|
CredentialsAPI.readDetail.mockResolvedValueOnce({
|
||||||
data: mockCredentials.results[0],
|
data: mockCredential,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('<Credential />', () => {
|
describe('<Credential />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
|
||||||
beforeEach(async () => {
|
test('initially renders user-based credential succesfully', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
|
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
|
||||||
});
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('initially renders succesfully', async () => {
|
test('initially renders org-based credential succesfully', async () => {
|
||||||
expect(wrapper.find('Credential').length).toBe(1);
|
CredentialsAPI.readDetail.mockResolvedValueOnce({
|
||||||
|
data: mockOrgCredential,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
// org-based credential detail needs access tab
|
||||||
|
await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show content error when user attempts to navigate to erroneous route', async () => {
|
test('should show content error when user attempts to navigate to erroneous route', async () => {
|
||||||
const history = createMemoryHistory({
|
const history = createMemoryHistory({
|
||||||
initialEntries: ['/credentials/1/foobar'],
|
initialEntries: ['/credentials/2/foobar'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />, {
|
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />, {
|
||||||
@@ -38,8 +57,8 @@ describe('<Credential />', () => {
|
|||||||
location: history.location,
|
location: history.location,
|
||||||
match: {
|
match: {
|
||||||
params: { id: 1 },
|
params: { id: 1 },
|
||||||
url: '/credentials/1/foobar',
|
url: '/credentials/2/foobar',
|
||||||
path: '/credentials/1/foobar',
|
path: '/credentials/2/foobar',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -47,19 +66,5 @@ describe('<Credential />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||||
expect(wrapper.find('ContentError Title').text()).toEqual('Not Found');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show content error if api throws an error', async () => {
|
|
||||||
CredentialsAPI.readDetail.mockImplementationOnce(() =>
|
|
||||||
Promise.reject(new Error())
|
|
||||||
);
|
|
||||||
await act(async () => {
|
|
||||||
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
|
|
||||||
});
|
|
||||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
|
||||||
expect(wrapper.find('ContentError Title').text()).toEqual(
|
|
||||||
'Something went wrong...'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,84 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Card, CardBody, PageSection } from '@patternfly/react-core';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import { PageSection, Card } from '@patternfly/react-core';
|
||||||
|
import { CardBody } from '@components/Card';
|
||||||
|
import ContentError from '@components/ContentError';
|
||||||
|
import ContentLoading from '@components/ContentLoading';
|
||||||
|
|
||||||
function CredentialAdd() {
|
import { CredentialTypesAPI, CredentialsAPI } from '@api';
|
||||||
|
import CredentialForm from '../shared/CredentialForm';
|
||||||
|
|
||||||
|
function CredentialAdd({ me }) {
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [credentialTypes, setCredentialTypes] = useState(null);
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { results: loadedCredentialTypes },
|
||||||
|
} = await CredentialTypesAPI.read({ or__kind: ['scm', 'ssh'] });
|
||||||
|
setCredentialTypes(loadedCredentialTypes);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
history.push('/credentials');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async values => {
|
||||||
|
const { organization, ...remainingValues } = values;
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { id: credentialId },
|
||||||
|
} = await CredentialsAPI.create({
|
||||||
|
user: (me && me.id) || null,
|
||||||
|
organization: (organization && organization.id) || null,
|
||||||
|
...remainingValues,
|
||||||
|
});
|
||||||
|
const url = `/credentials/${credentialId}/details`;
|
||||||
|
history.push(`${url}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
<CardBody>Coming soon :)</CardBody>
|
<CardBody>
|
||||||
|
<ContentError error={error} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isLoading) {
|
||||||
|
return <ContentLoading />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<PageSection>
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CredentialForm
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
credentialTypes={credentialTypes}
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { CredentialAdd as _CredentialAdd };
|
||||||
export default CredentialAdd;
|
export default CredentialAdd;
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
|
import { sleep } from '@testUtils/testUtils';
|
||||||
|
|
||||||
|
import { CredentialsAPI, CredentialTypesAPI } from '@api';
|
||||||
|
import CredentialAdd from './CredentialAdd';
|
||||||
|
|
||||||
|
jest.mock('@api');
|
||||||
|
|
||||||
|
CredentialTypesAPI.read.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'credential_type',
|
||||||
|
url: '/api/v2/credential_types/2/',
|
||||||
|
related: {
|
||||||
|
credentials: '/api/v2/credential_types/2/credentials/',
|
||||||
|
activity_stream: '/api/v2/credential_types/2/activity_stream/',
|
||||||
|
},
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
edit: false,
|
||||||
|
delete: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: '2020-02-12T19:42:43.551238Z',
|
||||||
|
modified: '2020-02-12T19:43:03.164800Z',
|
||||||
|
name: 'Source Control',
|
||||||
|
description: '',
|
||||||
|
kind: 'scm',
|
||||||
|
namespace: 'scm',
|
||||||
|
managed_by_tower: true,
|
||||||
|
inputs: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'username',
|
||||||
|
label: 'Username',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
type: 'string',
|
||||||
|
secret: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh_key_data',
|
||||||
|
label: 'SCM Private Key',
|
||||||
|
type: 'string',
|
||||||
|
format: 'ssh_private_key',
|
||||||
|
secret: true,
|
||||||
|
multiline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh_key_unlock',
|
||||||
|
label: 'Private Key Passphrase',
|
||||||
|
type: 'string',
|
||||||
|
secret: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
injectors: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'credential_type',
|
||||||
|
url: '/api/v2/credential_types/1/',
|
||||||
|
related: {
|
||||||
|
credentials: '/api/v2/credential_types/1/credentials/',
|
||||||
|
activity_stream: '/api/v2/credential_types/1/activity_stream/',
|
||||||
|
},
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
edit: false,
|
||||||
|
delete: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: '2020-02-12T19:42:43.539626Z',
|
||||||
|
modified: '2020-02-12T19:43:03.159739Z',
|
||||||
|
name: 'Machine',
|
||||||
|
description: '',
|
||||||
|
kind: 'ssh',
|
||||||
|
namespace: 'ssh',
|
||||||
|
managed_by_tower: true,
|
||||||
|
inputs: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'username',
|
||||||
|
label: 'Username',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
type: 'string',
|
||||||
|
secret: true,
|
||||||
|
ask_at_runtime: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh_key_data',
|
||||||
|
label: 'SSH Private Key',
|
||||||
|
type: 'string',
|
||||||
|
format: 'ssh_private_key',
|
||||||
|
secret: true,
|
||||||
|
multiline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh_public_key_data',
|
||||||
|
label: 'Signed SSH Certificate',
|
||||||
|
type: 'string',
|
||||||
|
multiline: true,
|
||||||
|
secret: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh_key_unlock',
|
||||||
|
label: 'Private Key Passphrase',
|
||||||
|
type: 'string',
|
||||||
|
secret: true,
|
||||||
|
ask_at_runtime: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'become_method',
|
||||||
|
label: 'Privilege Escalation Method',
|
||||||
|
type: 'string',
|
||||||
|
help_text:
|
||||||
|
'Specify a method for "become" operations. This is equivalent to specifying the --become-method Ansible parameter.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'become_username',
|
||||||
|
label: 'Privilege Escalation Username',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'become_password',
|
||||||
|
label: 'Privilege Escalation Password',
|
||||||
|
type: 'string',
|
||||||
|
secret: true,
|
||||||
|
ask_at_runtime: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
injectors: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
CredentialsAPI.create.mockResolvedValue({ data: { id: 13 } });
|
||||||
|
|
||||||
|
describe('<CredentialAdd />', () => {
|
||||||
|
let wrapper;
|
||||||
|
let history;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
history = createMemoryHistory({ initialEntries: ['/credentials'] });
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<CredentialAdd />, {
|
||||||
|
context: { router: { history } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Initially renders successfully', () => {
|
||||||
|
expect(wrapper.length).toBe(1);
|
||||||
|
});
|
||||||
|
test('handleSubmit should call the api and redirect to details page', async () => {
|
||||||
|
await waitForElement(wrapper, 'isLoading', el => el.length === 0);
|
||||||
|
|
||||||
|
wrapper.find('CredentialForm').prop('onSubmit')({
|
||||||
|
user: 1,
|
||||||
|
organization: null,
|
||||||
|
name: 'foo',
|
||||||
|
description: 'bar',
|
||||||
|
credential_type: '2',
|
||||||
|
inputs: {},
|
||||||
|
});
|
||||||
|
await sleep(1);
|
||||||
|
expect(CredentialsAPI.create).toHaveBeenCalledWith({
|
||||||
|
user: 1,
|
||||||
|
organization: null,
|
||||||
|
name: 'foo',
|
||||||
|
description: 'bar',
|
||||||
|
credential_type: '2',
|
||||||
|
inputs: {},
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toBe('/credentials/13/details');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleCancel should return the user back to the inventories list', async () => {
|
||||||
|
await waitForElement(wrapper, 'isLoading', el => el.length === 0);
|
||||||
|
wrapper.find('Button[aria-label="Cancel"]').simulate('click');
|
||||||
|
expect(history.location.pathname).toEqual('/credentials');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -70,7 +70,7 @@ function CredentialDetail({ i18n, credential }) {
|
|||||||
setHasContentLoading(false);
|
setHasContentLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderDetail = ({ id, label, type, secret }) => {
|
const renderDetail = ({ id, label, type }) => {
|
||||||
let detail;
|
let detail;
|
||||||
|
|
||||||
if (type === 'boolean') {
|
if (type === 'boolean') {
|
||||||
@@ -81,8 +81,16 @@ function CredentialDetail({ i18n, credential }) {
|
|||||||
value={<List>{inputs[id] && <ListItem>{label}</ListItem>}</List>}
|
value={<List>{inputs[id] && <ListItem>{label}</ListItem>}</List>}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (secret === true) {
|
} else if (inputs[id] === '$encrypted$') {
|
||||||
detail = <Detail key={id} label={label} value={i18n._(t`Encrypted`)} />;
|
const isEncrypted = true;
|
||||||
|
detail = (
|
||||||
|
<Detail
|
||||||
|
key={id}
|
||||||
|
label={label}
|
||||||
|
value={i18n._(t`Encrypted`)}
|
||||||
|
isEncrypted={isEncrypted}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
detail = <Detail key={id} label={label} value={inputs[id]} />;
|
detail = <Detail key={id} label={label} value={inputs[id]} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,10 +49,6 @@ describe('<CredentialDetail />', () => {
|
|||||||
mockCredential.summary_fields.credential_type.name
|
mockCredential.summary_fields.credential_type.name
|
||||||
);
|
);
|
||||||
expectDetailToMatch(wrapper, 'Username', mockCredential.inputs.username);
|
expectDetailToMatch(wrapper, 'Username', mockCredential.inputs.username);
|
||||||
expectDetailToMatch(wrapper, 'Password', 'Encrypted');
|
|
||||||
expectDetailToMatch(wrapper, 'SSH Private Key', 'Encrypted');
|
|
||||||
expectDetailToMatch(wrapper, 'Signed SSH Certificate', 'Encrypted');
|
|
||||||
expectDetailToMatch(wrapper, 'Private Key Passphrase', 'Encrypted');
|
|
||||||
expectDetailToMatch(
|
expectDetailToMatch(
|
||||||
wrapper,
|
wrapper,
|
||||||
'Privilege Escalation Method',
|
'Privilege Escalation Method',
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import { object } from 'prop-types';
|
||||||
|
|
||||||
|
import { CardBody } from '@components/Card';
|
||||||
|
import { CredentialsAPI, CredentialTypesAPI } from '@api';
|
||||||
|
import ContentError from '@components/ContentError';
|
||||||
|
import ContentLoading from '@components/ContentLoading';
|
||||||
|
import CredentialForm from '../shared/CredentialForm';
|
||||||
|
|
||||||
|
function CredentialEdit({ credential, me }) {
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [credentialTypes, setCredentialTypes] = useState(null);
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { results: loadedCredentialTypes },
|
||||||
|
} = await CredentialTypesAPI.read({ or__kind: ['scm', 'ssh'] });
|
||||||
|
setCredentialTypes(loadedCredentialTypes);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
const url = `/credentials/${credential.id}/details`;
|
||||||
|
|
||||||
|
history.push(`${url}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async values => {
|
||||||
|
const { organization, ...remainingValues } = values;
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { id: credentialId },
|
||||||
|
} = await CredentialsAPI.update(credential.id, {
|
||||||
|
user: (me && me.id) || null,
|
||||||
|
organization: (organization && organization.id) || null,
|
||||||
|
...remainingValues,
|
||||||
|
});
|
||||||
|
const url = `/credentials/${credentialId}/details`;
|
||||||
|
history.push(`${url}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ContentError />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <ContentLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardBody>
|
||||||
|
<CredentialForm
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
credential={credential}
|
||||||
|
credentialTypes={credentialTypes}
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CredentialEdit.proptype = {
|
||||||
|
inventory: object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export { CredentialEdit as _CredentialEdit };
|
||||||
|
export default CredentialEdit;
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
|
import { sleep } from '@testUtils/testUtils';
|
||||||
|
|
||||||
|
import { CredentialsAPI, CredentialTypesAPI } from '@api';
|
||||||
|
import CredentialEdit from './CredentialEdit';
|
||||||
|
|
||||||
|
jest.mock('@api');
|
||||||
|
|
||||||
|
const mockCredential = {
|
||||||
|
id: 3,
|
||||||
|
type: 'credential',
|
||||||
|
url: '/api/v2/credentials/3/',
|
||||||
|
related: {
|
||||||
|
named_url: '/api/v2/credentials/oersdgfasf++Machine+ssh++org/',
|
||||||
|
created_by: '/api/v2/users/1/',
|
||||||
|
modified_by: '/api/v2/users/1/',
|
||||||
|
organization: '/api/v2/organizations/1/',
|
||||||
|
activity_stream: '/api/v2/credentials/3/activity_stream/',
|
||||||
|
access_list: '/api/v2/credentials/3/access_list/',
|
||||||
|
object_roles: '/api/v2/credentials/3/object_roles/',
|
||||||
|
owner_users: '/api/v2/credentials/3/owner_users/',
|
||||||
|
owner_teams: '/api/v2/credentials/3/owner_teams/',
|
||||||
|
copy: '/api/v2/credentials/3/copy/',
|
||||||
|
input_sources: '/api/v2/credentials/3/input_sources/',
|
||||||
|
credential_type: '/api/v2/credential_types/1/',
|
||||||
|
},
|
||||||
|
summary_fields: {
|
||||||
|
organization: {
|
||||||
|
id: 1,
|
||||||
|
name: 'org',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
credential_type: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Machine',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
created_by: {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
},
|
||||||
|
modified_by: {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
},
|
||||||
|
object_roles: {
|
||||||
|
admin_role: {
|
||||||
|
description: 'Can manage all aspects of the credential',
|
||||||
|
name: 'Admin',
|
||||||
|
id: 36,
|
||||||
|
},
|
||||||
|
use_role: {
|
||||||
|
description: 'Can use the credential in a job template',
|
||||||
|
name: 'Use',
|
||||||
|
id: 37,
|
||||||
|
},
|
||||||
|
read_role: {
|
||||||
|
description: 'May view settings for the credential',
|
||||||
|
name: 'Read',
|
||||||
|
id: 38,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user_capabilities: {
|
||||||
|
edit: true,
|
||||||
|
delete: true,
|
||||||
|
copy: true,
|
||||||
|
use: true,
|
||||||
|
},
|
||||||
|
owners: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'user',
|
||||||
|
name: 'admin',
|
||||||
|
description: ' ',
|
||||||
|
url: '/api/v2/users/1/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'organization',
|
||||||
|
name: 'org',
|
||||||
|
description: '',
|
||||||
|
url: '/api/v2/organizations/1/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
created: '2020-02-18T15:35:04.563928Z',
|
||||||
|
modified: '2020-02-18T15:35:04.563957Z',
|
||||||
|
name: 'oersdgfasf',
|
||||||
|
description: '',
|
||||||
|
organization: 1,
|
||||||
|
credential_type: 1,
|
||||||
|
inputs: {},
|
||||||
|
kind: 'ssh',
|
||||||
|
cloud: false,
|
||||||
|
kubernetes: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
CredentialTypesAPI.read.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'credential_type',
|
||||||
|
url: '/api/v2/credential_types/2/',
|
||||||
|
related: {
|
||||||
|
credentials: '/api/v2/credential_types/2/credentials/',
|
||||||
|
activity_stream: '/api/v2/credential_types/2/activity_stream/',
|
||||||
|
},
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
edit: false,
|
||||||
|
delete: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: '2020-02-12T19:42:43.551238Z',
|
||||||
|
modified: '2020-02-12T19:43:03.164800Z',
|
||||||
|
name: 'Source Control',
|
||||||
|
description: '',
|
||||||
|
kind: 'scm',
|
||||||
|
namespace: 'scm',
|
||||||
|
managed_by_tower: true,
|
||||||
|
inputs: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'username',
|
||||||
|
label: 'Username',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
type: 'string',
|
||||||
|
secret: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh_key_data',
|
||||||
|
label: 'SCM Private Key',
|
||||||
|
type: 'string',
|
||||||
|
format: 'ssh_private_key',
|
||||||
|
secret: true,
|
||||||
|
multiline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh_key_unlock',
|
||||||
|
label: 'Private Key Passphrase',
|
||||||
|
type: 'string',
|
||||||
|
secret: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
injectors: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'credential_type',
|
||||||
|
url: '/api/v2/credential_types/1/',
|
||||||
|
related: {
|
||||||
|
credentials: '/api/v2/credential_types/1/credentials/',
|
||||||
|
activity_stream: '/api/v2/credential_types/1/activity_stream/',
|
||||||
|
},
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
edit: false,
|
||||||
|
delete: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: '2020-02-12T19:42:43.539626Z',
|
||||||
|
modified: '2020-02-12T19:43:03.159739Z',
|
||||||
|
name: 'Machine',
|
||||||
|
description: '',
|
||||||
|
kind: 'ssh',
|
||||||
|
namespace: 'ssh',
|
||||||
|
managed_by_tower: true,
|
||||||
|
inputs: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'username',
|
||||||
|
label: 'Username',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
type: 'string',
|
||||||
|
secret: true,
|
||||||
|
ask_at_runtime: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh_key_data',
|
||||||
|
label: 'SSH Private Key',
|
||||||
|
type: 'string',
|
||||||
|
format: 'ssh_private_key',
|
||||||
|
secret: true,
|
||||||
|
multiline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh_public_key_data',
|
||||||
|
label: 'Signed SSH Certificate',
|
||||||
|
type: 'string',
|
||||||
|
multiline: true,
|
||||||
|
secret: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh_key_unlock',
|
||||||
|
label: 'Private Key Passphrase',
|
||||||
|
type: 'string',
|
||||||
|
secret: true,
|
||||||
|
ask_at_runtime: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'become_method',
|
||||||
|
label: 'Privilege Escalation Method',
|
||||||
|
type: 'string',
|
||||||
|
help_text:
|
||||||
|
'Specify a method for "become" operations. This is equivalent to specifying the --become-method Ansible parameter.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'become_username',
|
||||||
|
label: 'Privilege Escalation Username',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'become_password',
|
||||||
|
label: 'Privilege Escalation Password',
|
||||||
|
type: 'string',
|
||||||
|
secret: true,
|
||||||
|
ask_at_runtime: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
injectors: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
CredentialsAPI.update.mockResolvedValue({ data: { id: 3 } });
|
||||||
|
|
||||||
|
describe('<CredentialEdit />', () => {
|
||||||
|
let wrapper;
|
||||||
|
let history;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
history = createMemoryHistory({ initialEntries: ['/credentials'] });
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<CredentialEdit credential={mockCredential} />,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initially renders successfully', async () => {
|
||||||
|
expect(wrapper.find('CredentialEdit').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleCancel returns the user to credential detail', async () => {
|
||||||
|
await waitForElement(wrapper, 'isLoading', el => el.length === 0);
|
||||||
|
wrapper.find('Button[aria-label="Cancel"]').simulate('click');
|
||||||
|
expect(history.location.pathname).toEqual('/credentials/3/details');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleSubmit should post to the api', async () => {
|
||||||
|
await waitForElement(wrapper, 'isLoading', el => el.length === 0);
|
||||||
|
|
||||||
|
wrapper.find('CredentialForm').prop('onSubmit')({
|
||||||
|
user: 1,
|
||||||
|
organization: null,
|
||||||
|
name: 'foo',
|
||||||
|
description: 'bar',
|
||||||
|
credential_type: '2',
|
||||||
|
inputs: {},
|
||||||
|
});
|
||||||
|
await sleep(1);
|
||||||
|
expect(CredentialsAPI.update).toHaveBeenCalledWith(3, {
|
||||||
|
user: 1,
|
||||||
|
organization: null,
|
||||||
|
name: 'foo',
|
||||||
|
description: 'bar',
|
||||||
|
credential_type: '2',
|
||||||
|
inputs: {},
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toBe('/credentials/3/details');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './CredentialEdit';
|
||||||
@@ -2,7 +2,7 @@ import React, { useState, useCallback } from 'react';
|
|||||||
import { Route, Switch } from 'react-router-dom';
|
import { Route, Switch } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
import { Config } from '@contexts/Config';
|
||||||
import Breadcrumbs from '@components/Breadcrumbs';
|
import Breadcrumbs from '@components/Breadcrumbs';
|
||||||
import Credential from './Credential';
|
import Credential from './Credential';
|
||||||
import CredentialAdd from './CredentialAdd';
|
import CredentialAdd from './CredentialAdd';
|
||||||
@@ -24,7 +24,9 @@ function Credentials({ i18n }) {
|
|||||||
'/credentials': i18n._(t`Credentials`),
|
'/credentials': i18n._(t`Credentials`),
|
||||||
'/credentials/add': i18n._(t`Create New Credential`),
|
'/credentials/add': i18n._(t`Create New Credential`),
|
||||||
[`/credentials/${credential.id}`]: `${credential.name}`,
|
[`/credentials/${credential.id}`]: `${credential.name}`,
|
||||||
|
[`/credentials/${credential.id}/edit`]: i18n._(t`Edit Details`),
|
||||||
[`/credentials/${credential.id}/details`]: i18n._(t`Details`),
|
[`/credentials/${credential.id}/details`]: i18n._(t`Details`),
|
||||||
|
[`/credentials/${credential.id}/access`]: i18n._(t`Access`),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[i18n]
|
[i18n]
|
||||||
@@ -35,7 +37,7 @@ function Credentials({ i18n }) {
|
|||||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/credentials/add">
|
<Route path="/credentials/add">
|
||||||
<CredentialAdd />
|
<Config>{({ me }) => <CredentialAdd me={me || {}} />}</Config>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/credentials/:id">
|
<Route path="/credentials/:id">
|
||||||
<Credential setBreadcrumb={buildBreadcrumbConfig} />
|
<Credential setBreadcrumb={buildBreadcrumbConfig} />
|
||||||
|
|||||||
217
awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx
Normal file
217
awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Formik, useField } from 'formik';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { func, shape } from 'prop-types';
|
||||||
|
import { Form, FormGroup, Title } from '@patternfly/react-core';
|
||||||
|
import FormField from '@components/FormField';
|
||||||
|
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
||||||
|
import AnsibleSelect from '@components/AnsibleSelect';
|
||||||
|
import { required } from '@util/validators';
|
||||||
|
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
|
||||||
|
import { FormColumnLayout, SubFormLayout } from '@components/FormLayout';
|
||||||
|
import { ManualSubForm, SourceControlSubForm } from './CredentialSubForms';
|
||||||
|
|
||||||
|
function CredentialFormFields({
|
||||||
|
i18n,
|
||||||
|
credentialTypes,
|
||||||
|
formik,
|
||||||
|
initialValues,
|
||||||
|
scmCredentialTypeId,
|
||||||
|
sshCredentialTypeId,
|
||||||
|
}) {
|
||||||
|
const [orgField, orgMeta, orgHelpers] = useField('organization');
|
||||||
|
const [credTypeField, credTypeMeta, credTypeHelpers] = useField({
|
||||||
|
name: 'credential_type',
|
||||||
|
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||||
|
});
|
||||||
|
|
||||||
|
const credentialTypeOptions = Object.keys(credentialTypes).map(key => {
|
||||||
|
return {
|
||||||
|
value: credentialTypes[key].id,
|
||||||
|
key: credentialTypes[key].kind,
|
||||||
|
label: credentialTypes[key].name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetSubFormFields = (value, form) => {
|
||||||
|
Object.keys(form.initialValues.inputs).forEach(label => {
|
||||||
|
if (parseInt(value, 10) === form.initialValues.credential_type) {
|
||||||
|
form.setFieldValue(`inputs.${label}`, initialValues.inputs[label]);
|
||||||
|
} else {
|
||||||
|
form.setFieldValue(`inputs.${label}`, '');
|
||||||
|
}
|
||||||
|
form.setFieldTouched(`inputs.${label}`, false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
id="credential-name"
|
||||||
|
label={i18n._(t`Name`)}
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
validate={required(null, i18n)}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
id="credential-description"
|
||||||
|
label={i18n._(t`Description`)}
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<OrganizationLookup
|
||||||
|
helperTextInvalid={orgMeta.error}
|
||||||
|
isValid={!orgMeta.touched || !orgMeta.error}
|
||||||
|
onBlur={() => orgHelpers.setTouched()}
|
||||||
|
onChange={value => {
|
||||||
|
orgHelpers.setValue(value);
|
||||||
|
}}
|
||||||
|
value={orgField.value}
|
||||||
|
touched={orgMeta.touched}
|
||||||
|
error={orgMeta.error}
|
||||||
|
/>
|
||||||
|
<FormGroup
|
||||||
|
fieldId="credential-credentialType"
|
||||||
|
helperTextInvalid={credTypeMeta.error}
|
||||||
|
isRequired
|
||||||
|
isValid={!credTypeMeta.touched || !credTypeMeta.error}
|
||||||
|
label={i18n._(t`Credential Type`)}
|
||||||
|
>
|
||||||
|
<AnsibleSelect
|
||||||
|
{...credTypeField}
|
||||||
|
id="credential_type"
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
key: '',
|
||||||
|
label: i18n._(t`Choose a Credential Type`),
|
||||||
|
isDisabled: true,
|
||||||
|
},
|
||||||
|
...credentialTypeOptions,
|
||||||
|
]}
|
||||||
|
onChange={(event, value) => {
|
||||||
|
credTypeHelpers.setValue(value);
|
||||||
|
resetSubFormFields(value, formik);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
{formik.values.credential_type !== undefined &&
|
||||||
|
formik.values.credential_type !== '' && (
|
||||||
|
<SubFormLayout>
|
||||||
|
<Title size="md">{i18n._(t`Type Details`)}</Title>
|
||||||
|
{
|
||||||
|
{
|
||||||
|
[sshCredentialTypeId]: <ManualSubForm />,
|
||||||
|
[scmCredentialTypeId]: <SourceControlSubForm />,
|
||||||
|
}[formik.values.credential_type]
|
||||||
|
}
|
||||||
|
</SubFormLayout>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CredentialForm({
|
||||||
|
credential = {},
|
||||||
|
credentialTypes,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
const initialValues = {
|
||||||
|
name: credential.name || '',
|
||||||
|
description: credential.description || '',
|
||||||
|
organization: credential?.summary_fields?.organization || null,
|
||||||
|
credential_type: credential.credential_type || '',
|
||||||
|
inputs: {
|
||||||
|
username: credential?.inputs?.username || '',
|
||||||
|
password: credential?.inputs?.password || '',
|
||||||
|
ssh_key_data: credential?.inputs?.ssh_key_data || '',
|
||||||
|
ssh_public_key_data: credential?.inputs?.ssh_public_key_data || '',
|
||||||
|
ssh_key_unlock: credential?.inputs?.ssh_key_unlock || '',
|
||||||
|
become_method: credential?.inputs?.become_method || '',
|
||||||
|
become_username: credential?.inputs?.become_username || '',
|
||||||
|
become_password: credential?.inputs?.become_password || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const scmCredentialTypeId = Object.keys(credentialTypes)
|
||||||
|
.filter(key => credentialTypes[key].kind === 'scm')
|
||||||
|
.map(key => credentialTypes[key].id)[0];
|
||||||
|
const sshCredentialTypeId = Object.keys(credentialTypes)
|
||||||
|
.filter(key => credentialTypes[key].kind === 'ssh')
|
||||||
|
.map(key => credentialTypes[key].id)[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={values => {
|
||||||
|
const scmKeys = [
|
||||||
|
'username',
|
||||||
|
'password',
|
||||||
|
'ssh_key_data',
|
||||||
|
'ssh_key_unlock',
|
||||||
|
];
|
||||||
|
const sshKeys = [
|
||||||
|
'username',
|
||||||
|
'password',
|
||||||
|
'ssh_key_data',
|
||||||
|
'ssh_public_key_data',
|
||||||
|
'ssh_key_unlock',
|
||||||
|
'become_method',
|
||||||
|
'become_username',
|
||||||
|
'become_password',
|
||||||
|
];
|
||||||
|
if (parseInt(values.credential_type, 10) === scmCredentialTypeId) {
|
||||||
|
Object.keys(values.inputs).forEach(key => {
|
||||||
|
if (scmKeys.indexOf(key) < 0) {
|
||||||
|
delete values.inputs[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
parseInt(values.credential_type, 10) === sshCredentialTypeId
|
||||||
|
) {
|
||||||
|
Object.keys(values.inputs).forEach(key => {
|
||||||
|
if (sshKeys.indexOf(key) < 0) {
|
||||||
|
delete values.inputs[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onSubmit(values);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formik => (
|
||||||
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
|
<FormColumnLayout>
|
||||||
|
<CredentialFormFields
|
||||||
|
formik={formik}
|
||||||
|
initialValues={initialValues}
|
||||||
|
credentialTypes={credentialTypes}
|
||||||
|
scmCredentialTypeId={scmCredentialTypeId}
|
||||||
|
sshCredentialTypeId={sshCredentialTypeId}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
<FormActionGroup
|
||||||
|
onCancel={onCancel}
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
/>
|
||||||
|
</FormColumnLayout>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CredentialForm.proptype = {
|
||||||
|
handleSubmit: func.isRequired,
|
||||||
|
handleCancel: func.isRequired,
|
||||||
|
credential: shape({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
CredentialForm.defaultProps = {
|
||||||
|
credential: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withI18n()(CredentialForm);
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
|
|
||||||
|
import CredentialForm from './CredentialForm';
|
||||||
|
|
||||||
|
const machineCredential = {
|
||||||
|
id: 3,
|
||||||
|
type: 'credential',
|
||||||
|
url: '/api/v2/credentials/3/',
|
||||||
|
related: {
|
||||||
|
named_url: '/api/v2/credentials/oersdgfasf++Machine+ssh++org/',
|
||||||
|
created_by: '/api/v2/users/1/',
|
||||||
|
modified_by: '/api/v2/users/1/',
|
||||||
|
organization: '/api/v2/organizations/1/',
|
||||||
|
activity_stream: '/api/v2/credentials/3/activity_stream/',
|
||||||
|
access_list: '/api/v2/credentials/3/access_list/',
|
||||||
|
object_roles: '/api/v2/credentials/3/object_roles/',
|
||||||
|
owner_users: '/api/v2/credentials/3/owner_users/',
|
||||||
|
owner_teams: '/api/v2/credentials/3/owner_teams/',
|
||||||
|
copy: '/api/v2/credentials/3/copy/',
|
||||||
|
input_sources: '/api/v2/credentials/3/input_sources/',
|
||||||
|
credential_type: '/api/v2/credential_types/1/',
|
||||||
|
},
|
||||||
|
summary_fields: {
|
||||||
|
organization: {
|
||||||
|
id: 1,
|
||||||
|
name: 'org',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
credential_type: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Machine',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
created_by: {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
},
|
||||||
|
modified_by: {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
},
|
||||||
|
object_roles: {
|
||||||
|
admin_role: {
|
||||||
|
description: 'Can manage all aspects of the credential',
|
||||||
|
name: 'Admin',
|
||||||
|
id: 36,
|
||||||
|
},
|
||||||
|
use_role: {
|
||||||
|
description: 'Can use the credential in a job template',
|
||||||
|
name: 'Use',
|
||||||
|
id: 37,
|
||||||
|
},
|
||||||
|
read_role: {
|
||||||
|
description: 'May view settings for the credential',
|
||||||
|
name: 'Read',
|
||||||
|
id: 38,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user_capabilities: {
|
||||||
|
edit: true,
|
||||||
|
delete: true,
|
||||||
|
copy: true,
|
||||||
|
use: true,
|
||||||
|
},
|
||||||
|
owners: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'user',
|
||||||
|
name: 'admin',
|
||||||
|
description: ' ',
|
||||||
|
url: '/api/v2/users/1/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'organization',
|
||||||
|
name: 'org',
|
||||||
|
description: '',
|
||||||
|
url: '/api/v2/organizations/1/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
created: '2020-02-18T15:35:04.563928Z',
|
||||||
|
modified: '2020-02-18T15:35:04.563957Z',
|
||||||
|
name: 'oersdgfasf',
|
||||||
|
description: '',
|
||||||
|
organization: 1,
|
||||||
|
credential_type: 1,
|
||||||
|
inputs: {},
|
||||||
|
kind: 'ssh',
|
||||||
|
cloud: false,
|
||||||
|
kubernetes: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceControlCredential = {
|
||||||
|
id: 4,
|
||||||
|
type: 'credential',
|
||||||
|
url: '/api/v2/credentials/4/',
|
||||||
|
related: {
|
||||||
|
named_url: '/api/v2/credentials/joijoij++Source Control+scm++/',
|
||||||
|
created_by: '/api/v2/users/1/',
|
||||||
|
modified_by: '/api/v2/users/1/',
|
||||||
|
activity_stream: '/api/v2/credentials/4/activity_stream/',
|
||||||
|
access_list: '/api/v2/credentials/4/access_list/',
|
||||||
|
object_roles: '/api/v2/credentials/4/object_roles/',
|
||||||
|
owner_users: '/api/v2/credentials/4/owner_users/',
|
||||||
|
owner_teams: '/api/v2/credentials/4/owner_teams/',
|
||||||
|
copy: '/api/v2/credentials/4/copy/',
|
||||||
|
input_sources: '/api/v2/credentials/4/input_sources/',
|
||||||
|
credential_type: '/api/v2/credential_types/2/',
|
||||||
|
user: '/api/v2/users/1/',
|
||||||
|
},
|
||||||
|
summary_fields: {
|
||||||
|
credential_type: {
|
||||||
|
id: 2,
|
||||||
|
name: 'Source Control',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
created_by: {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
},
|
||||||
|
modified_by: {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
},
|
||||||
|
object_roles: {
|
||||||
|
admin_role: {
|
||||||
|
description: 'Can manage all aspects of the credential',
|
||||||
|
name: 'Admin',
|
||||||
|
id: 39,
|
||||||
|
},
|
||||||
|
use_role: {
|
||||||
|
description: 'Can use the credential in a job template',
|
||||||
|
name: 'Use',
|
||||||
|
id: 40,
|
||||||
|
},
|
||||||
|
read_role: {
|
||||||
|
description: 'May view settings for the credential',
|
||||||
|
name: 'Read',
|
||||||
|
id: 41,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user_capabilities: {
|
||||||
|
edit: true,
|
||||||
|
delete: true,
|
||||||
|
copy: true,
|
||||||
|
use: true,
|
||||||
|
},
|
||||||
|
owners: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'user',
|
||||||
|
name: 'admin',
|
||||||
|
description: ' ',
|
||||||
|
url: '/api/v2/users/1/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
created: '2020-02-18T16:03:01.366287Z',
|
||||||
|
modified: '2020-02-18T16:03:01.366315Z',
|
||||||
|
name: 'joijoij',
|
||||||
|
description: 'ojiojojo',
|
||||||
|
organization: null,
|
||||||
|
credential_type: 2,
|
||||||
|
inputs: {
|
||||||
|
ssh_key_unlock: '$encrypted$',
|
||||||
|
},
|
||||||
|
kind: 'scm',
|
||||||
|
cloud: false,
|
||||||
|
kubernetes: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const credentialTypes = [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'credential_type',
|
||||||
|
url: '/api/v2/credential_types/2/',
|
||||||
|
related: {
|
||||||
|
credentials: '/api/v2/credential_types/2/credentials/',
|
||||||
|
activity_stream: '/api/v2/credential_types/2/activity_stream/',
|
||||||
|
},
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
edit: false,
|
||||||
|
delete: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: '2020-02-12T19:42:43.551238Z',
|
||||||
|
modified: '2020-02-12T19:43:03.164800Z',
|
||||||
|
name: 'Source Control',
|
||||||
|
description: '',
|
||||||
|
kind: 'scm',
|
||||||
|
namespace: 'scm',
|
||||||
|
managed_by_tower: true,
|
||||||
|
inputs: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'username',
|
||||||
|
label: 'Username',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
type: 'string',
|
||||||
|
secret: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh_key_data',
|
||||||
|
label: 'SCM Private Key',
|
||||||
|
type: 'string',
|
||||||
|
format: 'ssh_private_key',
|
||||||
|
secret: true,
|
||||||
|
multiline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh_key_unlock',
|
||||||
|
label: 'Private Key Passphrase',
|
||||||
|
type: 'string',
|
||||||
|
secret: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
injectors: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'credential_type',
|
||||||
|
url: '/api/v2/credential_types/1/',
|
||||||
|
related: {
|
||||||
|
credentials: '/api/v2/credential_types/1/credentials/',
|
||||||
|
activity_stream: '/api/v2/credential_types/1/activity_stream/',
|
||||||
|
},
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
edit: false,
|
||||||
|
delete: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: '2020-02-12T19:42:43.539626Z',
|
||||||
|
modified: '2020-02-12T19:43:03.159739Z',
|
||||||
|
name: 'Machine',
|
||||||
|
description: '',
|
||||||
|
kind: 'ssh',
|
||||||
|
namespace: 'ssh',
|
||||||
|
managed_by_tower: true,
|
||||||
|
inputs: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'username',
|
||||||
|
label: 'Username',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
type: 'string',
|
||||||
|
secret: true,
|
||||||
|
ask_at_runtime: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh_key_data',
|
||||||
|
label: 'SSH Private Key',
|
||||||
|
type: 'string',
|
||||||
|
format: 'ssh_private_key',
|
||||||
|
secret: true,
|
||||||
|
multiline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh_public_key_data',
|
||||||
|
label: 'Signed SSH Certificate',
|
||||||
|
type: 'string',
|
||||||
|
multiline: true,
|
||||||
|
secret: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssh_key_unlock',
|
||||||
|
label: 'Private Key Passphrase',
|
||||||
|
type: 'string',
|
||||||
|
secret: true,
|
||||||
|
ask_at_runtime: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'become_method',
|
||||||
|
label: 'Privilege Escalation Method',
|
||||||
|
type: 'string',
|
||||||
|
help_text:
|
||||||
|
'Specify a method for "become" operations. This is equivalent to specifying the --become-method Ansible parameter.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'become_username',
|
||||||
|
label: 'Privilege Escalation Username',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'become_password',
|
||||||
|
label: 'Privilege Escalation Password',
|
||||||
|
type: 'string',
|
||||||
|
secret: true,
|
||||||
|
ask_at_runtime: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
injectors: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('<CredentialForm />', () => {
|
||||||
|
let wrapper;
|
||||||
|
let onCancel;
|
||||||
|
let onSubmit;
|
||||||
|
|
||||||
|
const addFieldExpects = () => {
|
||||||
|
expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Organization"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Credential Type"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Username"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('FormGroup[label="Password"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('FormGroup[label="SSH Private Key"]').length).toBe(0);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="Signed SSH Certificate"]').length
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="Private Key Passphrase"]').length
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="Privelege Escalation Method"]').length
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="Privilege Escalation Username"]').length
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="Privilege Escalation Password"]').length
|
||||||
|
).toBe(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const machineFieldExpects = () => {
|
||||||
|
expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Organization"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Credential Type"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Username"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Password"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="SSH Private Key"]').length).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="Signed SSH Certificate"]').length
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="Private Key Passphrase"]').length
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="Privelege Escalation Method"]').length
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="Privilege Escalation Username"]').length
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="Privilege Escalation Password"]').length
|
||||||
|
).toBe(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceFieldExpects = () => {
|
||||||
|
expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Organization"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Credential Type"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Username"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Password"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="SSH Private Key"]').length).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="Signed SSH Certificate"]').length
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="Private Key Passphrase"]').length
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="Privelege Escalation Method"]').length
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="Privilege Escalation Username"]').length
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="Privilege Escalation Password"]').length
|
||||||
|
).toBe(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
onCancel = jest.fn();
|
||||||
|
onSubmit = jest.fn();
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<CredentialForm
|
||||||
|
onCancel={onCancel}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
credential={machineCredential}
|
||||||
|
credentialTypes={credentialTypes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Initially renders successfully', () => {
|
||||||
|
expect(wrapper.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display form fields on add properly', () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<CredentialForm
|
||||||
|
onCancel={onCancel}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
credentialTypes={credentialTypes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
addFieldExpects();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display form fields for machine credential properly', () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<CredentialForm
|
||||||
|
onCancel={onCancel}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
credential={machineCredential}
|
||||||
|
credentialTypes={credentialTypes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
machineFieldExpects();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display form fields for source control credential properly', () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<CredentialForm
|
||||||
|
onCancel={onCancel}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
credential={sourceControlCredential}
|
||||||
|
credentialTypes={credentialTypes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
sourceFieldExpects();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update form values', async () => {
|
||||||
|
// name and description change
|
||||||
|
act(() => {
|
||||||
|
wrapper.find('input#credential-name').simulate('change', {
|
||||||
|
target: { value: 'new Foo', name: 'name' },
|
||||||
|
});
|
||||||
|
wrapper.find('input#credential-description').simulate('change', {
|
||||||
|
target: { value: 'new Bar', name: 'description' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('input#credential-name').prop('value')).toEqual(
|
||||||
|
'new Foo'
|
||||||
|
);
|
||||||
|
expect(wrapper.find('input#credential-description').prop('value')).toEqual(
|
||||||
|
'new Bar'
|
||||||
|
);
|
||||||
|
// organization change
|
||||||
|
act(() => {
|
||||||
|
wrapper.find('OrganizationLookup').invoke('onBlur')();
|
||||||
|
wrapper.find('OrganizationLookup').invoke('onChange')({
|
||||||
|
id: 3,
|
||||||
|
name: 'organization',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('OrganizationLookup').prop('value')).toEqual({
|
||||||
|
id: 3,
|
||||||
|
name: 'organization',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display cred type subform when scm type select has a value', async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<CredentialForm
|
||||||
|
onCancel={onCancel}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
credentialTypes={credentialTypes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
addFieldExpects();
|
||||||
|
await act(async () => {
|
||||||
|
await wrapper
|
||||||
|
.find('AnsibleSelect[id="credential_type"]')
|
||||||
|
.invoke('onChange')(null, 1);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
machineFieldExpects();
|
||||||
|
await act(async () => {
|
||||||
|
await wrapper
|
||||||
|
.find('AnsibleSelect[id="credential_type"]')
|
||||||
|
.invoke('onChange')(null, 2);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
sourceFieldExpects();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call handleCancel when Cancel button is clicked', async () => {
|
||||||
|
expect(onCancel).not.toHaveBeenCalled();
|
||||||
|
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||||
|
expect(onCancel).toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useField } from 'formik';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import FormField, { PasswordField } from '@components/FormField';
|
||||||
|
import { FormGroup } from '@patternfly/react-core';
|
||||||
|
import AnsibleSelect from '@components/AnsibleSelect';
|
||||||
|
import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout';
|
||||||
|
import {
|
||||||
|
UsernameFormField,
|
||||||
|
PasswordFormField,
|
||||||
|
SSHKeyUnlockField,
|
||||||
|
SSHKeyDataField,
|
||||||
|
} from './SharedFields';
|
||||||
|
|
||||||
|
const ManualSubForm = ({ i18n }) => {
|
||||||
|
const becomeMethodOptions = [
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
key: '',
|
||||||
|
label: i18n._(t`Choose a Privelege Escalation Method`),
|
||||||
|
isDisabled: true,
|
||||||
|
},
|
||||||
|
...[
|
||||||
|
'sudo',
|
||||||
|
'su',
|
||||||
|
'pbrun',
|
||||||
|
'pfexec',
|
||||||
|
'dzdo',
|
||||||
|
'pmrun',
|
||||||
|
'runas',
|
||||||
|
'enable',
|
||||||
|
'doas',
|
||||||
|
'ksu',
|
||||||
|
'machinectl',
|
||||||
|
'sesu',
|
||||||
|
].map(val => ({ value: val, key: val, label: val })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const becomeMethodFieldArr = useField('inputs.become_method');
|
||||||
|
const becomeMethodField = becomeMethodFieldArr[0];
|
||||||
|
const becomeMethodHelpers = becomeMethodFieldArr[2];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormColumnLayout>
|
||||||
|
<UsernameFormField />
|
||||||
|
<PasswordFormField />
|
||||||
|
<FormFullWidthLayout>
|
||||||
|
<SSHKeyDataField />
|
||||||
|
<FormField
|
||||||
|
id="credential-sshPublicKeyData"
|
||||||
|
label={i18n._(t`Signed SSH Certificate`)}
|
||||||
|
name="inputs.ssh_public_key_data"
|
||||||
|
type="textarea"
|
||||||
|
/>
|
||||||
|
</FormFullWidthLayout>
|
||||||
|
<SSHKeyUnlockField />
|
||||||
|
<FormGroup
|
||||||
|
fieldId="credential-becomeMethod"
|
||||||
|
label={i18n._(t`Privelege Escalation Method`)}
|
||||||
|
>
|
||||||
|
<AnsibleSelect
|
||||||
|
{...becomeMethodField}
|
||||||
|
id="credential-becomeMethod"
|
||||||
|
data={becomeMethodOptions}
|
||||||
|
onChange={(event, value) => {
|
||||||
|
becomeMethodHelpers.setValue(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<FormField
|
||||||
|
id="credential-becomeUsername"
|
||||||
|
label={i18n._(t`Privilege Escalation Username`)}
|
||||||
|
name="inputs.become_username"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<PasswordField
|
||||||
|
id="credential-becomePassword"
|
||||||
|
label={i18n._(t`Privilege Escalation Password`)}
|
||||||
|
name="inputs.become_password"
|
||||||
|
/>
|
||||||
|
</FormColumnLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withI18n()(ManualSubForm);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import FormField, { PasswordField } from '@components/FormField';
|
||||||
|
|
||||||
|
export const UsernameFormField = withI18n()(({ i18n }) => (
|
||||||
|
<FormField
|
||||||
|
id="credentual-username"
|
||||||
|
label={i18n._(t`Username`)}
|
||||||
|
name="inputs.username"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
export const PasswordFormField = withI18n()(({ i18n }) => (
|
||||||
|
<PasswordField
|
||||||
|
id="credential-password"
|
||||||
|
label={i18n._(t`Password`)}
|
||||||
|
name="inputs.password"
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
export const SSHKeyDataField = withI18n()(({ i18n }) => (
|
||||||
|
<FormField
|
||||||
|
id="credential-sshKeyData"
|
||||||
|
label={i18n._(t`SSH Private Key`)}
|
||||||
|
name="inputs.ssh_key_data"
|
||||||
|
type="textarea"
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
export const SSHKeyUnlockField = withI18n()(({ i18n }) => (
|
||||||
|
<PasswordField
|
||||||
|
id="credential-sshKeyUnlock"
|
||||||
|
label={i18n._(t`Private Key Passphrase`)}
|
||||||
|
name="inputs.ssh_key_unlock"
|
||||||
|
/>
|
||||||
|
));
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout';
|
||||||
|
import {
|
||||||
|
UsernameFormField,
|
||||||
|
PasswordFormField,
|
||||||
|
SSHKeyUnlockField,
|
||||||
|
SSHKeyDataField,
|
||||||
|
} from './SharedFields';
|
||||||
|
|
||||||
|
const SourceControlSubForm = () => (
|
||||||
|
<>
|
||||||
|
<FormColumnLayout>
|
||||||
|
<UsernameFormField />
|
||||||
|
<PasswordFormField />
|
||||||
|
<SSHKeyUnlockField />
|
||||||
|
</FormColumnLayout>
|
||||||
|
<FormFullWidthLayout>
|
||||||
|
<SSHKeyDataField />
|
||||||
|
</FormFullWidthLayout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default withI18n()(SourceControlSubForm);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as ManualSubForm } from './ManualSubForm';
|
||||||
|
export { default as SourceControlSubForm } from './SourceControlSubForm';
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "credential",
|
||||||
|
"url": "/api/v2/credentials/2/",
|
||||||
|
"related": {
|
||||||
|
"named_url": "/api/v2/credentials/jojoijoij++Source Control+scm++/",
|
||||||
|
"created_by": "/api/v2/users/1/",
|
||||||
|
"modified_by": "/api/v2/users/1/",
|
||||||
|
"activity_stream": "/api/v2/credentials/2/activity_stream/",
|
||||||
|
"access_list": "/api/v2/credentials/2/access_list/",
|
||||||
|
"object_roles": "/api/v2/credentials/2/object_roles/",
|
||||||
|
"owner_users": "/api/v2/credentials/2/owner_users/",
|
||||||
|
"owner_teams": "/api/v2/credentials/2/owner_teams/",
|
||||||
|
"copy": "/api/v2/credentials/2/copy/",
|
||||||
|
"input_sources": "/api/v2/credentials/2/input_sources/",
|
||||||
|
"credential_type": "/api/v2/credential_types/2/",
|
||||||
|
"user": "/api/v2/users/1/"
|
||||||
|
},
|
||||||
|
"summary_fields": {
|
||||||
|
"credential_type": {
|
||||||
|
"id": 2,
|
||||||
|
"name": "Source Control",
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
},
|
||||||
|
"modified_by": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
},
|
||||||
|
"object_roles": {
|
||||||
|
"admin_role": {
|
||||||
|
"description": "Can manage all aspects of the credential",
|
||||||
|
"name": "Admin",
|
||||||
|
"id": 6
|
||||||
|
},
|
||||||
|
"use_role": {
|
||||||
|
"description": "Can use the credential in a job template",
|
||||||
|
"name": "Use",
|
||||||
|
"id": 7
|
||||||
|
},
|
||||||
|
"read_role": {
|
||||||
|
"description": "May view settings for the credential",
|
||||||
|
"name": "Read",
|
||||||
|
"id": 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"user_capabilities": {
|
||||||
|
"edit": true,
|
||||||
|
"delete": true,
|
||||||
|
"copy": true,
|
||||||
|
"use": true
|
||||||
|
},
|
||||||
|
"owners": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "user",
|
||||||
|
"name": "admin",
|
||||||
|
"description": " ",
|
||||||
|
"url": "/api/v2/users/1/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"created": "2020-02-12T19:59:11.508933Z",
|
||||||
|
"modified": "2020-02-12T19:59:11.508958Z",
|
||||||
|
"name": "jojoijoij",
|
||||||
|
"description": "",
|
||||||
|
"organization": null,
|
||||||
|
"credential_type": 2,
|
||||||
|
"inputs": {
|
||||||
|
"password": "$encrypted$",
|
||||||
|
"username": "uujoij",
|
||||||
|
"ssh_key_unlock": "$encrypted$"
|
||||||
|
},
|
||||||
|
"kind": "scm",
|
||||||
|
"cloud": false,
|
||||||
|
"kubernetes": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "credential",
|
||||||
|
"url": "/api/v2/credentials/3/",
|
||||||
|
"related": {
|
||||||
|
"named_url": "/api/v2/credentials/oersdgfasf++Machine+ssh++org/",
|
||||||
|
"created_by": "/api/v2/users/1/",
|
||||||
|
"modified_by": "/api/v2/users/1/",
|
||||||
|
"organization": "/api/v2/organizations/1/",
|
||||||
|
"activity_stream": "/api/v2/credentials/3/activity_stream/",
|
||||||
|
"access_list": "/api/v2/credentials/3/access_list/",
|
||||||
|
"object_roles": "/api/v2/credentials/3/object_roles/",
|
||||||
|
"owner_users": "/api/v2/credentials/3/owner_users/",
|
||||||
|
"owner_teams": "/api/v2/credentials/3/owner_teams/",
|
||||||
|
"copy": "/api/v2/credentials/3/copy/",
|
||||||
|
"input_sources": "/api/v2/credentials/3/input_sources/",
|
||||||
|
"credential_type": "/api/v2/credential_types/1/"
|
||||||
|
},
|
||||||
|
"summary_fields": {
|
||||||
|
"organization": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "org",
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"credential_type": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Machine",
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
},
|
||||||
|
"modified_by": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
},
|
||||||
|
"object_roles": {
|
||||||
|
"admin_role": {
|
||||||
|
"description": "Can manage all aspects of the credential",
|
||||||
|
"name": "Admin",
|
||||||
|
"id": 36
|
||||||
|
},
|
||||||
|
"use_role": {
|
||||||
|
"description": "Can use the credential in a job template",
|
||||||
|
"name": "Use",
|
||||||
|
"id": 37
|
||||||
|
},
|
||||||
|
"read_role": {
|
||||||
|
"description": "May view settings for the credential",
|
||||||
|
"name": "Read",
|
||||||
|
"id": 38
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"user_capabilities": {
|
||||||
|
"edit": true,
|
||||||
|
"delete": true,
|
||||||
|
"copy": true,
|
||||||
|
"use": true
|
||||||
|
},
|
||||||
|
"owners": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "user",
|
||||||
|
"name": "admin",
|
||||||
|
"description": " ",
|
||||||
|
"url": "/api/v2/users/1/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "organization",
|
||||||
|
"name": "org",
|
||||||
|
"description": "",
|
||||||
|
"url": "/api/v2/organizations/1/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"created": "2020-02-18T15:35:04.563928Z",
|
||||||
|
"modified": "2020-02-18T15:35:04.563957Z",
|
||||||
|
"name": "oersdgfasf",
|
||||||
|
"description": "",
|
||||||
|
"organization": 1,
|
||||||
|
"credential_type": 1,
|
||||||
|
"inputs": {},
|
||||||
|
"kind": "ssh",
|
||||||
|
"cloud": false,
|
||||||
|
"kubernetes": false
|
||||||
|
}
|
||||||
@@ -2,39 +2,34 @@ import React, { useState } from 'react';
|
|||||||
import { func, shape } from 'prop-types';
|
import { func, shape } from 'prop-types';
|
||||||
|
|
||||||
import { useRouteMatch } from 'react-router-dom';
|
import { useRouteMatch } from 'react-router-dom';
|
||||||
import { Formik, Field } from 'formik';
|
import { Formik, useField } from 'formik';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
import { Form } from '@patternfly/react-core';
|
import { Form } from '@patternfly/react-core';
|
||||||
|
|
||||||
import FormRow from '@components/FormRow';
|
|
||||||
import FormField, { FormSubmitError } from '@components/FormField';
|
import FormField, { FormSubmitError } from '@components/FormField';
|
||||||
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
||||||
import { VariablesField } from '@components/CodeMirrorInput';
|
import { VariablesField } from '@components/CodeMirrorInput';
|
||||||
import { required } from '@util/validators';
|
import { required } from '@util/validators';
|
||||||
import { InventoryLookup } from '@components/Lookup';
|
import { InventoryLookup } from '@components/Lookup';
|
||||||
|
import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout';
|
||||||
|
|
||||||
function HostForm({ handleSubmit, handleCancel, host, submitError, i18n }) {
|
function HostFormFields({ host, i18n }) {
|
||||||
const [inventory, setInventory] = useState(
|
const [inventory, setInventory] = useState(
|
||||||
host ? host.summary_fields.inventory : ''
|
host ? host.summary_fields.inventory : ''
|
||||||
);
|
);
|
||||||
|
|
||||||
const hostAddMatch = useRouteMatch('/hosts/add');
|
const hostAddMatch = useRouteMatch('/hosts/add');
|
||||||
|
const inventoryFieldArr = useField({
|
||||||
|
name: 'inventory',
|
||||||
|
validate: required(i18n._(t`Select aå value for this field`), i18n),
|
||||||
|
});
|
||||||
|
const inventoryMeta = inventoryFieldArr[1];
|
||||||
|
const inventoryHelpers = inventoryFieldArr[2];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<>
|
||||||
initialValues={{
|
|
||||||
name: host.name,
|
|
||||||
description: host.description,
|
|
||||||
inventory: host.inventory || '',
|
|
||||||
variables: host.variables,
|
|
||||||
}}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
>
|
|
||||||
{formik => (
|
|
||||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
|
||||||
<FormRow>
|
|
||||||
<FormField
|
<FormField
|
||||||
id="host-name"
|
id="host-name"
|
||||||
name="name"
|
name="name"
|
||||||
@@ -50,46 +45,55 @@ function HostForm({ handleSubmit, handleCancel, host, submitError, i18n }) {
|
|||||||
label={i18n._(t`Description`)}
|
label={i18n._(t`Description`)}
|
||||||
/>
|
/>
|
||||||
{hostAddMatch && (
|
{hostAddMatch && (
|
||||||
<Field
|
|
||||||
name="inventory"
|
|
||||||
validate={required(
|
|
||||||
i18n._(t`Select a value for this field`),
|
|
||||||
i18n
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{({ form }) => (
|
|
||||||
<InventoryLookup
|
<InventoryLookup
|
||||||
value={inventory}
|
value={inventory}
|
||||||
onBlur={() => form.setFieldTouched('inventory')}
|
onBlur={() => inventoryHelpers.setTouched()}
|
||||||
tooltip={i18n._(
|
tooltip={i18n._(
|
||||||
t`Select the inventory that this host will belong to.`
|
t`Select the inventory that this host will belong to.`
|
||||||
)}
|
)}
|
||||||
isValid={!form.touched.inventory || !form.errors.inventory}
|
isValid={!inventoryMeta.touched || !inventoryMeta.error}
|
||||||
helperTextInvalid={form.errors.inventory}
|
helperTextInvalid={inventoryMeta.error}
|
||||||
onChange={value => {
|
onChange={value => {
|
||||||
form.setFieldValue('inventory', value.id);
|
inventoryHelpers.setValuealue(value.id);
|
||||||
setInventory(value);
|
setInventory(value);
|
||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
touched={form.touched.inventory}
|
touched={inventoryMeta.touched}
|
||||||
error={form.errors.inventory}
|
error={inventoryMeta.error}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
<FormFullWidthLayout>
|
||||||
)}
|
|
||||||
</FormRow>
|
|
||||||
<FormRow>
|
|
||||||
<VariablesField
|
<VariablesField
|
||||||
id="host-variables"
|
id="host-variables"
|
||||||
name="variables"
|
name="variables"
|
||||||
label={i18n._(t`Variables`)}
|
label={i18n._(t`Variables`)}
|
||||||
/>
|
/>
|
||||||
</FormRow>
|
</FormFullWidthLayout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostForm({ handleSubmit, host, submitError, handleCancel, ...rest }) {
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
name: host.name,
|
||||||
|
description: host.description,
|
||||||
|
inventory: host.inventory || '',
|
||||||
|
variables: host.variables,
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
{formik => (
|
||||||
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
|
<FormColumnLayout>
|
||||||
|
<HostFormFields host={host} {...rest} />
|
||||||
<FormSubmitError error={submitError} />
|
<FormSubmitError error={submitError} />
|
||||||
<FormActionGroup
|
<FormActionGroup
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onSubmit={formik.handleSubmit}
|
onSubmit={formik.handleSubmit}
|
||||||
/>
|
/>
|
||||||
|
</FormColumnLayout>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Formik, Field } from 'formik';
|
import { Formik, useField } from 'formik';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { func, number, shape } from 'prop-types';
|
import { func, number, shape } from 'prop-types';
|
||||||
@@ -8,44 +8,26 @@ import { VariablesField } from '@components/CodeMirrorInput';
|
|||||||
import { Form } from '@patternfly/react-core';
|
import { Form } from '@patternfly/react-core';
|
||||||
import FormField, { FormSubmitError } from '@components/FormField';
|
import FormField, { FormSubmitError } from '@components/FormField';
|
||||||
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
||||||
import FormRow from '@components/FormRow';
|
|
||||||
import { required } from '@util/validators';
|
import { required } from '@util/validators';
|
||||||
import InstanceGroupsLookup from '@components/Lookup/InstanceGroupsLookup';
|
import InstanceGroupsLookup from '@components/Lookup/InstanceGroupsLookup';
|
||||||
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
|
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
|
||||||
import CredentialLookup from '@components/Lookup/CredentialLookup';
|
import CredentialLookup from '@components/Lookup/CredentialLookup';
|
||||||
|
import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout';
|
||||||
|
|
||||||
function InventoryForm({
|
function InventoryFormFields({ i18n, credentialTypeId }) {
|
||||||
inventory = {},
|
const [organizationField, organizationMeta, organizationHelpers] = useField({
|
||||||
i18n,
|
name: 'organization',
|
||||||
onCancel,
|
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||||
onSubmit,
|
});
|
||||||
instanceGroups,
|
const instanceGroupsFieldArr = useField('instanceGroups');
|
||||||
credentialTypeId,
|
const instanceGroupsField = instanceGroupsFieldArr[0];
|
||||||
submitError,
|
const instanceGroupsHelpers = instanceGroupsFieldArr[2];
|
||||||
}) {
|
|
||||||
const initialValues = {
|
const insightsCredentialFieldArr = useField('insights_credential');
|
||||||
name: inventory.name || '',
|
const insightsCredentialField = insightsCredentialFieldArr[0];
|
||||||
description: inventory.description || '',
|
const insightsCredentialHelpers = insightsCredentialFieldArr[2];
|
||||||
variables: inventory.variables || '---',
|
|
||||||
organization:
|
|
||||||
(inventory.summary_fields && inventory.summary_fields.organization) ||
|
|
||||||
null,
|
|
||||||
instanceGroups: instanceGroups || [],
|
|
||||||
insights_credential:
|
|
||||||
(inventory.summary_fields &&
|
|
||||||
inventory.summary_fields.insights_credential) ||
|
|
||||||
null,
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<>
|
||||||
initialValues={initialValues}
|
|
||||||
onSubmit={values => {
|
|
||||||
onSubmit(values);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formik => (
|
|
||||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
|
||||||
<FormRow>
|
|
||||||
<FormField
|
<FormField
|
||||||
id="inventory-name"
|
id="inventory-name"
|
||||||
label={i18n._(t`Name`)}
|
label={i18n._(t`Name`)}
|
||||||
@@ -60,66 +42,31 @@ function InventoryForm({
|
|||||||
name="description"
|
name="description"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<Field
|
|
||||||
id="inventory-organization"
|
|
||||||
label={i18n._(t`Organization`)}
|
|
||||||
name="organization"
|
|
||||||
validate={required(
|
|
||||||
i18n._(t`Select a value for this field`),
|
|
||||||
i18n
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{({ form, field }) => (
|
|
||||||
<OrganizationLookup
|
<OrganizationLookup
|
||||||
helperTextInvalid={form.errors.organization}
|
helperTextInvalid={organizationMeta.error}
|
||||||
isValid={
|
isValid={!organizationMeta.touched || !organizationMeta.error}
|
||||||
!form.touched.organization || !form.errors.organization
|
onBlur={() => organizationHelpers.setTouched()}
|
||||||
}
|
|
||||||
onBlur={() => form.setFieldTouched('organization')}
|
|
||||||
onChange={value => {
|
onChange={value => {
|
||||||
form.setFieldValue('organization', value);
|
organizationHelpers.setValue(value);
|
||||||
}}
|
}}
|
||||||
value={field.value}
|
value={organizationField.value}
|
||||||
touched={form.touched.organization}
|
touched={organizationMeta.touched}
|
||||||
error={form.errors.organization}
|
error={organizationMeta.error}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field
|
|
||||||
id="inventory-insights_credential"
|
|
||||||
label={i18n._(t`Insights Credential`)}
|
|
||||||
name="insights_credential"
|
|
||||||
>
|
|
||||||
{({ field, form }) => (
|
|
||||||
<CredentialLookup
|
<CredentialLookup
|
||||||
label={i18n._(t`Insights Credential`)}
|
label={i18n._(t`Insights Credential`)}
|
||||||
credentialTypeId={credentialTypeId}
|
credentialTypeId={credentialTypeId}
|
||||||
onChange={value =>
|
onChange={value => insightsCredentialHelpers.setValue(value)}
|
||||||
form.setFieldValue('insights_credential', value)
|
value={insightsCredentialField.value}
|
||||||
}
|
|
||||||
value={field.value}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</FormRow>
|
|
||||||
<FormRow>
|
|
||||||
<Field
|
|
||||||
id="inventory-instanceGroups"
|
|
||||||
label={i18n._(t`Instance Groups`)}
|
|
||||||
name="instanceGroups"
|
|
||||||
>
|
|
||||||
{({ field, form }) => (
|
|
||||||
<InstanceGroupsLookup
|
<InstanceGroupsLookup
|
||||||
value={field.value}
|
value={instanceGroupsField.value}
|
||||||
onChange={value => {
|
onChange={value => {
|
||||||
form.setFieldValue('instanceGroups', value);
|
instanceGroupsHelpers.setValue(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
<FormFullWidthLayout>
|
||||||
</Field>
|
|
||||||
</FormRow>
|
|
||||||
<FormRow>
|
|
||||||
<VariablesField
|
<VariablesField
|
||||||
tooltip={i18n._(
|
tooltip={i18n._(
|
||||||
t`Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Tower documentation for example syntax`
|
t`Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Tower documentation for example syntax`
|
||||||
@@ -128,14 +75,50 @@ function InventoryForm({
|
|||||||
name="variables"
|
name="variables"
|
||||||
label={i18n._(t`Variables`)}
|
label={i18n._(t`Variables`)}
|
||||||
/>
|
/>
|
||||||
</FormRow>
|
</FormFullWidthLayout>
|
||||||
<FormRow>
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryForm({
|
||||||
|
inventory = {},
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
submitError,
|
||||||
|
instanceGroups,
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
const initialValues = {
|
||||||
|
name: inventory.name || '',
|
||||||
|
description: inventory.description || '',
|
||||||
|
variables: inventory.variables || '---',
|
||||||
|
organization:
|
||||||
|
(inventory.summary_fields && inventory.summary_fields.organization) ||
|
||||||
|
null,
|
||||||
|
instanceGroups: instanceGroups || [],
|
||||||
|
insights_credential:
|
||||||
|
(inventory.summary_fields &&
|
||||||
|
inventory.summary_fields.insights_credential) ||
|
||||||
|
null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={values => {
|
||||||
|
onSubmit(values);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formik => (
|
||||||
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
|
<FormColumnLayout>
|
||||||
|
<InventoryFormFields {...rest} />
|
||||||
<FormSubmitError error={submitError} />
|
<FormSubmitError error={submitError} />
|
||||||
<FormActionGroup
|
<FormActionGroup
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onSubmit={formik.handleSubmit}
|
onSubmit={formik.handleSubmit}
|
||||||
/>
|
/>
|
||||||
</FormRow>
|
</FormColumnLayout>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import { Form, Card } from '@patternfly/react-core';
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
import { CardBody } from '@components/Card';
|
import { CardBody } from '@components/Card';
|
||||||
import FormRow from '@components/FormRow';
|
|
||||||
import FormField from '@components/FormField';
|
import FormField from '@components/FormField';
|
||||||
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
||||||
import { VariablesField } from '@components/CodeMirrorInput';
|
import { VariablesField } from '@components/CodeMirrorInput';
|
||||||
import { required } from '@util/validators';
|
import { required } from '@util/validators';
|
||||||
|
import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout';
|
||||||
|
|
||||||
function InventoryGroupForm({
|
function InventoryGroupForm({
|
||||||
i18n,
|
i18n,
|
||||||
@@ -31,7 +31,7 @@ function InventoryGroupForm({
|
|||||||
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
||||||
{formik => (
|
{formik => (
|
||||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
<FormRow css="grid-template-columns: repeat(auto-fit, minmax(300px, 500px));">
|
<FormColumnLayout>
|
||||||
<FormField
|
<FormField
|
||||||
id="inventoryGroup-name"
|
id="inventoryGroup-name"
|
||||||
name="name"
|
name="name"
|
||||||
@@ -46,19 +46,19 @@ function InventoryGroupForm({
|
|||||||
type="text"
|
type="text"
|
||||||
label={i18n._(t`Description`)}
|
label={i18n._(t`Description`)}
|
||||||
/>
|
/>
|
||||||
</FormRow>
|
<FormFullWidthLayout>
|
||||||
<FormRow>
|
|
||||||
<VariablesField
|
<VariablesField
|
||||||
id="host-variables"
|
id="host-variables"
|
||||||
name="variables"
|
name="variables"
|
||||||
label={i18n._(t`Variables`)}
|
label={i18n._(t`Variables`)}
|
||||||
/>
|
/>
|
||||||
</FormRow>
|
</FormFullWidthLayout>
|
||||||
<FormActionGroup
|
<FormActionGroup
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onSubmit={formik.handleSubmit}
|
onSubmit={formik.handleSubmit}
|
||||||
/>
|
/>
|
||||||
{error ? <div>error</div> : null}
|
{error ? <div>error</div> : null}
|
||||||
|
</FormColumnLayout>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useContext, useEffect, useState } from 'react';
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
import { Formik, Field } from 'formik';
|
import { Formik, useField } from 'formik';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Form, FormGroup } from '@patternfly/react-core';
|
import { Form, FormGroup } from '@patternfly/react-core';
|
||||||
@@ -11,27 +11,93 @@ import { ConfigContext } from '@contexts/Config';
|
|||||||
import AnsibleSelect from '@components/AnsibleSelect';
|
import AnsibleSelect from '@components/AnsibleSelect';
|
||||||
import ContentError from '@components/ContentError';
|
import ContentError from '@components/ContentError';
|
||||||
import ContentLoading from '@components/ContentLoading';
|
import ContentLoading from '@components/ContentLoading';
|
||||||
import FormRow from '@components/FormRow';
|
|
||||||
import FormField, { FormSubmitError } from '@components/FormField';
|
import FormField, { FormSubmitError } from '@components/FormField';
|
||||||
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
||||||
import { InstanceGroupsLookup } from '@components/Lookup/';
|
import { InstanceGroupsLookup } from '@components/Lookup/';
|
||||||
import { getAddedAndRemoved } from '@util/lists';
|
import { getAddedAndRemoved } from '@util/lists';
|
||||||
import { required, minMaxValue } from '@util/validators';
|
import { required, minMaxValue } from '@util/validators';
|
||||||
|
import { FormColumnLayout } from '@components/FormLayout';
|
||||||
|
|
||||||
function OrganizationForm({
|
function OrganizationFormFields({
|
||||||
organization,
|
|
||||||
i18n,
|
i18n,
|
||||||
me,
|
me,
|
||||||
onCancel,
|
instanceGroups,
|
||||||
onSubmit,
|
setInstanceGroups,
|
||||||
submitError,
|
|
||||||
}) {
|
}) {
|
||||||
|
const [venvField] = useField('custom_virtualenv');
|
||||||
|
|
||||||
const defaultVenv = {
|
const defaultVenv = {
|
||||||
label: i18n._(t`Use Default Ansible Environment`),
|
label: i18n._(t`Use Default Ansible Environment`),
|
||||||
value: '/venv/ansible/',
|
value: '/venv/ansible/',
|
||||||
key: 'default',
|
key: 'default',
|
||||||
};
|
};
|
||||||
const { custom_virtualenvs } = useContext(ConfigContext);
|
const { custom_virtualenvs } = useContext(ConfigContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
id="org-name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
label={i18n._(t`Name`)}
|
||||||
|
validate={required(null, i18n)}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
id="org-description"
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
label={i18n._(t`Description`)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
id="org-max_hosts"
|
||||||
|
name="max_hosts"
|
||||||
|
type="number"
|
||||||
|
label={i18n._(t`Max Hosts`)}
|
||||||
|
tooltip={i18n._(
|
||||||
|
t`The maximum number of hosts allowed to be managed by this organization.
|
||||||
|
Value defaults to 0 which means no limit. Refer to the Ansible
|
||||||
|
documentation for more details.`
|
||||||
|
)}
|
||||||
|
validate={minMaxValue(0, Number.MAX_SAFE_INTEGER, i18n)}
|
||||||
|
me={me || {}}
|
||||||
|
isDisabled={!me.is_superuser}
|
||||||
|
/>
|
||||||
|
{custom_virtualenvs && custom_virtualenvs.length > 1 && (
|
||||||
|
<FormGroup
|
||||||
|
fieldId="org-custom-virtualenv"
|
||||||
|
label={i18n._(t`Ansible Environment`)}
|
||||||
|
>
|
||||||
|
<AnsibleSelect
|
||||||
|
id="org-custom-virtualenv"
|
||||||
|
data={[
|
||||||
|
defaultVenv,
|
||||||
|
...custom_virtualenvs
|
||||||
|
.filter(value => value !== defaultVenv.value)
|
||||||
|
.map(value => ({ value, label: value, key: value })),
|
||||||
|
]}
|
||||||
|
{...venvField}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
<InstanceGroupsLookup
|
||||||
|
value={instanceGroups}
|
||||||
|
onChange={setInstanceGroups}
|
||||||
|
tooltip={i18n._(
|
||||||
|
t`Select the Instance Groups for this Organization to run on.`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrganizationForm({
|
||||||
|
organization,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
submitError,
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
const [contentError, setContentError] = useState(null);
|
const [contentError, setContentError] = useState(null);
|
||||||
const [hasContentLoading, setHasContentLoading] = useState(true);
|
const [hasContentLoading, setHasContentLoading] = useState(true);
|
||||||
const [initialInstanceGroups, setInitialInstanceGroups] = useState([]);
|
const [initialInstanceGroups, setInitialInstanceGroups] = useState([]);
|
||||||
@@ -100,79 +166,24 @@ function OrganizationForm({
|
|||||||
>
|
>
|
||||||
{formik => (
|
{formik => (
|
||||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
<FormRow>
|
<FormColumnLayout>
|
||||||
<FormField
|
<OrganizationFormFields
|
||||||
id="org-name"
|
instanceGroups={instanceGroups}
|
||||||
name="name"
|
setInstanceGroups={setInstanceGroups}
|
||||||
type="text"
|
{...rest}
|
||||||
label={i18n._(t`Name`)}
|
|
||||||
validate={required(null, i18n)}
|
|
||||||
isRequired
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
id="org-description"
|
|
||||||
name="description"
|
|
||||||
type="text"
|
|
||||||
label={i18n._(t`Description`)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
id="org-max_hosts"
|
|
||||||
name="max_hosts"
|
|
||||||
type="number"
|
|
||||||
label={i18n._(t`Max Hosts`)}
|
|
||||||
tooltip={i18n._(
|
|
||||||
t`The maximum number of hosts allowed to be managed by this organization.
|
|
||||||
Value defaults to 0 which means no limit. Refer to the Ansible
|
|
||||||
documentation for more details.`
|
|
||||||
)}
|
|
||||||
validate={minMaxValue(0, Number.MAX_SAFE_INTEGER, i18n)}
|
|
||||||
me={me || {}}
|
|
||||||
isDisabled={!me.is_superuser}
|
|
||||||
/>
|
|
||||||
{custom_virtualenvs && custom_virtualenvs.length > 1 && (
|
|
||||||
<Field name="custom_virtualenv">
|
|
||||||
{({ field }) => (
|
|
||||||
<FormGroup
|
|
||||||
fieldId="org-custom-virtualenv"
|
|
||||||
label={i18n._(t`Ansible Environment`)}
|
|
||||||
>
|
|
||||||
<AnsibleSelect
|
|
||||||
id="org-custom-virtualenv"
|
|
||||||
data={[
|
|
||||||
defaultVenv,
|
|
||||||
...custom_virtualenvs
|
|
||||||
.filter(value => value !== defaultVenv.value)
|
|
||||||
.map(value => ({ value, label: value, key: value })),
|
|
||||||
]}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</FormRow>
|
|
||||||
<InstanceGroupsLookup
|
|
||||||
value={instanceGroups}
|
|
||||||
onChange={setInstanceGroups}
|
|
||||||
tooltip={i18n._(
|
|
||||||
t`Select the Instance Groups for this Organization to run on.`
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<FormSubmitError error={submitError} />
|
<FormSubmitError error={submitError} />
|
||||||
<FormActionGroup
|
<FormActionGroup
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onSubmit={formik.handleSubmit}
|
onSubmit={formik.handleSubmit}
|
||||||
/>
|
/>
|
||||||
|
</FormColumnLayout>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
FormField.propTypes = {
|
|
||||||
label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
OrganizationForm.propTypes = {
|
OrganizationForm.propTypes = {
|
||||||
organization: PropTypes.shape(),
|
organization: PropTypes.shape(),
|
||||||
onSubmit: PropTypes.func.isRequired,
|
onSubmit: PropTypes.func.isRequired,
|
||||||
|
|||||||
@@ -121,6 +121,11 @@ describe('<OrganizationForm />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('changing inputs and saving triggers expected callback', async () => {
|
test('changing inputs and saving triggers expected callback', async () => {
|
||||||
|
OrganizationsAPI.readInstanceGroups.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
results: mockInstanceGroups,
|
||||||
|
},
|
||||||
|
});
|
||||||
let wrapper;
|
let wrapper;
|
||||||
const onSubmit = jest.fn();
|
const onSubmit = jest.fn();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
import { Card as _Card, PageSection } from '@patternfly/react-core';
|
|
||||||
import { CardBody } from '@components/Card';
|
import { CardBody } from '@components/Card';
|
||||||
import ProjectForm from '../shared/ProjectForm';
|
import ProjectForm from '../shared/ProjectForm';
|
||||||
import { ProjectsAPI } from '@api';
|
import { ProjectsAPI } from '@api';
|
||||||
|
|
||||||
const Card = styled(_Card)`
|
|
||||||
--pf-c-card--child--PaddingLeft: 0;
|
|
||||||
--pf-c-card--child--PaddingRight: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
function ProjectAdd() {
|
function ProjectAdd() {
|
||||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
import { Card } from '@patternfly/react-core';
|
||||||
import { Card as _Card } from '@patternfly/react-core';
|
|
||||||
import { CardBody } from '@components/Card';
|
import { CardBody } from '@components/Card';
|
||||||
import ProjectForm from '../shared/ProjectForm';
|
import ProjectForm from '../shared/ProjectForm';
|
||||||
import { ProjectsAPI } from '@api';
|
import { ProjectsAPI } from '@api';
|
||||||
|
|
||||||
// TODO: we are doing this in multiple add/edit screens -- move to
|
|
||||||
// common component?
|
|
||||||
const Card = styled(_Card)`
|
|
||||||
--pf-c-card--child--PaddingLeft: 0;
|
|
||||||
--pf-c-card--child--PaddingRight: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
function ProjectEdit({ project }) {
|
function ProjectEdit({ project }) {
|
||||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ describe('<ProjectEdit />', () => {
|
|||||||
scm_url: 'https://foo.bar',
|
scm_url: 'https://foo.bar',
|
||||||
scm_clean: true,
|
scm_clean: true,
|
||||||
credential: 100,
|
credential: 100,
|
||||||
local_path: '',
|
local_path: 'bar',
|
||||||
organization: 2,
|
organization: 2,
|
||||||
scm_update_on_launch: true,
|
scm_update_on_launch: true,
|
||||||
scm_update_cache_timeout: 3,
|
scm_update_cache_timeout: 3,
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Formik, Field } from 'formik';
|
import { Formik, useField } from 'formik';
|
||||||
import { Config } from '@contexts/Config';
|
import { Config } from '@contexts/Config';
|
||||||
import { Form, FormGroup } from '@patternfly/react-core';
|
import { Form, FormGroup, Title } from '@patternfly/react-core';
|
||||||
import AnsibleSelect from '@components/AnsibleSelect';
|
import AnsibleSelect from '@components/AnsibleSelect';
|
||||||
import ContentError from '@components/ContentError';
|
import ContentError from '@components/ContentError';
|
||||||
import ContentLoading from '@components/ContentLoading';
|
import ContentLoading from '@components/ContentLoading';
|
||||||
@@ -14,27 +14,18 @@ import FormField, {
|
|||||||
FieldTooltip,
|
FieldTooltip,
|
||||||
FormSubmitError,
|
FormSubmitError,
|
||||||
} from '@components/FormField';
|
} from '@components/FormField';
|
||||||
import FormRow from '@components/FormRow';
|
|
||||||
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
|
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
|
||||||
import { CredentialTypesAPI, ProjectsAPI } from '@api';
|
import { CredentialTypesAPI, ProjectsAPI } from '@api';
|
||||||
import { required } from '@util/validators';
|
import { required } from '@util/validators';
|
||||||
import styled from 'styled-components';
|
import { FormColumnLayout, SubFormLayout } from '@components/FormLayout';
|
||||||
import {
|
import {
|
||||||
GitSubForm,
|
GitSubForm,
|
||||||
HgSubForm,
|
HgSubForm,
|
||||||
SvnSubForm,
|
SvnSubForm,
|
||||||
InsightsSubForm,
|
InsightsSubForm,
|
||||||
SubFormTitle,
|
|
||||||
ManualSubForm,
|
ManualSubForm,
|
||||||
} from './ProjectSubForms';
|
} from './ProjectSubForms';
|
||||||
|
|
||||||
const ScmTypeFormRow = styled(FormRow)`
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
margin: 0 -24px;
|
|
||||||
padding: 24px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const fetchCredentials = async credential => {
|
const fetchCredentials = async credential => {
|
||||||
const [
|
const [
|
||||||
{
|
{
|
||||||
@@ -73,14 +64,236 @@ const fetchCredentials = async credential => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
function ProjectForm({ project, submitError, ...props }) {
|
function ProjectFormFields({
|
||||||
const { i18n, handleCancel, handleSubmit } = props;
|
project_base_dir,
|
||||||
|
project_local_paths,
|
||||||
|
formik,
|
||||||
|
i18n,
|
||||||
|
setCredentials,
|
||||||
|
credentials,
|
||||||
|
scmTypeOptions,
|
||||||
|
setScmSubFormState,
|
||||||
|
scmSubFormState,
|
||||||
|
setOrganization,
|
||||||
|
organization,
|
||||||
|
}) {
|
||||||
|
const scmFormFields = {
|
||||||
|
scm_url: '',
|
||||||
|
scm_branch: '',
|
||||||
|
scm_refspec: '',
|
||||||
|
credential: '',
|
||||||
|
scm_clean: false,
|
||||||
|
scm_delete_on_update: false,
|
||||||
|
scm_update_on_launch: false,
|
||||||
|
allow_override: false,
|
||||||
|
scm_update_cache_timeout: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [scmTypeField, scmTypeMeta, scmTypeHelpers] = useField({
|
||||||
|
name: 'scm_type',
|
||||||
|
validate: required(i18n._(t`Set a value for this field`), i18n),
|
||||||
|
});
|
||||||
|
const [venvField] = useField('custom_virtualenv');
|
||||||
|
const orgFieldArr = useField({
|
||||||
|
name: 'organization',
|
||||||
|
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||||
|
});
|
||||||
|
const organizationMeta = orgFieldArr[1];
|
||||||
|
const organizationHelpers = orgFieldArr[2];
|
||||||
|
|
||||||
|
/* Save current scm subform field values to state */
|
||||||
|
const saveSubFormState = form => {
|
||||||
|
const currentScmFormFields = { ...scmFormFields };
|
||||||
|
|
||||||
|
Object.keys(currentScmFormFields).forEach(label => {
|
||||||
|
currentScmFormFields[label] = form.values[label];
|
||||||
|
});
|
||||||
|
|
||||||
|
setScmSubFormState(currentScmFormFields);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If scm type is !== the initial scm type value,
|
||||||
|
* reset scm subform field values to defaults.
|
||||||
|
* If scm type is === the initial scm type value,
|
||||||
|
* reset scm subform field values to scmSubFormState.
|
||||||
|
*/
|
||||||
|
const resetScmTypeFields = (value, form) => {
|
||||||
|
if (form.values.scm_type === form.initialValues.scm_type) {
|
||||||
|
saveSubFormState(formik);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(scmFormFields).forEach(label => {
|
||||||
|
if (value === form.initialValues.scm_type) {
|
||||||
|
form.setFieldValue(label, scmSubFormState[label]);
|
||||||
|
} else {
|
||||||
|
form.setFieldValue(label, scmFormFields[label]);
|
||||||
|
}
|
||||||
|
form.setFieldTouched(label, false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCredentialSelection = (type, value) => {
|
||||||
|
setCredentials({
|
||||||
|
...credentials,
|
||||||
|
[type]: {
|
||||||
|
...credentials[type],
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
id="project-name"
|
||||||
|
label={i18n._(t`Name`)}
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
validate={required(null, i18n)}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
id="project-description"
|
||||||
|
label={i18n._(t`Description`)}
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<OrganizationLookup
|
||||||
|
helperTextInvalid={organizationMeta.error}
|
||||||
|
isValid={!organizationMeta.touched || !organizationMeta.error}
|
||||||
|
onBlur={() => organizationHelpers.setTouched()}
|
||||||
|
onChange={value => {
|
||||||
|
organizationHelpers.setValue(value.id);
|
||||||
|
setOrganization(value);
|
||||||
|
}}
|
||||||
|
value={organization}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormGroup
|
||||||
|
fieldId="project-scm-type"
|
||||||
|
helperTextInvalid={scmTypeMeta.error}
|
||||||
|
isRequired
|
||||||
|
isValid={!scmTypeMeta.touched || !scmTypeMeta.error}
|
||||||
|
label={i18n._(t`SCM Type`)}
|
||||||
|
>
|
||||||
|
<AnsibleSelect
|
||||||
|
{...scmTypeField}
|
||||||
|
id="scm_type"
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
key: '',
|
||||||
|
label: i18n._(t`Choose an SCM Type`),
|
||||||
|
isDisabled: true,
|
||||||
|
},
|
||||||
|
...scmTypeOptions.map(([value, label]) => {
|
||||||
|
if (label === 'Manual') {
|
||||||
|
value = 'manual';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
key: value,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
onChange={(event, value) => {
|
||||||
|
scmTypeHelpers.setValue(value);
|
||||||
|
resetScmTypeFields(value, formik);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
{formik.values.scm_type !== '' && (
|
||||||
|
<SubFormLayout>
|
||||||
|
<Title size="md">{i18n._(t`Type Details`)}</Title>
|
||||||
|
<FormColumnLayout>
|
||||||
|
{
|
||||||
|
{
|
||||||
|
manual: (
|
||||||
|
<ManualSubForm
|
||||||
|
localPath={formik.initialValues.local_path}
|
||||||
|
project_base_dir={project_base_dir}
|
||||||
|
project_local_paths={project_local_paths}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
git: (
|
||||||
|
<GitSubForm
|
||||||
|
credential={credentials.scm}
|
||||||
|
onCredentialSelection={handleCredentialSelection}
|
||||||
|
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
hg: (
|
||||||
|
<HgSubForm
|
||||||
|
credential={credentials.scm}
|
||||||
|
onCredentialSelection={handleCredentialSelection}
|
||||||
|
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
svn: (
|
||||||
|
<SvnSubForm
|
||||||
|
credential={credentials.scm}
|
||||||
|
onCredentialSelection={handleCredentialSelection}
|
||||||
|
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
insights: (
|
||||||
|
<InsightsSubForm
|
||||||
|
credential={credentials.insights}
|
||||||
|
onCredentialSelection={handleCredentialSelection}
|
||||||
|
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}[formik.values.scm_type]
|
||||||
|
}
|
||||||
|
</FormColumnLayout>
|
||||||
|
</SubFormLayout>
|
||||||
|
)}
|
||||||
|
<Config>
|
||||||
|
{({ custom_virtualenvs }) =>
|
||||||
|
custom_virtualenvs &&
|
||||||
|
custom_virtualenvs.length > 1 && (
|
||||||
|
<FormGroup
|
||||||
|
fieldId="project-custom-virtualenv"
|
||||||
|
label={i18n._(t`Ansible Environment`)}
|
||||||
|
>
|
||||||
|
<FieldTooltip
|
||||||
|
content={i18n._(t`Select the playbook to be executed by
|
||||||
|
this job.`)}
|
||||||
|
/>
|
||||||
|
<AnsibleSelect
|
||||||
|
id="project-custom-virtualenv"
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
label: i18n._(t`Use Default Ansible Environment`),
|
||||||
|
value: '/venv/ansible/',
|
||||||
|
key: 'default',
|
||||||
|
},
|
||||||
|
...custom_virtualenvs
|
||||||
|
.filter(datum => datum !== '/venv/ansible/')
|
||||||
|
.map(datum => ({
|
||||||
|
label: datum,
|
||||||
|
value: datum,
|
||||||
|
key: datum,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
{...venvField}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Config>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectForm({ i18n, project, submitError, ...props }) {
|
||||||
|
const { handleCancel, handleSubmit } = props;
|
||||||
const { summary_fields = {} } = project;
|
const { summary_fields = {} } = project;
|
||||||
const [contentError, setContentError] = useState(null);
|
const [contentError, setContentError] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [organization, setOrganization] = useState(
|
const [organization, setOrganization] = useState(null);
|
||||||
summary_fields.organization || null
|
|
||||||
);
|
|
||||||
const [scmSubFormState, setScmSubFormState] = useState(null);
|
const [scmSubFormState, setScmSubFormState] = useState(null);
|
||||||
const [scmTypeOptions, setScmTypeOptions] = useState(null);
|
const [scmTypeOptions, setScmTypeOptions] = useState(null);
|
||||||
const [credentials, setCredentials] = useState({
|
const [credentials, setCredentials] = useState({
|
||||||
@@ -114,60 +327,6 @@ function ProjectForm({ project, submitError, ...props }) {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [summary_fields.credential]);
|
}, [summary_fields.credential]);
|
||||||
|
|
||||||
const scmFormFields = {
|
|
||||||
scm_url: '',
|
|
||||||
scm_branch: '',
|
|
||||||
scm_refspec: '',
|
|
||||||
credential: '',
|
|
||||||
scm_clean: false,
|
|
||||||
scm_delete_on_update: false,
|
|
||||||
scm_update_on_launch: false,
|
|
||||||
allow_override: false,
|
|
||||||
scm_update_cache_timeout: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
/* Save current scm subform field values to state */
|
|
||||||
const saveSubFormState = form => {
|
|
||||||
const currentScmFormFields = { ...scmFormFields };
|
|
||||||
|
|
||||||
Object.keys(currentScmFormFields).forEach(label => {
|
|
||||||
currentScmFormFields[label] = form.values[label];
|
|
||||||
});
|
|
||||||
|
|
||||||
setScmSubFormState(currentScmFormFields);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If scm type is !== the initial scm type value,
|
|
||||||
* reset scm subform field values to defaults.
|
|
||||||
* If scm type is === the initial scm type value,
|
|
||||||
* reset scm subform field values to scmSubFormState.
|
|
||||||
*/
|
|
||||||
const resetScmTypeFields = (value, form) => {
|
|
||||||
if (form.values.scm_type === form.initialValues.scm_type) {
|
|
||||||
saveSubFormState(form);
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.keys(scmFormFields).forEach(label => {
|
|
||||||
if (value === form.initialValues.scm_type) {
|
|
||||||
form.setFieldValue(label, scmSubFormState[label]);
|
|
||||||
} else {
|
|
||||||
form.setFieldValue(label, scmFormFields[label]);
|
|
||||||
}
|
|
||||||
form.setFieldTouched(label, false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCredentialSelection = (type, value) => {
|
|
||||||
setCredentials({
|
|
||||||
...credentials,
|
|
||||||
[type]: {
|
|
||||||
...credentials[type],
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <ContentLoading />;
|
return <ContentLoading />;
|
||||||
}
|
}
|
||||||
@@ -206,193 +365,27 @@ function ProjectForm({ project, submitError, ...props }) {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
{formik => (
|
{formik => (
|
||||||
<Form
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
autoComplete="off"
|
<FormColumnLayout>
|
||||||
onSubmit={formik.handleSubmit}
|
<ProjectFormFields
|
||||||
css="padding: 0 24px"
|
|
||||||
>
|
|
||||||
<FormRow>
|
|
||||||
<FormField
|
|
||||||
id="project-name"
|
|
||||||
label={i18n._(t`Name`)}
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
validate={required(null, i18n)}
|
|
||||||
isRequired
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
id="project-description"
|
|
||||||
label={i18n._(t`Description`)}
|
|
||||||
name="description"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
name="organization"
|
|
||||||
validate={required(
|
|
||||||
i18n._(t`Select a value for this field`),
|
|
||||||
i18n
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{({ form }) => (
|
|
||||||
<OrganizationLookup
|
|
||||||
helperTextInvalid={form.errors.organization}
|
|
||||||
isValid={
|
|
||||||
!form.touched.organization || !form.errors.organization
|
|
||||||
}
|
|
||||||
onBlur={() => form.setFieldTouched('organization')}
|
|
||||||
onChange={value => {
|
|
||||||
form.setFieldValue('organization', value.id);
|
|
||||||
setOrganization(value);
|
|
||||||
}}
|
|
||||||
value={organization}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field
|
|
||||||
name="scm_type"
|
|
||||||
validate={required(
|
|
||||||
i18n._(t`Select a value for this field`),
|
|
||||||
i18n
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{({ field, form }) => (
|
|
||||||
<FormGroup
|
|
||||||
fieldId="project-scm-type"
|
|
||||||
helperTextInvalid={form.errors.scm_type}
|
|
||||||
isRequired
|
|
||||||
isValid={!form.touched.scm_type || !form.errors.scm_type}
|
|
||||||
label={i18n._(t`SCM Type`)}
|
|
||||||
>
|
|
||||||
<AnsibleSelect
|
|
||||||
{...field}
|
|
||||||
id="scm_type"
|
|
||||||
data={[
|
|
||||||
{
|
|
||||||
value: '',
|
|
||||||
key: '',
|
|
||||||
label: i18n._(t`Choose an SCM Type`),
|
|
||||||
isDisabled: true,
|
|
||||||
},
|
|
||||||
...scmTypeOptions.map(([value, label]) => {
|
|
||||||
if (label === 'Manual') {
|
|
||||||
value = 'manual';
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
key: value,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
onChange={(event, value) => {
|
|
||||||
form.setFieldValue('scm_type', value);
|
|
||||||
resetScmTypeFields(value, form);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
{formik.values.scm_type !== '' && (
|
|
||||||
<ScmTypeFormRow>
|
|
||||||
<SubFormTitle size="md">
|
|
||||||
{i18n._(t`Type Details`)}
|
|
||||||
</SubFormTitle>
|
|
||||||
{
|
|
||||||
{
|
|
||||||
manual: (
|
|
||||||
<ManualSubForm
|
|
||||||
localPath={formik.initialValues.local_path}
|
|
||||||
project_base_dir={project_base_dir}
|
project_base_dir={project_base_dir}
|
||||||
project_local_paths={project_local_paths}
|
project_local_paths={project_local_paths}
|
||||||
|
formik={formik}
|
||||||
|
i18n={i18n}
|
||||||
|
setCredentials={setCredentials}
|
||||||
|
credentials={credentials}
|
||||||
|
scmTypeOptions={scmTypeOptions}
|
||||||
|
setScmSubFormState={setScmSubFormState}
|
||||||
|
scmSubFormState={scmSubFormState}
|
||||||
|
setOrganization={setOrganization}
|
||||||
|
organization={organization}
|
||||||
/>
|
/>
|
||||||
),
|
|
||||||
git: (
|
|
||||||
<GitSubForm
|
|
||||||
credential={credentials.scm}
|
|
||||||
onCredentialSelection={handleCredentialSelection}
|
|
||||||
scmUpdateOnLaunch={
|
|
||||||
formik.values.scm_update_on_launch
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
hg: (
|
|
||||||
<HgSubForm
|
|
||||||
credential={credentials.scm}
|
|
||||||
onCredentialSelection={handleCredentialSelection}
|
|
||||||
scmUpdateOnLaunch={
|
|
||||||
formik.values.scm_update_on_launch
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
svn: (
|
|
||||||
<SvnSubForm
|
|
||||||
credential={credentials.scm}
|
|
||||||
onCredentialSelection={handleCredentialSelection}
|
|
||||||
scmUpdateOnLaunch={
|
|
||||||
formik.values.scm_update_on_launch
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
insights: (
|
|
||||||
<InsightsSubForm
|
|
||||||
credential={credentials.insights}
|
|
||||||
onCredentialSelection={handleCredentialSelection}
|
|
||||||
scmUpdateOnLaunch={
|
|
||||||
formik.values.scm_update_on_launch
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}[formik.values.scm_type]
|
|
||||||
}
|
|
||||||
</ScmTypeFormRow>
|
|
||||||
)}
|
|
||||||
<Config>
|
|
||||||
{({ custom_virtualenvs }) =>
|
|
||||||
custom_virtualenvs &&
|
|
||||||
custom_virtualenvs.length > 1 && (
|
|
||||||
<Field name="custom_virtualenv">
|
|
||||||
{({ field }) => (
|
|
||||||
<FormGroup
|
|
||||||
fieldId="project-custom-virtualenv"
|
|
||||||
label={i18n._(t`Ansible Environment`)}
|
|
||||||
>
|
|
||||||
<FieldTooltip
|
|
||||||
content={i18n._(t`Select the playbook to be executed by
|
|
||||||
this job.`)}
|
|
||||||
/>
|
|
||||||
<AnsibleSelect
|
|
||||||
id="project-custom-virtualenv"
|
|
||||||
data={[
|
|
||||||
{
|
|
||||||
label: i18n._(
|
|
||||||
t`Use Default Ansible Environment`
|
|
||||||
),
|
|
||||||
value: '/venv/ansible/',
|
|
||||||
key: 'default',
|
|
||||||
},
|
|
||||||
...custom_virtualenvs
|
|
||||||
.filter(datum => datum !== '/venv/ansible/')
|
|
||||||
.map(datum => ({
|
|
||||||
label: datum,
|
|
||||||
value: datum,
|
|
||||||
key: datum,
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</Config>
|
|
||||||
</FormRow>
|
|
||||||
<FormSubmitError error={submitError} />
|
<FormSubmitError error={submitError} />
|
||||||
<FormActionGroup
|
<FormActionGroup
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onSubmit={formik.handleSubmit}
|
onSubmit={formik.handleSubmit}
|
||||||
/>
|
/>
|
||||||
|
</FormColumnLayout>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Field } from 'formik';
|
import { useField } from 'formik';
|
||||||
import CredentialLookup from '@components/Lookup/CredentialLookup';
|
import CredentialLookup from '@components/Lookup/CredentialLookup';
|
||||||
import { required } from '@util/validators';
|
import { required } from '@util/validators';
|
||||||
import { ScmTypeOptions } from './SharedFields';
|
import { ScmTypeOptions } from './SharedFields';
|
||||||
@@ -11,30 +11,32 @@ const InsightsSubForm = ({
|
|||||||
credential,
|
credential,
|
||||||
onCredentialSelection,
|
onCredentialSelection,
|
||||||
scmUpdateOnLaunch,
|
scmUpdateOnLaunch,
|
||||||
}) => (
|
}) => {
|
||||||
|
const credFieldArr = useField({
|
||||||
|
name: 'credential',
|
||||||
|
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||||
|
});
|
||||||
|
const credMeta = credFieldArr[1];
|
||||||
|
const credHelpers = credFieldArr[2];
|
||||||
|
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<Field
|
|
||||||
name="credential"
|
|
||||||
validate={required(i18n._(t`Select a value for this field`), i18n)}
|
|
||||||
>
|
|
||||||
{({ form }) => (
|
|
||||||
<CredentialLookup
|
<CredentialLookup
|
||||||
credentialTypeId={credential.typeId}
|
credentialTypeId={credential.typeId}
|
||||||
label={i18n._(t`Insights Credential`)}
|
label={i18n._(t`Insights Credential`)}
|
||||||
helperTextInvalid={form.errors.credential}
|
helperTextInvalid={credMeta.error}
|
||||||
isValid={!form.touched.credential || !form.errors.credential}
|
isValid={!credMeta.touched || !credMeta.error}
|
||||||
onBlur={() => form.setFieldTouched('credential')}
|
onBlur={() => credHelpers.setTouched()}
|
||||||
onChange={value => {
|
onChange={value => {
|
||||||
onCredentialSelection('insights', value);
|
onCredentialSelection('insights', value);
|
||||||
form.setFieldValue('credential', value.id);
|
credHelpers.setValue(value.id);
|
||||||
}}
|
}}
|
||||||
value={credential.value}
|
value={credential.value}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<ScmTypeOptions hideAllowOverride scmUpdateOnLaunch={scmUpdateOnLaunch} />
|
<ScmTypeOptions hideAllowOverride scmUpdateOnLaunch={scmUpdateOnLaunch} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default withI18n()(InsightsSubForm);
|
export default withI18n()(InsightsSubForm);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Field } from 'formik';
|
import { useField } from 'formik';
|
||||||
import { required } from '@util/validators';
|
import { required } from '@util/validators';
|
||||||
import AnsibleSelect from '@components/AnsibleSelect';
|
import AnsibleSelect from '@components/AnsibleSelect';
|
||||||
import FormField, { FieldTooltip } from '@components/FormField';
|
import FormField, { FieldTooltip } from '@components/FormField';
|
||||||
@@ -34,6 +34,10 @@ const ManualSubForm = ({
|
|||||||
label: path,
|
label: path,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
const [pathField, pathMeta, pathHelpers] = useField({
|
||||||
|
name: 'local_path',
|
||||||
|
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -72,17 +76,11 @@ const ManualSubForm = ({
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{options.length !== 1 && (
|
|
||||||
<Field
|
|
||||||
name="local_path"
|
|
||||||
validate={required(i18n._(t`Select a value for this field`), i18n)}
|
|
||||||
>
|
|
||||||
{({ field, form }) => (
|
|
||||||
<FormGroup
|
<FormGroup
|
||||||
fieldId="project-local-path"
|
fieldId="project-local-path"
|
||||||
helperTextInvalid={form.errors.local_path}
|
helperTextInvalid={pathMeta.error}
|
||||||
isRequired
|
isRequired
|
||||||
isValid={!form.touched.local_path || !form.errors.local_path}
|
isValid={!pathMeta.touched || !pathMeta.error}
|
||||||
label={i18n._(t`Playbook Directory`)}
|
label={i18n._(t`Playbook Directory`)}
|
||||||
>
|
>
|
||||||
<FieldTooltip
|
<FieldTooltip
|
||||||
@@ -91,17 +89,14 @@ const ManualSubForm = ({
|
|||||||
directory provide the full path used to locate playbooks.`)}
|
directory provide the full path used to locate playbooks.`)}
|
||||||
/>
|
/>
|
||||||
<AnsibleSelect
|
<AnsibleSelect
|
||||||
{...field}
|
{...pathField}
|
||||||
id="local_path"
|
id="local_path"
|
||||||
data={options}
|
data={options}
|
||||||
onChange={(event, value) => {
|
onChange={(event, value) => {
|
||||||
form.setFieldValue('local_path', value);
|
pathHelpers.setValue(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Field } from 'formik';
|
import { useField } from 'formik';
|
||||||
import CredentialLookup from '@components/Lookup/CredentialLookup';
|
import CredentialLookup from '@components/Lookup/CredentialLookup';
|
||||||
import FormField, { CheckboxField } from '@components/FormField';
|
import FormField, { CheckboxField } from '@components/FormField';
|
||||||
import { required } from '@util/validators';
|
import { required } from '@util/validators';
|
||||||
import FormRow from '@components/FormRow';
|
|
||||||
import { FormGroup, Title } from '@patternfly/react-core';
|
import { FormGroup, Title } from '@patternfly/react-core';
|
||||||
import styled from 'styled-components';
|
import {
|
||||||
|
FormCheckboxLayout,
|
||||||
export const SubFormTitle = styled(Title)`
|
FormFullWidthLayout,
|
||||||
--pf-c-title--m-md--FontWeight: 700;
|
} from '@components/FormLayout';
|
||||||
grid-column: 1 / -1;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UrlFormField = withI18n()(({ i18n, tooltip }) => (
|
export const UrlFormField = withI18n()(({ i18n, tooltip }) => (
|
||||||
<FormField
|
<FormField
|
||||||
@@ -41,32 +38,28 @@ export const BranchFormField = withI18n()(({ i18n, label }) => (
|
|||||||
));
|
));
|
||||||
|
|
||||||
export const ScmCredentialFormField = withI18n()(
|
export const ScmCredentialFormField = withI18n()(
|
||||||
({ i18n, credential, onCredentialSelection }) => (
|
({ i18n, credential, onCredentialSelection }) => {
|
||||||
<Field name="credential">
|
const credHelpers = useField('credential')[2];
|
||||||
{({ form }) => (
|
|
||||||
|
return (
|
||||||
<CredentialLookup
|
<CredentialLookup
|
||||||
credentialTypeId={credential.typeId}
|
credentialTypeId={credential.typeId}
|
||||||
label={i18n._(t`SCM Credential`)}
|
label={i18n._(t`SCM Credential`)}
|
||||||
value={credential.value}
|
value={credential.value}
|
||||||
onChange={value => {
|
onChange={value => {
|
||||||
onCredentialSelection('scm', value);
|
onCredentialSelection('scm', value);
|
||||||
form.setFieldValue('credential', value ? value.id : '');
|
credHelpers.setValue(value ? value.id : '');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
);
|
||||||
</Field>
|
}
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ScmTypeOptions = withI18n()(
|
export const ScmTypeOptions = withI18n()(
|
||||||
({ i18n, scmUpdateOnLaunch, hideAllowOverride }) => (
|
({ i18n, scmUpdateOnLaunch, hideAllowOverride }) => (
|
||||||
<>
|
<FormFullWidthLayout>
|
||||||
<FormGroup
|
<FormGroup fieldId="project-option-checkboxes" label={i18n._(t`Options`)}>
|
||||||
css="grid-column: 1/-1"
|
<FormCheckboxLayout>
|
||||||
fieldId="project-option-checkboxes"
|
|
||||||
label={i18n._(t`Options`)}
|
|
||||||
>
|
|
||||||
<FormRow>
|
|
||||||
<CheckboxField
|
<CheckboxField
|
||||||
id="option-scm-clean"
|
id="option-scm-clean"
|
||||||
name="scm_clean"
|
name="scm_clean"
|
||||||
@@ -106,11 +99,12 @@ export const ScmTypeOptions = withI18n()(
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FormRow>
|
</FormCheckboxLayout>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
{scmUpdateOnLaunch && (
|
{scmUpdateOnLaunch && (
|
||||||
<>
|
<>
|
||||||
<SubFormTitle size="md">{i18n._(t`Option Details`)}</SubFormTitle>
|
<Title size="md">{i18n._(t`Option Details`)}</Title>
|
||||||
<FormField
|
<FormField
|
||||||
id="project-cache-timeout"
|
id="project-cache-timeout"
|
||||||
name="scm_update_cache_timeout"
|
name="scm_update_cache_timeout"
|
||||||
@@ -126,6 +120,6 @@ export const ScmTypeOptions = withI18n()(
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</FormFullWidthLayout>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export { SubFormTitle } from './SharedFields';
|
|
||||||
export { default as GitSubForm } from './GitSubForm';
|
export { default as GitSubForm } from './GitSubForm';
|
||||||
export { default as HgSubForm } from './HgSubForm';
|
export { default as HgSubForm } from './HgSubForm';
|
||||||
export { default as InsightsSubForm } from './InsightsSubForm';
|
export { default as InsightsSubForm } from './InsightsSubForm';
|
||||||
|
|||||||
@@ -2,36 +2,28 @@ import React, { useState } from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Formik, Field } from 'formik';
|
import { Formik, useField } from 'formik';
|
||||||
import { Form } from '@patternfly/react-core';
|
import { Form } from '@patternfly/react-core';
|
||||||
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
||||||
import FormField, { FormSubmitError } from '@components/FormField';
|
import FormField, { FormSubmitError } from '@components/FormField';
|
||||||
import FormRow from '@components/FormRow';
|
|
||||||
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
|
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
|
||||||
import { required } from '@util/validators';
|
import { required } from '@util/validators';
|
||||||
|
import { FormColumnLayout } from '@components/FormLayout';
|
||||||
|
|
||||||
function TeamForm(props) {
|
function TeamFormFields(props) {
|
||||||
const { team, handleCancel, handleSubmit, submitError, i18n } = props;
|
const { team, i18n } = props;
|
||||||
const [organization, setOrganization] = useState(
|
const [organization, setOrganization] = useState(
|
||||||
team.summary_fields ? team.summary_fields.organization : null
|
team.summary_fields ? team.summary_fields.organization : null
|
||||||
);
|
);
|
||||||
|
const orgFieldArr = useField({
|
||||||
|
name: 'organization',
|
||||||
|
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||||
|
});
|
||||||
|
const orgMeta = orgFieldArr[1];
|
||||||
|
const orgHelpers = orgFieldArr[2];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<>
|
||||||
initialValues={{
|
|
||||||
description: team.description || '',
|
|
||||||
name: team.name || '',
|
|
||||||
organization: team.organization || '',
|
|
||||||
}}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
>
|
|
||||||
{formik => (
|
|
||||||
<Form
|
|
||||||
autoComplete="off"
|
|
||||||
onSubmit={formik.handleSubmit}
|
|
||||||
css="padding: 0 24px"
|
|
||||||
>
|
|
||||||
<FormRow>
|
|
||||||
<FormField
|
<FormField
|
||||||
id="team-name"
|
id="team-name"
|
||||||
label={i18n._(t`Name`)}
|
label={i18n._(t`Name`)}
|
||||||
@@ -46,35 +38,43 @@ function TeamForm(props) {
|
|||||||
name="description"
|
name="description"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<Field
|
|
||||||
name="organization"
|
|
||||||
validate={required(
|
|
||||||
i18n._(t`Select a value for this field`),
|
|
||||||
i18n
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{({ form }) => (
|
|
||||||
<OrganizationLookup
|
<OrganizationLookup
|
||||||
helperTextInvalid={form.errors.organization}
|
helperTextInvalid={orgMeta.error}
|
||||||
isValid={
|
isValid={!orgMeta.touched || !orgMeta.error}
|
||||||
!form.touched.organization || !form.errors.organization
|
onBlur={() => orgHelpers.setTouched('organization')}
|
||||||
}
|
|
||||||
onBlur={() => form.setFieldTouched('organization')}
|
|
||||||
onChange={value => {
|
onChange={value => {
|
||||||
form.setFieldValue('organization', value.id);
|
orgHelpers.setValue(value.id);
|
||||||
setOrganization(value);
|
setOrganization(value);
|
||||||
}}
|
}}
|
||||||
value={organization}
|
value={organization}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
</>
|
||||||
</Field>
|
);
|
||||||
</FormRow>
|
}
|
||||||
|
|
||||||
|
function TeamForm(props) {
|
||||||
|
const { team, handleCancel, handleSubmit, submitError, ...rest } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
description: team.description || '',
|
||||||
|
name: team.name || '',
|
||||||
|
organization: team.organization || '',
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
{formik => (
|
||||||
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
|
<FormColumnLayout>
|
||||||
|
<TeamFormFields team={team} {...rest} />
|
||||||
<FormSubmitError error={submitError} />
|
<FormSubmitError error={submitError} />
|
||||||
<FormActionGroup
|
<FormActionGroup
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onSubmit={formik.handleSubmit}
|
onSubmit={formik.handleSubmit}
|
||||||
/>
|
/>
|
||||||
|
</FormColumnLayout>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { func, number, shape, string } from 'prop-types';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { Formik, Field } from 'formik';
|
import { Formik, Field } from 'formik';
|
||||||
import { Form, FormGroup, TextInput } from '@patternfly/react-core';
|
import { Form, FormGroup, TextInput } from '@patternfly/react-core';
|
||||||
import FormRow from '@components/FormRow';
|
import { FormFullWidthLayout } from '@components/FormLayout';
|
||||||
import AnsibleSelect from '@components/AnsibleSelect';
|
import AnsibleSelect from '@components/AnsibleSelect';
|
||||||
import InventorySourcesList from './InventorySourcesList';
|
import InventorySourcesList from './InventorySourcesList';
|
||||||
import JobTemplatesList from './JobTemplatesList';
|
import JobTemplatesList from './JobTemplatesList';
|
||||||
@@ -119,7 +119,7 @@ function NodeTypeStep({
|
|||||||
>
|
>
|
||||||
{() => (
|
{() => (
|
||||||
<Form css="margin-top: 20px;">
|
<Form css="margin-top: 20px;">
|
||||||
<FormRow>
|
<FormFullWidthLayout>
|
||||||
<Field name="name">
|
<Field name="name">
|
||||||
{({ field, form }) => {
|
{({ field, form }) => {
|
||||||
const isValid =
|
const isValid =
|
||||||
@@ -149,8 +149,6 @@ function NodeTypeStep({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Field>
|
</Field>
|
||||||
</FormRow>
|
|
||||||
<FormRow>
|
|
||||||
<Field name="description">
|
<Field name="description">
|
||||||
{({ field }) => (
|
{({ field }) => (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
@@ -169,8 +167,6 @@ function NodeTypeStep({
|
|||||||
</FormGroup>
|
</FormGroup>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
</FormRow>
|
|
||||||
<FormRow>
|
|
||||||
<FormGroup
|
<FormGroup
|
||||||
label={i18n._(t`Timeout`)}
|
label={i18n._(t`Timeout`)}
|
||||||
fieldId="approval-timeout"
|
fieldId="approval-timeout"
|
||||||
@@ -236,7 +232,7 @@ function NodeTypeStep({
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
</FormRow>
|
</FormFullWidthLayout>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ import FormField, {
|
|||||||
FormSubmitError,
|
FormSubmitError,
|
||||||
} from '@components/FormField';
|
} from '@components/FormField';
|
||||||
import FieldWithPrompt from '@components/FieldWithPrompt';
|
import FieldWithPrompt from '@components/FieldWithPrompt';
|
||||||
import FormRow from '@components/FormRow';
|
import {
|
||||||
|
FormColumnLayout,
|
||||||
|
FormFullWidthLayout,
|
||||||
|
FormCheckboxLayout,
|
||||||
|
} from '@components/FormLayout';
|
||||||
import CollapsibleSection from '@components/CollapsibleSection';
|
import CollapsibleSection from '@components/CollapsibleSection';
|
||||||
import { required } from '@util/validators';
|
import { required } from '@util/validators';
|
||||||
import styled from 'styled-components';
|
|
||||||
import { JobTemplate } from '@types';
|
import { JobTemplate } from '@types';
|
||||||
import {
|
import {
|
||||||
InventoryLookup,
|
InventoryLookup,
|
||||||
@@ -37,17 +40,6 @@ import { JobTemplatesAPI, ProjectsAPI } from '@api';
|
|||||||
import LabelSelect from './LabelSelect';
|
import LabelSelect from './LabelSelect';
|
||||||
import PlaybookSelect from './PlaybookSelect';
|
import PlaybookSelect from './PlaybookSelect';
|
||||||
|
|
||||||
const GridFormGroup = styled(FormGroup)`
|
|
||||||
& > label {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&& {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
class JobTemplateForm extends Component {
|
class JobTemplateForm extends Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
template: JobTemplate,
|
template: JobTemplate,
|
||||||
@@ -211,9 +203,10 @@ class JobTemplateForm extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AdvancedFieldsWrapper = template.isNew ? CollapsibleSection : 'div';
|
const AdvancedFieldsWrapper = template.isNew ? CollapsibleSection : 'div';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form autoComplete="off" onSubmit={handleSubmit}>
|
<Form autoComplete="off" onSubmit={handleSubmit}>
|
||||||
<FormRow>
|
<FormColumnLayout>
|
||||||
<FormField
|
<FormField
|
||||||
id="template-name"
|
id="template-name"
|
||||||
name="name"
|
name="name"
|
||||||
@@ -334,8 +327,7 @@ class JobTemplateForm extends Component {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Field>
|
</Field>
|
||||||
</FormRow>
|
<FormFullWidthLayout>
|
||||||
<FormRow>
|
|
||||||
<Field name="labels">
|
<Field name="labels">
|
||||||
{({ field }) => (
|
{({ field }) => (
|
||||||
<FormGroup label={i18n._(t`Labels`)} fieldId="template-labels">
|
<FormGroup label={i18n._(t`Labels`)} fieldId="template-labels">
|
||||||
@@ -352,8 +344,6 @@ class JobTemplateForm extends Component {
|
|||||||
</FormGroup>
|
</FormGroup>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
</FormRow>
|
|
||||||
<FormRow>
|
|
||||||
<Field name="credentials" fieldId="template-credentials">
|
<Field name="credentials" fieldId="template-credentials">
|
||||||
{({ field }) => (
|
{({ field }) => (
|
||||||
<MultiCredentialsLookup
|
<MultiCredentialsLookup
|
||||||
@@ -368,9 +358,8 @@ class JobTemplateForm extends Component {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
</FormRow>
|
|
||||||
<AdvancedFieldsWrapper label="Advanced">
|
<AdvancedFieldsWrapper label="Advanced">
|
||||||
<FormRow>
|
<FormColumnLayout>
|
||||||
<FormField
|
<FormField
|
||||||
id="template-forks"
|
id="template-forks"
|
||||||
name="forks"
|
name="forks"
|
||||||
@@ -462,13 +451,14 @@ class JobTemplateForm extends Component {
|
|||||||
</FormGroup>
|
</FormGroup>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
</FormRow>
|
<FormFullWidthLayout>
|
||||||
<Field name="instanceGroups">
|
<Field name="instanceGroups">
|
||||||
{({ field, form }) => (
|
{({ field, form }) => (
|
||||||
<InstanceGroupsLookup
|
<InstanceGroupsLookup
|
||||||
css="margin-top: 20px"
|
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={value => form.setFieldValue(field.name, value)}
|
onChange={value =>
|
||||||
|
form.setFieldValue(field.name, value)
|
||||||
|
}
|
||||||
tooltip={i18n._(t`Select the Instance Groups for this Organization
|
tooltip={i18n._(t`Select the Instance Groups for this Organization
|
||||||
to run on.`)}
|
to run on.`)}
|
||||||
/>
|
/>
|
||||||
@@ -478,7 +468,6 @@ class JobTemplateForm extends Component {
|
|||||||
{({ field, form }) => (
|
{({ field, form }) => (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
label={i18n._(t`Job Tags`)}
|
label={i18n._(t`Job Tags`)}
|
||||||
css="margin-top: 20px"
|
|
||||||
fieldId="template-job-tags"
|
fieldId="template-job-tags"
|
||||||
>
|
>
|
||||||
<FieldTooltip
|
<FieldTooltip
|
||||||
@@ -490,7 +479,9 @@ class JobTemplateForm extends Component {
|
|||||||
/>
|
/>
|
||||||
<TagMultiSelect
|
<TagMultiSelect
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={value => form.setFieldValue(field.name, value)}
|
onChange={value =>
|
||||||
|
form.setFieldValue(field.name, value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
)}
|
)}
|
||||||
@@ -499,7 +490,6 @@ class JobTemplateForm extends Component {
|
|||||||
{({ field, form }) => (
|
{({ field, form }) => (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
label={i18n._(t`Skip Tags`)}
|
label={i18n._(t`Skip Tags`)}
|
||||||
css="margin-top: 20px"
|
|
||||||
fieldId="template-skip-tags"
|
fieldId="template-skip-tags"
|
||||||
>
|
>
|
||||||
<FieldTooltip
|
<FieldTooltip
|
||||||
@@ -511,17 +501,18 @@ class JobTemplateForm extends Component {
|
|||||||
/>
|
/>
|
||||||
<TagMultiSelect
|
<TagMultiSelect
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={value => form.setFieldValue(field.name, value)}
|
onChange={value =>
|
||||||
|
form.setFieldValue(field.name, value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<GridFormGroup
|
<FormGroup
|
||||||
fieldId="template-option-checkboxes"
|
fieldId="template-option-checkboxes"
|
||||||
isInline
|
|
||||||
label={i18n._(t`Options`)}
|
label={i18n._(t`Options`)}
|
||||||
css="margin-top: 20px"
|
|
||||||
>
|
>
|
||||||
|
<FormCheckboxLayout>
|
||||||
<CheckboxField
|
<CheckboxField
|
||||||
id="option-privilege-escalation"
|
id="option-privilege-escalation"
|
||||||
name="become_enabled"
|
name="become_enabled"
|
||||||
@@ -563,14 +554,11 @@ class JobTemplateForm extends Component {
|
|||||||
tooltip={i18n._(t`If enabled, use cached facts if available
|
tooltip={i18n._(t`If enabled, use cached facts if available
|
||||||
and store discovered facts in the cache.`)}
|
and store discovered facts in the cache.`)}
|
||||||
/>
|
/>
|
||||||
</GridFormGroup>
|
</FormCheckboxLayout>
|
||||||
<div
|
</FormGroup>
|
||||||
css={`
|
</FormFullWidthLayout>
|
||||||
${allowCallbacks ? '' : 'display: none'}
|
{allowCallbacks && (
|
||||||
margin-top: 20px;
|
<>
|
||||||
`}
|
|
||||||
>
|
|
||||||
<FormRow>
|
|
||||||
{callbackUrl && (
|
{callbackUrl && (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
label={i18n._(t`Provisioning Callback URL`)}
|
label={i18n._(t`Provisioning Callback URL`)}
|
||||||
@@ -589,11 +577,14 @@ class JobTemplateForm extends Component {
|
|||||||
label={i18n._(t`Host Config Key`)}
|
label={i18n._(t`Host Config Key`)}
|
||||||
validate={allowCallbacks ? required(null, i18n) : null}
|
validate={allowCallbacks ? required(null, i18n) : null}
|
||||||
/>
|
/>
|
||||||
</FormRow>
|
</>
|
||||||
</div>
|
)}
|
||||||
|
</FormColumnLayout>
|
||||||
</AdvancedFieldsWrapper>
|
</AdvancedFieldsWrapper>
|
||||||
|
</FormFullWidthLayout>
|
||||||
<FormSubmitError error={submitError} />
|
<FormSubmitError error={submitError} />
|
||||||
<FormActionGroup onCancel={handleCancel} onSubmit={handleSubmit} />
|
<FormActionGroup onCancel={handleCancel} onSubmit={handleSubmit} />
|
||||||
|
</FormColumnLayout>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
import { Card as _Card, PageSection } from '@patternfly/react-core';
|
|
||||||
import { CardBody } from '@components/Card';
|
import { CardBody } from '@components/Card';
|
||||||
import UserForm from '../shared/UserForm';
|
import UserForm from '../shared/UserForm';
|
||||||
import { UsersAPI } from '@api';
|
import { UsersAPI } from '@api';
|
||||||
|
|
||||||
const Card = styled(_Card)`
|
|
||||||
--pf-c-card--child--PaddingLeft: 0;
|
|
||||||
--pf-c-card--child--PaddingRight: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
function UserAdd() {
|
function UserAdd() {
|
||||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ function UserEdit({ user, history }) {
|
|||||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||||
|
|
||||||
const handleSubmit = async values => {
|
const handleSubmit = async values => {
|
||||||
|
setFormSubmitError(null);
|
||||||
try {
|
try {
|
||||||
await UsersAPI.update(user.id, values);
|
await UsersAPI.update(user.id, values);
|
||||||
history.push(`/users/${user.id}/details`);
|
history.push(`/users/${user.id}/details`);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Formik, Field } from 'formik';
|
import { Formik, useField } from 'formik';
|
||||||
import { Form, FormGroup } from '@patternfly/react-core';
|
import { Form, FormGroup } from '@patternfly/react-core';
|
||||||
import AnsibleSelect from '@components/AnsibleSelect';
|
import AnsibleSelect from '@components/AnsibleSelect';
|
||||||
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
||||||
@@ -10,11 +10,11 @@ import FormField, {
|
|||||||
PasswordField,
|
PasswordField,
|
||||||
FormSubmitError,
|
FormSubmitError,
|
||||||
} from '@components/FormField';
|
} from '@components/FormField';
|
||||||
import FormRow from '@components/FormRow';
|
|
||||||
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
|
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
|
||||||
import { required, requiredEmail } from '@util/validators';
|
import { required, requiredEmail } from '@util/validators';
|
||||||
|
import { FormColumnLayout } from '@components/FormLayout';
|
||||||
|
|
||||||
function UserForm({ user, handleCancel, handleSubmit, submitError, i18n }) {
|
function UserFormFields({ user, i18n }) {
|
||||||
const [organization, setOrganization] = useState(null);
|
const [organization, setOrganization] = useState(null);
|
||||||
|
|
||||||
const userTypeOptions = [
|
const userTypeOptions = [
|
||||||
@@ -38,6 +38,100 @@ function UserForm({ user, handleCancel, handleSubmit, submitError, i18n }) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const organizationFieldArr = useField({
|
||||||
|
name: 'organization',
|
||||||
|
validate: !user.id
|
||||||
|
? required(i18n._(t`Select a value for this field`), i18n)
|
||||||
|
: () => undefined,
|
||||||
|
});
|
||||||
|
const organizationMeta = organizationFieldArr[1];
|
||||||
|
const organizationHelpers = organizationFieldArr[2];
|
||||||
|
|
||||||
|
const [userTypeField, userTypeMeta] = useField('user_type');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
id="user-username"
|
||||||
|
label={i18n._(t`Username`)}
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
validate={required(null, i18n)}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
id="user-email"
|
||||||
|
label={i18n._(t`Email`)}
|
||||||
|
name="email"
|
||||||
|
validate={requiredEmail(i18n)}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
<PasswordField
|
||||||
|
id="user-password"
|
||||||
|
label={i18n._(t`Password`)}
|
||||||
|
name="password"
|
||||||
|
validate={
|
||||||
|
!user.id
|
||||||
|
? required(i18n._(t`This field must not be blank`), i18n)
|
||||||
|
: () => undefined
|
||||||
|
}
|
||||||
|
isRequired={!user.id}
|
||||||
|
/>
|
||||||
|
<PasswordField
|
||||||
|
id="user-confirm-password"
|
||||||
|
label={i18n._(t`Confirm Password`)}
|
||||||
|
name="confirm_password"
|
||||||
|
validate={
|
||||||
|
!user.id
|
||||||
|
? required(i18n._(t`This field must not be blank`), i18n)
|
||||||
|
: () => undefined
|
||||||
|
}
|
||||||
|
isRequired={!user.id}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
id="user-first-name"
|
||||||
|
label={i18n._(t`First Name`)}
|
||||||
|
name="first_name"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
id="user-last-name"
|
||||||
|
label={i18n._(t`Last Name`)}
|
||||||
|
name="last_name"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
{!user.id && (
|
||||||
|
<OrganizationLookup
|
||||||
|
helperTextInvalid={organizationMeta.error}
|
||||||
|
isValid={!organizationMeta.touched || !organizationMeta.error}
|
||||||
|
onBlur={() => organizationHelpers.setTouched()}
|
||||||
|
onChange={value => {
|
||||||
|
organizationHelpers.setValue(value.id);
|
||||||
|
setOrganization(value);
|
||||||
|
}}
|
||||||
|
value={organization}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FormGroup
|
||||||
|
fieldId="user-type"
|
||||||
|
helperTextInvalid={userTypeMeta.error}
|
||||||
|
isRequired
|
||||||
|
isValid={!userTypeMeta.touched || !userTypeMeta.error}
|
||||||
|
label={i18n._(t`User Type`)}
|
||||||
|
>
|
||||||
|
<AnsibleSelect
|
||||||
|
isValid={!userTypeMeta.touched || !userTypeMeta.error}
|
||||||
|
id="user-type"
|
||||||
|
data={userTypeOptions}
|
||||||
|
{...userTypeField}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserForm({ user, handleCancel, handleSubmit, submitError, i18n }) {
|
||||||
const handleValidateAndSubmit = (values, { setErrors }) => {
|
const handleValidateAndSubmit = (values, { setErrors }) => {
|
||||||
if (values.password !== values.confirm_password) {
|
if (values.password !== values.confirm_password) {
|
||||||
setErrors({
|
setErrors({
|
||||||
@@ -81,111 +175,14 @@ function UserForm({ user, handleCancel, handleSubmit, submitError, i18n }) {
|
|||||||
>
|
>
|
||||||
{formik => (
|
{formik => (
|
||||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
<FormRow>
|
<FormColumnLayout>
|
||||||
<FormField
|
<UserFormFields user={user} i18n={i18n} />
|
||||||
id="user-username"
|
|
||||||
label={i18n._(t`Username`)}
|
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
validate={required(null, i18n)}
|
|
||||||
isRequired
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
id="user-email"
|
|
||||||
label={i18n._(t`Email`)}
|
|
||||||
name="email"
|
|
||||||
validate={requiredEmail(i18n)}
|
|
||||||
isRequired
|
|
||||||
/>
|
|
||||||
<PasswordField
|
|
||||||
id="user-password"
|
|
||||||
label={i18n._(t`Password`)}
|
|
||||||
name="password"
|
|
||||||
validate={
|
|
||||||
!user.id
|
|
||||||
? required(i18n._(t`This field must not be blank`), i18n)
|
|
||||||
: () => undefined
|
|
||||||
}
|
|
||||||
isRequired={!user.id}
|
|
||||||
/>
|
|
||||||
<PasswordField
|
|
||||||
id="user-confirm-password"
|
|
||||||
label={i18n._(t`Confirm Password`)}
|
|
||||||
name="confirm_password"
|
|
||||||
validate={
|
|
||||||
!user.id
|
|
||||||
? required(i18n._(t`This field must not be blank`), i18n)
|
|
||||||
: () => undefined
|
|
||||||
}
|
|
||||||
isRequired={!user.id}
|
|
||||||
/>
|
|
||||||
</FormRow>
|
|
||||||
<FormRow>
|
|
||||||
<FormField
|
|
||||||
id="user-first-name"
|
|
||||||
label={i18n._(t`First Name`)}
|
|
||||||
name="first_name"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
id="user-last-name"
|
|
||||||
label={i18n._(t`Last Name`)}
|
|
||||||
name="last_name"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
{!user.id && (
|
|
||||||
<Field
|
|
||||||
name="organization"
|
|
||||||
validate={required(
|
|
||||||
i18n._(t`Select a value for this field`),
|
|
||||||
i18n
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{({ form }) => (
|
|
||||||
<OrganizationLookup
|
|
||||||
helperTextInvalid={form.errors.organization}
|
|
||||||
isValid={
|
|
||||||
!form.touched.organization || !form.errors.organization
|
|
||||||
}
|
|
||||||
onBlur={() => form.setFieldTouched('organization')}
|
|
||||||
onChange={value => {
|
|
||||||
form.setFieldValue('organization', value.id);
|
|
||||||
setOrganization(value);
|
|
||||||
}}
|
|
||||||
value={organization}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
<Field name="user_type">
|
|
||||||
{({ form, field }) => {
|
|
||||||
const isValid =
|
|
||||||
!form.touched.user_type || !form.errors.user_type;
|
|
||||||
return (
|
|
||||||
<FormGroup
|
|
||||||
fieldId="user-type"
|
|
||||||
helperTextInvalid={form.errors.user_type}
|
|
||||||
isRequired
|
|
||||||
isValid={isValid}
|
|
||||||
label={i18n._(t`User Type`)}
|
|
||||||
>
|
|
||||||
<AnsibleSelect
|
|
||||||
isValid={isValid}
|
|
||||||
id="user-type"
|
|
||||||
data={userTypeOptions}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Field>
|
|
||||||
</FormRow>
|
|
||||||
<FormSubmitError error={submitError} />
|
<FormSubmitError error={submitError} />
|
||||||
<FormActionGroup
|
<FormActionGroup
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onSubmit={formik.handleSubmit}
|
onSubmit={formik.handleSubmit}
|
||||||
/>
|
/>
|
||||||
|
</FormColumnLayout>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
|
|||||||
Reference in New Issue
Block a user