Merge pull request #8684 from mabashian/8666-app-token-modal

Show modals with app/token info after creation

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-12-11 13:52:26 +00:00
committed by GitHub
20 changed files with 490 additions and 218 deletions

View File

@@ -8,7 +8,7 @@ import ApplicationForm from '../shared/ApplicationForm';
import { ApplicationsAPI } from '../../../api'; import { ApplicationsAPI } from '../../../api';
import { CardBody } from '../../../components/Card'; import { CardBody } from '../../../components/Card';
function ApplicationAdd() { function ApplicationAdd({ onSuccessfulAdd }) {
const history = useHistory(); const history = useHistory();
const [submitError, setSubmitError] = useState(null); const [submitError, setSubmitError] = useState(null);
@@ -53,10 +53,9 @@ function ApplicationAdd() {
const handleSubmit = async ({ ...values }) => { const handleSubmit = async ({ ...values }) => {
values.organization = values.organization.id; values.organization = values.organization.id;
try { try {
const { const { data } = await ApplicationsAPI.create(values);
data: { id }, onSuccessfulAdd(data);
} = await ApplicationsAPI.create(values); history.push(`/applications/${data.id}/details`);
history.push(`/applications/${id}/details`);
} catch (err) { } catch (err) {
setSubmitError(err); setSubmitError(err);
} }

View File

@@ -39,12 +39,16 @@ const options = {
}, },
}; };
const onSuccessfulAdd = jest.fn();
describe('<ApplicationAdd/>', () => { describe('<ApplicationAdd/>', () => {
let wrapper; let wrapper;
test('should render properly', async () => { test('should render properly', async () => {
ApplicationsAPI.readOptions.mockResolvedValue(options); ApplicationsAPI.readOptions.mockResolvedValue(options);
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<ApplicationAdd />); wrapper = mountWithContexts(
<ApplicationAdd onSuccessfulAdd={onSuccessfulAdd} />
);
}); });
expect(wrapper.find('ApplicationAdd').length).toBe(1); expect(wrapper.find('ApplicationAdd').length).toBe(1);
expect(wrapper.find('ApplicationForm').length).toBe(1); expect(wrapper.find('ApplicationForm').length).toBe(1);
@@ -59,9 +63,12 @@ describe('<ApplicationAdd/>', () => {
ApplicationsAPI.create.mockResolvedValue({ data: { id: 8 } }); ApplicationsAPI.create.mockResolvedValue({ data: { id: 8 } });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<ApplicationAdd />, { wrapper = mountWithContexts(
context: { router: { history } }, <ApplicationAdd onSuccessfulAdd={onSuccessfulAdd} />,
}); {
context: { router: { history } },
}
);
}); });
await act(async () => { await act(async () => {
@@ -124,6 +131,7 @@ describe('<ApplicationAdd/>', () => {
redirect_uris: 'http://www.google.com', redirect_uris: 'http://www.google.com',
}); });
expect(history.location.pathname).toBe('/applications/8/details'); expect(history.location.pathname).toBe('/applications/8/details');
expect(onSuccessfulAdd).toHaveBeenCalledWith({ id: 8 });
}); });
test('should cancel form properly', async () => { test('should cancel form properly', async () => {
@@ -134,9 +142,12 @@ describe('<ApplicationAdd/>', () => {
ApplicationsAPI.create.mockResolvedValue({ data: { id: 8 } }); ApplicationsAPI.create.mockResolvedValue({ data: { id: 8 } });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<ApplicationAdd />, { wrapper = mountWithContexts(
context: { router: { history } }, <ApplicationAdd onSuccessfulAdd={onSuccessfulAdd} />,
}); {
context: { router: { history } },
}
);
}); });
await act(async () => { await act(async () => {
wrapper.find('Button[aria-label="Cancel"]').prop('onClick')(); wrapper.find('Button[aria-label="Cancel"]').prop('onClick')();
@@ -157,7 +168,9 @@ describe('<ApplicationAdd/>', () => {
ApplicationsAPI.create.mockRejectedValue(error); ApplicationsAPI.create.mockRejectedValue(error);
ApplicationsAPI.readOptions.mockResolvedValue(options); ApplicationsAPI.readOptions.mockResolvedValue(options);
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<ApplicationAdd />); wrapper = mountWithContexts(
<ApplicationAdd onSuccessfulAdd={onSuccessfulAdd} />
);
}); });
await act(async () => { await act(async () => {
wrapper.find('Formik').prop('onSubmit')({ wrapper.find('Formik').prop('onSubmit')({
@@ -181,7 +194,9 @@ describe('<ApplicationAdd/>', () => {
}) })
); );
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<ApplicationAdd />); wrapper = mountWithContexts(
<ApplicationAdd onSuccessfulAdd={onSuccessfulAdd} />
);
}); });
wrapper.update(); wrapper.update();

View File

@@ -58,11 +58,12 @@ function ApplicationDetails({
<Detail <Detail
label={i18n._(t`Name`)} label={i18n._(t`Name`)}
value={application.name} value={application.name}
dataCy="jt-detail-name" dataCy="app-detail-name"
/> />
<Detail <Detail
label={i18n._(t`Description`)} label={i18n._(t`Description`)}
value={application.description} value={application.description}
dataCy="app-detail-description"
/> />
<Detail <Detail
label={i18n._(t`Organization`)} label={i18n._(t`Organization`)}
@@ -73,20 +74,29 @@ function ApplicationDetails({
{application.summary_fields.organization.name} {application.summary_fields.organization.name}
</Link> </Link>
} }
dataCy="app-detail-organization"
/> />
<Detail <Detail
label={i18n._(t`Authorization grant type`)} label={i18n._(t`Authorization grant type`)}
value={getAuthorizationGrantType( value={getAuthorizationGrantType(
application.authorization_grant_type application.authorization_grant_type
)} )}
dataCy="app-detail-authorization-grant-type"
/>
<Detail
label={i18n._(t`Client ID`)}
value={application.client_id}
dataCy="app-detail-client-id"
/> />
<Detail <Detail
label={i18n._(t`Redirect uris`)} label={i18n._(t`Redirect uris`)}
value={application.redirect_uris} value={application.redirect_uris}
dataCy="app-detail-redirect-uris"
/> />
<Detail <Detail
label={i18n._(t`Client type`)} label={i18n._(t`Client type`)}
value={getClientType(application.client_type)} value={getClientType(application.client_type)}
dataCy="app-detail-client-type"
/> />
</DetailList> </DetailList>
<CardActionsRow> <CardActionsRow>

View File

@@ -120,6 +120,9 @@ describe('<ApplicationDetails/>', () => {
expect(wrapper.find('Detail[label="Client type"]').prop('value')).toBe( expect(wrapper.find('Detail[label="Client type"]').prop('value')).toBe(
'Confidential' 'Confidential'
); );
expect(wrapper.find('Detail[label="Client ID"]').prop('value')).toBe(
'b1dmj8xzkbFm1ZQ27ygw2ZeE9I0AXqqeL74fiyk4'
);
expect(wrapper.find('Button[aria-label="Edit"]').prop('to')).toBe( expect(wrapper.find('Button[aria-label="Edit"]').prop('to')).toBe(
'/applications/10/edit' '/applications/10/edit'
); );

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useHistory, useParams } from 'react-router-dom'; import { useHistory, useParams } from 'react-router-dom';
import { Card, PageSection } from '@patternfly/react-core'; import { Card } from '@patternfly/react-core';
import ApplicationForm from '../shared/ApplicationForm'; import ApplicationForm from '../shared/ApplicationForm';
import { ApplicationsAPI } from '../../../api'; import { ApplicationsAPI } from '../../../api';
import { CardBody } from '../../../components/Card'; import { CardBody } from '../../../components/Card';
@@ -29,22 +29,18 @@ function ApplicationEdit({
history.push(`/applications/${id}/details`); history.push(`/applications/${id}/details`);
}; };
return ( return (
<> <Card>
<PageSection> <CardBody>
<Card> <ApplicationForm
<CardBody> onSubmit={handleSubmit}
<ApplicationForm application={application}
onSubmit={handleSubmit} onCancel={handleCancel}
application={application} authorizationOptions={authorizationOptions}
onCancel={handleCancel} clientTypeOptions={clientTypeOptions}
authorizationOptions={authorizationOptions} submitError={submitError}
clientTypeOptions={clientTypeOptions} />
submitError={submitError} </CardBody>
/> </Card>
</CardBody>
</Card>
</PageSection>
</>
); );
} }
export default ApplicationEdit; export default ApplicationEdit;

View File

@@ -1,14 +1,26 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
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 { Route, Switch } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import {
Alert,
ClipboardCopy,
ClipboardCopyVariant,
Modal,
} from '@patternfly/react-core';
import ApplicationsList from './ApplicationsList'; import ApplicationsList from './ApplicationsList';
import ApplicationAdd from './ApplicationAdd'; import ApplicationAdd from './ApplicationAdd';
import Application from './Application'; import Application from './Application';
import Breadcrumbs from '../../components/Breadcrumbs'; import Breadcrumbs from '../../components/Breadcrumbs';
import { Detail, DetailList } from '../../components/DetailList';
const ApplicationAlert = styled(Alert)`
margin-bottom: 20px;
`;
function Applications({ i18n }) { function Applications({ i18n }) {
const [applicationModalSource, setApplicationModalSource] = useState(null);
const [breadcrumbConfig, setBreadcrumbConfig] = useState({ const [breadcrumbConfig, setBreadcrumbConfig] = useState({
'/applications': i18n._(t`Applications`), '/applications': i18n._(t`Applications`),
'/applications/add': i18n._(t`Create New Application`), '/applications/add': i18n._(t`Create New Application`),
@@ -36,7 +48,9 @@ function Applications({ i18n }) {
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<Switch> <Switch>
<Route path="/applications/add"> <Route path="/applications/add">
<ApplicationAdd /> <ApplicationAdd
onSuccessfulAdd={app => setApplicationModalSource(app)}
/>
</Route> </Route>
<Route path="/applications/:id"> <Route path="/applications/:id">
<Application setBreadcrumb={buildBreadcrumbConfig} /> <Application setBreadcrumb={buildBreadcrumbConfig} />
@@ -45,6 +59,57 @@ function Applications({ i18n }) {
<ApplicationsList /> <ApplicationsList />
</Route> </Route>
</Switch> </Switch>
{applicationModalSource && (
<Modal
aria-label={i18n._(t`Application information`)}
isOpen
variant="medium"
title={i18n._(t`Application information`)}
onClose={() => setApplicationModalSource(null)}
>
{applicationModalSource.client_secret && (
<ApplicationAlert
variant="info"
isInline
title={i18n._(
t`This is the only time the client secret will be shown.`
)}
/>
)}
<DetailList stacked>
<Detail
label={i18n._(t`Name`)}
value={applicationModalSource.name}
/>
{applicationModalSource.client_id && (
<Detail
label={i18n._(t`Client ID`)}
value={
<ClipboardCopy
isReadOnly
variant={ClipboardCopyVariant.expansion}
>
{applicationModalSource.client_id}
</ClipboardCopy>
}
/>
)}
{applicationModalSource.client_secret && (
<Detail
label={i18n._(t`Client secret`)}
value={
<ClipboardCopy
isReadOnly
variant={ClipboardCopyVariant.expansion}
>
{applicationModalSource.client_secret}
</ClipboardCopy>
}
/>
)}
</DetailList>
</Modal>
)}
</> </>
); );
} }

View File

@@ -1,25 +1,48 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Applications from './Applications'; import Applications from './Applications';
describe('<Applications />', () => { describe('<Applications />', () => {
let pageWrapper; let wrapper;
let pageSections;
beforeEach(() => {
pageWrapper = mountWithContexts(<Applications />);
pageSections = pageWrapper.find('PageSection');
});
afterEach(() => { afterEach(() => {
pageWrapper.unmount(); wrapper.unmount();
}); });
test('initially renders without crashing', () => { test('renders successfully', () => {
expect(pageWrapper.length).toBe(1); wrapper = mountWithContexts(<Applications />);
const pageSections = wrapper.find('PageSection');
expect(wrapper.length).toBe(1);
expect(pageSections.length).toBe(1); expect(pageSections.length).toBe(1);
expect(pageSections.first().props().variant).toBe('light'); expect(pageSections.first().props().variant).toBe('light');
}); });
test('shows Application information modal after successful creation', async () => {
const history = createMemoryHistory({
initialEntries: ['/applications/add'],
});
wrapper = mountWithContexts(<Applications />, {
context: { router: { history } },
});
expect(wrapper.find('Modal[title="Application information"]').length).toBe(
0
);
await act(async () => {
wrapper
.find('ApplicationAdd')
.props()
.onSuccessfulAdd({
name: 'test',
client_id: 'foobar',
client_secret: 'aaaaaaaaaaaaaaaaaaaaaaaaaa',
});
});
wrapper.update();
expect(wrapper.find('Modal[title="Application information"]').length).toBe(
1
);
});
}); });

View File

@@ -14,7 +14,6 @@ import { Card, PageSection } from '@patternfly/react-core';
import useRequest from '../../util/useRequest'; import useRequest from '../../util/useRequest';
import { UsersAPI } from '../../api'; import { UsersAPI } from '../../api';
import ContentError from '../../components/ContentError'; import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading';
import RoutedTabs from '../../components/RoutedTabs'; import RoutedTabs from '../../components/RoutedTabs';
import UserDetail from './UserDetail'; import UserDetail from './UserDetail';
import UserEdit from './UserEdit'; import UserEdit from './UserEdit';
@@ -86,7 +85,7 @@ function User({ i18n, setBreadcrumb, me }) {
showCardHeader = false; showCardHeader = false;
} }
if (contentError) { if (!isLoading && contentError) {
return ( return (
<PageSection> <PageSection>
<Card> <Card>
@@ -107,41 +106,34 @@ function User({ i18n, setBreadcrumb, me }) {
<PageSection> <PageSection>
<Card> <Card>
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />} {showCardHeader && <RoutedTabs tabsArray={tabsArray} />}
{isLoading && <ContentLoading />} <Switch>
{!isLoading && user && ( <Redirect from="/users/:id" to="/users/:id/details" exact />
<Switch> {user && [
<Redirect from="/users/:id" to="/users/:id/details" exact /> <Route path="/users/:id/edit" key="edit">
{user && ( <UserEdit user={user} />
<Route path="/users/:id/edit"> </Route>,
<UserEdit user={user} /> <Route path="/users/:id/details" key="details">
</Route> <UserDetail user={user} />
)} </Route>,
{user && ( <Route path="/users/:id/organizations" key="organizations">
<Route path="/users/:id/details">
<UserDetail user={user} />
</Route>
)}
<Route path="/users/:id/organizations">
<UserOrganizations id={Number(match.params.id)} /> <UserOrganizations id={Number(match.params.id)} />
</Route> </Route>,
{user && ( <Route path="/users/:id/teams" key="teams">
<Route path="/users/:id/teams"> <UserTeams />
<UserTeams /> </Route>,
</Route> <Route path="/users/:id/roles" key="roles">
)} <UserRolesList user={user} />
{user && ( </Route>,
<Route path="/users/:id/roles"> <Route path="/users/:id/tokens" key="tokens">
<UserRolesList user={user} />
</Route>
)}
<Route path="/users/:id/tokens">
<UserTokens <UserTokens
user={user} user={user}
setBreadcrumb={setBreadcrumb} setBreadcrumb={setBreadcrumb}
id={Number(match.params.id)} id={Number(match.params.id)}
/> />
</Route> </Route>,
<Route key="not-found" path="*"> ]}
<Route key="not-found" path="*">
{!isLoading && (
<ContentError isNotFound> <ContentError isNotFound>
{match.params.id && ( {match.params.id && (
<Link to={`/users/${match.params.id}/details`}> <Link to={`/users/${match.params.id}/details`}>
@@ -149,9 +141,9 @@ function User({ i18n, setBreadcrumb, me }) {
</Link> </Link>
)} )}
</ContentError> </ContentError>
</Route> )}
</Switch> </Route>
)} </Switch>
</Card> </Card>
</PageSection> </PageSection>
); );

View File

@@ -22,10 +22,11 @@ async function getUsers() {
}; };
} }
UsersAPI.readDetail.mockResolvedValue({ data: mockDetails });
UsersAPI.read.mockImplementation(getUsers);
describe('<User />', () => { describe('<User />', () => {
test('initially renders successfully', async () => { test('initially renders successfully', async () => {
UsersAPI.readDetail.mockResolvedValue({ data: mockDetails });
UsersAPI.read.mockImplementation(getUsers);
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: ['/users/1'], initialEntries: ['/users/1'],
}); });
@@ -49,8 +50,6 @@ describe('<User />', () => {
}); });
test('tabs shown for users', async () => { test('tabs shown for users', async () => {
UsersAPI.readDetail.mockResolvedValue({ data: mockDetails });
UsersAPI.read.mockImplementation(getUsers);
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: ['/users/1'], initialEntries: ['/users/1'],
}); });
@@ -81,9 +80,7 @@ describe('<User />', () => {
expect(wrapper.find('Tabs TabButton').length).toEqual(6); expect(wrapper.find('Tabs TabButton').length).toEqual(6);
}); });
test('should not now Tokens tab', async () => { test('should not show Tokens tab', async () => {
UsersAPI.readDetail.mockResolvedValue({ data: mockDetails });
UsersAPI.read.mockImplementation(getUsers);
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: ['/users/1'], initialEntries: ['/users/1'],
}); });

View File

@@ -24,20 +24,16 @@ function UserToken({ i18n, setBreadcrumb, user }) {
isLoading, isLoading,
error, error,
request: fetchToken, request: fetchToken,
result: { token, actions }, result: { token },
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const [response, actionsResponse] = await Promise.all([ const response = await TokensAPI.readDetail(tokenId);
TokensAPI.readDetail(tokenId),
TokensAPI.readOptions(),
]);
setBreadcrumb(user, response.data); setBreadcrumb(user, response.data);
return { return {
token: response.data, token: response.data,
actions: actionsResponse.data.actions.POST,
}; };
}, [setBreadcrumb, user, tokenId]), }, [setBreadcrumb, user, tokenId]),
{ token: null, actions: null } { token: null }
); );
useEffect(() => { useEffect(() => {
fetchToken(); fetchToken();
@@ -97,7 +93,7 @@ function UserToken({ i18n, setBreadcrumb, user }) {
/> />
{token && ( {token && (
<Route path="/users/:id/tokens/:tokenId/details"> <Route path="/users/:id/tokens/:tokenId/details">
<UserTokenDetail canEditOrDelete={actions} token={token} /> <UserTokenDetail token={token} />
</Route> </Route>
)} )}
<Route key="not-found" path="*"> <Route key="not-found" path="*">

View File

@@ -54,9 +54,6 @@ describe('<UserToken/>', () => {
description: 'cdfsg', description: 'cdfsg',
scope: 'read', scope: 'read',
}); });
TokensAPI.readOptions.mockResolvedValue({
data: { actions: { POST: true } },
});
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<UserToken setBreadcrumb={jest.fn()} user={user} /> <UserToken setBreadcrumb={jest.fn()} user={user} />
@@ -87,15 +84,11 @@ describe('<UserToken/>', () => {
description: 'cdfsg', description: 'cdfsg',
scope: 'read', scope: 'read',
}); });
TokensAPI.readOptions.mockResolvedValue({
data: { actions: { POST: true } },
});
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<UserToken setBreadcrumb={jest.fn()} user={user} /> <UserToken setBreadcrumb={jest.fn()} user={user} />
); );
}); });
expect(TokensAPI.readDetail).toBeCalledWith(2); expect(TokensAPI.readDetail).toBeCalledWith(2);
expect(TokensAPI.readOptions).toBeCalled();
}); });
}); });

View File

@@ -6,22 +6,27 @@ import { TokensAPI, UsersAPI } from '../../../api';
import useRequest from '../../../util/useRequest'; import useRequest from '../../../util/useRequest';
import UserTokenFrom from '../shared/UserTokenForm'; import UserTokenFrom from '../shared/UserTokenForm';
function UserTokenAdd() { function UserTokenAdd({ onSuccessfulAdd }) {
const history = useHistory(); const history = useHistory();
const { id: userId } = useParams(); const { id: userId } = useParams();
const { error: submitError, request: handleSubmit } = useRequest( const { error: submitError, request: handleSubmit } = useRequest(
useCallback( useCallback(
async formData => { async formData => {
let response;
if (formData.application) { if (formData.application) {
formData.application = formData.application?.id || null; response = await UsersAPI.createToken(userId, {
await UsersAPI.createToken(userId, formData); ...formData,
application: formData.application?.id || null,
});
} else { } else {
await TokensAPI.create(formData); response = await TokensAPI.create(formData);
} }
history.push(`/users/${userId}/tokens`); onSuccessfulAdd(response.data);
history.push(`/users/${userId}/tokens/${response.data.id}/details`);
}, },
[history, userId] [history, userId, onSuccessfulAdd]
) )
); );

View File

@@ -18,29 +18,46 @@ jest.mock('react-router-dom', () => ({
})); }));
let wrapper; let wrapper;
const onSuccessfulAdd = jest.fn();
describe('<UserTokenAdd />', () => { describe('<UserTokenAdd />', () => {
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('handleSubmit should post to api', async () => { test('handleSubmit should post to api', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<UserTokenAdd />); wrapper = mountWithContexts(
<UserTokenAdd onSuccessfulAdd={onSuccessfulAdd} />
);
}); });
UsersAPI.createToken.mockResolvedValueOnce({ data: { id: 1 } }); UsersAPI.createToken.mockResolvedValueOnce({ data: { id: 1 } });
const tokenData = { const tokenData = {
application: 1, application: {
id: 1,
},
description: 'foo', description: 'foo',
scope: 'read', scope: 'read',
}; };
await act(async () => { await act(async () => {
wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData); wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData);
}); });
expect(UsersAPI.createToken).toHaveBeenCalledWith(1, tokenData); expect(UsersAPI.createToken).toHaveBeenCalledWith(1, {
application: 1,
description: 'foo',
scope: 'read',
});
}); });
test('should navigate to tokens list when cancel is clicked', async () => { test('should navigate to tokens list when cancel is clicked', async () => {
const history = createMemoryHistory({}); const history = createMemoryHistory({});
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<UserTokenAdd />, { wrapper = mountWithContexts(
context: { router: { history } }, <UserTokenAdd onSuccessfulAdd={onSuccessfulAdd} />,
}); {
context: { router: { history } },
}
);
}); });
await act(async () => { await act(async () => {
wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
@@ -48,51 +65,67 @@ describe('<UserTokenAdd />', () => {
expect(history.location.pathname).toEqual('/users/1/tokens'); expect(history.location.pathname).toEqual('/users/1/tokens');
}); });
test('successful form submission should trigger redirect', async () => { test('successful form submission with application', async () => {
const history = createMemoryHistory({}); const history = createMemoryHistory({});
const tokenData = { const tokenData = {
application: 1, application: {
id: 1,
},
description: 'foo', description: 'foo',
scope: 'read', scope: 'read',
}; };
const rtnData = {
id: 2,
token: 'abc',
refresh_token: 'def',
expires: '3020-03-28T14:26:48.099297Z',
};
UsersAPI.createToken.mockResolvedValueOnce({ UsersAPI.createToken.mockResolvedValueOnce({
data: { data: rtnData,
id: 2,
...tokenData,
},
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<UserTokenAdd />, { wrapper = mountWithContexts(
context: { router: { history } }, <UserTokenAdd onSuccessfulAdd={onSuccessfulAdd} />,
}); {
context: { router: { history } },
}
);
}); });
await waitForElement(wrapper, 'button[aria-label="Save"]'); await waitForElement(wrapper, 'button[aria-label="Save"]');
await act(async () => { await act(async () => {
wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData); wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData);
}); });
expect(history.location.pathname).toEqual('/users/1/tokens'); expect(history.location.pathname).toEqual('/users/1/tokens/2/details');
expect(onSuccessfulAdd).toHaveBeenCalledWith(rtnData);
}); });
test('should successful submit form with application', async () => { test('successful form submission without application', async () => {
const history = createMemoryHistory({}); const history = createMemoryHistory({});
const tokenData = { const tokenData = {
scope: 'read', scope: 'read',
}; };
const rtnData = {
id: 2,
token: 'abc',
refresh_token: null,
expires: '3020-03-28T14:26:48.099297Z',
};
TokensAPI.create.mockResolvedValueOnce({ TokensAPI.create.mockResolvedValueOnce({
data: { data: rtnData,
id: 2,
...tokenData,
},
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<UserTokenAdd />, { wrapper = mountWithContexts(
context: { router: { history } }, <UserTokenAdd onSuccessfulAdd={onSuccessfulAdd} />,
}); {
context: { router: { history } },
}
);
}); });
await waitForElement(wrapper, 'button[aria-label="Save"]'); await waitForElement(wrapper, 'button[aria-label="Save"]');
await act(async () => { await act(async () => {
wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData); wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData);
}); });
expect(history.location.pathname).toEqual('/users/1/tokens'); expect(history.location.pathname).toEqual('/users/1/tokens/2/details');
expect(onSuccessfulAdd).toHaveBeenCalledWith(rtnData);
}); });
}); });

View File

@@ -1,8 +1,7 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { Link, useHistory, useParams } from 'react-router-dom'; import { useHistory, useParams } 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 { Button } from '@patternfly/react-core';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import { CardBody, CardActionsRow } from '../../../components/Card'; import { CardBody, CardActionsRow } from '../../../components/Card';
@@ -14,11 +13,19 @@ import {
} from '../../../components/DetailList'; } from '../../../components/DetailList';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
import { TokensAPI } from '../../../api'; import { TokensAPI } from '../../../api';
import { formatDateString } from '../../../util/dates';
import useRequest, { useDismissableError } from '../../../util/useRequest'; import useRequest, { useDismissableError } from '../../../util/useRequest';
import { toTitleCase } from '../../../util/strings'; import { toTitleCase } from '../../../util/strings';
function UserTokenDetail({ token, canEditOrDelete, i18n }) { function UserTokenDetail({ token, i18n }) {
const { scope, description, created, modified, summary_fields } = token; const {
scope,
description,
created,
modified,
expires,
summary_fields,
} = token;
const history = useHistory(); const history = useHistory();
const { id, tokenId } = useParams(); const { id, tokenId } = useParams();
const { request: deleteToken, isLoading, error: deleteError } = useRequest( const { request: deleteToken, isLoading, error: deleteError } = useRequest(
@@ -37,39 +44,43 @@ function UserTokenDetail({ token, canEditOrDelete, i18n }) {
value={summary_fields?.application?.name} value={summary_fields?.application?.name}
dataCy="application-token-detail-name" dataCy="application-token-detail-name"
/> />
<Detail label={i18n._(t`Description`)} value={description} /> <Detail
<Detail label={i18n._(t`Scope`)} value={toTitleCase(scope)} /> label={i18n._(t`Description`)}
value={description}
dataCy="application-token-detail-description"
/>
<Detail
label={i18n._(t`Scope`)}
value={toTitleCase(scope)}
dataCy="application-token-detail-scope"
/>
<Detail
label={i18n._(t`Expires`)}
value={formatDateString(expires)}
dataCy="application-token-detail-expires"
/>
<UserDateDetail <UserDateDetail
label={i18n._(t`Created`)} label={i18n._(t`Created`)}
date={created} date={created}
user={summary_fields.user} user={summary_fields.user}
dataCy="application-token-detail-created"
/> />
<UserDateDetail <UserDateDetail
label={i18n._(t`Last Modified`)} label={i18n._(t`Last Modified`)}
date={modified} date={modified}
user={summary_fields.user} user={summary_fields.user}
dataCy="application-token-detail-last-modified"
/> />
</DetailList> </DetailList>
<CardActionsRow> <CardActionsRow>
{canEditOrDelete && ( <DeleteButton
<> name={summary_fields?.application?.name}
<Button modalTitle={i18n._(t`Delete User Token`)}
aria-label={i18n._(t`Edit`)} onConfirm={deleteToken}
component={Link} isDisabled={isLoading}
to={`/users/${id}/tokens/${tokenId}/details`} >
> {i18n._(t`Delete`)}
{i18n._(t`Edit`)} </DeleteButton>
</Button>
<DeleteButton
name={summary_fields?.application?.name}
modalTitle={i18n._(t`Delete User Token`)}
onConfirm={deleteToken}
isDisabled={isLoading}
>
{i18n._(t`Delete`)}
</DeleteButton>
</>
)}
</CardActionsRow> </CardActionsRow>
{error && ( {error && (
<AlertModal <AlertModal

View File

@@ -37,20 +37,12 @@ describe('<UserTokenDetail/>', () => {
description: 'cdfsg', description: 'cdfsg',
scope: 'read', scope: 'read',
}; };
test('should call api for token details and actions', async () => { test('should render properly', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(<UserTokenDetail token={token} />);
<UserTokenDetail canEditOrDelete token={token} />
);
}); });
expect(wrapper.find('UserTokenDetail').length).toBe(1); expect(wrapper.find('UserTokenDetail').length).toBe(1);
});
test('should call api for token details and actions', async () => {
await act(async () => {
wrapper = mountWithContexts(
<UserTokenDetail canEditOrDelete token={token} />
);
});
expect(wrapper.find('Detail[label="Application"]').prop('value')).toBe( expect(wrapper.find('Detail[label="Application"]').prop('value')).toBe(
'hg' 'hg'
@@ -65,23 +57,11 @@ describe('<UserTokenDetail/>', () => {
expect( expect(
wrapper.find('UserDateDetail[label="Last Modified"]').prop('date') wrapper.find('UserDateDetail[label="Last Modified"]').prop('date')
).toBe('2020-06-23T19:56:38.441353Z'); ).toBe('2020-06-23T19:56:38.441353Z');
expect(wrapper.find('Button[aria-label="Edit"]').length).toBe(1);
expect(wrapper.find('Button[aria-label="Delete"]').length).toBe(1); expect(wrapper.find('Button[aria-label="Delete"]').length).toBe(1);
}); });
test('should not render edit or delete buttons', async () => {
await act(async () => {
wrapper = mountWithContexts(
<UserTokenDetail canEditOrDelete={false} token={token} />
);
});
expect(wrapper.find('Button[aria-label="Edit"]').length).toBe(0);
expect(wrapper.find('Button[aria-label="Delete"]').length).toBe(0);
});
test('should delete token properly', async () => { test('should delete token properly', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(<UserTokenDetail token={token} />);
<UserTokenDetail canEditOrDelete token={token} />
);
}); });
await act(async () => await act(async () =>
wrapper.find('Button[aria-label="Delete"]').prop('onClick')() wrapper.find('Button[aria-label="Delete"]').prop('onClick')()
@@ -90,7 +70,7 @@ describe('<UserTokenDetail/>', () => {
await act(async () => wrapper.find('DeleteButton').prop('onConfirm')()); await act(async () => wrapper.find('DeleteButton').prop('onConfirm')());
expect(TokensAPI.destroy).toBeCalledWith(2); expect(TokensAPI.destroy).toBeCalledWith(2);
}); });
test('should throw deletion error', async () => { test('should display error on failed deletion', async () => {
TokensAPI.destroy.mockRejectedValue( TokensAPI.destroy.mockRejectedValue(
new Error({ new Error({
response: { response: {
@@ -104,9 +84,7 @@ describe('<UserTokenDetail/>', () => {
}) })
); );
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(<UserTokenDetail token={token} />);
<UserTokenDetail canEditOrDelete token={token} />
);
}); });
await act(async () => await act(async () =>
wrapper.find('Button[aria-label="Delete"]').prop('onClick')() wrapper.find('Button[aria-label="Delete"]').prop('onClick')()

View File

@@ -108,7 +108,7 @@ function UserTokenList({ i18n }) {
onRowClick={handleSelect} onRowClick={handleSelect}
toolbarSearchColumns={[ toolbarSearchColumns={[
{ {
name: i18n._(t`Name`), name: i18n._(t`Application name`),
key: 'application__name__icontains', key: 'application__name__icontains',
isDefault: true, isDefault: true,
}, },
@@ -119,7 +119,7 @@ function UserTokenList({ i18n }) {
]} ]}
toolbarSortColumns={[ toolbarSortColumns={[
{ {
name: i18n._(t`Name`), name: i18n._(t`Application name`),
key: 'application__name', key: 'application__name',
}, },
{ {

View File

@@ -36,28 +36,33 @@ function UserTokenListItem({ i18n, token, isSelected, onSelect }) {
/> />
<DataListItemCells <DataListItemCells
dataListCells={[ dataListCells={[
<DataListCell aria-label={i18n._(t`Token type`)} key="type">
<Link to={`/users/${id}/tokens/${token.id}/details`}>
{token.summary_fields?.application
? i18n._(t`Application access token`)
: i18n._(t`Personal access token`)}
</Link>
</DataListCell>,
<DataListCell <DataListCell
aria-label={i18n._(t`application name`)} aria-label={i18n._(t`Application name`)}
key={token.id} key="applicationName"
> >
{token.summary_fields?.application?.name ? ( {token.summary_fields?.application && (
<span> <span>
<NameLabel>{i18n._(t`Application`)}</NameLabel> <NameLabel>{i18n._(t`Application`)}</NameLabel>
<Link to={`/users/${id}/tokens/${token.id}/details`}> <Link
to={`/applications/${token.summary_fields.application.id}/details`}
>
{token.summary_fields.application.name} {token.summary_fields.application.name}
</Link> </Link>
</span> </span>
) : (
<Link to={`/users/${id}/tokens/${token.id}/details`}>
{i18n._(t`Personal access token`)}
</Link>
)} )}
</DataListCell>, </DataListCell>,
<DataListCell aria-label={i18n._(t`scope`)} key={token.scope}> <DataListCell aria-label={i18n._(t`Scope`)} key="scope">
<Label>{i18n._(t`Scope`)}</Label> <Label>{i18n._(t`Scope`)}</Label>
{toTitleCase(token.scope)} {toTitleCase(token.scope)}
</DataListCell>, </DataListCell>,
<DataListCell aria-label={i18n._(t`expiration`)} key="expiration"> <DataListCell aria-label={i18n._(t`Expiration`)} key="expiration">
<Label>{i18n._(t`Expires`)}</Label> <Label>{i18n._(t`Expires`)}</Label>
{formatDateString(token.expires)} {formatDateString(token.expires)}
</DataListCell>, </DataListCell>,

View File

@@ -21,7 +21,7 @@ const token = {
}, },
application: { application: {
id: 1, id: 1,
name: 'app', name: 'Foobar app',
}, },
}, },
created: '2020-06-23T15:06:43.188634Z', created: '2020-06-23T15:06:43.188634Z',
@@ -44,22 +44,57 @@ describe('<UserTokenListItem />', () => {
expect(wrapper.find('UserTokenListItem').length).toBe(1); expect(wrapper.find('UserTokenListItem').length).toBe(1);
}); });
test('should render proper data', async () => { test('should render application access token row properly', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<UserTokenListItem isSelected={false} token={token} /> <UserTokenListItem isSelected={false} token={token} />
); );
}); });
expect(wrapper.find('DataListCheck').prop('checked')).toBe(false); expect(wrapper.find('DataListCheck').prop('checked')).toBe(false);
expect(wrapper.find('PFDataListCell[aria-label="Token type"]').text()).toBe(
'Application access token'
);
expect( expect(
wrapper.find('PFDataListCell[aria-label="application name"]').text() wrapper.find('PFDataListCell[aria-label="Application name"]').text()
).toBe('Applicationapp'); ).toContain('Foobar app');
expect(wrapper.find('PFDataListCell[aria-label="scope"]').text()).toBe( expect(wrapper.find('PFDataListCell[aria-label="Scope"]').text()).toContain(
'ScopeRead' 'Read'
); );
expect(wrapper.find('PFDataListCell[aria-label="expiration"]').text()).toBe( expect(
'Expires10/25/3019, 3:06:43 PM' wrapper.find('PFDataListCell[aria-label="Expiration"]').text()
).toContain('10/25/3019, 3:06:43 PM');
});
test('should render personal access token row properly', async () => {
await act(async () => {
wrapper = mountWithContexts(
<UserTokenListItem
isSelected={false}
token={{
...token,
refresh_token: null,
application: null,
scope: 'write',
summary_fields: {
user: token.summary_fields.user,
},
}}
/>
);
});
expect(wrapper.find('DataListCheck').prop('checked')).toBe(false);
expect(wrapper.find('PFDataListCell[aria-label="Token type"]').text()).toBe(
'Personal access token'
); );
expect(
wrapper.find('PFDataListCell[aria-label="Application name"]').text()
).toBe('');
expect(wrapper.find('PFDataListCell[aria-label="Scope"]').text()).toContain(
'Write'
);
expect(
wrapper.find('PFDataListCell[aria-label="Expiration"]').text()
).toContain('10/25/3019, 3:06:43 PM');
}); });
test('should be checked', async () => { test('should be checked', async () => {

View File

@@ -1,24 +1,99 @@
import React from 'react'; import React, { useCallback, useState } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { Switch, Route, useParams } from 'react-router-dom'; import { Switch, Route, useParams } from 'react-router-dom';
import {
Alert,
ClipboardCopy,
ClipboardCopyVariant,
Modal,
} from '@patternfly/react-core';
import { formatDateString } from '../../../util/dates';
import { Detail, DetailList } from '../../../components/DetailList';
import UserTokenAdd from '../UserTokenAdd'; import UserTokenAdd from '../UserTokenAdd';
import UserTokenList from '../UserTokenList'; import UserTokenList from '../UserTokenList';
import UserToken from '../UserToken'; import UserToken from '../UserToken';
function UserTokens({ setBreadcrumb, user }) { const TokenAlert = styled(Alert)`
margin-bottom: 20px;
`;
function UserTokens({ i18n, setBreadcrumb, user }) {
const [tokenModalSource, setTokenModalSource] = useState(null);
const { id } = useParams(); const { id } = useParams();
const onSuccessfulAdd = useCallback(token => setTokenModalSource(token), [
setTokenModalSource,
]);
return ( return (
<Switch> <>
<Route key="add" path="/users/:id/tokens/add"> <Switch>
<UserTokenAdd id={Number(id)} /> <Route key="add" path="/users/:id/tokens/add">
</Route> <UserTokenAdd id={Number(id)} onSuccessfulAdd={onSuccessfulAdd} />
<Route key="token" path="/users/:id/tokens/:tokenId"> </Route>
<UserToken user={user} setBreadcrumb={setBreadcrumb} id={Number(id)} /> <Route key="token" path="/users/:id/tokens/:tokenId">
</Route> <UserToken
<Route key="list" path="/users/:id/tokens"> user={user}
<UserTokenList id={Number(id)} /> setBreadcrumb={setBreadcrumb}
</Route> id={Number(id)}
</Switch> />
</Route>
<Route key="list" path="/users/:id/tokens">
<UserTokenList id={Number(id)} />
</Route>
</Switch>
{tokenModalSource && (
<Modal
aria-label={i18n._(t`Token information`)}
isOpen
variant="medium"
title={i18n._(t`Token information`)}
onClose={() => setTokenModalSource(null)}
>
<TokenAlert
variant="info"
isInline
title={i18n._(
t`This is the only time the token value and associated refresh token value will be shown.`
)}
/>
<DetailList stacked>
{tokenModalSource.token && (
<Detail
label={i18n._(t`Token`)}
value={
<ClipboardCopy
isReadOnly
variant={ClipboardCopyVariant.expansion}
>
{tokenModalSource.token}
</ClipboardCopy>
}
/>
)}
{tokenModalSource.refresh_token && (
<Detail
label={i18n._(t`Refresh Token`)}
value={
<ClipboardCopy
isReadOnly
variant={ClipboardCopyVariant.expansion}
>
{tokenModalSource.refresh_token}
</ClipboardCopy>
}
/>
)}
<Detail
label={i18n._(t`Expires`)}
value={formatDateString(tokenModalSource.expires)}
/>
</DetailList>
</Modal>
)}
</>
); );
} }

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import UserTokens from './UserTokens';
describe('<UserTokens />', () => {
let wrapper;
afterEach(() => {
wrapper.unmount();
});
test('renders successfully', () => {
wrapper = mountWithContexts(<UserTokens />);
expect(wrapper.length).toBe(1);
});
test('shows Application information modal after successful creation', async () => {
const history = createMemoryHistory({
initialEntries: ['/users/1/tokens/add'],
});
wrapper = mountWithContexts(<UserTokens />, {
context: { router: { history } },
});
expect(wrapper.find('Modal[title="Token information"]').length).toBe(0);
await act(async () => {
wrapper
.find('UserTokenAdd')
.props()
.onSuccessfulAdd({
expires: '3020-03-28T14:26:48.099297Z',
token: 'foobar',
refresh_token: 'aaaaaaaaaaaaaaaaaaaaaaaaaa',
});
});
wrapper.update();
expect(wrapper.find('Modal[title="Token information"]').length).toBe(1);
});
});