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
51 changed files with 3062 additions and 1279 deletions

View File

@@ -4,6 +4,14 @@ class Credentials extends Base {
constructor(http) { constructor(http) {
super(http); super(http);
this.baseUrl = '/api/v2/credentials/'; this.baseUrl = '/api/v2/credentials/';
this.readAccessList = this.readAccessList.bind(this);
}
readAccessList(id, params) {
return this.http.get(`${this.baseUrl}${id}/access_list/`, {
params,
});
} }
} }

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { string, bool } from 'prop-types'; import { string, bool } from 'prop-types';
import { Field, useFormikContext } from 'formik'; import { useField } from 'formik';
import { Split, SplitItem } from '@patternfly/react-core'; import { Split, SplitItem } from '@patternfly/react-core';
import { yamlToJson, jsonToYaml, isJson } from '@util/yaml'; import { yamlToJson, jsonToYaml, isJson } from '@util/yaml';
import CodeMirrorInput from './CodeMirrorInput'; import CodeMirrorInput from './CodeMirrorInput';
@@ -8,14 +8,11 @@ import YamlJsonToggle from './YamlJsonToggle';
import { JSON_MODE, YAML_MODE } from './constants'; import { JSON_MODE, YAML_MODE } from './constants';
function VariablesField({ id, name, label, readOnly }) { function VariablesField({ id, name, label, readOnly }) {
const { values } = useFormikContext(); const [field, meta, helpers] = useField(name);
const value = values[name]; const [mode, setMode] = useState(isJson(field.value) ? JSON_MODE : YAML_MODE);
const [mode, setMode] = useState(isJson(value) ? JSON_MODE : YAML_MODE);
return ( return (
<Field name={name}> <>
{({ field, form }) => (
<div className="pf-c-form__group">
<Split gutter="sm"> <Split gutter="sm">
<SplitItem> <SplitItem>
<label htmlFor={id} className="pf-c-form__label"> <label htmlFor={id} className="pf-c-form__label">
@@ -31,10 +28,10 @@ function VariablesField({ id, name, label, readOnly }) {
newMode === YAML_MODE newMode === YAML_MODE
? jsonToYaml(field.value) ? jsonToYaml(field.value)
: yamlToJson(field.value); : yamlToJson(field.value);
form.setFieldValue(name, newVal); helpers.setValue(newVal);
setMode(newMode); setMode(newMode);
} catch (err) { } catch (err) {
form.setFieldError(name, err.message); helpers.setError(err.message);
} }
}} }}
/> />
@@ -45,21 +42,16 @@ function VariablesField({ id, name, label, readOnly }) {
readOnly={readOnly} readOnly={readOnly}
{...field} {...field}
onChange={newVal => { onChange={newVal => {
form.setFieldValue(name, newVal); helpers.setValue(newVal);
}} }}
hasErrors={!!form.errors[field.name]} hasErrors={!!meta.error}
/> />
{form.errors[field.name] ? ( {meta.error ? (
<div <div className="pf-c-form__helper-text pf-m-error" aria-live="polite">
className="pf-c-form__helper-text pf-m-error" {meta.error}
aria-live="polite"
>
{form.errors[field.name]}
</div> </div>
) : null} ) : null}
</div> </>
)}
</Field>
); );
} }
VariablesField.propTypes = { VariablesField.propTypes = {

View File

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

View File

@@ -14,7 +14,7 @@ const DetailName = styled(({ fullWidth, ...props }) => (
`} `}
`; `;
const DetailValue = styled(({ fullWidth, ...props }) => ( const DetailValue = styled(({ fullWidth, isEncrypted, ...props }) => (
<TextListItem {...props} /> <TextListItem {...props} />
))` ))`
word-break: break-all; word-break: break-all;
@@ -23,6 +23,12 @@ const DetailValue = styled(({ fullWidth, ...props }) => (
` `
grid-column: 2 / -1; grid-column: 2 / -1;
`} `}
${props =>
props.isEncrypted &&
`
text-transform: uppercase
color: var(--pf-global--Color--400);
`}
`; `;
const Detail = ({ const Detail = ({
@@ -32,6 +38,7 @@ const Detail = ({
className, className,
dataCy, dataCy,
alwaysVisible, alwaysVisible,
isEncrypted,
}) => { }) => {
if (!value && typeof value !== 'number' && !alwaysVisible) { if (!value && typeof value !== 'number' && !alwaysVisible) {
return null; return null;
@@ -55,6 +62,7 @@ const Detail = ({
component={TextListItemVariants.dd} component={TextListItemVariants.dd}
fullWidth={fullWidth} fullWidth={fullWidth}
data-cy={valueCy} data-cy={valueCy}
isEncrypted={isEncrypted}
> >
{value} {value}
</DetailValue> </DetailValue>

View File

@@ -9,6 +9,8 @@ const ActionGroup = styled(PFActionGroup)`
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
--pf-c-form__group--m-action--MarginTop: 0; --pf-c-form__group--m-action--MarginTop: 0;
grid-column: 1 / -1;
margin-right: calc(var(--pf-c-form__actions--MarginRight) * -1);
.pf-c-form__actions { .pf-c-form__actions {
& > button { & > button {

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { string, func } from 'prop-types'; import { string, func } from 'prop-types';
import { Field } from 'formik'; import { useField } from 'formik';
import { Checkbox, Tooltip } from '@patternfly/react-core'; import { Checkbox, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -10,9 +10,8 @@ const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
`; `;
function CheckboxField({ id, name, label, tooltip, validate, ...rest }) { function CheckboxField({ id, name, label, tooltip, validate, ...rest }) {
const [field] = useField({ name, validate });
return ( return (
<Field name={name} validate={validate}>
{({ field }) => (
<Checkbox <Checkbox
aria-label={label} aria-label={label}
label={ label={
@@ -34,8 +33,6 @@ function CheckboxField({ id, name, label, tooltip, validate, ...rest }) {
field.onChange(event); field.onChange(event);
}} }}
/> />
)}
</Field>
); );
} }
CheckboxField.propTypes = { CheckboxField.propTypes = {

View File

@@ -1,7 +1,12 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Field } from 'formik'; import { useField } from 'formik';
import { FormGroup, TextInput, Tooltip } from '@patternfly/react-core'; import {
FormGroup,
TextInput,
TextArea,
Tooltip,
} from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -18,19 +23,48 @@ function FormField(props) {
tooltipMaxWidth, tooltipMaxWidth,
validate, validate,
isRequired, isRequired,
type,
...rest ...rest
} = props; } = props;
return ( const [field, meta] = useField({ name, validate });
<Field name={name} validate={validate}> const isValid = !(meta.touched && meta.error);
{({ field, form }) => {
const isValid =
form && (!form.touched[field.name] || !form.errors[field.name]);
return ( return (
<>
{(type === 'textarea' && (
<FormGroup <FormGroup
fieldId={id} fieldId={id}
helperTextInvalid={form.errors[field.name]} helperTextInvalid={meta.error}
isRequired={isRequired}
isValid={isValid}
label={label}
>
{tooltip && (
<Tooltip
content={tooltip}
maxWidth={tooltipMaxWidth}
position="right"
>
<QuestionCircleIcon />
</Tooltip>
)}
<TextArea
id={id}
isRequired={isRequired}
isValid={isValid}
resizeOrientation="vertical"
{...rest}
{...field}
onChange={(value, event) => {
field.onChange(event);
}}
/>
</FormGroup>
)) || (
<FormGroup
fieldId={id}
helperTextInvalid={meta.error}
isRequired={isRequired} isRequired={isRequired}
isValid={isValid} isValid={isValid}
label={label} label={label}
@@ -55,16 +89,15 @@ function FormField(props) {
}} }}
/> />
</FormGroup> </FormGroup>
); )}
}} </>
</Field>
); );
} }
FormField.propTypes = { FormField.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
label: PropTypes.string.isRequired, label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
type: PropTypes.string, type: PropTypes.string,
validate: PropTypes.func, validate: PropTypes.func,
isRequired: PropTypes.bool, isRequired: PropTypes.bool,

View File

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Field } from 'formik'; import { useField } from 'formik';
import { import {
Button, Button,
ButtonVariant, ButtonVariant,
@@ -16,29 +16,25 @@ import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
function PasswordField(props) { function PasswordField(props) {
const { id, name, label, validate, isRequired, i18n } = props; const { id, name, label, validate, isRequired, i18n } = props;
const [inputType, setInputType] = useState('password'); const [inputType, setInputType] = useState('password');
const [field, meta] = useField({ name, validate });
const isValid = !(meta.touched && meta.error);
const handlePasswordToggle = () => { const handlePasswordToggle = () => {
setInputType(inputType === 'text' ? 'password' : 'text'); setInputType(inputType === 'text' ? 'password' : 'text');
}; };
return (
<Field name={name} validate={validate}>
{({ field, form }) => {
const isValid =
form && (!form.touched[field.name] || !form.errors[field.name]);
return ( return (
<FormGroup <FormGroup
fieldId={id} fieldId={id}
helperTextInvalid={form.errors[field.name]} helperTextInvalid={meta.error}
isRequired={isRequired} isRequired={isRequired}
isValid={isValid} isValid={isValid}
label={label} label={label}
> >
<InputGroup> <InputGroup>
<Tooltip <Tooltip
content={ content={inputType === 'password' ? i18n._(t`Show`) : i18n._(t`Hide`)}
inputType === 'password' ? i18n._(t`Show`) : i18n._(t`Hide`)
}
> >
<Button <Button
variant={ButtonVariant.control} variant={ButtonVariant.control}
@@ -51,20 +47,19 @@ function PasswordField(props) {
</Tooltip> </Tooltip>
<TextInput <TextInput
id={id} id={id}
placeholder={field.value === '$encrypted$' ? 'ENCRYPTED' : undefined}
{...field}
value={field.value === '$encrypted$' ? '' : field.value}
isRequired={isRequired} isRequired={isRequired}
isValid={isValid} isValid={isValid}
type={inputType} type={inputType}
{...field} onChange={(_, event) => {
onChange={(value, event) => {
field.onChange(event); field.onChange(event);
}} }}
/> />
</InputGroup> </InputGroup>
</FormGroup> </FormGroup>
); );
}}
</Field>
);
} }
PasswordField.propTypes = { PasswordField.propTypes = {

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 { "componentStyle": ComponentStyle {
"componentId": "Detail__DetailValue-sc-16ypsyv-1", "componentId": "Detail__DetailValue-sc-16ypsyv-1",
"isStatic": false, "isStatic": false,
"lastClassName": "yHlYM", "lastClassName": "kCDjmZ",
"rules": Array [ "rules": Array [
"word-break:break-all;", "word-break:break-all;",
[Function], [Function],
" ",
[Function],
], ],
}, },
"displayName": "Detail__DetailValue", "displayName": "Detail__DetailValue",
@@ -502,18 +504,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
fullWidth={false} fullWidth={false}
> >
<Component <Component
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM" className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
component="dd" component="dd"
data-cy={null} data-cy={null}
fullWidth={false} fullWidth={false}
> >
<TextListItem <TextListItem
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM" className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
component="dd" component="dd"
data-cy={null} data-cy={null}
> >
<dd <dd
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM" className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
data-cy={null} data-cy={null}
data-pf-content={true} data-pf-content={true}
> >
@@ -672,10 +674,12 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"componentStyle": ComponentStyle { "componentStyle": ComponentStyle {
"componentId": "Detail__DetailValue-sc-16ypsyv-1", "componentId": "Detail__DetailValue-sc-16ypsyv-1",
"isStatic": false, "isStatic": false,
"lastClassName": "yHlYM", "lastClassName": "kCDjmZ",
"rules": Array [ "rules": Array [
"word-break:break-all;", "word-break:break-all;",
[Function], [Function],
" ",
[Function],
], ],
}, },
"displayName": "Detail__DetailValue", "displayName": "Detail__DetailValue",
@@ -692,18 +696,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
fullWidth={false} fullWidth={false}
> >
<Component <Component
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM" className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
component="dd" component="dd"
data-cy={null} data-cy={null}
fullWidth={false} fullWidth={false}
> >
<TextListItem <TextListItem
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM" className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
component="dd" component="dd"
data-cy={null} data-cy={null}
> >
<dd <dd
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM" className="Detail__DetailValue-sc-16ypsyv-1 kCDjmZ"
data-cy={null} data-cy={null}
data-pf-content={true} data-pf-content={true}
> >

View File

@@ -6,22 +6,28 @@ import {
Switch, Switch,
useParams, useParams,
useLocation, useLocation,
useRouteMatch,
Route, Route,
Redirect, Redirect,
Link, Link,
} from 'react-router-dom'; } from 'react-router-dom';
import { TabbedCardHeader } from '@components/Card'; import { TabbedCardHeader } from '@components/Card';
import CardCloseButton from '@components/CardCloseButton'; import CardCloseButton from '@components/CardCloseButton';
import { ResourceAccessList } from '@components/ResourceAccessList';
import ContentError from '@components/ContentError'; import ContentError from '@components/ContentError';
import RoutedTabs from '@components/RoutedTabs'; import RoutedTabs from '@components/RoutedTabs';
import CredentialDetail from './CredentialDetail'; import CredentialDetail from './CredentialDetail';
import CredentialEdit from './CredentialEdit';
import { CredentialsAPI } from '@api'; import { CredentialsAPI } from '@api';
function Credential({ i18n, setBreadcrumb }) { function Credential({ i18n, setBreadcrumb }) {
const [credential, setCredential] = useState(null); const [credential, setCredential] = useState(null);
const [contentError, setContentError] = useState(null); const [contentError, setContentError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(true); const [hasContentLoading, setHasContentLoading] = useState(true);
const location = useLocation(); const { pathname } = useLocation();
const match = useRouteMatch({
path: '/credentials/:id',
});
const { id } = useParams(); const { id } = useParams();
useEffect(() => { useEffect(() => {
@@ -37,18 +43,20 @@ function Credential({ i18n, setBreadcrumb }) {
} }
} }
fetchData(); fetchData();
}, [id, setBreadcrumb]); }, [id, pathname, setBreadcrumb]);
const tabsArray = [ const tabsArray = [
{ name: i18n._(t`Details`), link: `/credentials/${id}/details`, id: 0 }, { name: i18n._(t`Details`), link: `/credentials/${id}/details`, id: 0 },
{ name: i18n._(t`Access`), link: `/credentials/${id}/access`, id: 1 },
{
name: i18n._(t`Notifications`),
link: `/credentials/${id}/notifications`,
id: 2,
},
]; ];
if (credential && credential.organization) {
tabsArray.push({
name: i18n._(t`Access`),
link: `/credentials/${id}/access`,
id: 1,
});
}
let cardHeader = hasContentLoading ? null : ( let cardHeader = hasContentLoading ? null : (
<TabbedCardHeader> <TabbedCardHeader>
<RoutedTabs tabsArray={tabsArray} /> <RoutedTabs tabsArray={tabsArray} />
@@ -56,7 +64,7 @@ function Credential({ i18n, setBreadcrumb }) {
</TabbedCardHeader> </TabbedCardHeader>
); );
if (location.pathname.endsWith('edit') || location.pathname.endsWith('add')) { if (pathname.endsWith('edit') || pathname.endsWith('add')) {
cardHeader = null; cardHeader = null;
} }
@@ -87,11 +95,45 @@ function Credential({ i18n, setBreadcrumb }) {
to="/credentials/:id/details" to="/credentials/:id/details"
exact exact
/> />
{credential && ( {credential && [
<Route path="/credentials/:id/details"> <Route
<CredentialDetail credential={credential} /> key="details"
</Route> path="/credentials/:id/details"
render={() => <CredentialDetail credential={credential} />}
/>,
<Route
key="edit"
path="/credentials/:id/edit"
render={() => <CredentialEdit credential={credential} />}
/>,
credential.organization && (
<Route
key="access"
path="/credentials/:id/access"
render={() => (
<ResourceAccessList
resource={credential}
apiModel={CredentialsAPI}
/>
)} )}
/>
),
<Route
key="not-found"
path="*"
render={() =>
!hasContentLoading && (
<ContentError isNotFound>
{match.params.id && (
<Link to={`/credentials/${match.params.id}/details`}>
{i18n._(`View Credential Details`)}
</Link>
)}
</ContentError>
)
}
/>,
]}
<Route <Route
key="not-found" key="not-found"
path="*" path="*"

View File

@@ -3,31 +3,50 @@ import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { CredentialsAPI } from '@api'; import { CredentialsAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { mockCredentials } from './shared'; import mockCredential from './shared/data.credential.json';
import mockOrgCredential from './shared/data.orgCredential.json';
import Credential from './Credential'; import Credential from './Credential';
jest.mock('@api'); jest.mock('@api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useRouteMatch: () => ({
url: '/credentials/2',
params: { id: 2 },
}),
}));
CredentialsAPI.readDetail.mockResolvedValue({ CredentialsAPI.readDetail.mockResolvedValueOnce({
data: mockCredentials.results[0], data: mockCredential,
}); });
describe('<Credential />', () => { describe('<Credential />', () => {
let wrapper; let wrapper;
beforeEach(async () => { test('initially renders user-based credential succesfully', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />); wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 1);
}); });
test('initially renders succesfully', async () => { test('initially renders org-based credential succesfully', async () => {
expect(wrapper.find('Credential').length).toBe(1); CredentialsAPI.readDetail.mockResolvedValueOnce({
data: mockOrgCredential,
});
await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
// org-based credential detail needs access tab
await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 2);
}); });
test('should show content error when user attempts to navigate to erroneous route', async () => { test('should show content error when user attempts to navigate to erroneous route', async () => {
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: ['/credentials/1/foobar'], initialEntries: ['/credentials/2/foobar'],
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />, { wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />, {
@@ -38,8 +57,8 @@ describe('<Credential />', () => {
location: history.location, location: history.location,
match: { match: {
params: { id: 1 }, params: { id: 1 },
url: '/credentials/1/foobar', url: '/credentials/2/foobar',
path: '/credentials/1/foobar', path: '/credentials/2/foobar',
}, },
}, },
}, },
@@ -47,19 +66,5 @@ describe('<Credential />', () => {
}); });
}); });
await waitForElement(wrapper, 'ContentError', el => el.length === 1); await waitForElement(wrapper, 'ContentError', el => el.length === 1);
expect(wrapper.find('ContentError Title').text()).toEqual('Not Found');
});
test('should show content error if api throws an error', async () => {
CredentialsAPI.readDetail.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
expect(wrapper.find('ContentError Title').text()).toEqual(
'Something went wrong...'
);
}); });
}); });

View File

@@ -1,14 +1,84 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { Card, CardBody, PageSection } from '@patternfly/react-core'; import { useHistory } from 'react-router-dom';
import { PageSection, Card } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
function CredentialAdd() { import { CredentialTypesAPI, CredentialsAPI } from '@api';
import CredentialForm from '../shared/CredentialForm';
function CredentialAdd({ me }) {
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [credentialTypes, setCredentialTypes] = useState(null);
const history = useHistory();
useEffect(() => {
const loadData = async () => {
try {
const {
data: { results: loadedCredentialTypes },
} = await CredentialTypesAPI.read({ or__kind: ['scm', 'ssh'] });
setCredentialTypes(loadedCredentialTypes);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
};
loadData();
}, []);
const handleCancel = () => {
history.push('/credentials');
};
const handleSubmit = async values => {
const { organization, ...remainingValues } = values;
try {
const {
data: { id: credentialId },
} = await CredentialsAPI.create({
user: (me && me.id) || null,
organization: (organization && organization.id) || null,
...remainingValues,
});
const url = `/credentials/${credentialId}/details`;
history.push(`${url}`);
} catch (err) {
setError(err);
}
};
if (error) {
return ( return (
<PageSection> <PageSection>
<Card> <Card>
<CardBody>Coming soon :)</CardBody> <CardBody>
<ContentError error={error} />
</CardBody>
</Card>
</PageSection>
);
}
if (isLoading) {
return <ContentLoading />;
}
return (
<PageSection>
<Card>
<CardBody>
<CredentialForm
onCancel={handleCancel}
onSubmit={handleSubmit}
credentialTypes={credentialTypes}
/>
</CardBody>
</Card> </Card>
</PageSection> </PageSection>
); );
} }
export { CredentialAdd as _CredentialAdd };
export default CredentialAdd; export default CredentialAdd;

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); setHasContentLoading(false);
}; };
const renderDetail = ({ id, label, type, secret }) => { const renderDetail = ({ id, label, type }) => {
let detail; let detail;
if (type === 'boolean') { if (type === 'boolean') {
@@ -81,8 +81,16 @@ function CredentialDetail({ i18n, credential }) {
value={<List>{inputs[id] && <ListItem>{label}</ListItem>}</List>} value={<List>{inputs[id] && <ListItem>{label}</ListItem>}</List>}
/> />
); );
} else if (secret === true) { } else if (inputs[id] === '$encrypted$') {
detail = <Detail key={id} label={label} value={i18n._(t`Encrypted`)} />; const isEncrypted = true;
detail = (
<Detail
key={id}
label={label}
value={i18n._(t`Encrypted`)}
isEncrypted={isEncrypted}
/>
);
} else { } else {
detail = <Detail key={id} label={label} value={inputs[id]} />; detail = <Detail key={id} label={label} value={inputs[id]} />;
} }

View File

@@ -49,10 +49,6 @@ describe('<CredentialDetail />', () => {
mockCredential.summary_fields.credential_type.name mockCredential.summary_fields.credential_type.name
); );
expectDetailToMatch(wrapper, 'Username', mockCredential.inputs.username); expectDetailToMatch(wrapper, 'Username', mockCredential.inputs.username);
expectDetailToMatch(wrapper, 'Password', 'Encrypted');
expectDetailToMatch(wrapper, 'SSH Private Key', 'Encrypted');
expectDetailToMatch(wrapper, 'Signed SSH Certificate', 'Encrypted');
expectDetailToMatch(wrapper, 'Private Key Passphrase', 'Encrypted');
expectDetailToMatch( expectDetailToMatch(
wrapper, wrapper,
'Privilege Escalation Method', 'Privilege Escalation Method',

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 { Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Config } from '@contexts/Config';
import Breadcrumbs from '@components/Breadcrumbs'; import Breadcrumbs from '@components/Breadcrumbs';
import Credential from './Credential'; import Credential from './Credential';
import CredentialAdd from './CredentialAdd'; import CredentialAdd from './CredentialAdd';
@@ -24,7 +24,9 @@ function Credentials({ i18n }) {
'/credentials': i18n._(t`Credentials`), '/credentials': i18n._(t`Credentials`),
'/credentials/add': i18n._(t`Create New Credential`), '/credentials/add': i18n._(t`Create New Credential`),
[`/credentials/${credential.id}`]: `${credential.name}`, [`/credentials/${credential.id}`]: `${credential.name}`,
[`/credentials/${credential.id}/edit`]: i18n._(t`Edit Details`),
[`/credentials/${credential.id}/details`]: i18n._(t`Details`), [`/credentials/${credential.id}/details`]: i18n._(t`Details`),
[`/credentials/${credential.id}/access`]: i18n._(t`Access`),
}); });
}, },
[i18n] [i18n]
@@ -35,7 +37,7 @@ function Credentials({ i18n }) {
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<Switch> <Switch>
<Route path="/credentials/add"> <Route path="/credentials/add">
<CredentialAdd /> <Config>{({ me }) => <CredentialAdd me={me || {}} />}</Config>
</Route> </Route>
<Route path="/credentials/:id"> <Route path="/credentials/:id">
<Credential setBreadcrumb={buildBreadcrumbConfig} /> <Credential setBreadcrumb={buildBreadcrumbConfig} />

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,39 +2,34 @@ import React, { useState } from 'react';
import { func, shape } from 'prop-types'; import { func, shape } from 'prop-types';
import { useRouteMatch } from 'react-router-dom'; import { useRouteMatch } from 'react-router-dom';
import { Formik, Field } from 'formik'; import { Formik, useField } from 'formik';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Form } from '@patternfly/react-core'; import { Form } from '@patternfly/react-core';
import FormRow from '@components/FormRow';
import FormField, { FormSubmitError } from '@components/FormField'; import FormField, { FormSubmitError } from '@components/FormField';
import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
import { VariablesField } from '@components/CodeMirrorInput'; import { VariablesField } from '@components/CodeMirrorInput';
import { required } from '@util/validators'; import { required } from '@util/validators';
import { InventoryLookup } from '@components/Lookup'; import { InventoryLookup } from '@components/Lookup';
import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout';
function HostForm({ handleSubmit, handleCancel, host, submitError, i18n }) { function HostFormFields({ host, i18n }) {
const [inventory, setInventory] = useState( const [inventory, setInventory] = useState(
host ? host.summary_fields.inventory : '' host ? host.summary_fields.inventory : ''
); );
const hostAddMatch = useRouteMatch('/hosts/add'); const hostAddMatch = useRouteMatch('/hosts/add');
const inventoryFieldArr = useField({
name: 'inventory',
validate: required(i18n._(t`Select aå value for this field`), i18n),
});
const inventoryMeta = inventoryFieldArr[1];
const inventoryHelpers = inventoryFieldArr[2];
return ( return (
<Formik <>
initialValues={{
name: host.name,
description: host.description,
inventory: host.inventory || '',
variables: host.variables,
}}
onSubmit={handleSubmit}
>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormRow>
<FormField <FormField
id="host-name" id="host-name"
name="name" name="name"
@@ -50,46 +45,55 @@ function HostForm({ handleSubmit, handleCancel, host, submitError, i18n }) {
label={i18n._(t`Description`)} label={i18n._(t`Description`)}
/> />
{hostAddMatch && ( {hostAddMatch && (
<Field
name="inventory"
validate={required(
i18n._(t`Select a value for this field`),
i18n
)}
>
{({ form }) => (
<InventoryLookup <InventoryLookup
value={inventory} value={inventory}
onBlur={() => form.setFieldTouched('inventory')} onBlur={() => inventoryHelpers.setTouched()}
tooltip={i18n._( tooltip={i18n._(
t`Select the inventory that this host will belong to.` t`Select the inventory that this host will belong to.`
)} )}
isValid={!form.touched.inventory || !form.errors.inventory} isValid={!inventoryMeta.touched || !inventoryMeta.error}
helperTextInvalid={form.errors.inventory} helperTextInvalid={inventoryMeta.error}
onChange={value => { onChange={value => {
form.setFieldValue('inventory', value.id); inventoryHelpers.setValuealue(value.id);
setInventory(value); setInventory(value);
}} }}
required required
touched={form.touched.inventory} touched={inventoryMeta.touched}
error={form.errors.inventory} error={inventoryMeta.error}
/> />
)} )}
</Field> <FormFullWidthLayout>
)}
</FormRow>
<FormRow>
<VariablesField <VariablesField
id="host-variables" id="host-variables"
name="variables" name="variables"
label={i18n._(t`Variables`)} label={i18n._(t`Variables`)}
/> />
</FormRow> </FormFullWidthLayout>
</>
);
}
function HostForm({ handleSubmit, host, submitError, handleCancel, ...rest }) {
return (
<Formik
initialValues={{
name: host.name,
description: host.description,
inventory: host.inventory || '',
variables: host.variables,
}}
onSubmit={handleSubmit}
>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<HostFormFields host={host} {...rest} />
<FormSubmitError error={submitError} /> <FormSubmitError error={submitError} />
<FormActionGroup <FormActionGroup
onCancel={handleCancel} onCancel={handleCancel}
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
/> />
</FormColumnLayout>
</Form> </Form>
)} )}
</Formik> </Formik>

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Formik, Field } from 'formik'; import { Formik, useField } from 'formik';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { func, number, shape } from 'prop-types'; import { func, number, shape } from 'prop-types';
@@ -8,44 +8,26 @@ import { VariablesField } from '@components/CodeMirrorInput';
import { Form } from '@patternfly/react-core'; import { Form } from '@patternfly/react-core';
import FormField, { FormSubmitError } from '@components/FormField'; import FormField, { FormSubmitError } from '@components/FormField';
import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
import FormRow from '@components/FormRow';
import { required } from '@util/validators'; import { required } from '@util/validators';
import InstanceGroupsLookup from '@components/Lookup/InstanceGroupsLookup'; import InstanceGroupsLookup from '@components/Lookup/InstanceGroupsLookup';
import OrganizationLookup from '@components/Lookup/OrganizationLookup'; import OrganizationLookup from '@components/Lookup/OrganizationLookup';
import CredentialLookup from '@components/Lookup/CredentialLookup'; import CredentialLookup from '@components/Lookup/CredentialLookup';
import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout';
function InventoryForm({ function InventoryFormFields({ i18n, credentialTypeId }) {
inventory = {}, const [organizationField, organizationMeta, organizationHelpers] = useField({
i18n, name: 'organization',
onCancel, validate: required(i18n._(t`Select a value for this field`), i18n),
onSubmit, });
instanceGroups, const instanceGroupsFieldArr = useField('instanceGroups');
credentialTypeId, const instanceGroupsField = instanceGroupsFieldArr[0];
submitError, const instanceGroupsHelpers = instanceGroupsFieldArr[2];
}) {
const initialValues = { const insightsCredentialFieldArr = useField('insights_credential');
name: inventory.name || '', const insightsCredentialField = insightsCredentialFieldArr[0];
description: inventory.description || '', const insightsCredentialHelpers = insightsCredentialFieldArr[2];
variables: inventory.variables || '---',
organization:
(inventory.summary_fields && inventory.summary_fields.organization) ||
null,
instanceGroups: instanceGroups || [],
insights_credential:
(inventory.summary_fields &&
inventory.summary_fields.insights_credential) ||
null,
};
return ( return (
<Formik <>
initialValues={initialValues}
onSubmit={values => {
onSubmit(values);
}}
>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormRow>
<FormField <FormField
id="inventory-name" id="inventory-name"
label={i18n._(t`Name`)} label={i18n._(t`Name`)}
@@ -60,66 +42,31 @@ function InventoryForm({
name="description" name="description"
type="text" type="text"
/> />
<Field
id="inventory-organization"
label={i18n._(t`Organization`)}
name="organization"
validate={required(
i18n._(t`Select a value for this field`),
i18n
)}
>
{({ form, field }) => (
<OrganizationLookup <OrganizationLookup
helperTextInvalid={form.errors.organization} helperTextInvalid={organizationMeta.error}
isValid={ isValid={!organizationMeta.touched || !organizationMeta.error}
!form.touched.organization || !form.errors.organization onBlur={() => organizationHelpers.setTouched()}
}
onBlur={() => form.setFieldTouched('organization')}
onChange={value => { onChange={value => {
form.setFieldValue('organization', value); organizationHelpers.setValue(value);
}} }}
value={field.value} value={organizationField.value}
touched={form.touched.organization} touched={organizationMeta.touched}
error={form.errors.organization} error={organizationMeta.error}
required required
/> />
)}
</Field>
<Field
id="inventory-insights_credential"
label={i18n._(t`Insights Credential`)}
name="insights_credential"
>
{({ field, form }) => (
<CredentialLookup <CredentialLookup
label={i18n._(t`Insights Credential`)} label={i18n._(t`Insights Credential`)}
credentialTypeId={credentialTypeId} credentialTypeId={credentialTypeId}
onChange={value => onChange={value => insightsCredentialHelpers.setValue(value)}
form.setFieldValue('insights_credential', value) value={insightsCredentialField.value}
}
value={field.value}
/> />
)}
</Field>
</FormRow>
<FormRow>
<Field
id="inventory-instanceGroups"
label={i18n._(t`Instance Groups`)}
name="instanceGroups"
>
{({ field, form }) => (
<InstanceGroupsLookup <InstanceGroupsLookup
value={field.value} value={instanceGroupsField.value}
onChange={value => { onChange={value => {
form.setFieldValue('instanceGroups', value); instanceGroupsHelpers.setValue(value);
}} }}
/> />
)} <FormFullWidthLayout>
</Field>
</FormRow>
<FormRow>
<VariablesField <VariablesField
tooltip={i18n._( tooltip={i18n._(
t`Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Tower documentation for example syntax` t`Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Tower documentation for example syntax`
@@ -128,14 +75,50 @@ function InventoryForm({
name="variables" name="variables"
label={i18n._(t`Variables`)} label={i18n._(t`Variables`)}
/> />
</FormRow> </FormFullWidthLayout>
<FormRow> </>
);
}
function InventoryForm({
inventory = {},
onSubmit,
onCancel,
submitError,
instanceGroups,
...rest
}) {
const initialValues = {
name: inventory.name || '',
description: inventory.description || '',
variables: inventory.variables || '---',
organization:
(inventory.summary_fields && inventory.summary_fields.organization) ||
null,
instanceGroups: instanceGroups || [],
insights_credential:
(inventory.summary_fields &&
inventory.summary_fields.insights_credential) ||
null,
};
return (
<Formik
initialValues={initialValues}
onSubmit={values => {
onSubmit(values);
}}
>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<InventoryFormFields {...rest} />
<FormSubmitError error={submitError} /> <FormSubmitError error={submitError} />
<FormActionGroup <FormActionGroup
onCancel={onCancel} onCancel={onCancel}
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
/> />
</FormRow> </FormColumnLayout>
</Form> </Form>
)} )}
</Formik> </Formik>

View File

@@ -6,11 +6,11 @@ import { Form, Card } from '@patternfly/react-core';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { CardBody } from '@components/Card'; import { CardBody } from '@components/Card';
import FormRow from '@components/FormRow';
import FormField from '@components/FormField'; import FormField from '@components/FormField';
import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
import { VariablesField } from '@components/CodeMirrorInput'; import { VariablesField } from '@components/CodeMirrorInput';
import { required } from '@util/validators'; import { required } from '@util/validators';
import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout';
function InventoryGroupForm({ function InventoryGroupForm({
i18n, i18n,
@@ -31,7 +31,7 @@ function InventoryGroupForm({
<Formik initialValues={initialValues} onSubmit={handleSubmit}> <Formik initialValues={initialValues} onSubmit={handleSubmit}>
{formik => ( {formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}> <Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormRow css="grid-template-columns: repeat(auto-fit, minmax(300px, 500px));"> <FormColumnLayout>
<FormField <FormField
id="inventoryGroup-name" id="inventoryGroup-name"
name="name" name="name"
@@ -46,19 +46,19 @@ function InventoryGroupForm({
type="text" type="text"
label={i18n._(t`Description`)} label={i18n._(t`Description`)}
/> />
</FormRow> <FormFullWidthLayout>
<FormRow>
<VariablesField <VariablesField
id="host-variables" id="host-variables"
name="variables" name="variables"
label={i18n._(t`Variables`)} label={i18n._(t`Variables`)}
/> />
</FormRow> </FormFullWidthLayout>
<FormActionGroup <FormActionGroup
onCancel={handleCancel} onCancel={handleCancel}
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
/> />
{error ? <div>error</div> : null} {error ? <div>error</div> : null}
</FormColumnLayout>
</Form> </Form>
)} )}
</Formik> </Formik>

View File

@@ -1,7 +1,7 @@
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { Formik, Field } from 'formik'; import { Formik, useField } from 'formik';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Form, FormGroup } from '@patternfly/react-core'; import { Form, FormGroup } from '@patternfly/react-core';
@@ -11,27 +11,93 @@ import { ConfigContext } from '@contexts/Config';
import AnsibleSelect from '@components/AnsibleSelect'; import AnsibleSelect from '@components/AnsibleSelect';
import ContentError from '@components/ContentError'; import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading'; import ContentLoading from '@components/ContentLoading';
import FormRow from '@components/FormRow';
import FormField, { FormSubmitError } from '@components/FormField'; import FormField, { FormSubmitError } from '@components/FormField';
import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
import { InstanceGroupsLookup } from '@components/Lookup/'; import { InstanceGroupsLookup } from '@components/Lookup/';
import { getAddedAndRemoved } from '@util/lists'; import { getAddedAndRemoved } from '@util/lists';
import { required, minMaxValue } from '@util/validators'; import { required, minMaxValue } from '@util/validators';
import { FormColumnLayout } from '@components/FormLayout';
function OrganizationForm({ function OrganizationFormFields({
organization,
i18n, i18n,
me, me,
onCancel, instanceGroups,
onSubmit, setInstanceGroups,
submitError,
}) { }) {
const [venvField] = useField('custom_virtualenv');
const defaultVenv = { const defaultVenv = {
label: i18n._(t`Use Default Ansible Environment`), label: i18n._(t`Use Default Ansible Environment`),
value: '/venv/ansible/', value: '/venv/ansible/',
key: 'default', key: 'default',
}; };
const { custom_virtualenvs } = useContext(ConfigContext); const { custom_virtualenvs } = useContext(ConfigContext);
return (
<>
<FormField
id="org-name"
name="name"
type="text"
label={i18n._(t`Name`)}
validate={required(null, i18n)}
isRequired
/>
<FormField
id="org-description"
name="description"
type="text"
label={i18n._(t`Description`)}
/>
<FormField
id="org-max_hosts"
name="max_hosts"
type="number"
label={i18n._(t`Max Hosts`)}
tooltip={i18n._(
t`The maximum number of hosts allowed to be managed by this organization.
Value defaults to 0 which means no limit. Refer to the Ansible
documentation for more details.`
)}
validate={minMaxValue(0, Number.MAX_SAFE_INTEGER, i18n)}
me={me || {}}
isDisabled={!me.is_superuser}
/>
{custom_virtualenvs && custom_virtualenvs.length > 1 && (
<FormGroup
fieldId="org-custom-virtualenv"
label={i18n._(t`Ansible Environment`)}
>
<AnsibleSelect
id="org-custom-virtualenv"
data={[
defaultVenv,
...custom_virtualenvs
.filter(value => value !== defaultVenv.value)
.map(value => ({ value, label: value, key: value })),
]}
{...venvField}
/>
</FormGroup>
)}
<InstanceGroupsLookup
value={instanceGroups}
onChange={setInstanceGroups}
tooltip={i18n._(
t`Select the Instance Groups for this Organization to run on.`
)}
/>
</>
);
}
function OrganizationForm({
organization,
onCancel,
onSubmit,
submitError,
...rest
}) {
const [contentError, setContentError] = useState(null); const [contentError, setContentError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(true); const [hasContentLoading, setHasContentLoading] = useState(true);
const [initialInstanceGroups, setInitialInstanceGroups] = useState([]); const [initialInstanceGroups, setInitialInstanceGroups] = useState([]);
@@ -100,79 +166,24 @@ function OrganizationForm({
> >
{formik => ( {formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}> <Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormRow> <FormColumnLayout>
<FormField <OrganizationFormFields
id="org-name" instanceGroups={instanceGroups}
name="name" setInstanceGroups={setInstanceGroups}
type="text" {...rest}
label={i18n._(t`Name`)}
validate={required(null, i18n)}
isRequired
/>
<FormField
id="org-description"
name="description"
type="text"
label={i18n._(t`Description`)}
/>
<FormField
id="org-max_hosts"
name="max_hosts"
type="number"
label={i18n._(t`Max Hosts`)}
tooltip={i18n._(
t`The maximum number of hosts allowed to be managed by this organization.
Value defaults to 0 which means no limit. Refer to the Ansible
documentation for more details.`
)}
validate={minMaxValue(0, Number.MAX_SAFE_INTEGER, i18n)}
me={me || {}}
isDisabled={!me.is_superuser}
/>
{custom_virtualenvs && custom_virtualenvs.length > 1 && (
<Field name="custom_virtualenv">
{({ field }) => (
<FormGroup
fieldId="org-custom-virtualenv"
label={i18n._(t`Ansible Environment`)}
>
<AnsibleSelect
id="org-custom-virtualenv"
data={[
defaultVenv,
...custom_virtualenvs
.filter(value => value !== defaultVenv.value)
.map(value => ({ value, label: value, key: value })),
]}
{...field}
/>
</FormGroup>
)}
</Field>
)}
</FormRow>
<InstanceGroupsLookup
value={instanceGroups}
onChange={setInstanceGroups}
tooltip={i18n._(
t`Select the Instance Groups for this Organization to run on.`
)}
/> />
<FormSubmitError error={submitError} /> <FormSubmitError error={submitError} />
<FormActionGroup <FormActionGroup
onCancel={handleCancel} onCancel={handleCancel}
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
/> />
</FormColumnLayout>
</Form> </Form>
)} )}
</Formik> </Formik>
); );
} }
FormField.propTypes = {
label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
};
OrganizationForm.propTypes = { OrganizationForm.propTypes = {
organization: PropTypes.shape(), organization: PropTypes.shape(),
onSubmit: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired,

View File

@@ -121,6 +121,11 @@ describe('<OrganizationForm />', () => {
}); });
test('changing inputs and saving triggers expected callback', async () => { test('changing inputs and saving triggers expected callback', async () => {
OrganizationsAPI.readInstanceGroups.mockReturnValue({
data: {
results: mockInstanceGroups,
},
});
let wrapper; let wrapper;
const onSubmit = jest.fn(); const onSubmit = jest.fn();
await act(async () => { await act(async () => {

View File

@@ -1,16 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import styled from 'styled-components'; import { Card, PageSection } from '@patternfly/react-core';
import { Card as _Card, PageSection } from '@patternfly/react-core';
import { CardBody } from '@components/Card'; import { CardBody } from '@components/Card';
import ProjectForm from '../shared/ProjectForm'; import ProjectForm from '../shared/ProjectForm';
import { ProjectsAPI } from '@api'; import { ProjectsAPI } from '@api';
const Card = styled(_Card)`
--pf-c-card--child--PaddingLeft: 0;
--pf-c-card--child--PaddingRight: 0;
`;
function ProjectAdd() { function ProjectAdd() {
const [formSubmitError, setFormSubmitError] = useState(null); const [formSubmitError, setFormSubmitError] = useState(null);
const history = useHistory(); const history = useHistory();

View File

@@ -1,18 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import styled from 'styled-components'; import { Card } from '@patternfly/react-core';
import { Card as _Card } from '@patternfly/react-core';
import { CardBody } from '@components/Card'; import { CardBody } from '@components/Card';
import ProjectForm from '../shared/ProjectForm'; import ProjectForm from '../shared/ProjectForm';
import { ProjectsAPI } from '@api'; import { ProjectsAPI } from '@api';
// TODO: we are doing this in multiple add/edit screens -- move to
// common component?
const Card = styled(_Card)`
--pf-c-card--child--PaddingLeft: 0;
--pf-c-card--child--PaddingRight: 0;
`;
function ProjectEdit({ project }) { function ProjectEdit({ project }) {
const [formSubmitError, setFormSubmitError] = useState(null); const [formSubmitError, setFormSubmitError] = useState(null);
const history = useHistory(); const history = useHistory();

View File

@@ -17,7 +17,7 @@ describe('<ProjectEdit />', () => {
scm_url: 'https://foo.bar', scm_url: 'https://foo.bar',
scm_clean: true, scm_clean: true,
credential: 100, credential: 100,
local_path: '', local_path: 'bar',
organization: 2, organization: 2,
scm_update_on_launch: true, scm_update_on_launch: true,
scm_update_cache_timeout: 3, scm_update_cache_timeout: 3,

View File

@@ -3,9 +3,9 @@ import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Formik, Field } from 'formik'; import { Formik, useField } from 'formik';
import { Config } from '@contexts/Config'; import { Config } from '@contexts/Config';
import { Form, FormGroup } from '@patternfly/react-core'; import { Form, FormGroup, Title } from '@patternfly/react-core';
import AnsibleSelect from '@components/AnsibleSelect'; import AnsibleSelect from '@components/AnsibleSelect';
import ContentError from '@components/ContentError'; import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading'; import ContentLoading from '@components/ContentLoading';
@@ -14,27 +14,18 @@ import FormField, {
FieldTooltip, FieldTooltip,
FormSubmitError, FormSubmitError,
} from '@components/FormField'; } from '@components/FormField';
import FormRow from '@components/FormRow';
import OrganizationLookup from '@components/Lookup/OrganizationLookup'; import OrganizationLookup from '@components/Lookup/OrganizationLookup';
import { CredentialTypesAPI, ProjectsAPI } from '@api'; import { CredentialTypesAPI, ProjectsAPI } from '@api';
import { required } from '@util/validators'; import { required } from '@util/validators';
import styled from 'styled-components'; import { FormColumnLayout, SubFormLayout } from '@components/FormLayout';
import { import {
GitSubForm, GitSubForm,
HgSubForm, HgSubForm,
SvnSubForm, SvnSubForm,
InsightsSubForm, InsightsSubForm,
SubFormTitle,
ManualSubForm, ManualSubForm,
} from './ProjectSubForms'; } from './ProjectSubForms';
const ScmTypeFormRow = styled(FormRow)`
background-color: #f5f5f5;
grid-column: 1 / -1;
margin: 0 -24px;
padding: 24px;
`;
const fetchCredentials = async credential => { const fetchCredentials = async credential => {
const [ const [
{ {
@@ -73,14 +64,236 @@ const fetchCredentials = async credential => {
}; };
}; };
function ProjectForm({ project, submitError, ...props }) { function ProjectFormFields({
const { i18n, handleCancel, handleSubmit } = props; project_base_dir,
project_local_paths,
formik,
i18n,
setCredentials,
credentials,
scmTypeOptions,
setScmSubFormState,
scmSubFormState,
setOrganization,
organization,
}) {
const scmFormFields = {
scm_url: '',
scm_branch: '',
scm_refspec: '',
credential: '',
scm_clean: false,
scm_delete_on_update: false,
scm_update_on_launch: false,
allow_override: false,
scm_update_cache_timeout: 0,
};
const [scmTypeField, scmTypeMeta, scmTypeHelpers] = useField({
name: 'scm_type',
validate: required(i18n._(t`Set a value for this field`), i18n),
});
const [venvField] = useField('custom_virtualenv');
const orgFieldArr = useField({
name: 'organization',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const organizationMeta = orgFieldArr[1];
const organizationHelpers = orgFieldArr[2];
/* Save current scm subform field values to state */
const saveSubFormState = form => {
const currentScmFormFields = { ...scmFormFields };
Object.keys(currentScmFormFields).forEach(label => {
currentScmFormFields[label] = form.values[label];
});
setScmSubFormState(currentScmFormFields);
};
/**
* If scm type is !== the initial scm type value,
* reset scm subform field values to defaults.
* If scm type is === the initial scm type value,
* reset scm subform field values to scmSubFormState.
*/
const resetScmTypeFields = (value, form) => {
if (form.values.scm_type === form.initialValues.scm_type) {
saveSubFormState(formik);
}
Object.keys(scmFormFields).forEach(label => {
if (value === form.initialValues.scm_type) {
form.setFieldValue(label, scmSubFormState[label]);
} else {
form.setFieldValue(label, scmFormFields[label]);
}
form.setFieldTouched(label, false);
});
};
const handleCredentialSelection = (type, value) => {
setCredentials({
...credentials,
[type]: {
...credentials[type],
value,
},
});
};
return (
<>
<FormField
id="project-name"
label={i18n._(t`Name`)}
name="name"
type="text"
validate={required(null, i18n)}
isRequired
/>
<FormField
id="project-description"
label={i18n._(t`Description`)}
name="description"
type="text"
/>
<OrganizationLookup
helperTextInvalid={organizationMeta.error}
isValid={!organizationMeta.touched || !organizationMeta.error}
onBlur={() => organizationHelpers.setTouched()}
onChange={value => {
organizationHelpers.setValue(value.id);
setOrganization(value);
}}
value={organization}
required
/>
<FormGroup
fieldId="project-scm-type"
helperTextInvalid={scmTypeMeta.error}
isRequired
isValid={!scmTypeMeta.touched || !scmTypeMeta.error}
label={i18n._(t`SCM Type`)}
>
<AnsibleSelect
{...scmTypeField}
id="scm_type"
data={[
{
value: '',
key: '',
label: i18n._(t`Choose an SCM Type`),
isDisabled: true,
},
...scmTypeOptions.map(([value, label]) => {
if (label === 'Manual') {
value = 'manual';
}
return {
label,
value,
key: value,
};
}),
]}
onChange={(event, value) => {
scmTypeHelpers.setValue(value);
resetScmTypeFields(value, formik);
}}
/>
</FormGroup>
{formik.values.scm_type !== '' && (
<SubFormLayout>
<Title size="md">{i18n._(t`Type Details`)}</Title>
<FormColumnLayout>
{
{
manual: (
<ManualSubForm
localPath={formik.initialValues.local_path}
project_base_dir={project_base_dir}
project_local_paths={project_local_paths}
/>
),
git: (
<GitSubForm
credential={credentials.scm}
onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/>
),
hg: (
<HgSubForm
credential={credentials.scm}
onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/>
),
svn: (
<SvnSubForm
credential={credentials.scm}
onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/>
),
insights: (
<InsightsSubForm
credential={credentials.insights}
onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/>
),
}[formik.values.scm_type]
}
</FormColumnLayout>
</SubFormLayout>
)}
<Config>
{({ custom_virtualenvs }) =>
custom_virtualenvs &&
custom_virtualenvs.length > 1 && (
<FormGroup
fieldId="project-custom-virtualenv"
label={i18n._(t`Ansible Environment`)}
>
<FieldTooltip
content={i18n._(t`Select the playbook to be executed by
this job.`)}
/>
<AnsibleSelect
id="project-custom-virtualenv"
data={[
{
label: i18n._(t`Use Default Ansible Environment`),
value: '/venv/ansible/',
key: 'default',
},
...custom_virtualenvs
.filter(datum => datum !== '/venv/ansible/')
.map(datum => ({
label: datum,
value: datum,
key: datum,
})),
]}
{...venvField}
/>
</FormGroup>
)
}
</Config>
</>
);
}
function ProjectForm({ i18n, project, submitError, ...props }) {
const { handleCancel, handleSubmit } = props;
const { summary_fields = {} } = project; const { summary_fields = {} } = project;
const [contentError, setContentError] = useState(null); const [contentError, setContentError] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [organization, setOrganization] = useState( const [organization, setOrganization] = useState(null);
summary_fields.organization || null
);
const [scmSubFormState, setScmSubFormState] = useState(null); const [scmSubFormState, setScmSubFormState] = useState(null);
const [scmTypeOptions, setScmTypeOptions] = useState(null); const [scmTypeOptions, setScmTypeOptions] = useState(null);
const [credentials, setCredentials] = useState({ const [credentials, setCredentials] = useState({
@@ -114,60 +327,6 @@ function ProjectForm({ project, submitError, ...props }) {
fetchData(); fetchData();
}, [summary_fields.credential]); }, [summary_fields.credential]);
const scmFormFields = {
scm_url: '',
scm_branch: '',
scm_refspec: '',
credential: '',
scm_clean: false,
scm_delete_on_update: false,
scm_update_on_launch: false,
allow_override: false,
scm_update_cache_timeout: 0,
};
/* Save current scm subform field values to state */
const saveSubFormState = form => {
const currentScmFormFields = { ...scmFormFields };
Object.keys(currentScmFormFields).forEach(label => {
currentScmFormFields[label] = form.values[label];
});
setScmSubFormState(currentScmFormFields);
};
/**
* If scm type is !== the initial scm type value,
* reset scm subform field values to defaults.
* If scm type is === the initial scm type value,
* reset scm subform field values to scmSubFormState.
*/
const resetScmTypeFields = (value, form) => {
if (form.values.scm_type === form.initialValues.scm_type) {
saveSubFormState(form);
}
Object.keys(scmFormFields).forEach(label => {
if (value === form.initialValues.scm_type) {
form.setFieldValue(label, scmSubFormState[label]);
} else {
form.setFieldValue(label, scmFormFields[label]);
}
form.setFieldTouched(label, false);
});
};
const handleCredentialSelection = (type, value) => {
setCredentials({
...credentials,
[type]: {
...credentials[type],
value,
},
});
};
if (isLoading) { if (isLoading) {
return <ContentLoading />; return <ContentLoading />;
} }
@@ -206,193 +365,27 @@ function ProjectForm({ project, submitError, ...props }) {
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
{formik => ( {formik => (
<Form <Form autoComplete="off" onSubmit={formik.handleSubmit}>
autoComplete="off" <FormColumnLayout>
onSubmit={formik.handleSubmit} <ProjectFormFields
css="padding: 0 24px"
>
<FormRow>
<FormField
id="project-name"
label={i18n._(t`Name`)}
name="name"
type="text"
validate={required(null, i18n)}
isRequired
/>
<FormField
id="project-description"
label={i18n._(t`Description`)}
name="description"
type="text"
/>
<Field
name="organization"
validate={required(
i18n._(t`Select a value for this field`),
i18n
)}
>
{({ form }) => (
<OrganizationLookup
helperTextInvalid={form.errors.organization}
isValid={
!form.touched.organization || !form.errors.organization
}
onBlur={() => form.setFieldTouched('organization')}
onChange={value => {
form.setFieldValue('organization', value.id);
setOrganization(value);
}}
value={organization}
required
/>
)}
</Field>
<Field
name="scm_type"
validate={required(
i18n._(t`Select a value for this field`),
i18n
)}
>
{({ field, form }) => (
<FormGroup
fieldId="project-scm-type"
helperTextInvalid={form.errors.scm_type}
isRequired
isValid={!form.touched.scm_type || !form.errors.scm_type}
label={i18n._(t`SCM Type`)}
>
<AnsibleSelect
{...field}
id="scm_type"
data={[
{
value: '',
key: '',
label: i18n._(t`Choose an SCM Type`),
isDisabled: true,
},
...scmTypeOptions.map(([value, label]) => {
if (label === 'Manual') {
value = 'manual';
}
return {
label,
value,
key: value,
};
}),
]}
onChange={(event, value) => {
form.setFieldValue('scm_type', value);
resetScmTypeFields(value, form);
}}
/>
</FormGroup>
)}
</Field>
{formik.values.scm_type !== '' && (
<ScmTypeFormRow>
<SubFormTitle size="md">
{i18n._(t`Type Details`)}
</SubFormTitle>
{
{
manual: (
<ManualSubForm
localPath={formik.initialValues.local_path}
project_base_dir={project_base_dir} project_base_dir={project_base_dir}
project_local_paths={project_local_paths} project_local_paths={project_local_paths}
formik={formik}
i18n={i18n}
setCredentials={setCredentials}
credentials={credentials}
scmTypeOptions={scmTypeOptions}
setScmSubFormState={setScmSubFormState}
scmSubFormState={scmSubFormState}
setOrganization={setOrganization}
organization={organization}
/> />
),
git: (
<GitSubForm
credential={credentials.scm}
onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={
formik.values.scm_update_on_launch
}
/>
),
hg: (
<HgSubForm
credential={credentials.scm}
onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={
formik.values.scm_update_on_launch
}
/>
),
svn: (
<SvnSubForm
credential={credentials.scm}
onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={
formik.values.scm_update_on_launch
}
/>
),
insights: (
<InsightsSubForm
credential={credentials.insights}
onCredentialSelection={handleCredentialSelection}
scmUpdateOnLaunch={
formik.values.scm_update_on_launch
}
/>
),
}[formik.values.scm_type]
}
</ScmTypeFormRow>
)}
<Config>
{({ custom_virtualenvs }) =>
custom_virtualenvs &&
custom_virtualenvs.length > 1 && (
<Field name="custom_virtualenv">
{({ field }) => (
<FormGroup
fieldId="project-custom-virtualenv"
label={i18n._(t`Ansible Environment`)}
>
<FieldTooltip
content={i18n._(t`Select the playbook to be executed by
this job.`)}
/>
<AnsibleSelect
id="project-custom-virtualenv"
data={[
{
label: i18n._(
t`Use Default Ansible Environment`
),
value: '/venv/ansible/',
key: 'default',
},
...custom_virtualenvs
.filter(datum => datum !== '/venv/ansible/')
.map(datum => ({
label: datum,
value: datum,
key: datum,
})),
]}
{...field}
/>
</FormGroup>
)}
</Field>
)
}
</Config>
</FormRow>
<FormSubmitError error={submitError} /> <FormSubmitError error={submitError} />
<FormActionGroup <FormActionGroup
onCancel={handleCancel} onCancel={handleCancel}
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
/> />
</FormColumnLayout>
</Form> </Form>
)} )}
</Formik> </Formik>

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Field } from 'formik'; import { useField } from 'formik';
import CredentialLookup from '@components/Lookup/CredentialLookup'; import CredentialLookup from '@components/Lookup/CredentialLookup';
import { required } from '@util/validators'; import { required } from '@util/validators';
import { ScmTypeOptions } from './SharedFields'; import { ScmTypeOptions } from './SharedFields';
@@ -11,30 +11,32 @@ const InsightsSubForm = ({
credential, credential,
onCredentialSelection, onCredentialSelection,
scmUpdateOnLaunch, scmUpdateOnLaunch,
}) => ( }) => {
const credFieldArr = useField({
name: 'credential',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const credMeta = credFieldArr[1];
const credHelpers = credFieldArr[2];
return (
<> <>
<Field
name="credential"
validate={required(i18n._(t`Select a value for this field`), i18n)}
>
{({ form }) => (
<CredentialLookup <CredentialLookup
credentialTypeId={credential.typeId} credentialTypeId={credential.typeId}
label={i18n._(t`Insights Credential`)} label={i18n._(t`Insights Credential`)}
helperTextInvalid={form.errors.credential} helperTextInvalid={credMeta.error}
isValid={!form.touched.credential || !form.errors.credential} isValid={!credMeta.touched || !credMeta.error}
onBlur={() => form.setFieldTouched('credential')} onBlur={() => credHelpers.setTouched()}
onChange={value => { onChange={value => {
onCredentialSelection('insights', value); onCredentialSelection('insights', value);
form.setFieldValue('credential', value.id); credHelpers.setValue(value.id);
}} }}
value={credential.value} value={credential.value}
required required
/> />
)}
</Field>
<ScmTypeOptions hideAllowOverride scmUpdateOnLaunch={scmUpdateOnLaunch} /> <ScmTypeOptions hideAllowOverride scmUpdateOnLaunch={scmUpdateOnLaunch} />
</> </>
); );
};
export default withI18n()(InsightsSubForm); export default withI18n()(InsightsSubForm);

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Field } from 'formik'; import { useField } from 'formik';
import { required } from '@util/validators'; import { required } from '@util/validators';
import AnsibleSelect from '@components/AnsibleSelect'; import AnsibleSelect from '@components/AnsibleSelect';
import FormField, { FieldTooltip } from '@components/FormField'; import FormField, { FieldTooltip } from '@components/FormField';
@@ -34,6 +34,10 @@ const ManualSubForm = ({
label: path, label: path,
})), })),
]; ];
const [pathField, pathMeta, pathHelpers] = useField({
name: 'local_path',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
return ( return (
<> <>
@@ -72,17 +76,11 @@ const ManualSubForm = ({
</span> </span>
} }
/> />
{options.length !== 1 && (
<Field
name="local_path"
validate={required(i18n._(t`Select a value for this field`), i18n)}
>
{({ field, form }) => (
<FormGroup <FormGroup
fieldId="project-local-path" fieldId="project-local-path"
helperTextInvalid={form.errors.local_path} helperTextInvalid={pathMeta.error}
isRequired isRequired
isValid={!form.touched.local_path || !form.errors.local_path} isValid={!pathMeta.touched || !pathMeta.error}
label={i18n._(t`Playbook Directory`)} label={i18n._(t`Playbook Directory`)}
> >
<FieldTooltip <FieldTooltip
@@ -91,17 +89,14 @@ const ManualSubForm = ({
directory provide the full path used to locate playbooks.`)} directory provide the full path used to locate playbooks.`)}
/> />
<AnsibleSelect <AnsibleSelect
{...field} {...pathField}
id="local_path" id="local_path"
data={options} data={options}
onChange={(event, value) => { onChange={(event, value) => {
form.setFieldValue('local_path', value); pathHelpers.setValue(value);
}} }}
/> />
</FormGroup> </FormGroup>
)}
</Field>
)}
</> </>
); );
}; };

View File

@@ -1,18 +1,15 @@
import React from 'react'; import React from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Field } from 'formik'; import { useField } from 'formik';
import CredentialLookup from '@components/Lookup/CredentialLookup'; import CredentialLookup from '@components/Lookup/CredentialLookup';
import FormField, { CheckboxField } from '@components/FormField'; import FormField, { CheckboxField } from '@components/FormField';
import { required } from '@util/validators'; import { required } from '@util/validators';
import FormRow from '@components/FormRow';
import { FormGroup, Title } from '@patternfly/react-core'; import { FormGroup, Title } from '@patternfly/react-core';
import styled from 'styled-components'; import {
FormCheckboxLayout,
export const SubFormTitle = styled(Title)` FormFullWidthLayout,
--pf-c-title--m-md--FontWeight: 700; } from '@components/FormLayout';
grid-column: 1 / -1;
`;
export const UrlFormField = withI18n()(({ i18n, tooltip }) => ( export const UrlFormField = withI18n()(({ i18n, tooltip }) => (
<FormField <FormField
@@ -41,32 +38,28 @@ export const BranchFormField = withI18n()(({ i18n, label }) => (
)); ));
export const ScmCredentialFormField = withI18n()( export const ScmCredentialFormField = withI18n()(
({ i18n, credential, onCredentialSelection }) => ( ({ i18n, credential, onCredentialSelection }) => {
<Field name="credential"> const credHelpers = useField('credential')[2];
{({ form }) => (
return (
<CredentialLookup <CredentialLookup
credentialTypeId={credential.typeId} credentialTypeId={credential.typeId}
label={i18n._(t`SCM Credential`)} label={i18n._(t`SCM Credential`)}
value={credential.value} value={credential.value}
onChange={value => { onChange={value => {
onCredentialSelection('scm', value); onCredentialSelection('scm', value);
form.setFieldValue('credential', value ? value.id : ''); credHelpers.setValue(value ? value.id : '');
}} }}
/> />
)} );
</Field> }
)
); );
export const ScmTypeOptions = withI18n()( export const ScmTypeOptions = withI18n()(
({ i18n, scmUpdateOnLaunch, hideAllowOverride }) => ( ({ i18n, scmUpdateOnLaunch, hideAllowOverride }) => (
<> <FormFullWidthLayout>
<FormGroup <FormGroup fieldId="project-option-checkboxes" label={i18n._(t`Options`)}>
css="grid-column: 1/-1" <FormCheckboxLayout>
fieldId="project-option-checkboxes"
label={i18n._(t`Options`)}
>
<FormRow>
<CheckboxField <CheckboxField
id="option-scm-clean" id="option-scm-clean"
name="scm_clean" name="scm_clean"
@@ -106,11 +99,12 @@ export const ScmTypeOptions = withI18n()(
)} )}
/> />
)} )}
</FormRow> </FormCheckboxLayout>
</FormGroup> </FormGroup>
{scmUpdateOnLaunch && ( {scmUpdateOnLaunch && (
<> <>
<SubFormTitle size="md">{i18n._(t`Option Details`)}</SubFormTitle> <Title size="md">{i18n._(t`Option Details`)}</Title>
<FormField <FormField
id="project-cache-timeout" id="project-cache-timeout"
name="scm_update_cache_timeout" name="scm_update_cache_timeout"
@@ -126,6 +120,6 @@ export const ScmTypeOptions = withI18n()(
/> />
</> </>
)} )}
</> </FormFullWidthLayout>
) )
); );

View File

@@ -1,4 +1,3 @@
export { SubFormTitle } from './SharedFields';
export { default as GitSubForm } from './GitSubForm'; export { default as GitSubForm } from './GitSubForm';
export { default as HgSubForm } from './HgSubForm'; export { default as HgSubForm } from './HgSubForm';
export { default as InsightsSubForm } from './InsightsSubForm'; export { default as InsightsSubForm } from './InsightsSubForm';

View File

@@ -2,36 +2,28 @@ import React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Formik, Field } from 'formik'; import { Formik, useField } from 'formik';
import { Form } from '@patternfly/react-core'; import { Form } from '@patternfly/react-core';
import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
import FormField, { FormSubmitError } from '@components/FormField'; import FormField, { FormSubmitError } from '@components/FormField';
import FormRow from '@components/FormRow';
import OrganizationLookup from '@components/Lookup/OrganizationLookup'; import OrganizationLookup from '@components/Lookup/OrganizationLookup';
import { required } from '@util/validators'; import { required } from '@util/validators';
import { FormColumnLayout } from '@components/FormLayout';
function TeamForm(props) { function TeamFormFields(props) {
const { team, handleCancel, handleSubmit, submitError, i18n } = props; const { team, i18n } = props;
const [organization, setOrganization] = useState( const [organization, setOrganization] = useState(
team.summary_fields ? team.summary_fields.organization : null team.summary_fields ? team.summary_fields.organization : null
); );
const orgFieldArr = useField({
name: 'organization',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const orgMeta = orgFieldArr[1];
const orgHelpers = orgFieldArr[2];
return ( return (
<Formik <>
initialValues={{
description: team.description || '',
name: team.name || '',
organization: team.organization || '',
}}
onSubmit={handleSubmit}
>
{formik => (
<Form
autoComplete="off"
onSubmit={formik.handleSubmit}
css="padding: 0 24px"
>
<FormRow>
<FormField <FormField
id="team-name" id="team-name"
label={i18n._(t`Name`)} label={i18n._(t`Name`)}
@@ -46,35 +38,43 @@ function TeamForm(props) {
name="description" name="description"
type="text" type="text"
/> />
<Field
name="organization"
validate={required(
i18n._(t`Select a value for this field`),
i18n
)}
>
{({ form }) => (
<OrganizationLookup <OrganizationLookup
helperTextInvalid={form.errors.organization} helperTextInvalid={orgMeta.error}
isValid={ isValid={!orgMeta.touched || !orgMeta.error}
!form.touched.organization || !form.errors.organization onBlur={() => orgHelpers.setTouched('organization')}
}
onBlur={() => form.setFieldTouched('organization')}
onChange={value => { onChange={value => {
form.setFieldValue('organization', value.id); orgHelpers.setValue(value.id);
setOrganization(value); setOrganization(value);
}} }}
value={organization} value={organization}
required required
/> />
)} </>
</Field> );
</FormRow> }
function TeamForm(props) {
const { team, handleCancel, handleSubmit, submitError, ...rest } = props;
return (
<Formik
initialValues={{
description: team.description || '',
name: team.name || '',
organization: team.organization || '',
}}
onSubmit={handleSubmit}
>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<TeamFormFields team={team} {...rest} />
<FormSubmitError error={submitError} /> <FormSubmitError error={submitError} />
<FormActionGroup <FormActionGroup
onCancel={handleCancel} onCancel={handleCancel}
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
/> />
</FormColumnLayout>
</Form> </Form>
)} )}
</Formik> </Formik>

View File

@@ -5,7 +5,7 @@ import { func, number, shape, string } from 'prop-types';
import styled from 'styled-components'; import styled from 'styled-components';
import { Formik, Field } from 'formik'; import { Formik, Field } from 'formik';
import { Form, FormGroup, TextInput } from '@patternfly/react-core'; import { Form, FormGroup, TextInput } from '@patternfly/react-core';
import FormRow from '@components/FormRow'; import { FormFullWidthLayout } from '@components/FormLayout';
import AnsibleSelect from '@components/AnsibleSelect'; import AnsibleSelect from '@components/AnsibleSelect';
import InventorySourcesList from './InventorySourcesList'; import InventorySourcesList from './InventorySourcesList';
import JobTemplatesList from './JobTemplatesList'; import JobTemplatesList from './JobTemplatesList';
@@ -119,7 +119,7 @@ function NodeTypeStep({
> >
{() => ( {() => (
<Form css="margin-top: 20px;"> <Form css="margin-top: 20px;">
<FormRow> <FormFullWidthLayout>
<Field name="name"> <Field name="name">
{({ field, form }) => { {({ field, form }) => {
const isValid = const isValid =
@@ -149,8 +149,6 @@ function NodeTypeStep({
); );
}} }}
</Field> </Field>
</FormRow>
<FormRow>
<Field name="description"> <Field name="description">
{({ field }) => ( {({ field }) => (
<FormGroup <FormGroup
@@ -169,8 +167,6 @@ function NodeTypeStep({
</FormGroup> </FormGroup>
)} )}
</Field> </Field>
</FormRow>
<FormRow>
<FormGroup <FormGroup
label={i18n._(t`Timeout`)} label={i18n._(t`Timeout`)}
fieldId="approval-timeout" fieldId="approval-timeout"
@@ -236,7 +232,7 @@ function NodeTypeStep({
</Field> </Field>
</div> </div>
</FormGroup> </FormGroup>
</FormRow> </FormFullWidthLayout>
</Form> </Form>
)} )}
</Formik> </Formik>

View File

@@ -22,10 +22,13 @@ import FormField, {
FormSubmitError, FormSubmitError,
} from '@components/FormField'; } from '@components/FormField';
import FieldWithPrompt from '@components/FieldWithPrompt'; import FieldWithPrompt from '@components/FieldWithPrompt';
import FormRow from '@components/FormRow'; import {
FormColumnLayout,
FormFullWidthLayout,
FormCheckboxLayout,
} from '@components/FormLayout';
import CollapsibleSection from '@components/CollapsibleSection'; import CollapsibleSection from '@components/CollapsibleSection';
import { required } from '@util/validators'; import { required } from '@util/validators';
import styled from 'styled-components';
import { JobTemplate } from '@types'; import { JobTemplate } from '@types';
import { import {
InventoryLookup, InventoryLookup,
@@ -37,17 +40,6 @@ import { JobTemplatesAPI, ProjectsAPI } from '@api';
import LabelSelect from './LabelSelect'; import LabelSelect from './LabelSelect';
import PlaybookSelect from './PlaybookSelect'; import PlaybookSelect from './PlaybookSelect';
const GridFormGroup = styled(FormGroup)`
& > label {
grid-column: 1 / -1;
}
&& {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
`;
class JobTemplateForm extends Component { class JobTemplateForm extends Component {
static propTypes = { static propTypes = {
template: JobTemplate, template: JobTemplate,
@@ -211,9 +203,10 @@ class JobTemplateForm extends Component {
} }
const AdvancedFieldsWrapper = template.isNew ? CollapsibleSection : 'div'; const AdvancedFieldsWrapper = template.isNew ? CollapsibleSection : 'div';
return ( return (
<Form autoComplete="off" onSubmit={handleSubmit}> <Form autoComplete="off" onSubmit={handleSubmit}>
<FormRow> <FormColumnLayout>
<FormField <FormField
id="template-name" id="template-name"
name="name" name="name"
@@ -334,8 +327,7 @@ class JobTemplateForm extends Component {
); );
}} }}
</Field> </Field>
</FormRow> <FormFullWidthLayout>
<FormRow>
<Field name="labels"> <Field name="labels">
{({ field }) => ( {({ field }) => (
<FormGroup label={i18n._(t`Labels`)} fieldId="template-labels"> <FormGroup label={i18n._(t`Labels`)} fieldId="template-labels">
@@ -352,8 +344,6 @@ class JobTemplateForm extends Component {
</FormGroup> </FormGroup>
)} )}
</Field> </Field>
</FormRow>
<FormRow>
<Field name="credentials" fieldId="template-credentials"> <Field name="credentials" fieldId="template-credentials">
{({ field }) => ( {({ field }) => (
<MultiCredentialsLookup <MultiCredentialsLookup
@@ -368,9 +358,8 @@ class JobTemplateForm extends Component {
/> />
)} )}
</Field> </Field>
</FormRow>
<AdvancedFieldsWrapper label="Advanced"> <AdvancedFieldsWrapper label="Advanced">
<FormRow> <FormColumnLayout>
<FormField <FormField
id="template-forks" id="template-forks"
name="forks" name="forks"
@@ -462,13 +451,14 @@ class JobTemplateForm extends Component {
</FormGroup> </FormGroup>
)} )}
</Field> </Field>
</FormRow> <FormFullWidthLayout>
<Field name="instanceGroups"> <Field name="instanceGroups">
{({ field, form }) => ( {({ field, form }) => (
<InstanceGroupsLookup <InstanceGroupsLookup
css="margin-top: 20px"
value={field.value} value={field.value}
onChange={value => form.setFieldValue(field.name, value)} onChange={value =>
form.setFieldValue(field.name, value)
}
tooltip={i18n._(t`Select the Instance Groups for this Organization tooltip={i18n._(t`Select the Instance Groups for this Organization
to run on.`)} to run on.`)}
/> />
@@ -478,7 +468,6 @@ class JobTemplateForm extends Component {
{({ field, form }) => ( {({ field, form }) => (
<FormGroup <FormGroup
label={i18n._(t`Job Tags`)} label={i18n._(t`Job Tags`)}
css="margin-top: 20px"
fieldId="template-job-tags" fieldId="template-job-tags"
> >
<FieldTooltip <FieldTooltip
@@ -490,7 +479,9 @@ class JobTemplateForm extends Component {
/> />
<TagMultiSelect <TagMultiSelect
value={field.value} value={field.value}
onChange={value => form.setFieldValue(field.name, value)} onChange={value =>
form.setFieldValue(field.name, value)
}
/> />
</FormGroup> </FormGroup>
)} )}
@@ -499,7 +490,6 @@ class JobTemplateForm extends Component {
{({ field, form }) => ( {({ field, form }) => (
<FormGroup <FormGroup
label={i18n._(t`Skip Tags`)} label={i18n._(t`Skip Tags`)}
css="margin-top: 20px"
fieldId="template-skip-tags" fieldId="template-skip-tags"
> >
<FieldTooltip <FieldTooltip
@@ -511,17 +501,18 @@ class JobTemplateForm extends Component {
/> />
<TagMultiSelect <TagMultiSelect
value={field.value} value={field.value}
onChange={value => form.setFieldValue(field.name, value)} onChange={value =>
form.setFieldValue(field.name, value)
}
/> />
</FormGroup> </FormGroup>
)} )}
</Field> </Field>
<GridFormGroup <FormGroup
fieldId="template-option-checkboxes" fieldId="template-option-checkboxes"
isInline
label={i18n._(t`Options`)} label={i18n._(t`Options`)}
css="margin-top: 20px"
> >
<FormCheckboxLayout>
<CheckboxField <CheckboxField
id="option-privilege-escalation" id="option-privilege-escalation"
name="become_enabled" name="become_enabled"
@@ -563,14 +554,11 @@ class JobTemplateForm extends Component {
tooltip={i18n._(t`If enabled, use cached facts if available tooltip={i18n._(t`If enabled, use cached facts if available
and store discovered facts in the cache.`)} and store discovered facts in the cache.`)}
/> />
</GridFormGroup> </FormCheckboxLayout>
<div </FormGroup>
css={` </FormFullWidthLayout>
${allowCallbacks ? '' : 'display: none'} {allowCallbacks && (
margin-top: 20px; <>
`}
>
<FormRow>
{callbackUrl && ( {callbackUrl && (
<FormGroup <FormGroup
label={i18n._(t`Provisioning Callback URL`)} label={i18n._(t`Provisioning Callback URL`)}
@@ -589,11 +577,14 @@ class JobTemplateForm extends Component {
label={i18n._(t`Host Config Key`)} label={i18n._(t`Host Config Key`)}
validate={allowCallbacks ? required(null, i18n) : null} validate={allowCallbacks ? required(null, i18n) : null}
/> />
</FormRow> </>
</div> )}
</FormColumnLayout>
</AdvancedFieldsWrapper> </AdvancedFieldsWrapper>
</FormFullWidthLayout>
<FormSubmitError error={submitError} /> <FormSubmitError error={submitError} />
<FormActionGroup onCancel={handleCancel} onSubmit={handleSubmit} /> <FormActionGroup onCancel={handleCancel} onSubmit={handleSubmit} />
</FormColumnLayout>
</Form> </Form>
); );
} }

View File

@@ -1,16 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import styled from 'styled-components'; import { Card, PageSection } from '@patternfly/react-core';
import { Card as _Card, PageSection } from '@patternfly/react-core';
import { CardBody } from '@components/Card'; import { CardBody } from '@components/Card';
import UserForm from '../shared/UserForm'; import UserForm from '../shared/UserForm';
import { UsersAPI } from '@api'; import { UsersAPI } from '@api';
const Card = styled(_Card)`
--pf-c-card--child--PaddingLeft: 0;
--pf-c-card--child--PaddingRight: 0;
`;
function UserAdd() { function UserAdd() {
const [formSubmitError, setFormSubmitError] = useState(null); const [formSubmitError, setFormSubmitError] = useState(null);
const history = useHistory(); const history = useHistory();

View File

@@ -9,6 +9,7 @@ function UserEdit({ user, history }) {
const [formSubmitError, setFormSubmitError] = useState(null); const [formSubmitError, setFormSubmitError] = useState(null);
const handleSubmit = async values => { const handleSubmit = async values => {
setFormSubmitError(null);
try { try {
await UsersAPI.update(user.id, values); await UsersAPI.update(user.id, values);
history.push(`/users/${user.id}/details`); history.push(`/users/${user.id}/details`);

View File

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Formik, Field } from 'formik'; import { Formik, useField } from 'formik';
import { Form, FormGroup } from '@patternfly/react-core'; import { Form, FormGroup } from '@patternfly/react-core';
import AnsibleSelect from '@components/AnsibleSelect'; import AnsibleSelect from '@components/AnsibleSelect';
import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
@@ -10,11 +10,11 @@ import FormField, {
PasswordField, PasswordField,
FormSubmitError, FormSubmitError,
} from '@components/FormField'; } from '@components/FormField';
import FormRow from '@components/FormRow';
import OrganizationLookup from '@components/Lookup/OrganizationLookup'; import OrganizationLookup from '@components/Lookup/OrganizationLookup';
import { required, requiredEmail } from '@util/validators'; import { required, requiredEmail } from '@util/validators';
import { FormColumnLayout } from '@components/FormLayout';
function UserForm({ user, handleCancel, handleSubmit, submitError, i18n }) { function UserFormFields({ user, i18n }) {
const [organization, setOrganization] = useState(null); const [organization, setOrganization] = useState(null);
const userTypeOptions = [ const userTypeOptions = [
@@ -38,6 +38,100 @@ function UserForm({ user, handleCancel, handleSubmit, submitError, i18n }) {
}, },
]; ];
const organizationFieldArr = useField({
name: 'organization',
validate: !user.id
? required(i18n._(t`Select a value for this field`), i18n)
: () => undefined,
});
const organizationMeta = organizationFieldArr[1];
const organizationHelpers = organizationFieldArr[2];
const [userTypeField, userTypeMeta] = useField('user_type');
return (
<>
<FormField
id="user-username"
label={i18n._(t`Username`)}
name="username"
type="text"
validate={required(null, i18n)}
isRequired
/>
<FormField
id="user-email"
label={i18n._(t`Email`)}
name="email"
validate={requiredEmail(i18n)}
isRequired
/>
<PasswordField
id="user-password"
label={i18n._(t`Password`)}
name="password"
validate={
!user.id
? required(i18n._(t`This field must not be blank`), i18n)
: () => undefined
}
isRequired={!user.id}
/>
<PasswordField
id="user-confirm-password"
label={i18n._(t`Confirm Password`)}
name="confirm_password"
validate={
!user.id
? required(i18n._(t`This field must not be blank`), i18n)
: () => undefined
}
isRequired={!user.id}
/>
<FormField
id="user-first-name"
label={i18n._(t`First Name`)}
name="first_name"
type="text"
/>
<FormField
id="user-last-name"
label={i18n._(t`Last Name`)}
name="last_name"
type="text"
/>
{!user.id && (
<OrganizationLookup
helperTextInvalid={organizationMeta.error}
isValid={!organizationMeta.touched || !organizationMeta.error}
onBlur={() => organizationHelpers.setTouched()}
onChange={value => {
organizationHelpers.setValue(value.id);
setOrganization(value);
}}
value={organization}
required
/>
)}
<FormGroup
fieldId="user-type"
helperTextInvalid={userTypeMeta.error}
isRequired
isValid={!userTypeMeta.touched || !userTypeMeta.error}
label={i18n._(t`User Type`)}
>
<AnsibleSelect
isValid={!userTypeMeta.touched || !userTypeMeta.error}
id="user-type"
data={userTypeOptions}
{...userTypeField}
/>
</FormGroup>
</>
);
}
function UserForm({ user, handleCancel, handleSubmit, submitError, i18n }) {
const handleValidateAndSubmit = (values, { setErrors }) => { const handleValidateAndSubmit = (values, { setErrors }) => {
if (values.password !== values.confirm_password) { if (values.password !== values.confirm_password) {
setErrors({ setErrors({
@@ -81,111 +175,14 @@ function UserForm({ user, handleCancel, handleSubmit, submitError, i18n }) {
> >
{formik => ( {formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}> <Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormRow> <FormColumnLayout>
<FormField <UserFormFields user={user} i18n={i18n} />
id="user-username"
label={i18n._(t`Username`)}
name="username"
type="text"
validate={required(null, i18n)}
isRequired
/>
<FormField
id="user-email"
label={i18n._(t`Email`)}
name="email"
validate={requiredEmail(i18n)}
isRequired
/>
<PasswordField
id="user-password"
label={i18n._(t`Password`)}
name="password"
validate={
!user.id
? required(i18n._(t`This field must not be blank`), i18n)
: () => undefined
}
isRequired={!user.id}
/>
<PasswordField
id="user-confirm-password"
label={i18n._(t`Confirm Password`)}
name="confirm_password"
validate={
!user.id
? required(i18n._(t`This field must not be blank`), i18n)
: () => undefined
}
isRequired={!user.id}
/>
</FormRow>
<FormRow>
<FormField
id="user-first-name"
label={i18n._(t`First Name`)}
name="first_name"
type="text"
/>
<FormField
id="user-last-name"
label={i18n._(t`Last Name`)}
name="last_name"
type="text"
/>
{!user.id && (
<Field
name="organization"
validate={required(
i18n._(t`Select a value for this field`),
i18n
)}
>
{({ form }) => (
<OrganizationLookup
helperTextInvalid={form.errors.organization}
isValid={
!form.touched.organization || !form.errors.organization
}
onBlur={() => form.setFieldTouched('organization')}
onChange={value => {
form.setFieldValue('organization', value.id);
setOrganization(value);
}}
value={organization}
required
/>
)}
</Field>
)}
<Field name="user_type">
{({ form, field }) => {
const isValid =
!form.touched.user_type || !form.errors.user_type;
return (
<FormGroup
fieldId="user-type"
helperTextInvalid={form.errors.user_type}
isRequired
isValid={isValid}
label={i18n._(t`User Type`)}
>
<AnsibleSelect
isValid={isValid}
id="user-type"
data={userTypeOptions}
{...field}
/>
</FormGroup>
);
}}
</Field>
</FormRow>
<FormSubmitError error={submitError} /> <FormSubmitError error={submitError} />
<FormActionGroup <FormActionGroup
onCancel={handleCancel} onCancel={handleCancel}
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
/> />
</FormColumnLayout>
</Form> </Form>
)} )}
</Formik> </Formik>