Merge pull request #8281 from mabashian/7835-galaxy-cred-org-2

Add Galaxy Credentials field to organizations form

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-10-20 15:37:08 +00:00
committed by GitHub
11 changed files with 400 additions and 235 deletions

View File

@@ -24,6 +24,12 @@ class Organizations extends InstanceGroupsMixin(NotificationsMixin(Base)) {
return this.http.options(`${this.baseUrl}${id}/teams/`); return this.http.options(`${this.baseUrl}${id}/teams/`);
} }
readGalaxyCredentials(id, params) {
return this.http.get(`${this.baseUrl}${id}/galaxy_credentials/`, {
params,
});
}
createUser(id, data) { createUser(id, data) {
return this.http.post(`${this.baseUrl}${id}/users/`, data); return this.http.post(`${this.baseUrl}${id}/users/`, data);
} }
@@ -48,6 +54,19 @@ class Organizations extends InstanceGroupsMixin(NotificationsMixin(Base)) {
{ id: notificationId, disassociate: true } { id: notificationId, disassociate: true }
); );
} }
associateGalaxyCredential(resourceId, credentialId) {
return this.http.post(`${this.baseUrl}${resourceId}/galaxy_credentials/`, {
id: credentialId,
});
}
disassociateGalaxyCredential(resourceId, credentialId) {
return this.http.post(`${this.baseUrl}${resourceId}/galaxy_credentials/`, {
id: credentialId,
disassociate: true,
});
}
} }
export default Organizations; export default Organizations;

View File

@@ -1,5 +1,13 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { bool, func, node, number, string, oneOfType } from 'prop-types'; import {
arrayOf,
bool,
func,
node,
number,
string,
oneOfType,
} from 'prop-types';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -36,6 +44,7 @@ function CredentialLookup({
tooltip, tooltip,
isDisabled, isDisabled,
autoPopulate, autoPopulate,
multiple,
}) { }) {
const autoPopulateLookup = useAutoPopulateLookup(onChange); const autoPopulateLookup = useAutoPopulateLookup(onChange);
const { const {
@@ -120,6 +129,7 @@ function CredentialLookup({
required={required} required={required}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
isDisabled={isDisabled} isDisabled={isDisabled}
multiple={multiple}
renderOptionsList={({ state, dispatch, canDelete }) => ( renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList <OptionsList
value={state.selectedItems} value={state.selectedItems}
@@ -154,6 +164,7 @@ function CredentialLookup({
name="credential" name="credential"
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })} selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
multiple={multiple}
/> />
)} )}
/> />
@@ -188,10 +199,11 @@ CredentialLookup.propTypes = {
helperTextInvalid: node, helperTextInvalid: node,
isValid: bool, isValid: bool,
label: string.isRequired, label: string.isRequired,
multiple: bool,
onBlur: func, onBlur: func,
onChange: func.isRequired, onChange: func.isRequired,
required: bool, required: bool,
value: Credential, value: oneOfType([Credential, arrayOf(Credential)]),
isDisabled: bool, isDisabled: bool,
autoPopulate: bool, autoPopulate: bool,
}; };
@@ -201,6 +213,7 @@ CredentialLookup.defaultProps = {
credentialTypeKind: '', credentialTypeKind: '',
helperTextInvalid: '', helperTextInvalid: '',
isValid: true, isValid: true,
multiple: false,
onBlur: () => {}, onBlur: () => {},
required: false, required: false,
value: null, value: null,

View File

@@ -1,9 +1,19 @@
import React, { Component } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Switch, Route, withRouter, Redirect, Link } from 'react-router-dom'; import {
Switch,
Route,
withRouter,
Redirect,
Link,
useLocation,
useParams,
useRouteMatch,
} from 'react-router-dom';
import { CaretLeftIcon } from '@patternfly/react-icons'; import { CaretLeftIcon } from '@patternfly/react-icons';
import { Card, PageSection } from '@patternfly/react-core'; import { Card, PageSection } from '@patternfly/react-core';
import useRequest from '../../util/useRequest';
import RoutedTabs from '../../components/RoutedTabs'; import RoutedTabs from '../../components/RoutedTabs';
import ContentError from '../../components/ContentError'; import ContentError from '../../components/ContentError';
import NotificationList from '../../components/NotificationList/NotificationList'; import NotificationList from '../../components/NotificationList/NotificationList';
@@ -13,214 +23,207 @@ import OrganizationEdit from './OrganizationEdit';
import OrganizationTeams from './OrganizationTeams'; import OrganizationTeams from './OrganizationTeams';
import { OrganizationsAPI } from '../../api'; import { OrganizationsAPI } from '../../api';
class Organization extends Component { function Organization({ i18n, setBreadcrumb, me }) {
constructor(props) { const location = useLocation();
super(props); const { id: organizationId } = useParams();
const match = useRouteMatch();
const initialUpdate = useRef(true);
this.state = { const {
organization: null, result: { organization },
hasContentLoading: true, isLoading: organizationLoading,
contentError: null, error: organizationError,
isInitialized: false, request: loadOrganization,
isNotifAdmin: false, } = useRequest(
isAuditorOfThisOrg: false, useCallback(async () => {
isAdminOfThisOrg: false, const [{ data }, credentialsRes] = await Promise.all([
}; OrganizationsAPI.readDetail(organizationId),
this.loadOrganization = this.loadOrganization.bind(this); OrganizationsAPI.readGalaxyCredentials(organizationId),
this.loadOrganizationAndRoles = this.loadOrganizationAndRoles.bind(this); ]);
} data.galaxy_credentials = credentialsRes.data.results;
async componentDidMount() {
await this.loadOrganizationAndRoles();
this.setState({ isInitialized: true });
}
async componentDidUpdate(prevProps) {
const { location, match } = this.props;
const url = `/organizations/${match.params.id}/`;
if (
prevProps.location.pathname.startsWith(url) &&
prevProps.location !== location &&
location.pathname === `${url}details`
) {
await this.loadOrganization();
}
}
async loadOrganizationAndRoles() {
const { match, setBreadcrumb } = this.props;
const id = parseInt(match.params.id, 10);
this.setState({ contentError: null, hasContentLoading: true });
try {
const [{ data }, notifAdminRes, auditorRes, adminRes] = await Promise.all(
[
OrganizationsAPI.readDetail(id),
OrganizationsAPI.read({
page_size: 1,
role_level: 'notification_admin_role',
}),
OrganizationsAPI.read({ id, role_level: 'auditor_role' }),
OrganizationsAPI.read({ id, role_level: 'admin_role' }),
]
);
setBreadcrumb(data); setBreadcrumb(data);
this.setState({
return {
organization: data, organization: data,
};
}, [setBreadcrumb, organizationId]),
{
organization: null,
}
);
const {
result: { isNotifAdmin, isAuditorOfThisOrg, isAdminOfThisOrg },
isLoading: rolesLoading,
error: rolesError,
request: loadRoles,
} = useRequest(
useCallback(async () => {
const [notifAdminRes, auditorRes, adminRes] = await Promise.all([
OrganizationsAPI.read({
page_size: 1,
role_level: 'notification_admin_role',
}),
OrganizationsAPI.read({
id: organizationId,
role_level: 'auditor_role',
}),
OrganizationsAPI.read({
id: organizationId,
role_level: 'admin_role',
}),
]);
return {
isNotifAdmin: notifAdminRes.data.results.length > 0, isNotifAdmin: notifAdminRes.data.results.length > 0,
isAuditorOfThisOrg: auditorRes.data.results.length > 0, isAuditorOfThisOrg: auditorRes.data.results.length > 0,
isAdminOfThisOrg: adminRes.data.results.length > 0, isAdminOfThisOrg: adminRes.data.results.length > 0,
}); };
} catch (err) { }, [organizationId]),
this.setState({ contentError: err }); {
} finally { isNotifAdmin: false,
this.setState({ hasContentLoading: false }); isAuditorOfThisOrg: false,
isAdminOfThisOrg: false,
} }
);
useEffect(() => {
loadOrganization();
loadRoles();
}, [loadOrganization, loadRoles]);
useEffect(() => {
if (initialUpdate.current) {
initialUpdate.current = false;
return;
}
if (location.pathname === `/organizations/${organizationId}/details`) {
loadOrganization();
}
}, [loadOrganization, organizationId, location.pathname]);
const canSeeNotificationsTab =
me.is_system_auditor || isNotifAdmin || isAuditorOfThisOrg;
const canToggleNotifications =
isNotifAdmin &&
(me.is_system_auditor || isAuditorOfThisOrg || isAdminOfThisOrg);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Organizations`)}
</>
),
link: `/organizations`,
id: 99,
},
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
{ name: i18n._(t`Access`), link: `${match.url}/access`, id: 1 },
{ name: i18n._(t`Teams`), link: `${match.url}/teams`, id: 2 },
];
if (canSeeNotificationsTab) {
tabsArray.push({
name: i18n._(t`Notifications`),
link: `${match.url}/notifications`,
id: 3,
});
} }
async loadOrganization() { let showCardHeader = true;
const { match, setBreadcrumb } = this.props;
const id = parseInt(match.params.id, 10);
this.setState({ contentError: null, hasContentLoading: true }); if (location.pathname.endsWith('edit')) {
try { showCardHeader = false;
const { data } = await OrganizationsAPI.readDetail(id);
setBreadcrumb(data);
this.setState({ organization: data });
} catch (err) {
this.setState({ contentError: err });
} finally {
this.setState({ hasContentLoading: false });
}
} }
render() { if (!organizationLoading && organizationError) {
const { location, match, me, i18n } = this.props;
const {
organization,
contentError,
hasContentLoading,
isInitialized,
isNotifAdmin,
isAuditorOfThisOrg,
isAdminOfThisOrg,
} = this.state;
const canSeeNotificationsTab =
me.is_system_auditor || isNotifAdmin || isAuditorOfThisOrg;
const canToggleNotifications =
isNotifAdmin &&
(me.is_system_auditor || isAuditorOfThisOrg || isAdminOfThisOrg);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Organizations`)}
</>
),
link: `/organizations`,
id: 99,
},
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
{ name: i18n._(t`Access`), link: `${match.url}/access`, id: 1 },
{ name: i18n._(t`Teams`), link: `${match.url}/teams`, id: 2 },
];
if (canSeeNotificationsTab) {
tabsArray.push({
name: i18n._(t`Notifications`),
link: `${match.url}/notifications`,
id: 3,
});
}
let showCardHeader = true;
if (!isInitialized || location.pathname.endsWith('edit')) {
showCardHeader = false;
}
if (!hasContentLoading && contentError) {
return (
<PageSection>
<Card>
<ContentError error={contentError}>
{contentError.response.status === 404 && (
<span>
{i18n._(t`Organization not found.`)}{' '}
<Link to="/organizations">
{i18n._(t`View all Organizations.`)}
</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
return ( return (
<PageSection> <PageSection>
<Card> <Card>
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />} <ContentError error={organizationError}>
<Switch> {organizationError.response.status === 404 && (
<Redirect <span>
from="/organizations/:id" {i18n._(t`Organization not found.`)}{' '}
to="/organizations/:id/details" <Link to="/organizations">
exact {i18n._(t`View all Organizations.`)}
/> </Link>
{organization && ( </span>
<Route path="/organizations/:id/edit">
<OrganizationEdit organization={organization} />
</Route>
)} )}
{organization && ( </ContentError>
<Route path="/organizations/:id/details">
<OrganizationDetail organization={organization} />
</Route>
)}
{organization && (
<Route path="/organizations/:id/access">
<ResourceAccessList
resource={organization}
apiModel={OrganizationsAPI}
/>
</Route>
)}
<Route path="/organizations/:id/teams">
<OrganizationTeams id={Number(match.params.id)} />
</Route>
{canSeeNotificationsTab && (
<Route path="/organizations/:id/notifications">
<NotificationList
id={Number(match.params.id)}
canToggleNotifications={canToggleNotifications}
apiModel={OrganizationsAPI}
showApprovalsToggle
/>
</Route>
)}
<Route key="not-found" path="*">
{!hasContentLoading && (
<ContentError isNotFound>
{match.params.id && (
<Link to={`/organizations/${match.params.id}/details`}>
{i18n._(t`View Organization Details`)}
</Link>
)}
</ContentError>
)}
</Route>
,
</Switch>
</Card> </Card>
</PageSection> </PageSection>
); );
} }
if (!rolesLoading && rolesError) {
return (
<PageSection>
<Card>
<ContentError error={rolesError} />
</Card>
</PageSection>
);
}
return (
<PageSection>
<Card>
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />}
<Switch>
<Redirect
from="/organizations/:id"
to="/organizations/:id/details"
exact
/>
{organization && (
<Route path="/organizations/:id/edit">
<OrganizationEdit organization={organization} />
</Route>
)}
{organization && (
<Route path="/organizations/:id/details">
<OrganizationDetail organization={organization} />
</Route>
)}
{organization && (
<Route path="/organizations/:id/access">
<ResourceAccessList
resource={organization}
apiModel={OrganizationsAPI}
/>
</Route>
)}
<Route path="/organizations/:id/teams">
<OrganizationTeams id={Number(match.params.id)} />
</Route>
{canSeeNotificationsTab && (
<Route path="/organizations/:id/notifications">
<NotificationList
id={Number(match.params.id)}
canToggleNotifications={canToggleNotifications}
apiModel={OrganizationsAPI}
showApprovalsToggle
/>
</Route>
)}
<Route key="not-found" path="*">
{!organizationLoading && !rolesLoading && (
<ContentError isNotFound>
{match.params.id && (
<Link to={`/organizations/${match.params.id}/details`}>
{i18n._(t`View Organization Details`)}
</Link>
)}
</ContentError>
)}
</Route>
,
</Switch>
</Card>
</PageSection>
);
} }
export default withI18n()(withRouter(Organization)); export default withI18n()(withRouter(Organization));

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { OrganizationsAPI } from '../../api'; import { OrganizationsAPI } from '../../api';
import { import {
@@ -37,30 +38,44 @@ async function getOrganizations(params) {
} }
describe('<Organization />', () => { describe('<Organization />', () => {
test('initially renders succesfully', () => { let wrapper;
beforeAll(() => {
OrganizationsAPI.readDetail.mockResolvedValue({ data: mockOrganization }); OrganizationsAPI.readDetail.mockResolvedValue({ data: mockOrganization });
OrganizationsAPI.readGalaxyCredentials.mockResolvedValue({
data: {
results: [],
},
});
});
test('initially renders succesfully', async () => {
OrganizationsAPI.read.mockImplementation(getOrganizations); OrganizationsAPI.read.mockImplementation(getOrganizations);
mountWithContexts(<Organization setBreadcrumb={() => {}} me={mockMe} />); await act(async () => {
mountWithContexts(<Organization setBreadcrumb={() => {}} me={mockMe} />);
});
}); });
test('notifications tab shown for admins', async done => { test('notifications tab shown for admins', async done => {
OrganizationsAPI.readDetail.mockResolvedValue({ data: mockOrganization });
OrganizationsAPI.read.mockImplementation(getOrganizations); OrganizationsAPI.read.mockImplementation(getOrganizations);
const wrapper = mountWithContexts( await act(async () => {
<Organization setBreadcrumb={() => {}} me={mockMe} /> wrapper = mountWithContexts(
); <Organization setBreadcrumb={() => {}} me={mockMe} />
);
});
const tabs = await waitForElement( const tabs = await waitForElement(
wrapper, wrapper,
'.pf-c-tabs__item', '.pf-c-tabs__item',
el => el.length === 5 el => el.length === 5
); );
expect(tabs.last().text()).toEqual('Notifications'); expect(tabs.last().text()).toEqual('Notifications');
wrapper.unmount();
done(); done();
}); });
test('notifications tab hidden with reduced permissions', async done => { test('notifications tab hidden with reduced permissions', async done => {
OrganizationsAPI.readDetail.mockResolvedValue({ data: mockOrganization });
OrganizationsAPI.read.mockResolvedValue({ OrganizationsAPI.read.mockResolvedValue({
count: 0, count: 0,
next: null, next: null,
@@ -68,15 +83,19 @@ describe('<Organization />', () => {
data: { results: [] }, data: { results: [] },
}); });
const wrapper = mountWithContexts( await act(async () => {
<Organization setBreadcrumb={() => {}} me={mockMe} /> wrapper = mountWithContexts(
); <Organization setBreadcrumb={() => {}} me={mockMe} />
);
});
const tabs = await waitForElement( const tabs = await waitForElement(
wrapper, wrapper,
'.pf-c-tabs__item', '.pf-c-tabs__item',
el => el.length === 4 el => el.length === 4
); );
tabs.forEach(tab => expect(tab.text()).not.toEqual('Notifications')); tabs.forEach(tab => expect(tab.text()).not.toEqual('Notifications'));
wrapper.unmount();
done(); done();
}); });
@@ -84,24 +103,27 @@ describe('<Organization />', () => {
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: ['/organizations/1/foobar'], initialEntries: ['/organizations/1/foobar'],
}); });
const wrapper = mountWithContexts( await act(async () => {
<Organization setBreadcrumb={() => {}} me={mockMe} />, wrapper = mountWithContexts(
{ <Organization setBreadcrumb={() => {}} me={mockMe} />,
context: { {
router: { context: {
history, router: {
route: { history,
location: history.location, route: {
match: { location: history.location,
params: { id: 1 }, match: {
url: '/organizations/1/foobar', params: { id: 1 },
path: '/organizations/1/foobar', url: '/organizations/1/foobar',
path: '/organizations/1/foobar',
},
}, },
}, },
}, },
}, }
} );
); });
await waitForElement(wrapper, 'ContentError', el => el.length === 1); await waitForElement(wrapper, 'ContentError', el => el.length === 1);
wrapper.unmount();
}); });
}); });

View File

@@ -16,9 +16,13 @@ function OrganizationAdd() {
try { try {
const { data: response } = await OrganizationsAPI.create(values); const { data: response } = await OrganizationsAPI.create(values);
await Promise.all( await Promise.all(
groupsToAssociate.map(id => groupsToAssociate
OrganizationsAPI.associateInstanceGroup(response.id, id) .map(id => OrganizationsAPI.associateInstanceGroup(response.id, id))
) .concat(
values.galaxy_credentials.map(({ id: credId }) =>
OrganizationsAPI.associateGalaxyCredential(response.id, credId)
)
)
); );
history.push(`/organizations/${response.id}`); history.push(`/organizations/${response.id}`);
} catch (error) { } catch (error) {

View File

@@ -16,11 +16,12 @@ describe('<OrganizationAdd />', () => {
name: 'new name', name: 'new name',
description: 'new description', description: 'new description',
custom_virtualenv: 'Buzz', custom_virtualenv: 'Buzz',
galaxy_credentials: [],
}; };
OrganizationsAPI.create.mockResolvedValueOnce({ data: {} }); OrganizationsAPI.create.mockResolvedValueOnce({ data: {} });
await act(async () => { await act(async () => {
const wrapper = mountWithContexts(<OrganizationAdd />); const wrapper = mountWithContexts(<OrganizationAdd />);
wrapper.find('OrganizationForm').prop('onSubmit')(updatedOrgData, [], []); wrapper.find('OrganizationForm').prop('onSubmit')(updatedOrgData, []);
}); });
expect(OrganizationsAPI.create).toHaveBeenCalledWith(updatedOrgData); expect(OrganizationsAPI.create).toHaveBeenCalledWith(updatedOrgData);
}); });
@@ -46,6 +47,7 @@ describe('<OrganizationAdd />', () => {
name: 'new name', name: 'new name',
description: 'new description', description: 'new description',
custom_virtualenv: 'Buzz', custom_virtualenv: 'Buzz',
galaxy_credentials: [],
}; };
OrganizationsAPI.create.mockResolvedValueOnce({ OrganizationsAPI.create.mockResolvedValueOnce({
data: { data: {
@@ -62,7 +64,7 @@ describe('<OrganizationAdd />', () => {
context: { router: { history } }, context: { router: { history } },
}); });
await waitForElement(wrapper, 'button[aria-label="Save"]'); await waitForElement(wrapper, 'button[aria-label="Save"]');
await wrapper.find('OrganizationForm').prop('onSubmit')(orgData, [3], []); await wrapper.find('OrganizationForm').prop('onSubmit')(orgData, [3]);
}); });
expect(history.location.pathname).toEqual('/organizations/5'); expect(history.location.pathname).toEqual('/organizations/5');
}); });
@@ -72,6 +74,7 @@ describe('<OrganizationAdd />', () => {
name: 'new name', name: 'new name',
description: 'new description', description: 'new description',
custom_virtualenv: 'Buzz', custom_virtualenv: 'Buzz',
galaxy_credentials: [],
}; };
OrganizationsAPI.create.mockResolvedValueOnce({ OrganizationsAPI.create.mockResolvedValueOnce({
data: { data: {
@@ -87,10 +90,42 @@ describe('<OrganizationAdd />', () => {
wrapper = mountWithContexts(<OrganizationAdd />); wrapper = mountWithContexts(<OrganizationAdd />);
}); });
await waitForElement(wrapper, 'button[aria-label="Save"]'); await waitForElement(wrapper, 'button[aria-label="Save"]');
await wrapper.find('OrganizationForm').prop('onSubmit')(orgData, [3], []); await wrapper.find('OrganizationForm').prop('onSubmit')(orgData, [3]);
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(5, 3); expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(5, 3);
}); });
test('onSubmit should post galaxy credentials', async () => {
const orgData = {
name: 'new name',
description: 'new description',
custom_virtualenv: 'Buzz',
galaxy_credentials: [
{
id: 9000,
},
],
};
OrganizationsAPI.create.mockResolvedValueOnce({
data: {
id: 5,
related: {
instance_groups: '/api/v2/organizations/5/instance_groups',
},
...orgData,
},
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<OrganizationAdd />);
});
await waitForElement(wrapper, 'button[aria-label="Save"]');
await wrapper.find('OrganizationForm').prop('onSubmit')(orgData, [3]);
expect(OrganizationsAPI.associateGalaxyCredential).toHaveBeenCalledWith(
5,
9000
);
});
test('AnsibleSelect component renders if there are virtual environments', async () => { test('AnsibleSelect component renders if there are virtual environments', async () => {
const mockInstanceGroups = [ const mockInstanceGroups = [
{ name: 'One', id: 1 }, { name: 'One', id: 1 },

View File

@@ -12,6 +12,7 @@ import {
import { CardBody, CardActionsRow } from '../../../components/Card'; import { CardBody, CardActionsRow } from '../../../components/Card';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import ChipGroup from '../../../components/ChipGroup'; import ChipGroup from '../../../components/ChipGroup';
import CredentialChip from '../../../components/CredentialChip';
import ContentError from '../../../components/ContentError'; import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading'; import ContentLoading from '../../../components/ContentLoading';
import DeleteButton from '../../../components/DeleteButton'; import DeleteButton from '../../../components/DeleteButton';
@@ -30,6 +31,7 @@ function OrganizationDetail({ i18n, organization }) {
created, created,
modified, modified,
summary_fields, summary_fields,
galaxy_credentials,
} = organization; } = organization;
const [contentError, setContentError] = useState(null); const [contentError, setContentError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(true); const [hasContentLoading, setHasContentLoading] = useState(true);
@@ -113,6 +115,23 @@ function OrganizationDetail({ i18n, organization }) {
} }
/> />
)} )}
{galaxy_credentials && galaxy_credentials.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Galaxy Credentials`)}
value={
<ChipGroup numChips={5} totalChips={galaxy_credentials.length}>
{galaxy_credentials.map(credential => (
<CredentialChip
credential={credential}
key={credential.id}
isReadOnly
/>
))}
</ChipGroup>
}
/>
)}
</DetailList> </DetailList>
<CardActionsRow> <CardActionsRow>
{summary_fields.user_capabilities.edit && ( {summary_fields.user_capabilities.edit && (

View File

@@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom';
import { CardBody } from '../../../components/Card'; import { CardBody } from '../../../components/Card';
import { OrganizationsAPI } from '../../../api'; import { OrganizationsAPI } from '../../../api';
import { Config } from '../../../contexts/Config'; import { Config } from '../../../contexts/Config';
import { getAddedAndRemoved } from '../../../util/lists';
import OrganizationForm from '../shared/OrganizationForm'; import OrganizationForm from '../shared/OrganizationForm';
function OrganizationEdit({ organization }) { function OrganizationEdit({ organization }) {
@@ -18,16 +18,39 @@ function OrganizationEdit({ organization }) {
groupsToDisassociate groupsToDisassociate
) => { ) => {
try { try {
const {
added: addedCredentials,
removed: removedCredentials,
} = getAddedAndRemoved(
organization.galaxy_credentials,
values.galaxy_credentials
);
const addedCredentialIds = addedCredentials.map(({ id }) => id);
const removedCredentialIds = removedCredentials.map(({ id }) => id);
await OrganizationsAPI.update(organization.id, values); await OrganizationsAPI.update(organization.id, values);
await Promise.all( await Promise.all(
groupsToAssociate.map(id => groupsToAssociate
OrganizationsAPI.associateInstanceGroup(organization.id, id) .map(id =>
) OrganizationsAPI.associateInstanceGroup(organization.id, id)
)
.concat(
addedCredentialIds.map(id =>
OrganizationsAPI.associateGalaxyCredential(organization.id, id)
)
)
); );
await Promise.all( await Promise.all(
groupsToDisassociate.map(id => groupsToDisassociate
OrganizationsAPI.disassociateInstanceGroup(organization.id, id) .map(id =>
) OrganizationsAPI.disassociateInstanceGroup(organization.id, id)
)
.concat(
removedCredentialIds.map(id =>
OrganizationsAPI.disassociateGalaxyCredential(organization.id, id)
)
)
); );
history.push(detailsUrl); history.push(detailsUrl);
} catch (error) { } catch (error) {

View File

@@ -48,7 +48,7 @@ class Organizations extends Component {
}; };
render() { render() {
const { match, history, location } = this.props; const { match } = this.props;
const { breadcrumbConfig } = this.state; const { breadcrumbConfig } = this.state;
return ( return (
@@ -62,8 +62,6 @@ class Organizations extends Component {
<Config> <Config>
{({ me }) => ( {({ me }) => (
<Organization <Organization
history={history}
location={location}
setBreadcrumb={this.setBreadcrumbConfig} setBreadcrumb={this.setBreadcrumbConfig}
me={me || {}} me={me || {}}
/> />

View File

@@ -1,6 +1,6 @@
import React, { useContext, useEffect, useState } from 'react'; import React, { useCallback, useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Formik, useField } from 'formik'; import { Formik, useField, useFormikContext } 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';
@@ -16,6 +16,7 @@ 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'; import { FormColumnLayout } from '../../../components/FormLayout';
import CredentialLookup from '../../../components/Lookup/CredentialLookup';
function OrganizationFormFields({ function OrganizationFormFields({
i18n, i18n,
@@ -23,8 +24,15 @@ function OrganizationFormFields({
instanceGroups, instanceGroups,
setInstanceGroups, setInstanceGroups,
}) { }) {
const { setFieldValue } = useFormikContext();
const [venvField] = useField('custom_virtualenv'); const [venvField] = useField('custom_virtualenv');
const [
galaxyCredentialsField,
galaxyCredentialsMeta,
galaxyCredentialsHelpers,
] = useField('galaxy_credentials');
const defaultVenv = { const defaultVenv = {
label: i18n._(t`Use Default Ansible Environment`), label: i18n._(t`Use Default Ansible Environment`),
value: '/venv/ansible/', value: '/venv/ansible/',
@@ -32,6 +40,13 @@ function OrganizationFormFields({
}; };
const { custom_virtualenvs } = useContext(ConfigContext); const { custom_virtualenvs } = useContext(ConfigContext);
const handleCredentialUpdate = useCallback(
value => {
setFieldValue('galaxy_credentials', value);
},
[setFieldValue]
);
return ( return (
<> <>
<FormField <FormField
@@ -86,6 +101,16 @@ function OrganizationFormFields({
t`Select the Instance Groups for this Organization to run on.` t`Select the Instance Groups for this Organization to run on.`
)} )}
/> />
<CredentialLookup
credentialTypeNamespace="galaxy_api_token"
label={i18n._(t`Galaxy Credentials`)}
helperTextInvalid={galaxyCredentialsMeta.error}
isValid={!galaxyCredentialsMeta.touched || !galaxyCredentialsMeta.error}
onBlur={() => galaxyCredentialsHelpers.setTouched()}
onChange={handleCredentialUpdate}
value={galaxyCredentialsField.value}
multiple
/>
</> </>
); );
} }
@@ -160,6 +185,7 @@ function OrganizationForm({
description: organization.description, description: organization.description,
custom_virtualenv: organization.custom_virtualenv || '', custom_virtualenv: organization.custom_virtualenv || '',
max_hosts: organization.max_hosts || '0', max_hosts: organization.max_hosts || '0',
galaxy_credentials: organization.galaxy_credentials || [],
}} }}
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >

View File

@@ -163,6 +163,7 @@ describe('<OrganizationForm />', () => {
expect(onSubmit.mock.calls[0][0]).toEqual({ expect(onSubmit.mock.calls[0][0]).toEqual({
name: 'new foo', name: 'new foo',
description: 'new bar', description: 'new bar',
galaxy_credentials: [],
custom_virtualenv: 'Fizz', custom_virtualenv: 'Fizz',
max_hosts: 134, max_hosts: 134,
}); });
@@ -211,6 +212,7 @@ describe('<OrganizationForm />', () => {
const mockDataForm = { const mockDataForm = {
name: 'Foo', name: 'Foo',
description: 'Bar', description: 'Bar',
galaxy_credentials: [],
max_hosts: 1, max_hosts: 1,
custom_virtualenv: 'Fizz', custom_virtualenv: 'Fizz',
}; };
@@ -315,6 +317,7 @@ describe('<OrganizationForm />', () => {
{ {
name: 'Foo', name: 'Foo',
description: 'Bar', description: 'Bar',
galaxy_credentials: [],
max_hosts: 0, max_hosts: 0,
custom_virtualenv: 'Fizz', custom_virtualenv: 'Fizz',
}, },