mirror of
https://github.com/ansible/awx.git
synced 2026-01-14 19:30:39 -03: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:
commit
54ab671512
@ -4,6 +4,14 @@ class Credentials extends Base {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
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 { string, bool } from 'prop-types';
|
||||
import { Field, useFormikContext } from 'formik';
|
||||
import { useField } from 'formik';
|
||||
import { Split, SplitItem } from '@patternfly/react-core';
|
||||
import { yamlToJson, jsonToYaml, isJson } from '@util/yaml';
|
||||
import CodeMirrorInput from './CodeMirrorInput';
|
||||
@ -8,58 +8,50 @@ import YamlJsonToggle from './YamlJsonToggle';
|
||||
import { JSON_MODE, YAML_MODE } from './constants';
|
||||
|
||||
function VariablesField({ id, name, label, readOnly }) {
|
||||
const { values } = useFormikContext();
|
||||
const value = values[name];
|
||||
const [mode, setMode] = useState(isJson(value) ? JSON_MODE : YAML_MODE);
|
||||
const [field, meta, helpers] = useField(name);
|
||||
const [mode, setMode] = useState(isJson(field.value) ? JSON_MODE : YAML_MODE);
|
||||
|
||||
return (
|
||||
<Field name={name}>
|
||||
{({ field, form }) => (
|
||||
<div className="pf-c-form__group">
|
||||
<Split gutter="sm">
|
||||
<SplitItem>
|
||||
<label htmlFor={id} className="pf-c-form__label">
|
||||
<span className="pf-c-form__label-text">{label}</span>
|
||||
</label>
|
||||
</SplitItem>
|
||||
<SplitItem>
|
||||
<YamlJsonToggle
|
||||
mode={mode}
|
||||
onChange={newMode => {
|
||||
try {
|
||||
const newVal =
|
||||
newMode === YAML_MODE
|
||||
? jsonToYaml(field.value)
|
||||
: yamlToJson(field.value);
|
||||
form.setFieldValue(name, newVal);
|
||||
setMode(newMode);
|
||||
} catch (err) {
|
||||
form.setFieldError(name, err.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SplitItem>
|
||||
</Split>
|
||||
<CodeMirrorInput
|
||||
<>
|
||||
<Split gutter="sm">
|
||||
<SplitItem>
|
||||
<label htmlFor={id} className="pf-c-form__label">
|
||||
<span className="pf-c-form__label-text">{label}</span>
|
||||
</label>
|
||||
</SplitItem>
|
||||
<SplitItem>
|
||||
<YamlJsonToggle
|
||||
mode={mode}
|
||||
readOnly={readOnly}
|
||||
{...field}
|
||||
onChange={newVal => {
|
||||
form.setFieldValue(name, newVal);
|
||||
onChange={newMode => {
|
||||
try {
|
||||
const newVal =
|
||||
newMode === YAML_MODE
|
||||
? jsonToYaml(field.value)
|
||||
: yamlToJson(field.value);
|
||||
helpers.setValue(newVal);
|
||||
setMode(newMode);
|
||||
} catch (err) {
|
||||
helpers.setError(err.message);
|
||||
}
|
||||
}}
|
||||
hasErrors={!!form.errors[field.name]}
|
||||
/>
|
||||
{form.errors[field.name] ? (
|
||||
<div
|
||||
className="pf-c-form__helper-text pf-m-error"
|
||||
aria-live="polite"
|
||||
>
|
||||
{form.errors[field.name]}
|
||||
</div>
|
||||
) : null}
|
||||
</SplitItem>
|
||||
</Split>
|
||||
<CodeMirrorInput
|
||||
mode={mode}
|
||||
readOnly={readOnly}
|
||||
{...field}
|
||||
onChange={newVal => {
|
||||
helpers.setValue(newVal);
|
||||
}}
|
||||
hasErrors={!!meta.error}
|
||||
/>
|
||||
{meta.error ? (
|
||||
<div className="pf-c-form__helper-text pf-m-error" aria-live="polite">
|
||||
{meta.error}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
VariablesField.propTypes = {
|
||||
|
||||
@ -19,7 +19,7 @@ function ExpandingContainer({ isExpanded, children }) {
|
||||
});
|
||||
useEffect(() => {
|
||||
setContentHeight(ref.current.scrollHeight);
|
||||
}, [setContentHeight]);
|
||||
}, [setContentHeight, children]);
|
||||
const height = isExpanded ? contentHeight : '0';
|
||||
return (
|
||||
<Container
|
||||
|
||||
@ -14,7 +14,7 @@ const DetailName = styled(({ fullWidth, ...props }) => (
|
||||
`}
|
||||
`;
|
||||
|
||||
const DetailValue = styled(({ fullWidth, ...props }) => (
|
||||
const DetailValue = styled(({ fullWidth, isEncrypted, ...props }) => (
|
||||
<TextListItem {...props} />
|
||||
))`
|
||||
word-break: break-all;
|
||||
@ -23,6 +23,12 @@ const DetailValue = styled(({ fullWidth, ...props }) => (
|
||||
`
|
||||
grid-column: 2 / -1;
|
||||
`}
|
||||
${props =>
|
||||
props.isEncrypted &&
|
||||
`
|
||||
text-transform: uppercase
|
||||
color: var(--pf-global--Color--400);
|
||||
`}
|
||||
`;
|
||||
|
||||
const Detail = ({
|
||||
@ -32,6 +38,7 @@ const Detail = ({
|
||||
className,
|
||||
dataCy,
|
||||
alwaysVisible,
|
||||
isEncrypted,
|
||||
}) => {
|
||||
if (!value && typeof value !== 'number' && !alwaysVisible) {
|
||||
return null;
|
||||
@ -55,6 +62,7 @@ const Detail = ({
|
||||
component={TextListItemVariants.dd}
|
||||
fullWidth={fullWidth}
|
||||
data-cy={valueCy}
|
||||
isEncrypted={isEncrypted}
|
||||
>
|
||||
{value}
|
||||
</DetailValue>
|
||||
|
||||
@ -9,6 +9,8 @@ const ActionGroup = styled(PFActionGroup)`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
--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 {
|
||||
& > button {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { string, func } from 'prop-types';
|
||||
import { Field } from 'formik';
|
||||
import { useField } from 'formik';
|
||||
import { Checkbox, Tooltip } from '@patternfly/react-core';
|
||||
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
@ -10,32 +10,29 @@ const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
|
||||
`;
|
||||
|
||||
function CheckboxField({ id, name, label, tooltip, validate, ...rest }) {
|
||||
const [field] = useField({ name, validate });
|
||||
return (
|
||||
<Field name={name} validate={validate}>
|
||||
{({ field }) => (
|
||||
<Checkbox
|
||||
aria-label={label}
|
||||
label={
|
||||
<span>
|
||||
{label}
|
||||
|
||||
{tooltip && (
|
||||
<Tooltip position="right" content={tooltip}>
|
||||
<QuestionCircleIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
id={id}
|
||||
{...rest}
|
||||
isChecked={field.value}
|
||||
{...field}
|
||||
onChange={(value, event) => {
|
||||
field.onChange(event);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Checkbox
|
||||
aria-label={label}
|
||||
label={
|
||||
<span>
|
||||
{label}
|
||||
|
||||
{tooltip && (
|
||||
<Tooltip position="right" content={tooltip}>
|
||||
<QuestionCircleIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
id={id}
|
||||
{...rest}
|
||||
isChecked={field.value}
|
||||
{...field}
|
||||
onChange={(value, event) => {
|
||||
field.onChange(event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
CheckboxField.propTypes = {
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Field } from 'formik';
|
||||
import { FormGroup, TextInput, Tooltip } from '@patternfly/react-core';
|
||||
import { useField } from 'formik';
|
||||
import {
|
||||
FormGroup,
|
||||
TextInput,
|
||||
TextArea,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
|
||||
@ -18,53 +23,81 @@ function FormField(props) {
|
||||
tooltipMaxWidth,
|
||||
validate,
|
||||
isRequired,
|
||||
type,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Field name={name} validate={validate}>
|
||||
{({ field, form }) => {
|
||||
const isValid =
|
||||
form && (!form.touched[field.name] || !form.errors[field.name]);
|
||||
const [field, meta] = useField({ name, validate });
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
fieldId={id}
|
||||
helperTextInvalid={form.errors[field.name]}
|
||||
return (
|
||||
<>
|
||||
{(type === 'textarea' && (
|
||||
<FormGroup
|
||||
fieldId={id}
|
||||
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}
|
||||
label={label}
|
||||
>
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
content={tooltip}
|
||||
maxWidth={tooltipMaxWidth}
|
||||
position="right"
|
||||
>
|
||||
<QuestionCircleIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
<TextInput
|
||||
id={id}
|
||||
isRequired={isRequired}
|
||||
isValid={isValid}
|
||||
{...rest}
|
||||
{...field}
|
||||
onChange={(value, event) => {
|
||||
field.onChange(event);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
resizeOrientation="vertical"
|
||||
{...rest}
|
||||
{...field}
|
||||
onChange={(value, event) => {
|
||||
field.onChange(event);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
)) || (
|
||||
<FormGroup
|
||||
fieldId={id}
|
||||
helperTextInvalid={meta.error}
|
||||
isRequired={isRequired}
|
||||
isValid={isValid}
|
||||
label={label}
|
||||
>
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
content={tooltip}
|
||||
maxWidth={tooltipMaxWidth}
|
||||
position="right"
|
||||
>
|
||||
<QuestionCircleIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
<TextInput
|
||||
id={id}
|
||||
isRequired={isRequired}
|
||||
isValid={isValid}
|
||||
{...rest}
|
||||
{...field}
|
||||
onChange={(value, event) => {
|
||||
field.onChange(event);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
FormField.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
|
||||
type: PropTypes.string,
|
||||
validate: PropTypes.func,
|
||||
isRequired: PropTypes.bool,
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Field } from 'formik';
|
||||
import { useField } from 'formik';
|
||||
import {
|
||||
Button,
|
||||
ButtonVariant,
|
||||
@ -16,54 +16,49 @@ import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
|
||||
function PasswordField(props) {
|
||||
const { id, name, label, validate, isRequired, i18n } = props;
|
||||
const [inputType, setInputType] = useState('password');
|
||||
const [field, meta] = useField({ name, validate });
|
||||
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
|
||||
const handlePasswordToggle = () => {
|
||||
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 (
|
||||
<FormGroup
|
||||
fieldId={id}
|
||||
helperTextInvalid={form.errors[field.name]}
|
||||
isRequired={isRequired}
|
||||
isValid={isValid}
|
||||
label={label}
|
||||
<FormGroup
|
||||
fieldId={id}
|
||||
helperTextInvalid={meta.error}
|
||||
isRequired={isRequired}
|
||||
isValid={isValid}
|
||||
label={label}
|
||||
>
|
||||
<InputGroup>
|
||||
<Tooltip
|
||||
content={inputType === 'password' ? i18n._(t`Show`) : i18n._(t`Hide`)}
|
||||
>
|
||||
<Button
|
||||
variant={ButtonVariant.control}
|
||||
aria-label={i18n._(t`Toggle Password`)}
|
||||
onClick={handlePasswordToggle}
|
||||
>
|
||||
<InputGroup>
|
||||
<Tooltip
|
||||
content={
|
||||
inputType === 'password' ? i18n._(t`Show`) : i18n._(t`Hide`)
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant={ButtonVariant.control}
|
||||
aria-label={i18n._(t`Toggle Password`)}
|
||||
onClick={handlePasswordToggle}
|
||||
>
|
||||
{inputType === 'password' && <EyeSlashIcon />}
|
||||
{inputType === 'text' && <EyeIcon />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TextInput
|
||||
id={id}
|
||||
isRequired={isRequired}
|
||||
isValid={isValid}
|
||||
type={inputType}
|
||||
{...field}
|
||||
onChange={(value, event) => {
|
||||
field.onChange(event);
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
{inputType === 'password' && <EyeSlashIcon />}
|
||||
{inputType === 'text' && <EyeIcon />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TextInput
|
||||
id={id}
|
||||
placeholder={field.value === '$encrypted$' ? 'ENCRYPTED' : undefined}
|
||||
{...field}
|
||||
value={field.value === '$encrypted$' ? '' : field.value}
|
||||
isRequired={isRequired}
|
||||
isValid={isValid}
|
||||
type={inputType}
|
||||
onChange={(_, event) => {
|
||||
field.onChange(event);
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
"componentId": "Detail__DetailValue-sc-16ypsyv-1",
|
||||
"isStatic": false,
|
||||
"lastClassName": "yHlYM",
|
||||
"lastClassName": "kCDjmZ",
|
||||
"rules": Array [
|
||||
"word-break:break-all;",
|
||||
[Function],
|
||||
" ",
|
||||
[Function],
|
||||
],
|
||||
},
|
||||
"displayName": "Detail__DetailValue",
|
||||
@ -502,18 +504,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
fullWidth={false}
|
||||
>
|
||||
<Component
|
||||
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
|
||||
className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
|
||||
component="dd"
|
||||
data-cy={null}
|
||||
fullWidth={false}
|
||||
>
|
||||
<TextListItem
|
||||
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
|
||||
className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
|
||||
component="dd"
|
||||
data-cy={null}
|
||||
>
|
||||
<dd
|
||||
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
|
||||
className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
|
||||
data-cy={null}
|
||||
data-pf-content={true}
|
||||
>
|
||||
@ -672,10 +674,12 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "Detail__DetailValue-sc-16ypsyv-1",
|
||||
"isStatic": false,
|
||||
"lastClassName": "yHlYM",
|
||||
"lastClassName": "kCDjmZ",
|
||||
"rules": Array [
|
||||
"word-break:break-all;",
|
||||
[Function],
|
||||
" ",
|
||||
[Function],
|
||||
],
|
||||
},
|
||||
"displayName": "Detail__DetailValue",
|
||||
@ -692,18 +696,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
fullWidth={false}
|
||||
>
|
||||
<Component
|
||||
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
|
||||
className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
|
||||
component="dd"
|
||||
data-cy={null}
|
||||
fullWidth={false}
|
||||
>
|
||||
<TextListItem
|
||||
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
|
||||
className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
|
||||
component="dd"
|
||||
data-cy={null}
|
||||
>
|
||||
<dd
|
||||
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
|
||||
className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
|
||||
data-cy={null}
|
||||
data-pf-content={true}
|
||||
>
|
||||
|
||||
@ -6,22 +6,28 @@ import {
|
||||
Switch,
|
||||
useParams,
|
||||
useLocation,
|
||||
useRouteMatch,
|
||||
Route,
|
||||
Redirect,
|
||||
Link,
|
||||
} from 'react-router-dom';
|
||||
import { TabbedCardHeader } from '@components/Card';
|
||||
import CardCloseButton from '@components/CardCloseButton';
|
||||
import { ResourceAccessList } from '@components/ResourceAccessList';
|
||||
import ContentError from '@components/ContentError';
|
||||
import RoutedTabs from '@components/RoutedTabs';
|
||||
import CredentialDetail from './CredentialDetail';
|
||||
import CredentialEdit from './CredentialEdit';
|
||||
import { CredentialsAPI } from '@api';
|
||||
|
||||
function Credential({ i18n, setBreadcrumb }) {
|
||||
const [credential, setCredential] = useState(null);
|
||||
const [contentError, setContentError] = useState(null);
|
||||
const [hasContentLoading, setHasContentLoading] = useState(true);
|
||||
const location = useLocation();
|
||||
const { pathname } = useLocation();
|
||||
const match = useRouteMatch({
|
||||
path: '/credentials/:id',
|
||||
});
|
||||
const { id } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
@ -37,18 +43,20 @@ function Credential({ i18n, setBreadcrumb }) {
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [id, setBreadcrumb]);
|
||||
}, [id, pathname, setBreadcrumb]);
|
||||
|
||||
const tabsArray = [
|
||||
{ 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 : (
|
||||
<TabbedCardHeader>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
@ -56,7 +64,7 @@ function Credential({ i18n, setBreadcrumb }) {
|
||||
</TabbedCardHeader>
|
||||
);
|
||||
|
||||
if (location.pathname.endsWith('edit') || location.pathname.endsWith('add')) {
|
||||
if (pathname.endsWith('edit') || pathname.endsWith('add')) {
|
||||
cardHeader = null;
|
||||
}
|
||||
|
||||
@ -87,11 +95,45 @@ function Credential({ i18n, setBreadcrumb }) {
|
||||
to="/credentials/:id/details"
|
||||
exact
|
||||
/>
|
||||
{credential && (
|
||||
<Route path="/credentials/:id/details">
|
||||
<CredentialDetail credential={credential} />
|
||||
</Route>
|
||||
)}
|
||||
{credential && [
|
||||
<Route
|
||||
key="details"
|
||||
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
|
||||
key="not-found"
|
||||
path="*"
|
||||
|
||||
@ -3,31 +3,50 @@ import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { CredentialsAPI } from '@api';
|
||||
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';
|
||||
|
||||
jest.mock('@api');
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useRouteMatch: () => ({
|
||||
url: '/credentials/2',
|
||||
params: { id: 2 },
|
||||
}),
|
||||
}));
|
||||
|
||||
CredentialsAPI.readDetail.mockResolvedValue({
|
||||
data: mockCredentials.results[0],
|
||||
CredentialsAPI.readDetail.mockResolvedValueOnce({
|
||||
data: mockCredential,
|
||||
});
|
||||
|
||||
describe('<Credential />', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(async () => {
|
||||
test('initially renders user-based credential succesfully', async () => {
|
||||
await act(async () => {
|
||||
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 () => {
|
||||
expect(wrapper.find('Credential').length).toBe(1);
|
||||
test('initially renders org-based credential succesfully', async () => {
|
||||
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 () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/credentials/1/foobar'],
|
||||
initialEntries: ['/credentials/2/foobar'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />, {
|
||||
@ -38,8 +57,8 @@ describe('<Credential />', () => {
|
||||
location: history.location,
|
||||
match: {
|
||||
params: { id: 1 },
|
||||
url: '/credentials/1/foobar',
|
||||
path: '/credentials/1/foobar',
|
||||
url: '/credentials/2/foobar',
|
||||
path: '/credentials/2/foobar',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -47,19 +66,5 @@ describe('<Credential />', () => {
|
||||
});
|
||||
});
|
||||
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 { Card, CardBody, PageSection } from '@patternfly/react-core';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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 (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<ContentError error={error} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
if (isLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<CardBody>Coming soon :)</CardBody>
|
||||
<CardBody>
|
||||
<CredentialForm
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
credentialTypes={credentialTypes}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
export { CredentialAdd as _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);
|
||||
};
|
||||
|
||||
const renderDetail = ({ id, label, type, secret }) => {
|
||||
const renderDetail = ({ id, label, type }) => {
|
||||
let detail;
|
||||
|
||||
if (type === 'boolean') {
|
||||
@ -81,8 +81,16 @@ function CredentialDetail({ i18n, credential }) {
|
||||
value={<List>{inputs[id] && <ListItem>{label}</ListItem>}</List>}
|
||||
/>
|
||||
);
|
||||
} else if (secret === true) {
|
||||
detail = <Detail key={id} label={label} value={i18n._(t`Encrypted`)} />;
|
||||
} else if (inputs[id] === '$encrypted$') {
|
||||
const isEncrypted = true;
|
||||
detail = (
|
||||
<Detail
|
||||
key={id}
|
||||
label={label}
|
||||
value={i18n._(t`Encrypted`)}
|
||||
isEncrypted={isEncrypted}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
detail = <Detail key={id} label={label} value={inputs[id]} />;
|
||||
}
|
||||
|
||||
@ -49,10 +49,6 @@ describe('<CredentialDetail />', () => {
|
||||
mockCredential.summary_fields.credential_type.name
|
||||
);
|
||||
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(
|
||||
wrapper,
|
||||
'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 { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import { Config } from '@contexts/Config';
|
||||
import Breadcrumbs from '@components/Breadcrumbs';
|
||||
import Credential from './Credential';
|
||||
import CredentialAdd from './CredentialAdd';
|
||||
@ -24,7 +24,9 @@ function Credentials({ i18n }) {
|
||||
'/credentials': i18n._(t`Credentials`),
|
||||
'/credentials/add': i18n._(t`Create New Credential`),
|
||||
[`/credentials/${credential.id}`]: `${credential.name}`,
|
||||
[`/credentials/${credential.id}/edit`]: i18n._(t`Edit Details`),
|
||||
[`/credentials/${credential.id}/details`]: i18n._(t`Details`),
|
||||
[`/credentials/${credential.id}/access`]: i18n._(t`Access`),
|
||||
});
|
||||
},
|
||||
[i18n]
|
||||
@ -35,7 +37,7 @@ function Credentials({ i18n }) {
|
||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
||||
<Switch>
|
||||
<Route path="/credentials/add">
|
||||
<CredentialAdd />
|
||||
<Config>{({ me }) => <CredentialAdd me={me || {}} />}</Config>
|
||||
</Route>
|
||||
<Route path="/credentials/:id">
|
||||
<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,26 +2,78 @@ import React, { useState } from 'react';
|
||||
import { func, shape } from 'prop-types';
|
||||
|
||||
import { useRouteMatch } from 'react-router-dom';
|
||||
import { Formik, Field } from 'formik';
|
||||
import { Formik, useField } from 'formik';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import { Form } from '@patternfly/react-core';
|
||||
|
||||
import FormRow from '@components/FormRow';
|
||||
import FormField, { FormSubmitError } from '@components/FormField';
|
||||
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
||||
import { VariablesField } from '@components/CodeMirrorInput';
|
||||
import { required } from '@util/validators';
|
||||
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(
|
||||
host ? host.summary_fields.inventory : ''
|
||||
);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<FormField
|
||||
id="host-name"
|
||||
name="name"
|
||||
type="text"
|
||||
label={i18n._(t`Name`)}
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
/>
|
||||
<FormField
|
||||
id="host-description"
|
||||
name="description"
|
||||
type="text"
|
||||
label={i18n._(t`Description`)}
|
||||
/>
|
||||
{hostAddMatch && (
|
||||
<InventoryLookup
|
||||
value={inventory}
|
||||
onBlur={() => inventoryHelpers.setTouched()}
|
||||
tooltip={i18n._(
|
||||
t`Select the inventory that this host will belong to.`
|
||||
)}
|
||||
isValid={!inventoryMeta.touched || !inventoryMeta.error}
|
||||
helperTextInvalid={inventoryMeta.error}
|
||||
onChange={value => {
|
||||
inventoryHelpers.setValuealue(value.id);
|
||||
setInventory(value);
|
||||
}}
|
||||
required
|
||||
touched={inventoryMeta.touched}
|
||||
error={inventoryMeta.error}
|
||||
/>
|
||||
)}
|
||||
<FormFullWidthLayout>
|
||||
<VariablesField
|
||||
id="host-variables"
|
||||
name="variables"
|
||||
label={i18n._(t`Variables`)}
|
||||
/>
|
||||
</FormFullWidthLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HostForm({ handleSubmit, host, submitError, handleCancel, ...rest }) {
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
@ -34,62 +86,14 @@ function HostForm({ handleSubmit, handleCancel, host, submitError, i18n }) {
|
||||
>
|
||||
{formik => (
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormRow>
|
||||
<FormField
|
||||
id="host-name"
|
||||
name="name"
|
||||
type="text"
|
||||
label={i18n._(t`Name`)}
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
<FormColumnLayout>
|
||||
<HostFormFields host={host} {...rest} />
|
||||
<FormSubmitError error={submitError} />
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
<FormField
|
||||
id="host-description"
|
||||
name="description"
|
||||
type="text"
|
||||
label={i18n._(t`Description`)}
|
||||
/>
|
||||
{hostAddMatch && (
|
||||
<Field
|
||||
name="inventory"
|
||||
validate={required(
|
||||
i18n._(t`Select a value for this field`),
|
||||
i18n
|
||||
)}
|
||||
>
|
||||
{({ form }) => (
|
||||
<InventoryLookup
|
||||
value={inventory}
|
||||
onBlur={() => form.setFieldTouched('inventory')}
|
||||
tooltip={i18n._(
|
||||
t`Select the inventory that this host will belong to.`
|
||||
)}
|
||||
isValid={!form.touched.inventory || !form.errors.inventory}
|
||||
helperTextInvalid={form.errors.inventory}
|
||||
onChange={value => {
|
||||
form.setFieldValue('inventory', value.id);
|
||||
setInventory(value);
|
||||
}}
|
||||
required
|
||||
touched={form.touched.inventory}
|
||||
error={form.errors.inventory}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<VariablesField
|
||||
id="host-variables"
|
||||
name="variables"
|
||||
label={i18n._(t`Variables`)}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormSubmitError error={submitError} />
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
</FormColumnLayout>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Formik, Field } from 'formik';
|
||||
import { Formik, useField } from 'formik';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { func, number, shape } from 'prop-types';
|
||||
@ -8,20 +8,85 @@ import { VariablesField } from '@components/CodeMirrorInput';
|
||||
import { Form } from '@patternfly/react-core';
|
||||
import FormField, { FormSubmitError } from '@components/FormField';
|
||||
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
||||
import FormRow from '@components/FormRow';
|
||||
import { required } from '@util/validators';
|
||||
import InstanceGroupsLookup from '@components/Lookup/InstanceGroupsLookup';
|
||||
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
|
||||
import CredentialLookup from '@components/Lookup/CredentialLookup';
|
||||
import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout';
|
||||
|
||||
function InventoryFormFields({ i18n, credentialTypeId }) {
|
||||
const [organizationField, organizationMeta, organizationHelpers] = useField({
|
||||
name: 'organization',
|
||||
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||
});
|
||||
const instanceGroupsFieldArr = useField('instanceGroups');
|
||||
const instanceGroupsField = instanceGroupsFieldArr[0];
|
||||
const instanceGroupsHelpers = instanceGroupsFieldArr[2];
|
||||
|
||||
const insightsCredentialFieldArr = useField('insights_credential');
|
||||
const insightsCredentialField = insightsCredentialFieldArr[0];
|
||||
const insightsCredentialHelpers = insightsCredentialFieldArr[2];
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
id="inventory-name"
|
||||
label={i18n._(t`Name`)}
|
||||
name="name"
|
||||
type="text"
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
/>
|
||||
<FormField
|
||||
id="inventory-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);
|
||||
}}
|
||||
value={organizationField.value}
|
||||
touched={organizationMeta.touched}
|
||||
error={organizationMeta.error}
|
||||
required
|
||||
/>
|
||||
<CredentialLookup
|
||||
label={i18n._(t`Insights Credential`)}
|
||||
credentialTypeId={credentialTypeId}
|
||||
onChange={value => insightsCredentialHelpers.setValue(value)}
|
||||
value={insightsCredentialField.value}
|
||||
/>
|
||||
<InstanceGroupsLookup
|
||||
value={instanceGroupsField.value}
|
||||
onChange={value => {
|
||||
instanceGroupsHelpers.setValue(value);
|
||||
}}
|
||||
/>
|
||||
<FormFullWidthLayout>
|
||||
<VariablesField
|
||||
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`
|
||||
)}
|
||||
id="inventory-variables"
|
||||
name="variables"
|
||||
label={i18n._(t`Variables`)}
|
||||
/>
|
||||
</FormFullWidthLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InventoryForm({
|
||||
inventory = {},
|
||||
i18n,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
instanceGroups,
|
||||
credentialTypeId,
|
||||
onCancel,
|
||||
submitError,
|
||||
instanceGroups,
|
||||
...rest
|
||||
}) {
|
||||
const initialValues = {
|
||||
name: inventory.name || '',
|
||||
@ -36,6 +101,7 @@ function InventoryForm({
|
||||
inventory.summary_fields.insights_credential) ||
|
||||
null,
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
@ -45,97 +111,14 @@ function InventoryForm({
|
||||
>
|
||||
{formik => (
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormRow>
|
||||
<FormField
|
||||
id="inventory-name"
|
||||
label={i18n._(t`Name`)}
|
||||
name="name"
|
||||
type="text"
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
/>
|
||||
<FormField
|
||||
id="inventory-description"
|
||||
label={i18n._(t`Description`)}
|
||||
name="description"
|
||||
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
|
||||
helperTextInvalid={form.errors.organization}
|
||||
isValid={
|
||||
!form.touched.organization || !form.errors.organization
|
||||
}
|
||||
onBlur={() => form.setFieldTouched('organization')}
|
||||
onChange={value => {
|
||||
form.setFieldValue('organization', value);
|
||||
}}
|
||||
value={field.value}
|
||||
touched={form.touched.organization}
|
||||
error={form.errors.organization}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
id="inventory-insights_credential"
|
||||
label={i18n._(t`Insights Credential`)}
|
||||
name="insights_credential"
|
||||
>
|
||||
{({ field, form }) => (
|
||||
<CredentialLookup
|
||||
label={i18n._(t`Insights Credential`)}
|
||||
credentialTypeId={credentialTypeId}
|
||||
onChange={value =>
|
||||
form.setFieldValue('insights_credential', value)
|
||||
}
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<Field
|
||||
id="inventory-instanceGroups"
|
||||
label={i18n._(t`Instance Groups`)}
|
||||
name="instanceGroups"
|
||||
>
|
||||
{({ field, form }) => (
|
||||
<InstanceGroupsLookup
|
||||
value={field.value}
|
||||
onChange={value => {
|
||||
form.setFieldValue('instanceGroups', value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<VariablesField
|
||||
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`
|
||||
)}
|
||||
id="inventory-variables"
|
||||
name="variables"
|
||||
label={i18n._(t`Variables`)}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<FormColumnLayout>
|
||||
<InventoryFormFields {...rest} />
|
||||
<FormSubmitError error={submitError} />
|
||||
<FormActionGroup
|
||||
onCancel={onCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
</FormRow>
|
||||
</FormColumnLayout>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
@ -6,11 +6,11 @@ import { Form, Card } from '@patternfly/react-core';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import { CardBody } from '@components/Card';
|
||||
import FormRow from '@components/FormRow';
|
||||
import FormField from '@components/FormField';
|
||||
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
||||
import { VariablesField } from '@components/CodeMirrorInput';
|
||||
import { required } from '@util/validators';
|
||||
import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout';
|
||||
|
||||
function InventoryGroupForm({
|
||||
i18n,
|
||||
@ -31,7 +31,7 @@ function InventoryGroupForm({
|
||||
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
||||
{formik => (
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormRow css="grid-template-columns: repeat(auto-fit, minmax(300px, 500px));">
|
||||
<FormColumnLayout>
|
||||
<FormField
|
||||
id="inventoryGroup-name"
|
||||
name="name"
|
||||
@ -46,19 +46,19 @@ function InventoryGroupForm({
|
||||
type="text"
|
||||
label={i18n._(t`Description`)}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<VariablesField
|
||||
id="host-variables"
|
||||
name="variables"
|
||||
label={i18n._(t`Variables`)}
|
||||
<FormFullWidthLayout>
|
||||
<VariablesField
|
||||
id="host-variables"
|
||||
name="variables"
|
||||
label={i18n._(t`Variables`)}
|
||||
/>
|
||||
</FormFullWidthLayout>
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
{error ? <div>error</div> : null}
|
||||
{error ? <div>error</div> : null}
|
||||
</FormColumnLayout>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { Formik, Field } from 'formik';
|
||||
import { Formik, useField } from 'formik';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Form, FormGroup } from '@patternfly/react-core';
|
||||
@ -11,27 +11,93 @@ import { ConfigContext } from '@contexts/Config';
|
||||
import AnsibleSelect from '@components/AnsibleSelect';
|
||||
import ContentError from '@components/ContentError';
|
||||
import ContentLoading from '@components/ContentLoading';
|
||||
import FormRow from '@components/FormRow';
|
||||
import FormField, { FormSubmitError } from '@components/FormField';
|
||||
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
||||
import { InstanceGroupsLookup } from '@components/Lookup/';
|
||||
import { getAddedAndRemoved } from '@util/lists';
|
||||
import { required, minMaxValue } from '@util/validators';
|
||||
import { FormColumnLayout } from '@components/FormLayout';
|
||||
|
||||
function OrganizationForm({
|
||||
organization,
|
||||
function OrganizationFormFields({
|
||||
i18n,
|
||||
me,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
submitError,
|
||||
instanceGroups,
|
||||
setInstanceGroups,
|
||||
}) {
|
||||
const [venvField] = useField('custom_virtualenv');
|
||||
|
||||
const defaultVenv = {
|
||||
label: i18n._(t`Use Default Ansible Environment`),
|
||||
value: '/venv/ansible/',
|
||||
key: 'default',
|
||||
};
|
||||
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 [hasContentLoading, setHasContentLoading] = useState(true);
|
||||
const [initialInstanceGroups, setInitialInstanceGroups] = useState([]);
|
||||
@ -100,79 +166,24 @@ function OrganizationForm({
|
||||
>
|
||||
{formik => (
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormRow>
|
||||
<FormField
|
||||
id="org-name"
|
||||
name="name"
|
||||
type="text"
|
||||
label={i18n._(t`Name`)}
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
<FormColumnLayout>
|
||||
<OrganizationFormFields
|
||||
instanceGroups={instanceGroups}
|
||||
setInstanceGroups={setInstanceGroups}
|
||||
{...rest}
|
||||
/>
|
||||
<FormField
|
||||
id="org-description"
|
||||
name="description"
|
||||
type="text"
|
||||
label={i18n._(t`Description`)}
|
||||
<FormSubmitError error={submitError} />
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
<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} />
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
</FormColumnLayout>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
FormField.propTypes = {
|
||||
label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
|
||||
};
|
||||
|
||||
OrganizationForm.propTypes = {
|
||||
organization: PropTypes.shape(),
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
|
||||
@ -121,6 +121,11 @@ describe('<OrganizationForm />', () => {
|
||||
});
|
||||
|
||||
test('changing inputs and saving triggers expected callback', async () => {
|
||||
OrganizationsAPI.readInstanceGroups.mockReturnValue({
|
||||
data: {
|
||||
results: mockInstanceGroups,
|
||||
},
|
||||
});
|
||||
let wrapper;
|
||||
const onSubmit = jest.fn();
|
||||
await act(async () => {
|
||||
|
||||
@ -1,16 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { Card as _Card, PageSection } from '@patternfly/react-core';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
import { CardBody } from '@components/Card';
|
||||
import ProjectForm from '../shared/ProjectForm';
|
||||
import { ProjectsAPI } from '@api';
|
||||
|
||||
const Card = styled(_Card)`
|
||||
--pf-c-card--child--PaddingLeft: 0;
|
||||
--pf-c-card--child--PaddingRight: 0;
|
||||
`;
|
||||
|
||||
function ProjectAdd() {
|
||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||
const history = useHistory();
|
||||
|
||||
@ -1,18 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { Card as _Card } from '@patternfly/react-core';
|
||||
import { Card } from '@patternfly/react-core';
|
||||
import { CardBody } from '@components/Card';
|
||||
import ProjectForm from '../shared/ProjectForm';
|
||||
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 }) {
|
||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||
const history = useHistory();
|
||||
|
||||
@ -17,7 +17,7 @@ describe('<ProjectEdit />', () => {
|
||||
scm_url: 'https://foo.bar',
|
||||
scm_clean: true,
|
||||
credential: 100,
|
||||
local_path: '',
|
||||
local_path: 'bar',
|
||||
organization: 2,
|
||||
scm_update_on_launch: true,
|
||||
scm_update_cache_timeout: 3,
|
||||
|
||||
@ -3,9 +3,9 @@ import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Formik, Field } from 'formik';
|
||||
import { Formik, useField } from 'formik';
|
||||
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 ContentError from '@components/ContentError';
|
||||
import ContentLoading from '@components/ContentLoading';
|
||||
@ -14,27 +14,18 @@ import FormField, {
|
||||
FieldTooltip,
|
||||
FormSubmitError,
|
||||
} from '@components/FormField';
|
||||
import FormRow from '@components/FormRow';
|
||||
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
|
||||
import { CredentialTypesAPI, ProjectsAPI } from '@api';
|
||||
import { required } from '@util/validators';
|
||||
import styled from 'styled-components';
|
||||
import { FormColumnLayout, SubFormLayout } from '@components/FormLayout';
|
||||
import {
|
||||
GitSubForm,
|
||||
HgSubForm,
|
||||
SvnSubForm,
|
||||
InsightsSubForm,
|
||||
SubFormTitle,
|
||||
ManualSubForm,
|
||||
} from './ProjectSubForms';
|
||||
|
||||
const ScmTypeFormRow = styled(FormRow)`
|
||||
background-color: #f5f5f5;
|
||||
grid-column: 1 / -1;
|
||||
margin: 0 -24px;
|
||||
padding: 24px;
|
||||
`;
|
||||
|
||||
const fetchCredentials = async credential => {
|
||||
const [
|
||||
{
|
||||
@ -73,14 +64,236 @@ const fetchCredentials = async credential => {
|
||||
};
|
||||
};
|
||||
|
||||
function ProjectForm({ project, submitError, ...props }) {
|
||||
const { i18n, handleCancel, handleSubmit } = props;
|
||||
function ProjectFormFields({
|
||||
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 [contentError, setContentError] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [organization, setOrganization] = useState(
|
||||
summary_fields.organization || null
|
||||
);
|
||||
const [organization, setOrganization] = useState(null);
|
||||
const [scmSubFormState, setScmSubFormState] = useState(null);
|
||||
const [scmTypeOptions, setScmTypeOptions] = useState(null);
|
||||
const [credentials, setCredentials] = useState({
|
||||
@ -114,60 +327,6 @@ function ProjectForm({ project, submitError, ...props }) {
|
||||
fetchData();
|
||||
}, [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) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
@ -206,193 +365,27 @@ function ProjectForm({ project, submitError, ...props }) {
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{formik => (
|
||||
<Form
|
||||
autoComplete="off"
|
||||
onSubmit={formik.handleSubmit}
|
||||
css="padding: 0 24px"
|
||||
>
|
||||
<FormRow>
|
||||
<FormField
|
||||
id="project-name"
|
||||
label={i18n._(t`Name`)}
|
||||
name="name"
|
||||
type="text"
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormColumnLayout>
|
||||
<ProjectFormFields
|
||||
project_base_dir={project_base_dir}
|
||||
project_local_paths={project_local_paths}
|
||||
formik={formik}
|
||||
i18n={i18n}
|
||||
setCredentials={setCredentials}
|
||||
credentials={credentials}
|
||||
scmTypeOptions={scmTypeOptions}
|
||||
setScmSubFormState={setScmSubFormState}
|
||||
scmSubFormState={scmSubFormState}
|
||||
setOrganization={setOrganization}
|
||||
organization={organization}
|
||||
/>
|
||||
<FormField
|
||||
id="project-description"
|
||||
label={i18n._(t`Description`)}
|
||||
name="description"
|
||||
type="text"
|
||||
<FormSubmitError error={submitError} />
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
<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_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]
|
||||
}
|
||||
</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} />
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
</FormColumnLayout>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Field } from 'formik';
|
||||
import { useField } from 'formik';
|
||||
import CredentialLookup from '@components/Lookup/CredentialLookup';
|
||||
import { required } from '@util/validators';
|
||||
import { ScmTypeOptions } from './SharedFields';
|
||||
@ -11,30 +11,32 @@ const InsightsSubForm = ({
|
||||
credential,
|
||||
onCredentialSelection,
|
||||
scmUpdateOnLaunch,
|
||||
}) => (
|
||||
<>
|
||||
<Field
|
||||
name="credential"
|
||||
validate={required(i18n._(t`Select a value for this field`), i18n)}
|
||||
>
|
||||
{({ form }) => (
|
||||
<CredentialLookup
|
||||
credentialTypeId={credential.typeId}
|
||||
label={i18n._(t`Insights Credential`)}
|
||||
helperTextInvalid={form.errors.credential}
|
||||
isValid={!form.touched.credential || !form.errors.credential}
|
||||
onBlur={() => form.setFieldTouched('credential')}
|
||||
onChange={value => {
|
||||
onCredentialSelection('insights', value);
|
||||
form.setFieldValue('credential', value.id);
|
||||
}}
|
||||
value={credential.value}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<ScmTypeOptions hideAllowOverride 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 (
|
||||
<>
|
||||
<CredentialLookup
|
||||
credentialTypeId={credential.typeId}
|
||||
label={i18n._(t`Insights Credential`)}
|
||||
helperTextInvalid={credMeta.error}
|
||||
isValid={!credMeta.touched || !credMeta.error}
|
||||
onBlur={() => credHelpers.setTouched()}
|
||||
onChange={value => {
|
||||
onCredentialSelection('insights', value);
|
||||
credHelpers.setValue(value.id);
|
||||
}}
|
||||
value={credential.value}
|
||||
required
|
||||
/>
|
||||
<ScmTypeOptions hideAllowOverride scmUpdateOnLaunch={scmUpdateOnLaunch} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default withI18n()(InsightsSubForm);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Field } from 'formik';
|
||||
import { useField } from 'formik';
|
||||
import { required } from '@util/validators';
|
||||
import AnsibleSelect from '@components/AnsibleSelect';
|
||||
import FormField, { FieldTooltip } from '@components/FormField';
|
||||
@ -34,6 +34,10 @@ const ManualSubForm = ({
|
||||
label: path,
|
||||
})),
|
||||
];
|
||||
const [pathField, pathMeta, pathHelpers] = useField({
|
||||
name: 'local_path',
|
||||
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -72,36 +76,27 @@ const ManualSubForm = ({
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{options.length !== 1 && (
|
||||
<Field
|
||||
name="local_path"
|
||||
validate={required(i18n._(t`Select a value for this field`), i18n)}
|
||||
>
|
||||
{({ field, form }) => (
|
||||
<FormGroup
|
||||
fieldId="project-local-path"
|
||||
helperTextInvalid={form.errors.local_path}
|
||||
isRequired
|
||||
isValid={!form.touched.local_path || !form.errors.local_path}
|
||||
label={i18n._(t`Playbook Directory`)}
|
||||
>
|
||||
<FieldTooltip
|
||||
content={i18n._(t`Select from the list of directories found in
|
||||
the Project Base Path. Together the base path and the playbook
|
||||
directory provide the full path used to locate playbooks.`)}
|
||||
/>
|
||||
<AnsibleSelect
|
||||
{...field}
|
||||
id="local_path"
|
||||
data={options}
|
||||
onChange={(event, value) => {
|
||||
form.setFieldValue('local_path', value);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<FormGroup
|
||||
fieldId="project-local-path"
|
||||
helperTextInvalid={pathMeta.error}
|
||||
isRequired
|
||||
isValid={!pathMeta.touched || !pathMeta.error}
|
||||
label={i18n._(t`Playbook Directory`)}
|
||||
>
|
||||
<FieldTooltip
|
||||
content={i18n._(t`Select from the list of directories found in
|
||||
the Project Base Path. Together the base path and the playbook
|
||||
directory provide the full path used to locate playbooks.`)}
|
||||
/>
|
||||
<AnsibleSelect
|
||||
{...pathField}
|
||||
id="local_path"
|
||||
data={options}
|
||||
onChange={(event, value) => {
|
||||
pathHelpers.setValue(value);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,18 +1,15 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Field } from 'formik';
|
||||
import { useField } from 'formik';
|
||||
import CredentialLookup from '@components/Lookup/CredentialLookup';
|
||||
import FormField, { CheckboxField } from '@components/FormField';
|
||||
import { required } from '@util/validators';
|
||||
import FormRow from '@components/FormRow';
|
||||
import { FormGroup, Title } from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const SubFormTitle = styled(Title)`
|
||||
--pf-c-title--m-md--FontWeight: 700;
|
||||
grid-column: 1 / -1;
|
||||
`;
|
||||
import {
|
||||
FormCheckboxLayout,
|
||||
FormFullWidthLayout,
|
||||
} from '@components/FormLayout';
|
||||
|
||||
export const UrlFormField = withI18n()(({ i18n, tooltip }) => (
|
||||
<FormField
|
||||
@ -41,32 +38,28 @@ export const BranchFormField = withI18n()(({ i18n, label }) => (
|
||||
));
|
||||
|
||||
export const ScmCredentialFormField = withI18n()(
|
||||
({ i18n, credential, onCredentialSelection }) => (
|
||||
<Field name="credential">
|
||||
{({ form }) => (
|
||||
<CredentialLookup
|
||||
credentialTypeId={credential.typeId}
|
||||
label={i18n._(t`SCM Credential`)}
|
||||
value={credential.value}
|
||||
onChange={value => {
|
||||
onCredentialSelection('scm', value);
|
||||
form.setFieldValue('credential', value ? value.id : '');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)
|
||||
({ i18n, credential, onCredentialSelection }) => {
|
||||
const credHelpers = useField('credential')[2];
|
||||
|
||||
return (
|
||||
<CredentialLookup
|
||||
credentialTypeId={credential.typeId}
|
||||
label={i18n._(t`SCM Credential`)}
|
||||
value={credential.value}
|
||||
onChange={value => {
|
||||
onCredentialSelection('scm', value);
|
||||
credHelpers.setValue(value ? value.id : '');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const ScmTypeOptions = withI18n()(
|
||||
({ i18n, scmUpdateOnLaunch, hideAllowOverride }) => (
|
||||
<>
|
||||
<FormGroup
|
||||
css="grid-column: 1/-1"
|
||||
fieldId="project-option-checkboxes"
|
||||
label={i18n._(t`Options`)}
|
||||
>
|
||||
<FormRow>
|
||||
<FormFullWidthLayout>
|
||||
<FormGroup fieldId="project-option-checkboxes" label={i18n._(t`Options`)}>
|
||||
<FormCheckboxLayout>
|
||||
<CheckboxField
|
||||
id="option-scm-clean"
|
||||
name="scm_clean"
|
||||
@ -81,9 +74,9 @@ export const ScmTypeOptions = withI18n()(
|
||||
label={i18n._(t`Delete`)}
|
||||
tooltip={i18n._(
|
||||
t`Delete the local repository in its entirety prior to
|
||||
performing an update. Depending on the size of the
|
||||
repository this may significantly increase the amount
|
||||
of time required to complete an update.`
|
||||
performing an update. Depending on the size of the
|
||||
repository this may significantly increase the amount
|
||||
of time required to complete an update.`
|
||||
)}
|
||||
/>
|
||||
<CheckboxField
|
||||
@ -92,7 +85,7 @@ export const ScmTypeOptions = withI18n()(
|
||||
label={i18n._(t`Update Revision on Launch`)}
|
||||
tooltip={i18n._(
|
||||
t`Each time a job runs using this project, update the
|
||||
revision of the project prior to starting the job.`
|
||||
revision of the project prior to starting the job.`
|
||||
)}
|
||||
/>
|
||||
{!hideAllowOverride && (
|
||||
@ -102,15 +95,16 @@ export const ScmTypeOptions = withI18n()(
|
||||
label={i18n._(t`Allow Branch Override`)}
|
||||
tooltip={i18n._(
|
||||
t`Allow changing the SCM branch or revision in a job
|
||||
template that uses this project.`
|
||||
template that uses this project.`
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormRow>
|
||||
</FormCheckboxLayout>
|
||||
</FormGroup>
|
||||
|
||||
{scmUpdateOnLaunch && (
|
||||
<>
|
||||
<SubFormTitle size="md">{i18n._(t`Option Details`)}</SubFormTitle>
|
||||
<Title size="md">{i18n._(t`Option Details`)}</Title>
|
||||
<FormField
|
||||
id="project-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 HgSubForm } from './HgSubForm';
|
||||
export { default as InsightsSubForm } from './InsightsSubForm';
|
||||
|
||||
@ -2,19 +2,59 @@ import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Formik, Field } from 'formik';
|
||||
import { Formik, useField } from 'formik';
|
||||
import { Form } from '@patternfly/react-core';
|
||||
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
||||
import FormField, { FormSubmitError } from '@components/FormField';
|
||||
import FormRow from '@components/FormRow';
|
||||
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
|
||||
import { required } from '@util/validators';
|
||||
import { FormColumnLayout } from '@components/FormLayout';
|
||||
|
||||
function TeamForm(props) {
|
||||
const { team, handleCancel, handleSubmit, submitError, i18n } = props;
|
||||
function TeamFormFields(props) {
|
||||
const { team, i18n } = props;
|
||||
const [organization, setOrganization] = useState(
|
||||
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 (
|
||||
<>
|
||||
<FormField
|
||||
id="team-name"
|
||||
label={i18n._(t`Name`)}
|
||||
name="name"
|
||||
type="text"
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
/>
|
||||
<FormField
|
||||
id="team-description"
|
||||
label={i18n._(t`Description`)}
|
||||
name="description"
|
||||
type="text"
|
||||
/>
|
||||
<OrganizationLookup
|
||||
helperTextInvalid={orgMeta.error}
|
||||
isValid={!orgMeta.touched || !orgMeta.error}
|
||||
onBlur={() => orgHelpers.setTouched('organization')}
|
||||
onChange={value => {
|
||||
orgHelpers.setValue(value.id);
|
||||
setOrganization(value);
|
||||
}}
|
||||
value={organization}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamForm(props) {
|
||||
const { team, handleCancel, handleSubmit, submitError, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Formik
|
||||
@ -26,55 +66,15 @@ function TeamForm(props) {
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{formik => (
|
||||
<Form
|
||||
autoComplete="off"
|
||||
onSubmit={formik.handleSubmit}
|
||||
css="padding: 0 24px"
|
||||
>
|
||||
<FormRow>
|
||||
<FormField
|
||||
id="team-name"
|
||||
label={i18n._(t`Name`)}
|
||||
name="name"
|
||||
type="text"
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormColumnLayout>
|
||||
<TeamFormFields team={team} {...rest} />
|
||||
<FormSubmitError error={submitError} />
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
<FormField
|
||||
id="team-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>
|
||||
</FormRow>
|
||||
<FormSubmitError error={submitError} />
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
</FormColumnLayout>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
@ -5,7 +5,7 @@ import { func, number, shape, string } from 'prop-types';
|
||||
import styled from 'styled-components';
|
||||
import { Formik, Field } from 'formik';
|
||||
import { Form, FormGroup, TextInput } from '@patternfly/react-core';
|
||||
import FormRow from '@components/FormRow';
|
||||
import { FormFullWidthLayout } from '@components/FormLayout';
|
||||
import AnsibleSelect from '@components/AnsibleSelect';
|
||||
import InventorySourcesList from './InventorySourcesList';
|
||||
import JobTemplatesList from './JobTemplatesList';
|
||||
@ -119,7 +119,7 @@ function NodeTypeStep({
|
||||
>
|
||||
{() => (
|
||||
<Form css="margin-top: 20px;">
|
||||
<FormRow>
|
||||
<FormFullWidthLayout>
|
||||
<Field name="name">
|
||||
{({ field, form }) => {
|
||||
const isValid =
|
||||
@ -149,8 +149,6 @@ function NodeTypeStep({
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<Field name="description">
|
||||
{({ field }) => (
|
||||
<FormGroup
|
||||
@ -169,8 +167,6 @@ function NodeTypeStep({
|
||||
</FormGroup>
|
||||
)}
|
||||
</Field>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<FormGroup
|
||||
label={i18n._(t`Timeout`)}
|
||||
fieldId="approval-timeout"
|
||||
@ -236,7 +232,7 @@ function NodeTypeStep({
|
||||
</Field>
|
||||
</div>
|
||||
</FormGroup>
|
||||
</FormRow>
|
||||
</FormFullWidthLayout>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
@ -22,10 +22,13 @@ import FormField, {
|
||||
FormSubmitError,
|
||||
} from '@components/FormField';
|
||||
import FieldWithPrompt from '@components/FieldWithPrompt';
|
||||
import FormRow from '@components/FormRow';
|
||||
import {
|
||||
FormColumnLayout,
|
||||
FormFullWidthLayout,
|
||||
FormCheckboxLayout,
|
||||
} from '@components/FormLayout';
|
||||
import CollapsibleSection from '@components/CollapsibleSection';
|
||||
import { required } from '@util/validators';
|
||||
import styled from 'styled-components';
|
||||
import { JobTemplate } from '@types';
|
||||
import {
|
||||
InventoryLookup,
|
||||
@ -37,17 +40,6 @@ import { JobTemplatesAPI, ProjectsAPI } from '@api';
|
||||
import LabelSelect from './LabelSelect';
|
||||
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 {
|
||||
static propTypes = {
|
||||
template: JobTemplate,
|
||||
@ -211,9 +203,10 @@ class JobTemplateForm extends Component {
|
||||
}
|
||||
|
||||
const AdvancedFieldsWrapper = template.isNew ? CollapsibleSection : 'div';
|
||||
|
||||
return (
|
||||
<Form autoComplete="off" onSubmit={handleSubmit}>
|
||||
<FormRow>
|
||||
<FormColumnLayout>
|
||||
<FormField
|
||||
id="template-name"
|
||||
name="name"
|
||||
@ -334,266 +327,264 @@ class JobTemplateForm extends Component {
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<Field name="labels">
|
||||
{({ field }) => (
|
||||
<FormGroup label={i18n._(t`Labels`)} fieldId="template-labels">
|
||||
<FieldTooltip
|
||||
content={i18n._(t`Optional labels that describe this job template,
|
||||
such as 'dev' or 'test'. Labels can be used to group and filter
|
||||
job templates and completed jobs.`)}
|
||||
/>
|
||||
<LabelSelect
|
||||
value={field.value}
|
||||
onChange={labels => setFieldValue('labels', labels)}
|
||||
onError={this.setContentError}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</Field>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<Field name="credentials" fieldId="template-credentials">
|
||||
{({ field }) => (
|
||||
<MultiCredentialsLookup
|
||||
value={field.value}
|
||||
onChange={newCredentials =>
|
||||
setFieldValue('credentials', newCredentials)
|
||||
}
|
||||
onError={this.setContentError}
|
||||
tooltip={i18n._(
|
||||
t`Select credentials that allow Tower to access the nodes this job will be ran against. You can only select one credential of each type. For machine credentials (SSH), checking "Prompt on launch" without selecting credentials will require you to select a machine credential at run time. If you select credentials and check "Prompt on launch", the selected credential(s) become the defaults that can be updated at run time.`
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormRow>
|
||||
<AdvancedFieldsWrapper label="Advanced">
|
||||
<FormRow>
|
||||
<FormField
|
||||
id="template-forks"
|
||||
name="forks"
|
||||
type="number"
|
||||
min="0"
|
||||
label={i18n._(t`Forks`)}
|
||||
tooltip={
|
||||
<span>
|
||||
{i18n._(t`The number of parallel or simultaneous
|
||||
processes to use while executing the playbook. An empty value,
|
||||
or a value less than 1 will use the Ansible default which is
|
||||
usually 5. The default number of forks can be overwritten
|
||||
with a change to`)}{' '}
|
||||
<code>ansible.cfg</code>.{' '}
|
||||
{i18n._(t`Refer to the Ansible documentation for details
|
||||
about the configuration file.`)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<FormField
|
||||
id="template-limit"
|
||||
name="limit"
|
||||
type="text"
|
||||
label={i18n._(t`Limit`)}
|
||||
tooltip={i18n._(t`Provide a host pattern to further constrain
|
||||
the list of hosts that will be managed or affected by the
|
||||
playbook. Multiple patterns are allowed. Refer to Ansible
|
||||
documentation for more information and examples on patterns.`)}
|
||||
/>
|
||||
<Field name="verbosity">
|
||||
<FormFullWidthLayout>
|
||||
<Field name="labels">
|
||||
{({ field }) => (
|
||||
<FormGroup
|
||||
fieldId="template-verbosity"
|
||||
label={i18n._(t`Verbosity`)}
|
||||
>
|
||||
<FormGroup label={i18n._(t`Labels`)} fieldId="template-labels">
|
||||
<FieldTooltip
|
||||
content={i18n._(t`Control the level of output ansible will
|
||||
produce as the playbook executes.`)}
|
||||
content={i18n._(t`Optional labels that describe this job template,
|
||||
such as 'dev' or 'test'. Labels can be used to group and filter
|
||||
job templates and completed jobs.`)}
|
||||
/>
|
||||
<AnsibleSelect
|
||||
id="template-verbosity"
|
||||
data={verbosityOptions}
|
||||
{...field}
|
||||
<LabelSelect
|
||||
value={field.value}
|
||||
onChange={labels => setFieldValue('labels', labels)}
|
||||
onError={this.setContentError}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</Field>
|
||||
<FormField
|
||||
id="template-job-slicing"
|
||||
name="job_slice_count"
|
||||
type="number"
|
||||
min="1"
|
||||
label={i18n._(t`Job Slicing`)}
|
||||
tooltip={i18n._(t`Divide the work done by this job template
|
||||
into the specified number of job slices, each running the
|
||||
same tasks against a portion of the inventory.`)}
|
||||
/>
|
||||
<FormField
|
||||
id="template-timeout"
|
||||
name="timeout"
|
||||
type="number"
|
||||
min="0"
|
||||
label={i18n._(t`Timeout`)}
|
||||
tooltip={i18n._(t`The amount of time (in seconds) to run
|
||||
before the task is canceled. Defaults to 0 for no job
|
||||
timeout.`)}
|
||||
/>
|
||||
<Field name="diff_mode">
|
||||
{({ field, form }) => (
|
||||
<FormGroup
|
||||
fieldId="template-show-changes"
|
||||
label={i18n._(t`Show Changes`)}
|
||||
>
|
||||
<FieldTooltip
|
||||
content={i18n._(t`If enabled, show the changes made by
|
||||
Ansible tasks, where supported. This is equivalent
|
||||
to Ansible’s --diff mode.`)}
|
||||
/>
|
||||
<div>
|
||||
<Switch
|
||||
id="template-show-changes"
|
||||
label={field.value ? i18n._(t`On`) : i18n._(t`Off`)}
|
||||
isChecked={field.value}
|
||||
onChange={checked =>
|
||||
form.setFieldValue(field.name, checked)
|
||||
}
|
||||
<Field name="credentials" fieldId="template-credentials">
|
||||
{({ field }) => (
|
||||
<MultiCredentialsLookup
|
||||
value={field.value}
|
||||
onChange={newCredentials =>
|
||||
setFieldValue('credentials', newCredentials)
|
||||
}
|
||||
onError={this.setContentError}
|
||||
tooltip={i18n._(
|
||||
t`Select credentials that allow Tower to access the nodes this job will be ran against. You can only select one credential of each type. For machine credentials (SSH), checking "Prompt on launch" without selecting credentials will require you to select a machine credential at run time. If you select credentials and check "Prompt on launch", the selected credential(s) become the defaults that can be updated at run time.`
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<AdvancedFieldsWrapper label="Advanced">
|
||||
<FormColumnLayout>
|
||||
<FormField
|
||||
id="template-forks"
|
||||
name="forks"
|
||||
type="number"
|
||||
min="0"
|
||||
label={i18n._(t`Forks`)}
|
||||
tooltip={
|
||||
<span>
|
||||
{i18n._(t`The number of parallel or simultaneous
|
||||
processes to use while executing the playbook. An empty value,
|
||||
or a value less than 1 will use the Ansible default which is
|
||||
usually 5. The default number of forks can be overwritten
|
||||
with a change to`)}{' '}
|
||||
<code>ansible.cfg</code>.{' '}
|
||||
{i18n._(t`Refer to the Ansible documentation for details
|
||||
about the configuration file.`)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<FormField
|
||||
id="template-limit"
|
||||
name="limit"
|
||||
type="text"
|
||||
label={i18n._(t`Limit`)}
|
||||
tooltip={i18n._(t`Provide a host pattern to further constrain
|
||||
the list of hosts that will be managed or affected by the
|
||||
playbook. Multiple patterns are allowed. Refer to Ansible
|
||||
documentation for more information and examples on patterns.`)}
|
||||
/>
|
||||
<Field name="verbosity">
|
||||
{({ field }) => (
|
||||
<FormGroup
|
||||
fieldId="template-verbosity"
|
||||
label={i18n._(t`Verbosity`)}
|
||||
>
|
||||
<FieldTooltip
|
||||
content={i18n._(t`Control the level of output ansible will
|
||||
produce as the playbook executes.`)}
|
||||
/>
|
||||
<AnsibleSelect
|
||||
id="template-verbosity"
|
||||
data={verbosityOptions}
|
||||
{...field}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</Field>
|
||||
<FormField
|
||||
id="template-job-slicing"
|
||||
name="job_slice_count"
|
||||
type="number"
|
||||
min="1"
|
||||
label={i18n._(t`Job Slicing`)}
|
||||
tooltip={i18n._(t`Divide the work done by this job template
|
||||
into the specified number of job slices, each running the
|
||||
same tasks against a portion of the inventory.`)}
|
||||
/>
|
||||
<FormField
|
||||
id="template-timeout"
|
||||
name="timeout"
|
||||
type="number"
|
||||
min="0"
|
||||
label={i18n._(t`Timeout`)}
|
||||
tooltip={i18n._(t`The amount of time (in seconds) to run
|
||||
before the task is canceled. Defaults to 0 for no job
|
||||
timeout.`)}
|
||||
/>
|
||||
<Field name="diff_mode">
|
||||
{({ field, form }) => (
|
||||
<FormGroup
|
||||
fieldId="template-show-changes"
|
||||
label={i18n._(t`Show Changes`)}
|
||||
>
|
||||
<FieldTooltip
|
||||
content={i18n._(t`If enabled, show the changes made by
|
||||
Ansible tasks, where supported. This is equivalent
|
||||
to Ansible’s --diff mode.`)}
|
||||
/>
|
||||
<div>
|
||||
<Switch
|
||||
id="template-show-changes"
|
||||
label={field.value ? i18n._(t`On`) : i18n._(t`Off`)}
|
||||
isChecked={field.value}
|
||||
onChange={checked =>
|
||||
form.setFieldValue(field.name, checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
)}
|
||||
</Field>
|
||||
<FormFullWidthLayout>
|
||||
<Field name="instanceGroups">
|
||||
{({ field, form }) => (
|
||||
<InstanceGroupsLookup
|
||||
value={field.value}
|
||||
onChange={value =>
|
||||
form.setFieldValue(field.name, value)
|
||||
}
|
||||
tooltip={i18n._(t`Select the Instance Groups for this Organization
|
||||
to run on.`)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="job_tags">
|
||||
{({ field, form }) => (
|
||||
<FormGroup
|
||||
label={i18n._(t`Job Tags`)}
|
||||
fieldId="template-job-tags"
|
||||
>
|
||||
<FieldTooltip
|
||||
content={i18n._(t`Tags are useful when you have a large
|
||||
playbook, and you want to run a specific part of a
|
||||
play or task. Use commas to separate multiple tags.
|
||||
Refer to Ansible Tower documentation for details on
|
||||
the usage of tags.`)}
|
||||
/>
|
||||
<TagMultiSelect
|
||||
value={field.value}
|
||||
onChange={value =>
|
||||
form.setFieldValue(field.name, value)
|
||||
}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="skip_tags">
|
||||
{({ field, form }) => (
|
||||
<FormGroup
|
||||
label={i18n._(t`Skip Tags`)}
|
||||
fieldId="template-skip-tags"
|
||||
>
|
||||
<FieldTooltip
|
||||
content={i18n._(t`Skip tags are useful when you have a
|
||||
large playbook, and you want to skip specific parts of a
|
||||
play or task. Use commas to separate multiple tags. Refer
|
||||
to Ansible Tower documentation for details on the usage
|
||||
of tags.`)}
|
||||
/>
|
||||
<TagMultiSelect
|
||||
value={field.value}
|
||||
onChange={value =>
|
||||
form.setFieldValue(field.name, value)
|
||||
}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</Field>
|
||||
<FormGroup
|
||||
fieldId="template-option-checkboxes"
|
||||
label={i18n._(t`Options`)}
|
||||
>
|
||||
<FormCheckboxLayout>
|
||||
<CheckboxField
|
||||
id="option-privilege-escalation"
|
||||
name="become_enabled"
|
||||
label={i18n._(t`Privilege Escalation`)}
|
||||
tooltip={i18n._(t`If enabled, run this playbook as an
|
||||
administrator.`)}
|
||||
/>
|
||||
<Checkbox
|
||||
aria-label={i18n._(t`Provisioning Callbacks`)}
|
||||
label={
|
||||
<span>
|
||||
{i18n._(t`Provisioning Callbacks`)}
|
||||
|
||||
<FieldTooltip
|
||||
content={i18n._(t`Enables creation of a provisioning
|
||||
callback URL. Using the URL a host can contact BRAND_NAME
|
||||
and request a configuration update using this job
|
||||
template.`)}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
id="option-callbacks"
|
||||
isChecked={allowCallbacks}
|
||||
onChange={checked => {
|
||||
this.setState({ allowCallbacks: checked });
|
||||
}}
|
||||
/>
|
||||
<CheckboxField
|
||||
id="option-concurrent"
|
||||
name="allow_simultaneous"
|
||||
label={i18n._(t`Concurrent Jobs`)}
|
||||
tooltip={i18n._(t`If enabled, simultaneous runs of this job
|
||||
template will be allowed.`)}
|
||||
/>
|
||||
<CheckboxField
|
||||
id="option-fact-cache"
|
||||
name="use_fact_cache"
|
||||
label={i18n._(t`Fact Cache`)}
|
||||
tooltip={i18n._(t`If enabled, use cached facts if available
|
||||
and store discovered facts in the cache.`)}
|
||||
/>
|
||||
</FormCheckboxLayout>
|
||||
</FormGroup>
|
||||
</FormFullWidthLayout>
|
||||
{allowCallbacks && (
|
||||
<>
|
||||
{callbackUrl && (
|
||||
<FormGroup
|
||||
label={i18n._(t`Provisioning Callback URL`)}
|
||||
fieldId="template-callback-url"
|
||||
>
|
||||
<TextInput
|
||||
id="template-callback-url"
|
||||
isDisabled
|
||||
value={callbackUrl}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
<FormField
|
||||
id="template-host-config-key"
|
||||
name="host_config_key"
|
||||
label={i18n._(t`Host Config Key`)}
|
||||
validate={allowCallbacks ? required(null, i18n) : null}
|
||||
/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
)}
|
||||
</Field>
|
||||
</FormRow>
|
||||
<Field name="instanceGroups">
|
||||
{({ field, form }) => (
|
||||
<InstanceGroupsLookup
|
||||
css="margin-top: 20px"
|
||||
value={field.value}
|
||||
onChange={value => form.setFieldValue(field.name, value)}
|
||||
tooltip={i18n._(t`Select the Instance Groups for this Organization
|
||||
to run on.`)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="job_tags">
|
||||
{({ field, form }) => (
|
||||
<FormGroup
|
||||
label={i18n._(t`Job Tags`)}
|
||||
css="margin-top: 20px"
|
||||
fieldId="template-job-tags"
|
||||
>
|
||||
<FieldTooltip
|
||||
content={i18n._(t`Tags are useful when you have a large
|
||||
playbook, and you want to run a specific part of a
|
||||
play or task. Use commas to separate multiple tags.
|
||||
Refer to Ansible Tower documentation for details on
|
||||
the usage of tags.`)}
|
||||
/>
|
||||
<TagMultiSelect
|
||||
value={field.value}
|
||||
onChange={value => form.setFieldValue(field.name, value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="skip_tags">
|
||||
{({ field, form }) => (
|
||||
<FormGroup
|
||||
label={i18n._(t`Skip Tags`)}
|
||||
css="margin-top: 20px"
|
||||
fieldId="template-skip-tags"
|
||||
>
|
||||
<FieldTooltip
|
||||
content={i18n._(t`Skip tags are useful when you have a
|
||||
large playbook, and you want to skip specific parts of a
|
||||
play or task. Use commas to separate multiple tags. Refer
|
||||
to Ansible Tower documentation for details on the usage
|
||||
of tags.`)}
|
||||
/>
|
||||
<TagMultiSelect
|
||||
value={field.value}
|
||||
onChange={value => form.setFieldValue(field.name, value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</Field>
|
||||
<GridFormGroup
|
||||
fieldId="template-option-checkboxes"
|
||||
isInline
|
||||
label={i18n._(t`Options`)}
|
||||
css="margin-top: 20px"
|
||||
>
|
||||
<CheckboxField
|
||||
id="option-privilege-escalation"
|
||||
name="become_enabled"
|
||||
label={i18n._(t`Privilege Escalation`)}
|
||||
tooltip={i18n._(t`If enabled, run this playbook as an
|
||||
administrator.`)}
|
||||
/>
|
||||
<Checkbox
|
||||
aria-label={i18n._(t`Provisioning Callbacks`)}
|
||||
label={
|
||||
<span>
|
||||
{i18n._(t`Provisioning Callbacks`)}
|
||||
|
||||
<FieldTooltip
|
||||
content={i18n._(t`Enables creation of a provisioning
|
||||
callback URL. Using the URL a host can contact BRAND_NAME
|
||||
and request a configuration update using this job
|
||||
template.`)}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
id="option-callbacks"
|
||||
isChecked={allowCallbacks}
|
||||
onChange={checked => {
|
||||
this.setState({ allowCallbacks: checked });
|
||||
}}
|
||||
/>
|
||||
<CheckboxField
|
||||
id="option-concurrent"
|
||||
name="allow_simultaneous"
|
||||
label={i18n._(t`Concurrent Jobs`)}
|
||||
tooltip={i18n._(t`If enabled, simultaneous runs of this job
|
||||
template will be allowed.`)}
|
||||
/>
|
||||
<CheckboxField
|
||||
id="option-fact-cache"
|
||||
name="use_fact_cache"
|
||||
label={i18n._(t`Fact Cache`)}
|
||||
tooltip={i18n._(t`If enabled, use cached facts if available
|
||||
and store discovered facts in the cache.`)}
|
||||
/>
|
||||
</GridFormGroup>
|
||||
<div
|
||||
css={`
|
||||
${allowCallbacks ? '' : 'display: none'}
|
||||
margin-top: 20px;
|
||||
`}
|
||||
>
|
||||
<FormRow>
|
||||
{callbackUrl && (
|
||||
<FormGroup
|
||||
label={i18n._(t`Provisioning Callback URL`)}
|
||||
fieldId="template-callback-url"
|
||||
>
|
||||
<TextInput
|
||||
id="template-callback-url"
|
||||
isDisabled
|
||||
value={callbackUrl}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
<FormField
|
||||
id="template-host-config-key"
|
||||
name="host_config_key"
|
||||
label={i18n._(t`Host Config Key`)}
|
||||
validate={allowCallbacks ? required(null, i18n) : null}
|
||||
/>
|
||||
</FormRow>
|
||||
</div>
|
||||
</AdvancedFieldsWrapper>
|
||||
<FormSubmitError error={submitError} />
|
||||
<FormActionGroup onCancel={handleCancel} onSubmit={handleSubmit} />
|
||||
</>
|
||||
)}
|
||||
</FormColumnLayout>
|
||||
</AdvancedFieldsWrapper>
|
||||
</FormFullWidthLayout>
|
||||
<FormSubmitError error={submitError} />
|
||||
<FormActionGroup onCancel={handleCancel} onSubmit={handleSubmit} />
|
||||
</FormColumnLayout>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,16 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { Card as _Card, PageSection } from '@patternfly/react-core';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
import { CardBody } from '@components/Card';
|
||||
import UserForm from '../shared/UserForm';
|
||||
import { UsersAPI } from '@api';
|
||||
|
||||
const Card = styled(_Card)`
|
||||
--pf-c-card--child--PaddingLeft: 0;
|
||||
--pf-c-card--child--PaddingRight: 0;
|
||||
`;
|
||||
|
||||
function UserAdd() {
|
||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||
const history = useHistory();
|
||||
|
||||
@ -9,6 +9,7 @@ function UserEdit({ user, history }) {
|
||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||
|
||||
const handleSubmit = async values => {
|
||||
setFormSubmitError(null);
|
||||
try {
|
||||
await UsersAPI.update(user.id, values);
|
||||
history.push(`/users/${user.id}/details`);
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Formik, Field } from 'formik';
|
||||
import { Formik, useField } from 'formik';
|
||||
import { Form, FormGroup } from '@patternfly/react-core';
|
||||
import AnsibleSelect from '@components/AnsibleSelect';
|
||||
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
||||
@ -10,11 +10,11 @@ import FormField, {
|
||||
PasswordField,
|
||||
FormSubmitError,
|
||||
} from '@components/FormField';
|
||||
import FormRow from '@components/FormRow';
|
||||
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
|
||||
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 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 }) => {
|
||||
if (values.password !== values.confirm_password) {
|
||||
setErrors({
|
||||
@ -81,111 +175,14 @@ function UserForm({ user, handleCancel, handleSubmit, submitError, i18n }) {
|
||||
>
|
||||
{formik => (
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormRow>
|
||||
<FormField
|
||||
id="user-username"
|
||||
label={i18n._(t`Username`)}
|
||||
name="username"
|
||||
type="text"
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
<FormColumnLayout>
|
||||
<UserFormFields user={user} i18n={i18n} />
|
||||
<FormSubmitError error={submitError} />
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
<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} />
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
</FormColumnLayout>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user