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:
softwarefactory-project-zuul[bot] 2020-02-21 20:05:31 +00:00 committed by GitHub
commit 54ab671512
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 3062 additions and 1279 deletions

View File

@ -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,
});
}
}

View File

@ -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 = {

View File

@ -19,7 +19,7 @@ function ExpandingContainer({ isExpanded, children }) {
});
useEffect(() => {
setContentHeight(ref.current.scrollHeight);
}, [setContentHeight]);
}, [setContentHeight, children]);
const height = isExpanded ? contentHeight : '0';
return (
<Container

View File

@ -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>

View File

@ -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 {

View File

@ -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}
&nbsp;
{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}
&nbsp;
{tooltip && (
<Tooltip position="right" content={tooltip}>
<QuestionCircleIcon />
</Tooltip>
)}
</span>
}
id={id}
{...rest}
isChecked={field.value}
{...field}
onChange={(value, event) => {
field.onChange(event);
}}
/>
);
}
CheckboxField.propTypes = {

View File

@ -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,

View File

@ -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>
);
}

View 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;
}
`;

View File

@ -0,0 +1,6 @@
export {
FormColumnLayout,
FormFullWidthLayout,
FormCheckboxLayout,
SubFormLayout,
} from './FormLayout';

View File

@ -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>;
}

View File

@ -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);
});
});

View File

@ -1 +0,0 @@
export { default } from './FormRow';

View File

@ -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}
>

View File

@ -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="*"

View File

@ -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...'
);
});
});

View File

@ -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;

View File

@ -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');
});
});

View File

@ -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]} />;
}

View File

@ -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',

View File

@ -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;

View File

@ -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');
});
});

View File

@ -0,0 +1 @@
export { default } from './CredentialEdit';

View File

@ -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} />

View 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);

View File

@ -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();
});
});

View File

@ -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);

View File

@ -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"
/>
));

View File

@ -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);

View File

@ -0,0 +1,2 @@
export { default as ManualSubForm } from './ManualSubForm';
export { default as SourceControlSubForm } from './SourceControlSubForm';

View File

@ -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
}

View File

@ -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
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,

View File

@ -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 () => {

View File

@ -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();

View File

@ -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();

View File

@ -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,

View File

@ -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>

View File

@ -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);

View File

@ -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>
</>
);
};

View File

@ -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>
)
);

View File

@ -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';

View File

@ -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>

View File

@ -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>

View File

@ -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&#x2019s --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&#x2019s --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`)}
&nbsp;
<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`)}
&nbsp;
<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>
);
}

View File

@ -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();

View File

@ -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`);

View File

@ -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>