Add Crendential Type Details

Add credential type Details

See: https://github.com/ansible/awx/issues/7430
This commit is contained in:
nixocio 2020-06-24 09:46:54 -04:00
parent 879ab50a12
commit bd660254a5
7 changed files with 411 additions and 25 deletions

View File

@ -1,25 +1,121 @@
import React from 'react';
import { Route, Switch, Redirect } from 'react-router-dom';
import React, { useEffect, useCallback } from 'react';
import {
Link,
Redirect,
Route,
Switch,
useLocation,
useParams,
} from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import useRequest from '../../util/useRequest';
import { CredentialTypesAPI } from '../../api';
import RoutedTabs from '../../components/RoutedTabs';
import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading';
import CredentialTypeDetails from './CredentialTypeDetails';
import CredentialTypeEdit from './CredentialTypeEdit';
function CredentialType() {
function CredentialType({ i18n, setBreadcrumb }) {
const { id } = useParams();
const { pathname } = useLocation();
const {
isLoading,
error: contentError,
request: fetchCredentialTypes,
result: credentialType,
} = useRequest(
useCallback(async () => {
const { data } = await CredentialTypesAPI.readDetail(id);
return data;
}, [id])
);
useEffect(() => {
fetchCredentialTypes();
}, [fetchCredentialTypes, pathname]);
useEffect(() => {
if (credentialType) {
setBreadcrumb(credentialType);
}
}, [credentialType, setBreadcrumb]);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to credential types`)}
</>
),
link: '/credential_types',
id: 99,
},
{
name: i18n._(t`Details`),
link: `/credential_types/${id}/details`,
id: 0,
},
];
let cardHeader = <RoutedTabs tabsArray={tabsArray} />;
if (pathname.endsWith('edit')) {
cardHeader = null;
}
if (!isLoading && contentError) {
return (
<PageSection>
<Card>
<ContentError error={contentError}>
{contentError.response?.status === 404 && (
<span>
{i18n._(t`Credential type not found.`)}{' '}
<Link to="/credential_types">
{i18n._(t`View all credential types`)}
</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
return (
<Switch>
<Redirect
from="/credential_types/:id"
to="/credential_types/:id/details"
exact
/>
<Route path="/credential_types/:id/edit">
<CredentialTypeEdit />
</Route>
<Route path="/credential_types/:id/details">
<CredentialTypeDetails />
</Route>
</Switch>
<PageSection>
<Card>
{cardHeader}
{isLoading && <ContentLoading />}
{!isLoading && credentialType && (
<Switch>
<Redirect
from="/credential_types/:id"
to="/credential_types/:id/details"
exact
/>
{credentialType && (
<>
<Route path="/credential_types/:id/edit">
<CredentialTypeEdit />
</Route>
<Route path="/credential_types/:id/details">
<CredentialTypeDetails credentialType={credentialType} />
</Route>
</>
)}
</Switch>
)}
</Card>
</PageSection>
);
}
export default CredentialType;
export default withI18n()(CredentialType);

View File

@ -0,0 +1,58 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import { CredentialTypesAPI } from '../../api';
import CredentialType from './CredentialType';
jest.mock('../../api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useRouteMatch: () => ({
url: '/credential_types',
}),
useParams: () => ({ id: 42 }),
}));
describe('<CredentialType/>', () => {
let wrapper;
test('should render details properly', async () => {
await act(async () => {
wrapper = mountWithContexts(<CredentialType setBreadcrumb={() => {}} />);
});
wrapper.update();
expect(wrapper.find('CredentialType').length).toBe(1);
expect(CredentialTypesAPI.readDetail).toBeCalledWith(42);
});
test('should render expected tabs', async () => {
const expectedTabs = ['Back to credential types', 'Details'];
await act(async () => {
wrapper = mountWithContexts(<CredentialType setBreadcrumb={() => {}} />);
});
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
});
});
test('should show content error when user attempts to navigate to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/credential_types/42/foobar'],
});
await act(async () => {
wrapper = mountWithContexts(<CredentialType setBreadcrumb={() => {}} />, {
context: {
router: {
history,
},
},
});
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
});

View File

@ -1,12 +1,103 @@
import React from 'react';
import { Card, PageSection } from '@patternfly/react-core';
import React, { useCallback } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Link, useHistory } from 'react-router-dom';
import { Button } from '@patternfly/react-core';
import { VariablesDetail } from '../../../components/CodeMirrorInput';
import AlertModal from '../../../components/AlertModal';
import { CardBody, CardActionsRow } from '../../../components/Card';
import DeleteButton from '../../../components/DeleteButton';
import {
Detail,
DetailList,
UserDateDetail,
} from '../../../components/DetailList';
import useRequest, { useDismissableError } from '../../../util/useRequest';
import { CredentialTypesAPI } from '../../../api';
import { jsonToYaml } from '../../../util/yaml';
function CredentialTypeDetails({ credentialType, i18n }) {
const { id, name, description, injectors, inputs } = credentialType;
const history = useHistory();
const {
request: deleteCredentialType,
isLoading,
error: deleteError,
} = useRequest(
useCallback(async () => {
await CredentialTypesAPI.destroy(id);
history.push(`/credential_types`);
}, [id, history])
);
const { error, dismissError } = useDismissableError(deleteError);
function CredentialTypeDetails() {
return (
<PageSection>
<Card>Credential Type Details</Card>
</PageSection>
<CardBody>
<DetailList>
<Detail
label={i18n._(t`Name`)}
value={name}
dataCy="credential-type-detail-name"
/>
<Detail label={i18n._(t`Description`)} value={description} />
<VariablesDetail
label={i18n._(t`Input configuration`)}
value={jsonToYaml(JSON.stringify(inputs))}
rows={6}
/>
<VariablesDetail
label={i18n._(t`Injector configuration`)}
value={jsonToYaml(JSON.stringify(injectors))}
rows={6}
/>
<UserDateDetail
label={i18n._(t`Created`)}
date={credentialType.created}
user={credentialType.summary_fields.created_by}
/>
<UserDateDetail
label={i18n._(t`Last Modified`)}
date={credentialType.modified}
user={credentialType.summary_fields.modified_by}
/>
</DetailList>
<CardActionsRow>
{credentialType.summary_fields.user_capabilities &&
credentialType.summary_fields.user_capabilities.edit && (
<Button
aria-label={i18n._(t`edit`)}
component={Link}
to={`credential_types/${id}/edit`}
>
{i18n._(t`Edit`)}
</Button>
)}
{credentialType.summary_fields.user_capabilities &&
credentialType.summary_fields.user_capabilities.delete && (
<DeleteButton
name={name}
modalTitle={i18n._(t`Delete credential type`)}
onConfirm={deleteCredentialType}
isDisabled={isLoading}
>
{i18n._(t`Delete`)}
</DeleteButton>
)}
</CardActionsRow>
{error && (
<AlertModal
isOpen={error}
onClose={dismissError}
title={i18n._(t`Error`)}
variant="error"
/>
)}
</CardBody>
);
}
export default CredentialTypeDetails;
export default withI18n()(CredentialTypeDetails);

View File

@ -0,0 +1,136 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { CredentialTypesAPI } from '../../../api';
import { jsonToYaml } from '../../../util/yaml';
import CredentialTypeDetails from './CredentialTypeDetails';
jest.mock('../../../api');
const credentialTypeData = {
name: 'Foo',
description: 'Bar',
kind: 'cloud',
inputs: {
fields: [
{
id: 'username',
type: 'string',
label: 'Jenkins username',
},
{
id: 'password',
type: 'string',
label: 'Jenkins password',
secret: true,
},
],
required: ['username', 'password'],
},
injectors: {
extra_vars: {
Jenkins_password: '{{ password }}',
Jenkins_username: '{{ username }}',
},
},
summary_fields: {
created_by: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
modified_by: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
user_capabilities: {
edit: true,
delete: true,
},
},
created: '2020-06-25T16:52:36.127008Z',
modified: '2020-06-25T16:52:36.127022Z',
};
function expectDetailToMatch(wrapper, label, value) {
const detail = wrapper.find(`Detail[label="${label}"]`);
expect(detail).toHaveLength(1);
expect(detail.prop('value')).toEqual(value);
}
describe('<CredentialTypeDetails/>', () => {
let wrapper;
test('should render details properly', async () => {
await act(async () => {
wrapper = mountWithContexts(
<CredentialTypeDetails credentialType={credentialTypeData} />
);
});
wrapper.update();
expectDetailToMatch(wrapper, 'Name', credentialTypeData.name);
expectDetailToMatch(wrapper, 'Description', credentialTypeData.description);
const dates = wrapper.find('UserDateDetail');
expect(dates).toHaveLength(2);
expect(dates.at(0).prop('date')).toEqual(credentialTypeData.created);
expect(dates.at(1).prop('date')).toEqual(credentialTypeData.modified);
const vars = wrapper.find('VariablesDetail');
expect(vars).toHaveLength(2);
expect(vars.at(0).prop('label')).toEqual('Input configuration');
expect(vars.at(0).prop('value')).toEqual(
jsonToYaml(JSON.stringify(credentialTypeData.inputs))
);
expect(vars.at(1).prop('label')).toEqual('Injector configuration');
expect(vars.at(1).prop('value')).toEqual(
jsonToYaml(JSON.stringify(credentialTypeData.injectors))
);
});
test('expected api call is made for delete', async () => {
const history = createMemoryHistory({
initialEntries: ['/credential_types/42/details'],
});
await act(async () => {
wrapper = mountWithContexts(
<CredentialTypeDetails credentialType={credentialTypeData} />,
{
context: { router: { history } },
}
);
});
await act(async () => {
wrapper.find('DeleteButton').invoke('onConfirm')();
});
expect(CredentialTypesAPI.destroy).toHaveBeenCalledTimes(1);
expect(history.location.pathname).toBe('/credential_types');
});
test('should not render delete button', async () => {
credentialTypeData.summary_fields.user_capabilities.delete = false;
await act(async () => {
wrapper = mountWithContexts(
<CredentialTypeDetails credentialType={credentialTypeData} />
);
});
wrapper.update();
expect(wrapper.find('Button[aria-label="Delete"]').length).toBe(0);
});
test('should not render edit button', async () => {
credentialTypeData.summary_fields.user_capabilities.edit = false;
await act(async () => {
wrapper = mountWithContexts(
<CredentialTypeDetails credentialType={credentialTypeData} />
);
});
wrapper.update();
expect(wrapper.find('Button[aria-label="Edit"]').length).toBe(0);
});
});

View File

@ -15,6 +15,7 @@ import PaginatedDataList, {
import ErrorDetail from '../../../components/ErrorDetail';
import AlertModal from '../../../components/AlertModal';
import DatalistToolbar from '../../../components/DataListToolbar';
import CredentialTypeListItem from './CredentialTypeListItem';
const QS_CONFIG = getQSConfig('credential_type', {

View File

@ -53,7 +53,7 @@ function CredentialTypeListItem({
key="name"
aria-label={i18n._(t`credential type name`)}
>
<Link to={`${detailUrl} `}>
<Link to={`${detailUrl}`}>
<b>{credentialType.name}</b>
</Link>
</DataListCell>,

View File

@ -23,6 +23,10 @@ function CredentialTypes({ i18n }) {
'/credential_types': i18n._(t`Credential Types`),
'/credential_types/add': i18n._(t`Create Credential Types`),
[`/credential_types/${credentialTypes.id}`]: `${credentialTypes.name}`,
[`/credential_types/${credentialTypes.id}/edit`]: i18n._(
t`Edit Details`
),
[`/credential_types/${credentialTypes.id}/details`]: i18n._(t`Details`),
});
},
[i18n]