mirror of
https://github.com/ansible/awx.git
synced 2026-05-08 01:47:35 -02:30
Merge pull request #5075 from AlexSCorey/credentialsLookUp
Credentials look up Reviewed-by: Alex Corey <Alex.swansboro@gmail.com> https://github.com/AlexSCorey
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import AdHocCommands from './models/AdHocCommands';
|
import AdHocCommands from './models/AdHocCommands';
|
||||||
import Config from './models/Config';
|
import Config from './models/Config';
|
||||||
|
import CredentialTypes from './models/CredentialTypes';
|
||||||
|
import Credentials from './models/Credentials';
|
||||||
import InstanceGroups from './models/InstanceGroups';
|
import InstanceGroups from './models/InstanceGroups';
|
||||||
import Inventories from './models/Inventories';
|
import Inventories from './models/Inventories';
|
||||||
import InventorySources from './models/InventorySources';
|
import InventorySources from './models/InventorySources';
|
||||||
@@ -23,6 +25,8 @@ import WorkflowJobTemplates from './models/WorkflowJobTemplates';
|
|||||||
|
|
||||||
const AdHocCommandsAPI = new AdHocCommands();
|
const AdHocCommandsAPI = new AdHocCommands();
|
||||||
const ConfigAPI = new Config();
|
const ConfigAPI = new Config();
|
||||||
|
const CredentialsAPI = new Credentials();
|
||||||
|
const CredentialTypesAPI = new CredentialTypes();
|
||||||
const InstanceGroupsAPI = new InstanceGroups();
|
const InstanceGroupsAPI = new InstanceGroups();
|
||||||
const InventoriesAPI = new Inventories();
|
const InventoriesAPI = new Inventories();
|
||||||
const InventorySourcesAPI = new InventorySources();
|
const InventorySourcesAPI = new InventorySources();
|
||||||
@@ -47,6 +51,8 @@ const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
|
|||||||
export {
|
export {
|
||||||
AdHocCommandsAPI,
|
AdHocCommandsAPI,
|
||||||
ConfigAPI,
|
ConfigAPI,
|
||||||
|
CredentialsAPI,
|
||||||
|
CredentialTypesAPI,
|
||||||
InstanceGroupsAPI,
|
InstanceGroupsAPI,
|
||||||
InventoriesAPI,
|
InventoriesAPI,
|
||||||
InventorySourcesAPI,
|
InventorySourcesAPI,
|
||||||
|
|||||||
10
awx/ui_next/src/api/models/CredentialTypes.js
Normal file
10
awx/ui_next/src/api/models/CredentialTypes.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import Base from '../Base';
|
||||||
|
|
||||||
|
class CredentialTypes extends Base {
|
||||||
|
constructor(http) {
|
||||||
|
super(http);
|
||||||
|
this.baseUrl = '/api/v2/credential_types/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CredentialTypes;
|
||||||
10
awx/ui_next/src/api/models/Credentials.js
Normal file
10
awx/ui_next/src/api/models/Credentials.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import Base from '../Base';
|
||||||
|
|
||||||
|
class Credentials extends Base {
|
||||||
|
constructor(http) {
|
||||||
|
super(http);
|
||||||
|
this.baseUrl = '/api/v2/credentials/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Credentials;
|
||||||
@@ -44,6 +44,19 @@ class JobTemplates extends InstanceGroupsMixin(NotificationsMixin(Base)) {
|
|||||||
readCredentials(id, params) {
|
readCredentials(id, params) {
|
||||||
return this.http.get(`${this.baseUrl}${id}/credentials/`, { params });
|
return this.http.get(`${this.baseUrl}${id}/credentials/`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
associateCredentials(id, credentialId) {
|
||||||
|
return this.http.post(`${this.baseUrl}${id}/credentials/`, {
|
||||||
|
id: credentialId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disassociateCredentials(id, credentialId) {
|
||||||
|
return this.http.post(`${this.baseUrl}${id}/credentials/`, {
|
||||||
|
id: credentialId,
|
||||||
|
disassociate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default JobTemplates;
|
export default JobTemplates;
|
||||||
|
|||||||
@@ -15,16 +15,19 @@ import {
|
|||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
InputGroup as PFInputGroup,
|
InputGroup as PFInputGroup,
|
||||||
Modal,
|
Modal,
|
||||||
|
ToolbarItem,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
import AnsibleSelect from '../AnsibleSelect';
|
||||||
import PaginatedDataList from '../PaginatedDataList';
|
import PaginatedDataList from '../PaginatedDataList';
|
||||||
|
import VerticalSeperator from '../VerticalSeparator';
|
||||||
import DataListToolbar from '../DataListToolbar';
|
import DataListToolbar from '../DataListToolbar';
|
||||||
import CheckboxListItem from '../CheckboxListItem';
|
import CheckboxListItem from '../CheckboxListItem';
|
||||||
import SelectedList from '../SelectedList';
|
import SelectedList from '../SelectedList';
|
||||||
import { ChipGroup, Chip } from '../Chip';
|
import { ChipGroup, Chip, CredentialChip } from '../Chip';
|
||||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||||
|
|
||||||
const SearchButton = styled(Button)`
|
const SearchButton = styled(Button)`
|
||||||
@@ -83,14 +86,20 @@ class Lookup extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
const { location } = this.props;
|
const { location, selectedCategory } = this.props;
|
||||||
if (location !== prevProps.location) {
|
if (
|
||||||
|
location !== prevProps.location ||
|
||||||
|
prevProps.selectedCategory !== selectedCategory
|
||||||
|
) {
|
||||||
this.getData();
|
this.getData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assertCorrectValueType() {
|
assertCorrectValueType() {
|
||||||
const { multiple, value } = this.props;
|
const { multiple, value, selectCategoryOptions } = this.props;
|
||||||
|
if (selectCategoryOptions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!multiple && Array.isArray(value)) {
|
if (!multiple && Array.isArray(value)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Lookup value must not be an array unless `multiple` is set'
|
'Lookup value must not be an array unless `multiple` is set'
|
||||||
@@ -123,7 +132,13 @@ class Lookup extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleSelected(row) {
|
toggleSelected(row) {
|
||||||
const { name, onLookupSave, multiple } = this.props;
|
const {
|
||||||
|
name,
|
||||||
|
onLookupSave,
|
||||||
|
multiple,
|
||||||
|
onToggleItem,
|
||||||
|
selectCategoryOptions,
|
||||||
|
} = this.props;
|
||||||
const {
|
const {
|
||||||
lookupSelectedItems: updatedSelectedItems,
|
lookupSelectedItems: updatedSelectedItems,
|
||||||
isModalOpen,
|
isModalOpen,
|
||||||
@@ -132,8 +147,10 @@ class Lookup extends React.Component {
|
|||||||
const selectedIndex = updatedSelectedItems.findIndex(
|
const selectedIndex = updatedSelectedItems.findIndex(
|
||||||
selectedRow => selectedRow.id === row.id
|
selectedRow => selectedRow.id === row.id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (multiple) {
|
if (multiple) {
|
||||||
|
if (selectCategoryOptions) {
|
||||||
|
onToggleItem(row, isModalOpen);
|
||||||
|
}
|
||||||
if (selectedIndex > -1) {
|
if (selectedIndex > -1) {
|
||||||
updatedSelectedItems.splice(selectedIndex, 1);
|
updatedSelectedItems.splice(selectedIndex, 1);
|
||||||
this.setState({ lookupSelectedItems: updatedSelectedItems });
|
this.setState({ lookupSelectedItems: updatedSelectedItems });
|
||||||
@@ -156,7 +173,7 @@ class Lookup extends React.Component {
|
|||||||
|
|
||||||
handleModalToggle() {
|
handleModalToggle() {
|
||||||
const { isModalOpen } = this.state;
|
const { isModalOpen } = this.state;
|
||||||
const { value, multiple } = this.props;
|
const { value, multiple, selectCategory } = this.props;
|
||||||
// Resets the selected items from parent state whenever modal is opened
|
// Resets the selected items from parent state whenever modal is opened
|
||||||
// This handles the case where the user closes/cancels the modal and
|
// This handles the case where the user closes/cancels the modal and
|
||||||
// opens it again
|
// opens it again
|
||||||
@@ -168,6 +185,9 @@ class Lookup extends React.Component {
|
|||||||
this.setState({ lookupSelectedItems });
|
this.setState({ lookupSelectedItems });
|
||||||
} else {
|
} else {
|
||||||
this.clearQSParams();
|
this.clearQSParams();
|
||||||
|
if (selectCategory) {
|
||||||
|
selectCategory(null, 'Machine');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.setState(prevState => ({
|
this.setState(prevState => ({
|
||||||
isModalOpen: !prevState.isModalOpen,
|
isModalOpen: !prevState.isModalOpen,
|
||||||
@@ -180,8 +200,9 @@ class Lookup extends React.Component {
|
|||||||
const value = multiple
|
const value = multiple
|
||||||
? lookupSelectedItems
|
? lookupSelectedItems
|
||||||
: lookupSelectedItems[0] || null;
|
: lookupSelectedItems[0] || null;
|
||||||
onLookupSave(value, name);
|
|
||||||
this.handleModalToggle();
|
this.handleModalToggle();
|
||||||
|
onLookupSave(value, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearQSParams() {
|
clearQSParams() {
|
||||||
@@ -201,6 +222,7 @@ class Lookup extends React.Component {
|
|||||||
count,
|
count,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
const {
|
const {
|
||||||
|
form,
|
||||||
id,
|
id,
|
||||||
lookupHeader,
|
lookupHeader,
|
||||||
value,
|
value,
|
||||||
@@ -208,27 +230,40 @@ class Lookup extends React.Component {
|
|||||||
multiple,
|
multiple,
|
||||||
name,
|
name,
|
||||||
onBlur,
|
onBlur,
|
||||||
|
selectCategory,
|
||||||
required,
|
required,
|
||||||
i18n,
|
i18n,
|
||||||
|
selectCategoryOptions,
|
||||||
|
selectedCategory,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const header = lookupHeader || i18n._(t`Items`);
|
const header = lookupHeader || i18n._(t`Items`);
|
||||||
const canDelete = !required || (multiple && value.length > 1);
|
const canDelete = !required || (multiple && value.length > 1);
|
||||||
|
const chips = () => {
|
||||||
const chips = value ? (
|
return selectCategoryOptions && selectCategoryOptions.length > 0 ? (
|
||||||
<ChipGroup>
|
<ChipGroup>
|
||||||
{(multiple ? value : [value]).map(chip => (
|
{(multiple ? value : [value]).map(chip => (
|
||||||
<Chip
|
<CredentialChip
|
||||||
key={chip.id}
|
key={chip.id}
|
||||||
onClick={() => this.toggleSelected(chip)}
|
onClick={() => this.toggleSelected(chip)}
|
||||||
isReadOnly={!canDelete}
|
isReadOnly={!canDelete}
|
||||||
>
|
credential={chip}
|
||||||
{chip.name}
|
/>
|
||||||
</Chip>
|
))}
|
||||||
))}
|
</ChipGroup>
|
||||||
</ChipGroup>
|
) : (
|
||||||
) : null;
|
<ChipGroup>
|
||||||
|
{(multiple ? value : [value]).map(chip => (
|
||||||
|
<Chip
|
||||||
|
key={chip.id}
|
||||||
|
onClick={() => this.toggleSelected(chip)}
|
||||||
|
isReadOnly={!canDelete}
|
||||||
|
>
|
||||||
|
{chip.name}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<InputGroup onBlur={onBlur}>
|
<InputGroup onBlur={onBlur}>
|
||||||
@@ -240,7 +275,9 @@ class Lookup extends React.Component {
|
|||||||
>
|
>
|
||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
</SearchButton>
|
</SearchButton>
|
||||||
<ChipHolder className="pf-c-form-control">{chips}</ChipHolder>
|
<ChipHolder className="pf-c-form-control">
|
||||||
|
{value ? chips(value) : null}
|
||||||
|
</ChipHolder>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<Modal
|
<Modal
|
||||||
className="awx-c-modal"
|
className="awx-c-modal"
|
||||||
@@ -265,6 +302,21 @@ class Lookup extends React.Component {
|
|||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
{selectCategoryOptions && selectCategoryOptions.length > 0 && (
|
||||||
|
<ToolbarItem css=" display: flex; align-items: center;">
|
||||||
|
<span css="flex: 0 0 25%;">Selected Category</span>
|
||||||
|
<VerticalSeperator />
|
||||||
|
<AnsibleSelect
|
||||||
|
css="flex: 1 1 75%;"
|
||||||
|
id="multiCredentialsLookUp-select"
|
||||||
|
label="Selected Category"
|
||||||
|
data={selectCategoryOptions}
|
||||||
|
value={selectedCategory.label}
|
||||||
|
onChange={selectCategory}
|
||||||
|
form={form}
|
||||||
|
/>
|
||||||
|
</ToolbarItem>
|
||||||
|
)}
|
||||||
<PaginatedDataList
|
<PaginatedDataList
|
||||||
items={results}
|
items={results}
|
||||||
itemCount={count}
|
itemCount={count}
|
||||||
@@ -277,9 +329,18 @@ class Lookup extends React.Component {
|
|||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
name={multiple ? item.name : name}
|
name={multiple ? item.name : name}
|
||||||
label={item.name}
|
label={item.name}
|
||||||
isSelected={lookupSelectedItems.some(i => i.id === item.id)}
|
isSelected={
|
||||||
|
selectCategoryOptions
|
||||||
|
? value.some(i => i.id === item.id)
|
||||||
|
: lookupSelectedItems.some(i => i.id === item.id)
|
||||||
|
}
|
||||||
onSelect={() => this.toggleSelected(item)}
|
onSelect={() => this.toggleSelected(item)}
|
||||||
isRadio={!multiple}
|
isRadio={
|
||||||
|
!multiple ||
|
||||||
|
(selectCategoryOptions &&
|
||||||
|
selectCategoryOptions.length &&
|
||||||
|
selectedCategory.value !== 'Vault')
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
|
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
|
||||||
@@ -288,10 +349,13 @@ class Lookup extends React.Component {
|
|||||||
{lookupSelectedItems.length > 0 && (
|
{lookupSelectedItems.length > 0 && (
|
||||||
<SelectedList
|
<SelectedList
|
||||||
label={i18n._(t`Selected`)}
|
label={i18n._(t`Selected`)}
|
||||||
selected={lookupSelectedItems}
|
selected={selectCategoryOptions ? value : lookupSelectedItems}
|
||||||
showOverflowAfter={5}
|
showOverflowAfter={5}
|
||||||
onRemove={this.toggleSelected}
|
onRemove={this.toggleSelected}
|
||||||
isReadOnly={!canDelete}
|
isReadOnly={!canDelete}
|
||||||
|
isCredentialList={
|
||||||
|
selectCategoryOptions && selectCategoryOptions.length > 0
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{error ? <div>error</div> : ''}
|
{error ? <div>error</div> : ''}
|
||||||
|
|||||||
162
awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx
Normal file
162
awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { FormGroup, Tooltip } from '@patternfly/react-core';
|
||||||
|
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
import { CredentialsAPI, CredentialTypesAPI } from '@api';
|
||||||
|
import Lookup from '@components/Lookup';
|
||||||
|
|
||||||
|
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
|
||||||
|
margin-left: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
class MultiCredentialsLookup extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
selectedCredentialType: { label: 'Machine', id: 1, kind: 'ssh' },
|
||||||
|
credentialTypes: [],
|
||||||
|
};
|
||||||
|
this.loadCredentialTypes = this.loadCredentialTypes.bind(this);
|
||||||
|
this.handleCredentialTypeSelect = this.handleCredentialTypeSelect.bind(
|
||||||
|
this
|
||||||
|
);
|
||||||
|
this.loadCredentials = this.loadCredentials.bind(this);
|
||||||
|
this.toggleCredentialSelection = this.toggleCredentialSelection.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.loadCredentialTypes();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCredentialTypes() {
|
||||||
|
const { onError } = this.props;
|
||||||
|
try {
|
||||||
|
const { data } = await CredentialTypesAPI.read();
|
||||||
|
const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault'];
|
||||||
|
const credentialTypes = [];
|
||||||
|
data.results.forEach(cred => {
|
||||||
|
acceptableTypes.forEach(aT => {
|
||||||
|
if (aT === cred.kind) {
|
||||||
|
// This object has several repeated values as some of it's children
|
||||||
|
// require different field values.
|
||||||
|
cred = {
|
||||||
|
id: cred.id,
|
||||||
|
key: cred.id,
|
||||||
|
kind: cred.kind,
|
||||||
|
type: cred.namespace,
|
||||||
|
value: cred.name,
|
||||||
|
label: cred.name,
|
||||||
|
isDisabled: false,
|
||||||
|
};
|
||||||
|
credentialTypes.push(cred);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.setState({ credentialTypes });
|
||||||
|
} catch (err) {
|
||||||
|
onError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCredentials(params) {
|
||||||
|
const { selectedCredentialType } = this.state;
|
||||||
|
params.credential_type = selectedCredentialType.id || 1;
|
||||||
|
return CredentialsAPI.read(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCredentialSelection(newCredential) {
|
||||||
|
const { onChange, credentials: credentialsToUpdate } = this.props;
|
||||||
|
|
||||||
|
let newCredentialsList;
|
||||||
|
const isSelectedCredentialInState =
|
||||||
|
credentialsToUpdate.filter(cred => cred.id === newCredential.id).length >
|
||||||
|
0;
|
||||||
|
|
||||||
|
if (isSelectedCredentialInState) {
|
||||||
|
newCredentialsList = credentialsToUpdate.filter(
|
||||||
|
cred => cred.id !== newCredential.id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
newCredentialsList = credentialsToUpdate.filter(
|
||||||
|
credential =>
|
||||||
|
credential.kind === 'vault' || credential.kind !== newCredential.kind
|
||||||
|
);
|
||||||
|
newCredentialsList = [...newCredentialsList, newCredential];
|
||||||
|
}
|
||||||
|
onChange(newCredentialsList);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCredentialTypeSelect(value, type) {
|
||||||
|
const { credentialTypes } = this.state;
|
||||||
|
const selectedType = credentialTypes.filter(item => item.label === type);
|
||||||
|
this.setState({ selectedCredentialType: selectedType[0] });
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { selectedCredentialType, credentialTypes } = this.state;
|
||||||
|
const { tooltip, i18n, credentials } = this.props;
|
||||||
|
return (
|
||||||
|
<FormGroup label={i18n._(t`Credentials`)} fieldId="org-credentials">
|
||||||
|
{tooltip && (
|
||||||
|
<Tooltip position="right" content={tooltip}>
|
||||||
|
<QuestionCircleIcon />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{credentialTypes && (
|
||||||
|
<Lookup
|
||||||
|
selectCategoryOptions={credentialTypes}
|
||||||
|
selectCategory={this.handleCredentialTypeSelect}
|
||||||
|
selectedCategory={selectedCredentialType}
|
||||||
|
onToggleItem={this.toggleCredentialSelection}
|
||||||
|
onloadCategories={this.loadCredentialTypes}
|
||||||
|
id="org-credentials"
|
||||||
|
lookupHeader={i18n._(t`Credentials`)}
|
||||||
|
name="credentials"
|
||||||
|
value={credentials}
|
||||||
|
multiple
|
||||||
|
onLookupSave={() => {}}
|
||||||
|
getItems={this.loadCredentials}
|
||||||
|
qsNamespace="credentials"
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
name: i18n._(t`Name`),
|
||||||
|
key: 'name',
|
||||||
|
isSortable: true,
|
||||||
|
isSearchable: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
sortedColumnKey="name"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MultiCredentialsLookup.propTypes = {
|
||||||
|
tooltip: PropTypes.string,
|
||||||
|
credentials: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
id: PropTypes.number,
|
||||||
|
name: PropTypes.string,
|
||||||
|
description: PropTypes.string,
|
||||||
|
kind: PropTypes.string,
|
||||||
|
clound: PropTypes.bool,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
onError: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
MultiCredentialsLookup.defaultProps = {
|
||||||
|
tooltip: '',
|
||||||
|
credentials: [],
|
||||||
|
};
|
||||||
|
export { MultiCredentialsLookup as _MultiCredentialsLookup };
|
||||||
|
|
||||||
|
export default withI18n()(MultiCredentialsLookup);
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
|
import MultiCredentialsLookup from './MultiCredentialsLookup';
|
||||||
|
import { CredentialsAPI, CredentialTypesAPI } from '@api';
|
||||||
|
|
||||||
|
jest.mock('@api');
|
||||||
|
|
||||||
|
describe('<MultiCredentialsLookup />', () => {
|
||||||
|
let wrapper;
|
||||||
|
let lookup;
|
||||||
|
let credLookup;
|
||||||
|
let onChange;
|
||||||
|
|
||||||
|
const credentials = [
|
||||||
|
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
|
||||||
|
{ id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
|
||||||
|
{ name: 'Gatsby', id: 21, kind: 'vault' },
|
||||||
|
{ name: 'Gatsby', id: 8, kind: 'Machine' },
|
||||||
|
];
|
||||||
|
beforeEach(() => {
|
||||||
|
CredentialTypesAPI.read.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 400,
|
||||||
|
kind: 'ssh',
|
||||||
|
namespace: 'biz',
|
||||||
|
name: 'Amazon Web Services',
|
||||||
|
},
|
||||||
|
{ id: 500, kind: 'vault', namespace: 'buzz', name: 'Vault' },
|
||||||
|
{ id: 600, kind: 'machine', namespace: 'fuzz', name: 'Machine' },
|
||||||
|
],
|
||||||
|
count: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
CredentialsAPI.read.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
results: [
|
||||||
|
{ id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' },
|
||||||
|
{ id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' },
|
||||||
|
{ id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' },
|
||||||
|
{ id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' },
|
||||||
|
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
|
||||||
|
],
|
||||||
|
count: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onChange = jest.fn();
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<MultiCredentialsLookup
|
||||||
|
onError={() => {}}
|
||||||
|
credentials={credentials}
|
||||||
|
onChange={onChange}
|
||||||
|
tooltip="This is credentials look up"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
lookup = wrapper.find('Lookup');
|
||||||
|
credLookup = wrapper.find('MultiCredentialsLookup');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MultiCredentialsLookup renders properly', () => {
|
||||||
|
expect(wrapper.find('MultiCredentialsLookup')).toHaveLength(1);
|
||||||
|
expect(CredentialTypesAPI.read).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onChange is called when you click to remove a credential from input', async () => {
|
||||||
|
const chip = wrapper.find('PFChip');
|
||||||
|
const button = chip.at(1).find('Button');
|
||||||
|
expect(chip).toHaveLength(4);
|
||||||
|
button.prop('onClick')();
|
||||||
|
expect(onChange).toBeCalledWith([
|
||||||
|
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
|
||||||
|
{ id: 21, kind: 'vault', name: 'Gatsby' },
|
||||||
|
{ id: 8, kind: 'Machine', name: 'Gatsby' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can change credential types', () => {
|
||||||
|
lookup.prop('selectCategory')({}, 'Vault');
|
||||||
|
expect(credLookup.state('selectedCredentialType')).toEqual({
|
||||||
|
id: 500,
|
||||||
|
key: 500,
|
||||||
|
kind: 'vault',
|
||||||
|
type: 'buzz',
|
||||||
|
value: 'Vault',
|
||||||
|
label: 'Vault',
|
||||||
|
isDisabled: false,
|
||||||
|
});
|
||||||
|
expect(CredentialsAPI.read).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
test('Toggle credentials only adds 1 credential per credential type except vault(see below)', () => {
|
||||||
|
lookup.prop('onToggleItem')({ name: 'Party', id: 9, kind: 'Machine' });
|
||||||
|
expect(onChange).toBeCalledWith([
|
||||||
|
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
|
||||||
|
{ id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
|
||||||
|
{ id: 21, kind: 'vault', name: 'Gatsby' },
|
||||||
|
{ id: 9, kind: 'Machine', name: 'Party' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
test('Toggle credentials only adds 1 credential per credential type', () => {
|
||||||
|
lookup.prop('onToggleItem')({ name: 'Party', id: 22, kind: 'vault' });
|
||||||
|
expect(onChange).toBeCalledWith([
|
||||||
|
...credentials,
|
||||||
|
{ name: 'Party', id: 22, kind: 'vault' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,3 +2,4 @@ export { default } from './Lookup';
|
|||||||
export { default as InstanceGroupsLookup } from './InstanceGroupsLookup';
|
export { default as InstanceGroupsLookup } from './InstanceGroupsLookup';
|
||||||
export { default as InventoryLookup } from './InventoryLookup';
|
export { default as InventoryLookup } from './InventoryLookup';
|
||||||
export { default as ProjectLookup } from './ProjectLookup';
|
export { default as ProjectLookup } from './ProjectLookup';
|
||||||
|
export { default as MultiCredentialsLookup } from './MultiCredentialsLookup';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { Component } from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Split as PFSplit, SplitItem } from '@patternfly/react-core';
|
import { Split as PFSplit, SplitItem } from '@patternfly/react-core';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { ChipGroup, Chip } from '../Chip';
|
import { ChipGroup, Chip, CredentialChip } from '../Chip';
|
||||||
import VerticalSeparator from '../VerticalSeparator';
|
import VerticalSeparator from '../VerticalSeparator';
|
||||||
|
|
||||||
const Split = styled(PFSplit)`
|
const Split = styled(PFSplit)`
|
||||||
@@ -27,23 +27,34 @@ class SelectedList extends Component {
|
|||||||
onRemove,
|
onRemove,
|
||||||
displayKey,
|
displayKey,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
|
isCredentialList,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
const chips = isCredentialList
|
||||||
|
? selected.map(item => (
|
||||||
|
<CredentialChip
|
||||||
|
key={item.id}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
onClick={() => onRemove(item)}
|
||||||
|
credential={item}
|
||||||
|
>
|
||||||
|
{item[displayKey]}
|
||||||
|
</CredentialChip>
|
||||||
|
))
|
||||||
|
: selected.map(item => (
|
||||||
|
<Chip
|
||||||
|
key={item.id}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
onClick={() => onRemove(item)}
|
||||||
|
>
|
||||||
|
{item[displayKey]}
|
||||||
|
</Chip>
|
||||||
|
));
|
||||||
return (
|
return (
|
||||||
<Split>
|
<Split>
|
||||||
<SplitLabelItem>{label}</SplitLabelItem>
|
<SplitLabelItem>{label}</SplitLabelItem>
|
||||||
<VerticalSeparator />
|
<VerticalSeparator />
|
||||||
<SplitItem>
|
<SplitItem>
|
||||||
<ChipGroup showOverflowAfter={showOverflowAfter}>
|
<ChipGroup showOverflowAfter={showOverflowAfter}>{chips}</ChipGroup>
|
||||||
{selected.map(item => (
|
|
||||||
<Chip
|
|
||||||
key={item.id}
|
|
||||||
isReadOnly={isReadOnly}
|
|
||||||
onClick={() => onRemove(item)}
|
|
||||||
>
|
|
||||||
{item[displayKey]}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</ChipGroup>
|
|
||||||
</SplitItem>
|
</SplitItem>
|
||||||
</Split>
|
</Split>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function JobTemplateAdd({ history, i18n }) {
|
|||||||
organizationId,
|
organizationId,
|
||||||
instanceGroups,
|
instanceGroups,
|
||||||
initialInstanceGroups,
|
initialInstanceGroups,
|
||||||
|
credentials,
|
||||||
...remainingValues
|
...remainingValues
|
||||||
} = values;
|
} = values;
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ function JobTemplateAdd({ history, i18n }) {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
submitLabels(id, labels, organizationId),
|
submitLabels(id, labels, organizationId),
|
||||||
submitInstanceGroups(id, instanceGroups),
|
submitInstanceGroups(id, instanceGroups),
|
||||||
|
submitCredentials(id, credentials),
|
||||||
]);
|
]);
|
||||||
history.push(`/templates/${type}/${id}/details`);
|
history.push(`/templates/${type}/${id}/details`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -60,6 +62,13 @@ function JobTemplateAdd({ history, i18n }) {
|
|||||||
return Promise.all(associatePromises);
|
return Promise.all(associatePromises);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitCredentials(templateId, credentials = []) {
|
||||||
|
const associateCredentials = credentials.map(cred =>
|
||||||
|
JobTemplatesAPI.associateCredentials(templateId, cred.id)
|
||||||
|
);
|
||||||
|
return Promise.all(associateCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
history.push(`/templates`);
|
history.push(`/templates`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ class JobTemplateEdit extends Component {
|
|||||||
organizationId,
|
organizationId,
|
||||||
instanceGroups,
|
instanceGroups,
|
||||||
initialInstanceGroups,
|
initialInstanceGroups,
|
||||||
|
credentials,
|
||||||
...remainingValues
|
...remainingValues
|
||||||
} = values;
|
} = values;
|
||||||
|
|
||||||
@@ -118,6 +119,7 @@ class JobTemplateEdit extends Component {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.submitLabels(labels, organizationId),
|
this.submitLabels(labels, organizationId),
|
||||||
this.submitInstanceGroups(instanceGroups, initialInstanceGroups),
|
this.submitInstanceGroups(instanceGroups, initialInstanceGroups),
|
||||||
|
this.submitCredentials(credentials),
|
||||||
]);
|
]);
|
||||||
history.push(this.detailsUrl);
|
history.push(this.detailsUrl);
|
||||||
} catch (formSubmitError) {
|
} catch (formSubmitError) {
|
||||||
@@ -154,13 +156,30 @@ class JobTemplateEdit extends Component {
|
|||||||
async submitInstanceGroups(groups, initialGroups) {
|
async submitInstanceGroups(groups, initialGroups) {
|
||||||
const { template } = this.props;
|
const { template } = this.props;
|
||||||
const { added, removed } = getAddedAndRemoved(initialGroups, groups);
|
const { added, removed } = getAddedAndRemoved(initialGroups, groups);
|
||||||
const associatePromises = added.map(group =>
|
const disassociatePromises = await removed.map(group =>
|
||||||
JobTemplatesAPI.associateInstanceGroup(template.id, group.id)
|
|
||||||
);
|
|
||||||
const disassociatePromises = removed.map(group =>
|
|
||||||
JobTemplatesAPI.disassociateInstanceGroup(template.id, group.id)
|
JobTemplatesAPI.disassociateInstanceGroup(template.id, group.id)
|
||||||
);
|
);
|
||||||
return Promise.all([...associatePromises, ...disassociatePromises]);
|
const associatePromises = await added.map(group =>
|
||||||
|
JobTemplatesAPI.associateInstanceGroup(template.id, group.id)
|
||||||
|
);
|
||||||
|
return Promise.all([...disassociatePromises, ...associatePromises]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitCredentials(newCredentials) {
|
||||||
|
const { template } = this.props;
|
||||||
|
const { added, removed } = getAddedAndRemoved(
|
||||||
|
template.summary_fields.credentials,
|
||||||
|
newCredentials
|
||||||
|
);
|
||||||
|
const disassociateCredentials = removed.map(cred =>
|
||||||
|
JobTemplatesAPI.disassociateCredentials(template.id, cred.id)
|
||||||
|
);
|
||||||
|
const disassociatePromise = await Promise.all(disassociateCredentials);
|
||||||
|
const associateCredentials = added.map(cred =>
|
||||||
|
JobTemplatesAPI.associateCredentials(template.id, cred.id)
|
||||||
|
);
|
||||||
|
const associatePromise = Promise.all(associateCredentials);
|
||||||
|
return Promise.all([disassociatePromise, associatePromise]);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCancel() {
|
handleCancel() {
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ const mockJobTemplate = {
|
|||||||
id: 2,
|
id: 2,
|
||||||
organization_id: 1,
|
organization_id: 1,
|
||||||
},
|
},
|
||||||
|
credentials: [
|
||||||
|
{ id: 1, kind: 'cloud', name: 'Foo' },
|
||||||
|
{ id: 2, kind: 'ssh', name: 'Bar' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
InventoryLookup,
|
InventoryLookup,
|
||||||
InstanceGroupsLookup,
|
InstanceGroupsLookup,
|
||||||
ProjectLookup,
|
ProjectLookup,
|
||||||
|
MultiCredentialsLookup,
|
||||||
} from '@components/Lookup';
|
} from '@components/Lookup';
|
||||||
import { JobTemplatesAPI } from '@api';
|
import { JobTemplatesAPI } from '@api';
|
||||||
import LabelSelect from './LabelSelect';
|
import LabelSelect from './LabelSelect';
|
||||||
@@ -61,6 +62,7 @@ class JobTemplateForm extends Component {
|
|||||||
inventory: null,
|
inventory: null,
|
||||||
labels: { results: [] },
|
labels: { results: [] },
|
||||||
project: null,
|
project: null,
|
||||||
|
credentials: [],
|
||||||
},
|
},
|
||||||
isNew: true,
|
isNew: true,
|
||||||
},
|
},
|
||||||
@@ -148,7 +150,6 @@ class JobTemplateForm extends Component {
|
|||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const verbosityOptions = [
|
const verbosityOptions = [
|
||||||
{ value: '0', key: '0', label: i18n._(t`0 (Normal)`) },
|
{ value: '0', key: '0', label: i18n._(t`0 (Normal)`) },
|
||||||
{ value: '1', key: '1', label: i18n._(t`1 (Verbose)`) },
|
{ value: '1', key: '1', label: i18n._(t`1 (Verbose)`) },
|
||||||
@@ -310,6 +311,24 @@ class JobTemplateForm extends Component {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<Field
|
||||||
|
name="credentials"
|
||||||
|
fieldId="template-credentials"
|
||||||
|
render={({ field }) => (
|
||||||
|
<MultiCredentialsLookup
|
||||||
|
credentials={field.value}
|
||||||
|
onChange={newCredentials =>
|
||||||
|
setFieldValue('credentials', newCredentials)
|
||||||
|
}
|
||||||
|
onError={err => this.setState({ contentError: err })}
|
||||||
|
tooltip={i18n._(
|
||||||
|
t`Select credentials that allow Tower to access the nodes this job will be ran against. You can only select one credential of each type. For machine credentials (SSH), checking "Prompt on launch" without selecting credentials will require you to select a machine credential at run time. If you select credentials and check "Prompt on launch", the selected credential(s) become the defaults that can be updated at run time.`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
<AdvancedFieldsWrapper label="Advanced">
|
<AdvancedFieldsWrapper label="Advanced">
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -578,6 +597,7 @@ const FormikApp = withFormik({
|
|||||||
organizationId: summary_fields.inventory.organization_id || null,
|
organizationId: summary_fields.inventory.organization_id || null,
|
||||||
initialInstanceGroups: [],
|
initialInstanceGroups: [],
|
||||||
instanceGroups: [],
|
instanceGroups: [],
|
||||||
|
credentials: summary_fields.credentials || [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
handleSubmit: (values, { props }) => props.handleSubmit(values),
|
handleSubmit: (values, { props }) => props.handleSubmit(values),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { act } from 'react-dom/test-utils';
|
|||||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
import { sleep } from '@testUtils/testUtils';
|
import { sleep } from '@testUtils/testUtils';
|
||||||
import JobTemplateForm from './JobTemplateForm';
|
import JobTemplateForm from './JobTemplateForm';
|
||||||
import { LabelsAPI, JobTemplatesAPI, ProjectsAPI } from '@api';
|
import { LabelsAPI, JobTemplatesAPI, ProjectsAPI, CredentialsAPI } from '@api';
|
||||||
|
|
||||||
jest.mock('@api');
|
jest.mock('@api');
|
||||||
|
|
||||||
@@ -28,6 +28,10 @@ describe('<JobTemplateForm />', () => {
|
|||||||
name: 'qux',
|
name: 'qux',
|
||||||
},
|
},
|
||||||
labels: { results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }] },
|
labels: { results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }] },
|
||||||
|
credentials: [
|
||||||
|
{ id: 1, kind: 'cloud', name: 'Foo' },
|
||||||
|
{ id: 2, kind: 'ssh', name: 'Bar' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const mockInstanceGroups = [
|
const mockInstanceGroups = [
|
||||||
@@ -55,10 +59,21 @@ describe('<JobTemplateForm />', () => {
|
|||||||
policy_instance_list: [],
|
policy_instance_list: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const mockCredentials = [
|
||||||
|
{ id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' },
|
||||||
|
{ id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' },
|
||||||
|
{ id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' },
|
||||||
|
{ id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' },
|
||||||
|
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
|
||||||
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
LabelsAPI.read.mockReturnValue({
|
LabelsAPI.read.mockReturnValue({
|
||||||
data: mockData.summary_fields.labels,
|
data: mockData.summary_fields.labels,
|
||||||
});
|
});
|
||||||
|
CredentialsAPI.read.mockReturnValue({
|
||||||
|
data: { results: mockCredentials },
|
||||||
|
});
|
||||||
JobTemplatesAPI.readInstanceGroups.mockReturnValue({
|
JobTemplatesAPI.readInstanceGroups.mockReturnValue({
|
||||||
data: { results: mockInstanceGroups },
|
data: { results: mockInstanceGroups },
|
||||||
});
|
});
|
||||||
@@ -134,6 +149,13 @@ describe('<JobTemplateForm />', () => {
|
|||||||
target: { value: 'new baz type', name: 'playbook' },
|
target: { value: 'new baz type', name: 'playbook' },
|
||||||
});
|
});
|
||||||
expect(form.state('values').playbook).toEqual('new baz type');
|
expect(form.state('values').playbook).toEqual('new baz type');
|
||||||
|
wrapper
|
||||||
|
.find('CredentialChip')
|
||||||
|
.at(0)
|
||||||
|
.prop('onClick')();
|
||||||
|
expect(form.state('values').credentials).toEqual([
|
||||||
|
{ id: 2, kind: 'ssh', name: 'Bar' },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should call handleSubmit when Submit button is clicked', async () => {
|
test('should call handleSubmit when Submit button is clicked', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user