mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 18:09:57 -03:30
Adds /#/workflow_approvals list and details and allows users to approve or deny workflow approvals from these interfaces
This commit is contained in:
parent
b338da40c5
commit
ee7f73623f
@ -32,6 +32,7 @@ import Tokens from './models/Tokens';
|
||||
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
|
||||
import UnifiedJobs from './models/UnifiedJobs';
|
||||
import Users from './models/Users';
|
||||
import WorkflowApprovals from './models/WorkflowApprovals';
|
||||
import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates';
|
||||
import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
|
||||
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
|
||||
@ -71,6 +72,7 @@ const TokensAPI = new Tokens();
|
||||
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
|
||||
const UnifiedJobsAPI = new UnifiedJobs();
|
||||
const UsersAPI = new Users();
|
||||
const WorkflowApprovalsAPI = new WorkflowApprovals();
|
||||
const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates();
|
||||
const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes();
|
||||
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
|
||||
@ -111,6 +113,7 @@ export {
|
||||
UnifiedJobTemplatesAPI,
|
||||
UnifiedJobsAPI,
|
||||
UsersAPI,
|
||||
WorkflowApprovalsAPI,
|
||||
WorkflowApprovalTemplatesAPI,
|
||||
WorkflowJobTemplateNodesAPI,
|
||||
WorkflowJobTemplatesAPI,
|
||||
|
||||
18
awx/ui_next/src/api/models/WorkflowApprovals.js
Normal file
18
awx/ui_next/src/api/models/WorkflowApprovals.js
Normal file
@ -0,0 +1,18 @@
|
||||
import Base from '../Base';
|
||||
|
||||
class WorkflowApprovals extends Base {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/workflow_approvals/';
|
||||
}
|
||||
|
||||
approve(id) {
|
||||
return this.http.post(`${this.baseUrl}${id}/approve/`);
|
||||
}
|
||||
|
||||
deny(id) {
|
||||
return this.http.post(`${this.baseUrl}${id}/deny/`);
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkflowApprovals;
|
||||
@ -17,6 +17,7 @@ import Settings from './screens/Setting';
|
||||
import Teams from './screens/Team';
|
||||
import Templates from './screens/Template';
|
||||
import Users from './screens/User';
|
||||
import WorkflowApprovals from './screens/WorkflowApproval';
|
||||
|
||||
// Ideally, this should just be a regular object that we export, but we
|
||||
// need the i18n. When lingui3 arrives, we will be able to import i18n
|
||||
@ -126,6 +127,11 @@ function getRouteConfig(i18n) {
|
||||
path: '/applications',
|
||||
screen: Applications,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Workflow Approvals`),
|
||||
path: '/workflow_approvals',
|
||||
screen: WorkflowApprovals,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -138,7 +138,7 @@ function NotificationTemplatesList({ i18n }) {
|
||||
key="delete"
|
||||
onDelete={handleDelete}
|
||||
itemsToDelete={selected}
|
||||
pluralizedItemName="Organizations"
|
||||
pluralizedItemName={i18n._(t`Notification Templates`)}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
@ -164,7 +164,7 @@ function NotificationTemplatesList({ i18n }) {
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={clearDeletionError}
|
||||
>
|
||||
{i18n._(t`Failed to delete one or more organizations.`)}
|
||||
{i18n._(t`Failed to delete one or more notification template.`)}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
</>
|
||||
|
||||
116
awx/ui_next/src/screens/WorkflowApproval/WorkflowApproval.jsx
Normal file
116
awx/ui_next/src/screens/WorkflowApproval/WorkflowApproval.jsx
Normal file
@ -0,0 +1,116 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import {
|
||||
Link,
|
||||
Switch,
|
||||
Route,
|
||||
Redirect,
|
||||
useParams,
|
||||
useRouteMatch,
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import useRequest from '../../util/useRequest';
|
||||
import RoutedTabs from '../../components/RoutedTabs';
|
||||
import ContentError from '../../components/ContentError';
|
||||
import { WorkflowApprovalsAPI } from '../../api';
|
||||
import WorkflowApprovalDetail from './WorkflowApprovalDetail';
|
||||
|
||||
function WorkflowApproval({ setBreadcrumb, i18n }) {
|
||||
const { id: workflowApprovalId } = useParams();
|
||||
const match = useRouteMatch();
|
||||
const location = useLocation();
|
||||
const {
|
||||
result: { workflowApproval },
|
||||
isLoading,
|
||||
error,
|
||||
request: fetchWorkflowApproval,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const detail = await WorkflowApprovalsAPI.readDetail(workflowApprovalId);
|
||||
setBreadcrumb(detail.data);
|
||||
return {
|
||||
workflowApproval: detail.data,
|
||||
};
|
||||
}, [workflowApprovalId, setBreadcrumb]),
|
||||
{ workflowApproval: null }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkflowApproval();
|
||||
}, [fetchWorkflowApproval, location.pathname]);
|
||||
|
||||
if (!isLoading && error) {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<ContentError error={error}>
|
||||
{error.response.status === 404 && (
|
||||
<span>
|
||||
{i18n._(t`Workflow Approval not found.`)}{' '}
|
||||
<Link to="/workflow_approvals">
|
||||
{i18n._(t`View all Workflow Approvals.`)}
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</ContentError>
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{i18n._(t`Back to Workflow Approvals`)}
|
||||
</>
|
||||
),
|
||||
link: `/workflow_approvals`,
|
||||
id: 99,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Details`),
|
||||
link: `${match.url}/details`,
|
||||
id: 0,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<RoutedTabs tabsArray={tabs} />
|
||||
<Switch>
|
||||
<Redirect
|
||||
from="/workflow_approvals/:id"
|
||||
to="/workflow_approvals/:id/details"
|
||||
exact
|
||||
/>
|
||||
{workflowApproval && (
|
||||
<Route path="/workflow_approvals/:id/details">
|
||||
<WorkflowApprovalDetail
|
||||
workflowApproval={workflowApproval}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</Route>
|
||||
)}
|
||||
<Route key="not-found" path="*">
|
||||
{!isLoading && (
|
||||
<ContentError isNotFound>
|
||||
{match.params.id && (
|
||||
<Link to={`/workflow_approvals/${match.params.id}/details`}>
|
||||
{i18n._(t`View Workflow Approval Details`)}
|
||||
</Link>
|
||||
)}
|
||||
</ContentError>
|
||||
)}
|
||||
</Route>
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(WorkflowApproval);
|
||||
@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { WorkflowApprovalsAPI } from '../../api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../testUtils/enzymeHelpers';
|
||||
import mockDetails from './data.workflowApproval.json';
|
||||
import WorkflowApproval from './WorkflowApproval';
|
||||
|
||||
jest.mock('../../api');
|
||||
|
||||
const mockMe = {
|
||||
is_super_user: true,
|
||||
is_system_auditor: false,
|
||||
};
|
||||
|
||||
describe('<WorkflowApproval />', () => {
|
||||
test('initially renders succesfully', async () => {
|
||||
WorkflowApprovalsAPI.readDetail.mockResolvedValue({ data: mockDetails });
|
||||
await act(async () => {
|
||||
mountWithContexts(
|
||||
<WorkflowApproval setBreadcrumb={() => {}} me={mockMe} />
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should show content error when user attempts to navigate to erroneous route', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/workflow_approvals/1/foobar'],
|
||||
});
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowApproval setBreadcrumb={() => {}} me={mockMe} />,
|
||||
{
|
||||
context: {
|
||||
router: {
|
||||
history,
|
||||
route: {
|
||||
location: history.location,
|
||||
match: {
|
||||
params: { id: 1 },
|
||||
url: '/workflow_approvals/1/foobar',
|
||||
path: '/workflow_approvals/1/foobar',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,171 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Link, useHistory, useParams } from 'react-router-dom';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import { CardBody, CardActionsRow } from '../../../components/Card';
|
||||
import DeleteButton from '../../../components/DeleteButton';
|
||||
import {
|
||||
Detail,
|
||||
DetailList,
|
||||
UserDateDetail,
|
||||
} from '../../../components/DetailList';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import WorkflowApprovalActionButtons from '../shared/WorkflowApprovalActionButtons';
|
||||
import WorkflowApprovalStatus from '../shared/WorkflowApprovalStatus';
|
||||
import { formatDateString, secondsToHHMMSS } from '../../../util/dates';
|
||||
import { WorkflowApprovalsAPI } from '../../../api';
|
||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||
|
||||
function WorkflowApprovalDetail({ i18n, workflowApproval }) {
|
||||
const { id: workflowApprovalId } = useParams();
|
||||
const history = useHistory();
|
||||
const {
|
||||
request: deleteWorkflowApproval,
|
||||
isLoading,
|
||||
error: deleteError,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
await WorkflowApprovalsAPI.destroy(workflowApprovalId);
|
||||
history.push(`/workflow_approvals`);
|
||||
}, [workflowApprovalId, history])
|
||||
);
|
||||
|
||||
const { error, dismissError } = useDismissableError(deleteError);
|
||||
|
||||
const sourceWorkflowJob =
|
||||
workflowApproval?.summary_fields?.source_workflow_job;
|
||||
|
||||
const sourceWorkflowJobTemplate =
|
||||
workflowApproval?.summary_fields?.workflow_job_template;
|
||||
|
||||
const handleSuccesfulAction = useCallback(() => {
|
||||
history.push(`/workflow_approvals/${workflowApprovalId}`);
|
||||
}, [history, workflowApprovalId]);
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
<DetailList gutter="sm">
|
||||
<Detail
|
||||
label={i18n._(t`Name`)}
|
||||
value={workflowApproval.name}
|
||||
dataCy="wa-detail-name"
|
||||
/>
|
||||
<Detail
|
||||
label={i18n._(t`Description`)}
|
||||
value={workflowApproval.description}
|
||||
/>
|
||||
{workflowApproval.status === 'pending' && (
|
||||
<Detail
|
||||
label={i18n._(t`Expires`)}
|
||||
value={
|
||||
workflowApproval.approval_expiration
|
||||
? formatDateString(workflowApproval.approval_expiration)
|
||||
: i18n._(t`Never`)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{workflowApproval.status !== 'pending' && (
|
||||
<Detail
|
||||
label={i18n._(t`Status`)}
|
||||
value={
|
||||
<WorkflowApprovalStatus workflowApproval={workflowApproval} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{workflowApproval.summary_fields.approved_or_denied_by && (
|
||||
<Detail
|
||||
label={i18n._(t`Actor`)}
|
||||
value={
|
||||
<Link
|
||||
to={`/users/${workflowApproval.summary_fields.approved_or_denied_by.id}`}
|
||||
>
|
||||
{workflowApproval.summary_fields.approved_or_denied_by.username}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Detail
|
||||
label={i18n._(t`Explanation`)}
|
||||
value={workflowApproval.job_explanation}
|
||||
/>
|
||||
<Detail
|
||||
label={i18n._(t`Workflow Job`)}
|
||||
value={
|
||||
sourceWorkflowJob && (
|
||||
<Link to={`/jobs/workflow/${sourceWorkflowJob?.id}`}>
|
||||
{`${sourceWorkflowJob?.id} - ${sourceWorkflowJob?.name}`}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Detail
|
||||
label={i18n._(t`Workflow Job Template`)}
|
||||
value={
|
||||
sourceWorkflowJobTemplate && (
|
||||
<Link
|
||||
to={`/templates/workflow_job_template/${sourceWorkflowJobTemplate?.id}`}
|
||||
>
|
||||
{sourceWorkflowJobTemplate?.name}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<UserDateDetail
|
||||
label={i18n._(t`Created`)}
|
||||
date={workflowApproval.created}
|
||||
user={workflowApproval.summary_fields.created_by}
|
||||
/>
|
||||
<Detail
|
||||
label={i18n._(t`Last Modified`)}
|
||||
value={formatDateString(workflowApproval.modified)}
|
||||
/>
|
||||
<Detail
|
||||
label={i18n._(t`Finished`)}
|
||||
value={formatDateString(workflowApproval.finished)}
|
||||
/>
|
||||
<Detail
|
||||
label={i18n._(t`Canceled`)}
|
||||
value={formatDateString(workflowApproval.canceled_on)}
|
||||
/>
|
||||
<Detail
|
||||
label={i18n._(t`Elapsed`)}
|
||||
value={secondsToHHMMSS(workflowApproval.elapsed)}
|
||||
/>
|
||||
</DetailList>
|
||||
<CardActionsRow>
|
||||
{workflowApproval.can_approve_or_deny && (
|
||||
<WorkflowApprovalActionButtons
|
||||
icon={false}
|
||||
workflowApproval={workflowApproval}
|
||||
onSuccessfulAction={handleSuccesfulAction}
|
||||
/>
|
||||
)}
|
||||
{workflowApproval.summary_fields.user_capabilities &&
|
||||
workflowApproval.summary_fields.user_capabilities.delete && (
|
||||
<DeleteButton
|
||||
name={workflowApproval.name}
|
||||
modalTitle={i18n._(t`Delete Workflow Approval`)}
|
||||
onConfirm={deleteWorkflowApproval}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</DeleteButton>
|
||||
)}
|
||||
</CardActionsRow>
|
||||
{error && (
|
||||
<AlertModal
|
||||
isOpen={error}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={dismissError}
|
||||
>
|
||||
{i18n._(t`Failed to delete workflow approval.`)}
|
||||
<ErrorDetail error={error} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(WorkflowApprovalDetail);
|
||||
@ -0,0 +1,208 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import { WorkflowApprovalsAPI } from '../../../api';
|
||||
import { formatDateString } from '../../../util/dates';
|
||||
import WorkflowApprovalDetail from './WorkflowApprovalDetail';
|
||||
import workflowApproval from '../data.workflowApproval.json';
|
||||
|
||||
jest.mock('../../../api');
|
||||
|
||||
describe('<WorkflowApprovalDetail />', () => {
|
||||
test('initially renders succesfully', () => {
|
||||
mountWithContexts(
|
||||
<WorkflowApprovalDetail workflowApproval={workflowApproval} />
|
||||
);
|
||||
});
|
||||
|
||||
test('should render Details', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowApprovalDetail workflowApproval={workflowApproval} />
|
||||
);
|
||||
function assertDetail(label, value) {
|
||||
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
|
||||
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
|
||||
}
|
||||
assertDetail('Name', workflowApproval.name);
|
||||
assertDetail('Description', workflowApproval.description);
|
||||
assertDetail('Expires', 'Never');
|
||||
assertDetail(
|
||||
'Workflow Job',
|
||||
`${workflowApproval.summary_fields.workflow_job.id} - ${workflowApproval.summary_fields.workflow_job.name}`
|
||||
);
|
||||
assertDetail(
|
||||
'Workflow Job Template',
|
||||
workflowApproval.summary_fields.workflow_job_template.name
|
||||
);
|
||||
const dateDetails = wrapper.find('UserDateDetail');
|
||||
expect(dateDetails).toHaveLength(1);
|
||||
expect(dateDetails.at(0).prop('label')).toEqual('Created');
|
||||
expect(dateDetails.at(0).prop('date')).toEqual(
|
||||
'2020-10-09T17:13:12.067947Z'
|
||||
);
|
||||
expect(dateDetails.at(0).prop('user')).toEqual(
|
||||
workflowApproval.summary_fields.created_by
|
||||
);
|
||||
assertDetail('Last Modified', formatDateString(workflowApproval.modified));
|
||||
assertDetail('Elapsed', '00:00:22');
|
||||
expect(wrapper.find('WorkflowApprovalActionButtons').length).toBe(1);
|
||||
expect(wrapper.find('DeleteButton').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should show expiration date/time', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowApprovalDetail
|
||||
workflowApproval={{
|
||||
...workflowApproval,
|
||||
approval_expiration: '2020-10-10T17:13:12.067947Z',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find(`Detail[label="Expires"] dd`).text()).toBe(
|
||||
`${formatDateString('2020-10-10T17:13:12.067947Z')}`
|
||||
);
|
||||
});
|
||||
|
||||
test('should show finished date/time', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowApprovalDetail
|
||||
workflowApproval={{
|
||||
...workflowApproval,
|
||||
finished: '2020-10-10T17:13:12.067947Z',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find(`Detail[label="Finished"] dd`).text()).toBe(
|
||||
`${formatDateString('2020-10-10T17:13:12.067947Z')}`
|
||||
);
|
||||
});
|
||||
|
||||
test('should show canceled date/time', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowApprovalDetail
|
||||
workflowApproval={{
|
||||
...workflowApproval,
|
||||
canceled_on: '2020-10-10T17:13:12.067947Z',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find(`Detail[label="Canceled"] dd`).text()).toBe(
|
||||
`${formatDateString('2020-10-10T17:13:12.067947Z')}`
|
||||
);
|
||||
});
|
||||
|
||||
test('should show explanation', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowApprovalDetail
|
||||
workflowApproval={{
|
||||
...workflowApproval,
|
||||
job_explanation: 'Some explanation text',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find(`Detail[label="Explanation"] dd`).text()).toBe(
|
||||
'Some explanation text'
|
||||
);
|
||||
});
|
||||
|
||||
test('should show status when not pending', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowApprovalDetail
|
||||
workflowApproval={{
|
||||
...workflowApproval,
|
||||
status: 'successful',
|
||||
summary_fields: {
|
||||
...workflowApproval.summary_fields,
|
||||
approved_or_denied_by: {
|
||||
id: 1,
|
||||
username: 'Foobar',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('WorkflowApprovalStatus Label').text()).toBe(
|
||||
'Approved'
|
||||
);
|
||||
});
|
||||
|
||||
test('should show actor when available', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowApprovalDetail
|
||||
workflowApproval={{
|
||||
...workflowApproval,
|
||||
summary_fields: {
|
||||
...workflowApproval.summary_fields,
|
||||
approved_or_denied_by: {
|
||||
id: 1,
|
||||
username: 'Foobar',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find(`Detail[label="Actor"] dd`).text()).toBe('Foobar');
|
||||
});
|
||||
|
||||
test('action buttons should be hidden when user cannot approve or deny', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowApprovalDetail
|
||||
workflowApproval={{
|
||||
...workflowApproval,
|
||||
can_approve_or_deny: false,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('WorkflowApprovalActionButtons').length).toBe(0);
|
||||
});
|
||||
|
||||
test('delete button should be hidden when user cannot delete', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowApprovalDetail
|
||||
workflowApproval={{
|
||||
...workflowApproval,
|
||||
summary_fields: {
|
||||
...workflowApproval.summary_fields,
|
||||
user_capabilities: {
|
||||
delete: false,
|
||||
start: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('DeleteButton').length).toBe(0);
|
||||
});
|
||||
|
||||
test('Error dialog shown for failed deletion', async () => {
|
||||
WorkflowApprovalsAPI.destroy.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowApprovalDetail workflowApproval={workflowApproval} />
|
||||
);
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'WorkflowApprovalDetail Button[aria-label="Delete"]'
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper.find('DeleteButton').invoke('onConfirm')();
|
||||
});
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'Modal[title="Error!"]',
|
||||
el => el.length === 1
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper.find('Modal[title="Error!"]').invoke('onClose')();
|
||||
});
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'Modal[title="Error!"]',
|
||||
el => el.length === 0
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,3 @@
|
||||
import WorkflowApprovalDetail from './WorkflowApprovalDetail';
|
||||
|
||||
export default WorkflowApprovalDetail;
|
||||
@ -0,0 +1,187 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useLocation, useRouteMatch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
import { WorkflowApprovalsAPI } from '../../../api';
|
||||
import PaginatedDataList, {
|
||||
ToolbarDeleteButton,
|
||||
} from '../../../components/PaginatedDataList';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import DataListToolbar from '../../../components/DataListToolbar';
|
||||
import WorkflowApprovalListItem from './WorkflowApprovalListItem';
|
||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import useSelected from '../../../util/useSelected';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import useWsWorkflowApprovals from './useWsWorkflowApprovals';
|
||||
|
||||
const QS_CONFIG = getQSConfig('workflow_approvals', {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
order_by: '-started',
|
||||
});
|
||||
|
||||
function WorkflowApprovalsList({ i18n }) {
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch();
|
||||
|
||||
const {
|
||||
result: { results, count, relatedSearchableKeys, searchableKeys },
|
||||
error: contentError,
|
||||
isLoading: isWorkflowApprovalsLoading,
|
||||
request: fetchWorkflowApprovals,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const [response, actionsResponse] = await Promise.all([
|
||||
WorkflowApprovalsAPI.read(params),
|
||||
WorkflowApprovalsAPI.readOptions(),
|
||||
]);
|
||||
return {
|
||||
results: response.data.results,
|
||||
count: response.data.count,
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [location]),
|
||||
{
|
||||
results: [],
|
||||
count: 0,
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkflowApprovals();
|
||||
}, [fetchWorkflowApprovals]);
|
||||
|
||||
// TODO: update QS_CONFIG to be safe for deps array
|
||||
const fetchWorkflowApprovalsById = useCallback(
|
||||
async ids => {
|
||||
const params = { ...parseQueryString(QS_CONFIG, location.search) };
|
||||
params.id__in = ids.join(',');
|
||||
const { data } = await WorkflowApprovalsAPI.read(params);
|
||||
return data.results;
|
||||
},
|
||||
[location.search] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
const workflowApprovals = useWsWorkflowApprovals(
|
||||
results,
|
||||
fetchWorkflowApprovals,
|
||||
fetchWorkflowApprovalsById,
|
||||
QS_CONFIG
|
||||
);
|
||||
|
||||
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
|
||||
workflowApprovals
|
||||
);
|
||||
|
||||
const {
|
||||
isLoading: isDeleteLoading,
|
||||
deleteItems: deleteWorkflowApprovals,
|
||||
deletionError,
|
||||
clearDeletionError,
|
||||
} = useDeleteItems(
|
||||
useCallback(async () => {
|
||||
return Promise.all(
|
||||
selected.map(({ id }) => WorkflowApprovalsAPI.destroy(id))
|
||||
);
|
||||
}, [selected]),
|
||||
{
|
||||
qsConfig: QS_CONFIG,
|
||||
allItemsSelected: isAllSelected,
|
||||
fetchItems: fetchWorkflowApprovals,
|
||||
}
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteWorkflowApprovals();
|
||||
setSelected([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedDataList
|
||||
contentError={contentError}
|
||||
hasContentLoading={isWorkflowApprovalsLoading || isDeleteLoading}
|
||||
items={workflowApprovals}
|
||||
itemCount={count}
|
||||
pluralizedItemName={i18n._(t`Workflow Approvals`)}
|
||||
qsConfig={QS_CONFIG}
|
||||
onRowClick={handleSelect}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Started`),
|
||||
key: 'started',
|
||||
},
|
||||
]}
|
||||
renderToolbar={props => (
|
||||
<DataListToolbar
|
||||
{...props}
|
||||
showSelectAll
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={set =>
|
||||
setSelected(set ? [...workflowApprovals] : [])
|
||||
}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
<ToolbarDeleteButton
|
||||
key="delete"
|
||||
onDelete={handleDelete}
|
||||
itemsToDelete={selected}
|
||||
pluralizedItemName={i18n._(t`Workflow Approvals`)}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
renderItem={workflowApproval => (
|
||||
<WorkflowApprovalListItem
|
||||
key={workflowApproval.id}
|
||||
workflowApproval={workflowApproval}
|
||||
detailUrl={`${match.url}/${workflowApproval.id}`}
|
||||
isSelected={selected.some(
|
||||
row => row.id === workflowApproval.id
|
||||
)}
|
||||
onSelect={() => handleSelect(workflowApproval)}
|
||||
onSuccessfulAction={fetchWorkflowApprovals}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</PageSection>
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={clearDeletionError}
|
||||
>
|
||||
{i18n._(t`Failed to delete one or more workflow approval.`)}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(WorkflowApprovalsList);
|
||||
@ -0,0 +1,317 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { WorkflowApprovalsAPI } from '../../../api';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import WorkflowApprovalList from './WorkflowApprovalList';
|
||||
|
||||
jest.mock('../../../api');
|
||||
|
||||
const mockWorkflowApprovals = [
|
||||
{
|
||||
id: 221,
|
||||
type: 'workflow_approval',
|
||||
url: '/api/v2/workflow_approvals/221/',
|
||||
related: {
|
||||
created_by: '/api/v2/users/1/',
|
||||
unified_job_template: '/api/v2/workflow_approval_templates/10/',
|
||||
source_workflow_job: '/api/v2/workflow_jobs/220/',
|
||||
workflow_approval_template: '/api/v2/workflow_approval_templates/10/',
|
||||
approve: '/api/v2/workflow_approvals/221/approve/',
|
||||
deny: '/api/v2/workflow_approvals/221/deny/',
|
||||
},
|
||||
summary_fields: {
|
||||
workflow_job_template: {
|
||||
id: 9,
|
||||
name: 'Approval @ 9:15:26 AM',
|
||||
description: '',
|
||||
},
|
||||
workflow_job: {
|
||||
id: 220,
|
||||
name: 'Approval @ 9:15:26 AM',
|
||||
description: '',
|
||||
},
|
||||
workflow_approval_template: {
|
||||
id: 10,
|
||||
name: 'approval copy',
|
||||
description: '',
|
||||
timeout: 30,
|
||||
},
|
||||
unified_job_template: {
|
||||
id: 10,
|
||||
name: 'approval copy',
|
||||
description: '',
|
||||
unified_job_type: 'workflow_approval',
|
||||
},
|
||||
created_by: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
},
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
start: true,
|
||||
},
|
||||
source_workflow_job: {
|
||||
id: 220,
|
||||
name: 'Approval @ 9:15:26 AM',
|
||||
description: '',
|
||||
status: 'failed',
|
||||
failed: true,
|
||||
elapsed: 89.766,
|
||||
},
|
||||
},
|
||||
created: '2020-10-09T19:58:27.337904Z',
|
||||
modified: '2020-10-09T19:58:27.338000Z',
|
||||
name: 'approval copy',
|
||||
description: '',
|
||||
unified_job_template: 10,
|
||||
launch_type: 'workflow',
|
||||
status: 'failed',
|
||||
failed: true,
|
||||
started: '2020-10-09T19:58:27.337904Z',
|
||||
finished: '2020-10-09T19:59:26.974046Z',
|
||||
canceled_on: null,
|
||||
elapsed: 59.636,
|
||||
job_explanation:
|
||||
'The approval node approval copy (221) has expired after 30 seconds.',
|
||||
can_approve_or_deny: false,
|
||||
approval_expiration: null,
|
||||
timed_out: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
type: 'workflow_approval',
|
||||
url: '/api/v2/workflow_approvals/6/',
|
||||
related: {
|
||||
created_by: '/api/v2/users/1/',
|
||||
unified_job_template: '/api/v2/workflow_approval_templates/8/',
|
||||
source_workflow_job: '/api/v2/workflow_jobs/5/',
|
||||
workflow_approval_template: '/api/v2/workflow_approval_templates/8/',
|
||||
approve: '/api/v2/workflow_approvals/6/approve/',
|
||||
deny: '/api/v2/workflow_approvals/6/deny/',
|
||||
approved_or_denied_by: '/api/v2/users/1/',
|
||||
},
|
||||
summary_fields: {
|
||||
workflow_job_template: {
|
||||
id: 7,
|
||||
name: 'Approval',
|
||||
description: '',
|
||||
},
|
||||
workflow_job: {
|
||||
id: 5,
|
||||
name: 'Approval',
|
||||
description: '',
|
||||
},
|
||||
workflow_approval_template: {
|
||||
id: 8,
|
||||
name: 'approval',
|
||||
description: '',
|
||||
timeout: 0,
|
||||
},
|
||||
unified_job_template: {
|
||||
id: 8,
|
||||
name: 'approval',
|
||||
description: '',
|
||||
unified_job_type: 'workflow_approval',
|
||||
},
|
||||
approved_or_denied_by: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
},
|
||||
created_by: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
},
|
||||
user_capabilities: {
|
||||
delete: false,
|
||||
start: false,
|
||||
},
|
||||
source_workflow_job: {
|
||||
id: 5,
|
||||
name: 'Approval',
|
||||
description: '',
|
||||
status: 'successful',
|
||||
failed: false,
|
||||
elapsed: 168.233,
|
||||
},
|
||||
},
|
||||
created: '2020-10-05T20:14:53.875701Z',
|
||||
modified: '2020-10-05T20:17:41.211373Z',
|
||||
name: 'approval',
|
||||
description: '',
|
||||
unified_job_template: 8,
|
||||
launch_type: 'workflow',
|
||||
status: 'successful',
|
||||
failed: false,
|
||||
started: '2020-10-05T20:14:53.875701Z',
|
||||
finished: '2020-10-05T20:17:41.200738Z',
|
||||
canceled_on: null,
|
||||
elapsed: 167.325,
|
||||
job_explanation: '',
|
||||
can_approve_or_deny: false,
|
||||
approval_expiration: null,
|
||||
timed_out: false,
|
||||
},
|
||||
];
|
||||
|
||||
describe('<WorkflowApprovalList />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
WorkflowApprovalsAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: mockWorkflowApprovals.length,
|
||||
results: mockWorkflowApprovals,
|
||||
},
|
||||
});
|
||||
|
||||
WorkflowApprovalsAPI.readOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
related_search_fields: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should load and render workflow approvals', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<WorkflowApprovalList />);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('WorkflowApprovalListItem')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('should select workflow approval when checked', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<WorkflowApprovalList />);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('WorkflowApprovalListItem')
|
||||
.first()
|
||||
.invoke('onSelect')();
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('WorkflowApprovalListItem')
|
||||
.first()
|
||||
.prop('isSelected')
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
test('should select all', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<WorkflowApprovalList />);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('DataListToolbar').invoke('onSelectAll')(true);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
const items = wrapper.find('WorkflowApprovalListItem');
|
||||
expect(items).toHaveLength(2);
|
||||
items.forEach(item => {
|
||||
expect(item.prop('isSelected')).toEqual(true);
|
||||
});
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('WorkflowApprovalListItem')
|
||||
.first()
|
||||
.prop('isSelected')
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
test('should disable delete button', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<WorkflowApprovalList />);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('WorkflowApprovalListItem')
|
||||
.at(1)
|
||||
.invoke('onSelect')();
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('ToolbarDeleteButton button').prop('disabled')).toEqual(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('should call delete api', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<WorkflowApprovalList />);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('WorkflowApprovalListItem')
|
||||
.at(0)
|
||||
.invoke('onSelect')();
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper.find('ToolbarDeleteButton').invoke('onDelete')();
|
||||
});
|
||||
|
||||
expect(WorkflowApprovalsAPI.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should show deletion error', async () => {
|
||||
WorkflowApprovalsAPI.destroy.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'delete',
|
||||
url: '/api/v2/workflow_approvals/221',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
},
|
||||
})
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<WorkflowApprovalList />);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(WorkflowApprovalsAPI.read).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('WorkflowApprovalListItem')
|
||||
.at(0)
|
||||
.invoke('onSelect')();
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('ToolbarDeleteButton').invoke('onDelete')();
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
const modal = wrapper.find('Modal');
|
||||
expect(modal).toHaveLength(1);
|
||||
expect(modal.prop('title')).toEqual('Error!');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,119 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { string, bool, func } from 'prop-types';
|
||||
import {
|
||||
DataListAction as _DataListAction,
|
||||
DataListCheck,
|
||||
DataListItem,
|
||||
DataListItemCells,
|
||||
DataListItemRow,
|
||||
} from '@patternfly/react-core';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import DataListCell from '../../../components/DataListCell';
|
||||
import { WorkflowApproval } from '../../../types';
|
||||
import { formatDateString } from '../../../util/dates';
|
||||
import WorkflowApprovalActionButtons from '../shared/WorkflowApprovalActionButtons';
|
||||
import WorkflowApprovalStatus from '../shared/WorkflowApprovalStatus';
|
||||
|
||||
const DataListAction = styled(_DataListAction)`
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: repeat(2, 40px);
|
||||
`;
|
||||
|
||||
const JobLabel = styled.b`
|
||||
margin-right: 24px;
|
||||
`;
|
||||
|
||||
function WorkflowApprovalListItem({
|
||||
workflowApproval,
|
||||
isSelected,
|
||||
onSelect,
|
||||
detailUrl,
|
||||
i18n,
|
||||
}) {
|
||||
const [actionTaken, setActionTaken] = useState(false);
|
||||
const labelId = `check-action-${workflowApproval.id}`;
|
||||
const workflowJob = workflowApproval?.summary_fields?.source_workflow_job;
|
||||
|
||||
const getStatus = () => {
|
||||
if (
|
||||
workflowApproval.status === 'pending' &&
|
||||
workflowApproval.approval_expiration
|
||||
) {
|
||||
return i18n._(
|
||||
t`Expires on ${formatDateString(workflowApproval.approval_expiration)}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
workflowApproval.status === 'pending' &&
|
||||
!workflowApproval.approval_expiration
|
||||
) {
|
||||
return i18n._(t`Never expires`);
|
||||
}
|
||||
return <WorkflowApprovalStatus workflowApproval={workflowApproval} />;
|
||||
};
|
||||
|
||||
const handleSuccesfulAction = useCallback(() => {
|
||||
setActionTaken(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DataListItem
|
||||
key={workflowApproval.id}
|
||||
aria-labelledby={labelId}
|
||||
id={`${workflowApproval.id}`}
|
||||
>
|
||||
<DataListItemRow>
|
||||
<DataListCheck
|
||||
id={`select-workflowApproval-${workflowApproval.id}`}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key="title" id={labelId}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{workflowApproval.name}</b>
|
||||
</Link>
|
||||
</DataListCell>,
|
||||
<DataListCell key="job" id={labelId}>
|
||||
{workflowJob && (
|
||||
<>
|
||||
<JobLabel>{i18n._(t`Job`)}</JobLabel>
|
||||
<Link to={`/jobs/workflow/${workflowJob?.id}`}>
|
||||
{`${workflowJob?.id} - ${workflowJob?.name}`}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</DataListCell>,
|
||||
<DataListCell key="status">{getStatus()}</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction aria-label="actions" aria-labelledby={labelId}>
|
||||
{workflowApproval.can_approve_or_deny && !actionTaken ? (
|
||||
<WorkflowApprovalActionButtons
|
||||
workflowApproval={workflowApproval}
|
||||
onSuccessfulAction={handleSuccesfulAction}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
);
|
||||
}
|
||||
|
||||
WorkflowApprovalListItem.propTypes = {
|
||||
workflowApproval: WorkflowApproval.isRequired,
|
||||
detailUrl: string.isRequired,
|
||||
isSelected: bool.isRequired,
|
||||
onSelect: func.isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(WorkflowApprovalListItem);
|
||||
@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import WorkflowApprovalListItem from './WorkflowApprovalListItem';
|
||||
import { WorkflowApprovalsAPI } from '../../../api';
|
||||
import workflowApproval from '../data.workflowApproval.json';
|
||||
|
||||
jest.mock('../../../api/models/WorkflowApprovals');
|
||||
|
||||
describe('<WorkflowApprovalListItem />', () => {
|
||||
test('action buttons shown to users with ability to approve/deny', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowApprovalListItem
|
||||
isSelected={false}
|
||||
detailUrl={`/workflow_approvals/${workflowApproval.id}`}
|
||||
onSelect={() => {}}
|
||||
workflowApproval={workflowApproval}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('WorkflowApprovalActionButtons').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('action buttons hidden from users without ability to approve/deny', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowApprovalListItem
|
||||
isSelected={false}
|
||||
detailUrl={`/workflow_approvals/${workflowApproval.id}`}
|
||||
onSelect={() => {}}
|
||||
workflowApproval={{ ...workflowApproval, can_approve_or_deny: false }}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('WorkflowApprovalActionButtons').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should hide action buttons after successful action', async () => {
|
||||
WorkflowApprovalsAPI.approve.mockResolvedValue();
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowApprovalListItem
|
||||
isSelected={false}
|
||||
detailUrl={`/workflow_approvals/${workflowApproval.id}`}
|
||||
onSelect={() => {}}
|
||||
workflowApproval={workflowApproval}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('WorkflowApprovalActionButtons').exists()).toBeTruthy();
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Approve"]').prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(WorkflowApprovalsAPI.approve).toHaveBeenCalled();
|
||||
expect(wrapper.find('WorkflowApprovalActionButtons').exists()).toBeFalsy();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,4 @@
|
||||
import WorkflowApprovalList from './WorkflowApprovalList';
|
||||
|
||||
export default WorkflowApprovalList;
|
||||
export { default as WorkflowApprovalListItem } from './WorkflowApprovalListItem';
|
||||
@ -0,0 +1,61 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import useWebsocket from '../../../util/useWebsocket';
|
||||
import useThrottle from '../../../util/useThrottle';
|
||||
|
||||
export default function useWsWorkflowApprovals(
|
||||
initialWorkflowApprovals,
|
||||
fetchWorkflowApprovals
|
||||
) {
|
||||
const [workflowApprovals, setWorkflowApprovals] = useState(
|
||||
initialWorkflowApprovals
|
||||
);
|
||||
const [reloadEntireList, setReloadEntireList] = useState(false);
|
||||
const throttledListRefresh = useThrottle(reloadEntireList, 1000);
|
||||
const lastMessage = useWebsocket({
|
||||
jobs: ['status_changed'],
|
||||
control: ['limit_reached_1'],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setWorkflowApprovals(initialWorkflowApprovals);
|
||||
}, [initialWorkflowApprovals]);
|
||||
|
||||
useEffect(
|
||||
function reloadWorkflowApprovalsList() {
|
||||
(async () => {
|
||||
if (!throttledListRefresh) {
|
||||
return;
|
||||
}
|
||||
setReloadEntireList(false);
|
||||
fetchWorkflowApprovals();
|
||||
})();
|
||||
},
|
||||
[throttledListRefresh, fetchWorkflowApprovals]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function processWsMessage() {
|
||||
if (!(lastMessage?.type === 'workflow_approval')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = workflowApprovals.findIndex(
|
||||
p => p.id === lastMessage.unified_job_id
|
||||
);
|
||||
|
||||
if (
|
||||
(index > -1 &&
|
||||
!['new', 'pending', 'waiting', 'running'].includes(
|
||||
lastMessage.status
|
||||
)) ||
|
||||
(index === -1 && lastMessage.status === 'pending')
|
||||
) {
|
||||
setReloadEntireList(true);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps,
|
||||
[lastMessage]
|
||||
);
|
||||
|
||||
return workflowApprovals;
|
||||
}
|
||||
@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import WS from 'jest-websocket-mock';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import useWsWorkflowApprovals from './useWsWorkflowApprovals';
|
||||
|
||||
/*
|
||||
Jest mock timers don’t play well with jest-websocket-mock,
|
||||
so we'll stub out throttling to resolve immediately
|
||||
*/
|
||||
jest.mock('../../../util/useThrottle', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(val => val),
|
||||
}));
|
||||
|
||||
function TestInner() {
|
||||
return <div />;
|
||||
}
|
||||
function Test({ workflowApprovals, fetchWorkflowApprovals }) {
|
||||
const updatedWorkflowApprovals = useWsWorkflowApprovals(
|
||||
workflowApprovals,
|
||||
fetchWorkflowApprovals
|
||||
);
|
||||
return <TestInner workflowApprovals={updatedWorkflowApprovals} />;
|
||||
}
|
||||
|
||||
describe('useWsWorkflowApprovals hook', () => {
|
||||
let debug;
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
debug = global.console.debug; // eslint-disable-line prefer-destructuring
|
||||
global.console.debug = () => {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.console.debug = debug;
|
||||
WS.clean();
|
||||
});
|
||||
|
||||
test('should return workflow approvals list', () => {
|
||||
const workflowApprovals = [{ id: 1, status: 'successful' }];
|
||||
wrapper = mountWithContexts(
|
||||
<Test
|
||||
workflowApprovals={workflowApprovals}
|
||||
fetchWorkflowApprovals={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper.find('TestInner').prop('workflowApprovals')).toEqual(
|
||||
workflowApprovals
|
||||
);
|
||||
});
|
||||
|
||||
test('should establish websocket connection', async () => {
|
||||
global.document.cookie = 'csrftoken=abc123';
|
||||
const mockServer = new WS('wss://localhost/websocket/');
|
||||
|
||||
const workflowApprovals = [{ id: 1, status: 'successful' }];
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Test
|
||||
workflowApprovals={workflowApprovals}
|
||||
fetchWorkflowApprovals={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await mockServer.connected;
|
||||
await expect(mockServer).toReceiveMessage(
|
||||
JSON.stringify({
|
||||
xrftoken: 'abc123',
|
||||
groups: {
|
||||
jobs: ['status_changed'],
|
||||
control: ['limit_reached_1'],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should refetch after new approval job is created', async () => {
|
||||
global.document.cookie = 'csrftoken=abc123';
|
||||
const mockServer = new WS('wss://localhost/websocket/');
|
||||
const workflowApprovals = [{ id: 1, status: 'successful' }];
|
||||
const fetchWorkflowApprovals = jest.fn(() => []);
|
||||
await act(async () => {
|
||||
wrapper = await mountWithContexts(
|
||||
<Test
|
||||
workflowApprovals={workflowApprovals}
|
||||
fetchWorkflowApprovals={fetchWorkflowApprovals}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await mockServer.connected;
|
||||
await act(async () => {
|
||||
mockServer.send(
|
||||
JSON.stringify({
|
||||
unified_job_id: 2,
|
||||
type: 'workflow_approval',
|
||||
status: 'pending',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(fetchWorkflowApprovals).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should refetch after approval job in current list is updated', async () => {
|
||||
global.document.cookie = 'csrftoken=abc123';
|
||||
const mockServer = new WS('wss://localhost/websocket/');
|
||||
const workflowApprovals = [{ id: 1, status: 'pending' }];
|
||||
const fetchWorkflowApprovals = jest.fn(() => []);
|
||||
await act(async () => {
|
||||
wrapper = await mountWithContexts(
|
||||
<Test
|
||||
workflowApprovals={workflowApprovals}
|
||||
fetchWorkflowApprovals={fetchWorkflowApprovals}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await mockServer.connected;
|
||||
await act(async () => {
|
||||
mockServer.send(
|
||||
JSON.stringify({
|
||||
unified_job_id: 1,
|
||||
type: 'workflow_approval',
|
||||
status: 'successful',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(fetchWorkflowApprovals).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should not refetch when message is not workflow approval', async () => {
|
||||
global.document.cookie = 'csrftoken=abc123';
|
||||
const mockServer = new WS('wss://localhost/websocket/');
|
||||
const workflowApprovals = [{ id: 1, status: 'successful' }];
|
||||
const fetchWorkflowApprovals = jest.fn(() => []);
|
||||
await act(async () => {
|
||||
wrapper = await mountWithContexts(
|
||||
<Test
|
||||
workflowApprovals={workflowApprovals}
|
||||
fetchWorkflowApprovals={fetchWorkflowApprovals}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await mockServer.connected;
|
||||
await act(async () => {
|
||||
mockServer.send(
|
||||
JSON.stringify({
|
||||
unified_job_id: 1,
|
||||
type: 'job',
|
||||
status: 'successful',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(fetchWorkflowApprovals).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,42 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Route, Switch, useRouteMatch } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import WorkflowApprovalList from './WorkflowApprovalList';
|
||||
import WorkflowApproval from './WorkflowApproval';
|
||||
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
|
||||
|
||||
function WorkflowApprovals({ i18n }) {
|
||||
const match = useRouteMatch();
|
||||
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
|
||||
'/workflow_approvals': i18n._(t`Workflow Approvals`),
|
||||
});
|
||||
|
||||
const updateBreadcrumbConfig = useCallback(
|
||||
workflowApproval => {
|
||||
const { id } = workflowApproval;
|
||||
setBreadcrumbConfig({
|
||||
'/workflow_approvals': i18n._(t`Workflow Approvals`),
|
||||
[`/workflow_approvals/${id}`]: workflowApproval.name,
|
||||
[`/workflow_approvals/${id}/details`]: i18n._(t`Details`),
|
||||
});
|
||||
},
|
||||
[i18n]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
||||
<Switch>
|
||||
<Route path={`${match.url}/:id`}>
|
||||
<WorkflowApproval setBreadcrumb={updateBreadcrumbConfig} />
|
||||
</Route>
|
||||
<Route path={`${match.url}`}>
|
||||
<WorkflowApprovalList />
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(WorkflowApprovals);
|
||||
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import WorkflowApprovals from './WorkflowApprovals';
|
||||
|
||||
describe('<WorkflowApprovals />', () => {
|
||||
test('initially renders succesfully', () => {
|
||||
mountWithContexts(<WorkflowApprovals />);
|
||||
});
|
||||
|
||||
test('should display a breadcrumb heading', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/workflow_approvals'],
|
||||
});
|
||||
const match = {
|
||||
path: '/workflow_approvals',
|
||||
url: '/workflow_approvals',
|
||||
isExact: true,
|
||||
};
|
||||
|
||||
const wrapper = mountWithContexts(<WorkflowApprovals />, {
|
||||
context: {
|
||||
router: {
|
||||
history,
|
||||
route: {
|
||||
location: history.location,
|
||||
match,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,71 @@
|
||||
{
|
||||
"id": 218,
|
||||
"type": "workflow_approval",
|
||||
"url": "/api/v2/workflow_approvals/218/",
|
||||
"related": {
|
||||
"created_by": "/api/v2/users/1/",
|
||||
"unified_job_template": "/api/v2/workflow_approval_templates/10/",
|
||||
"source_workflow_job": "/api/v2/workflow_jobs/216/",
|
||||
"workflow_approval_template": "/api/v2/workflow_approval_templates/10/",
|
||||
"approve": "/api/v2/workflow_approvals/218/approve/",
|
||||
"deny": "/api/v2/workflow_approvals/218/deny/"
|
||||
},
|
||||
"summary_fields": {
|
||||
"workflow_job_template": {
|
||||
"id": 9,
|
||||
"name": "Approval @ 9:15:26 AM",
|
||||
"description": ""
|
||||
},
|
||||
"workflow_job": {
|
||||
"id": 216,
|
||||
"name": "Approval @ 9:15:26 AM",
|
||||
"description": ""
|
||||
},
|
||||
"workflow_approval_template": {
|
||||
"id": 10,
|
||||
"name": "approval copy",
|
||||
"description": "",
|
||||
"timeout": 0
|
||||
},
|
||||
"unified_job_template": {
|
||||
"id": 10,
|
||||
"name": "approval copy",
|
||||
"description": "",
|
||||
"unified_job_type": "workflow_approval"
|
||||
},
|
||||
"created_by": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"first_name": "",
|
||||
"last_name": ""
|
||||
},
|
||||
"user_capabilities": {
|
||||
"delete": true,
|
||||
"start": true
|
||||
},
|
||||
"source_workflow_job": {
|
||||
"id": 216,
|
||||
"name": "Approval @ 9:15:26 AM",
|
||||
"description": "",
|
||||
"status": "running",
|
||||
"failed": false,
|
||||
"elapsed": 0.0
|
||||
}
|
||||
},
|
||||
"created": "2020-10-09T17:13:12.067947Z",
|
||||
"modified": "2020-10-09T17:13:12.068147Z",
|
||||
"name": "approval",
|
||||
"description": "description of approval",
|
||||
"unified_job_template": 10,
|
||||
"launch_type": "workflow",
|
||||
"status": "pending",
|
||||
"failed": false,
|
||||
"started": "2020-10-09T17:13:12.067947Z",
|
||||
"finished": null,
|
||||
"canceled_on": null,
|
||||
"elapsed": 22.879029,
|
||||
"job_explanation": "",
|
||||
"can_approve_or_deny": true,
|
||||
"approval_expiration": null,
|
||||
"timed_out": false
|
||||
}
|
||||
1
awx/ui_next/src/screens/WorkflowApproval/index.js
Normal file
1
awx/ui_next/src/screens/WorkflowApproval/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './WorkflowApprovals';
|
||||
@ -0,0 +1,115 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, Tooltip } from '@patternfly/react-core';
|
||||
import { CheckIcon, CloseIcon } from '@patternfly/react-icons';
|
||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||
import AlertModal from '../../../components/AlertModal/AlertModal';
|
||||
import ErrorDetail from '../../../components/ErrorDetail/ErrorDetail';
|
||||
import { WorkflowApprovalsAPI } from '../../../api';
|
||||
|
||||
function WorkflowApprovalActionButtons({
|
||||
workflowApproval,
|
||||
icon,
|
||||
i18n,
|
||||
onSuccessfulAction,
|
||||
}) {
|
||||
const {
|
||||
isLoading: approveApprovalLoading,
|
||||
error: approveApprovalError,
|
||||
request: approveApprovalNode,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
await WorkflowApprovalsAPI.approve(workflowApproval.id);
|
||||
if (onSuccessfulAction) {
|
||||
onSuccessfulAction();
|
||||
}
|
||||
}, [onSuccessfulAction, workflowApproval.id]),
|
||||
{}
|
||||
);
|
||||
|
||||
const {
|
||||
isLoading: denyApprovalLoading,
|
||||
error: denyApprovalError,
|
||||
request: denyApprovalNode,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
await WorkflowApprovalsAPI.deny(workflowApproval.id);
|
||||
if (onSuccessfulAction) {
|
||||
onSuccessfulAction();
|
||||
}
|
||||
}, [onSuccessfulAction, workflowApproval.id]),
|
||||
{}
|
||||
);
|
||||
|
||||
const {
|
||||
error: approveError,
|
||||
dismissError: dismissApproveError,
|
||||
} = useDismissableError(approveApprovalError);
|
||||
|
||||
const {
|
||||
error: denyError,
|
||||
dismissError: dismissDenyError,
|
||||
} = useDismissableError(denyApprovalError);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip content={i18n._(t`Approve`)} position="top">
|
||||
<Button
|
||||
isDisabled={approveApprovalLoading || denyApprovalLoading}
|
||||
aria-label={i18n._(t`Approve`)}
|
||||
variant={icon ? 'plain' : 'primary'}
|
||||
onClick={approveApprovalNode}
|
||||
>
|
||||
{icon ? <CheckIcon /> : i18n._(t`Approve`)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={i18n._(t`Deny`)} position="top">
|
||||
<Button
|
||||
isDisabled={approveApprovalLoading || denyApprovalLoading}
|
||||
aria-label={i18n._(t`Deny`)}
|
||||
variant={icon ? 'plain' : 'danger'}
|
||||
onClick={denyApprovalNode}
|
||||
>
|
||||
{icon ? <CloseIcon /> : i18n._(t`Deny`)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{approveError && (
|
||||
<AlertModal
|
||||
isOpen={approveError}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={dismissApproveError}
|
||||
>
|
||||
{i18n._(t`Failed to approve this approval node.`)}
|
||||
<ErrorDetail error={approveError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
{denyError && (
|
||||
<AlertModal
|
||||
isOpen={denyError}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={dismissDenyError}
|
||||
>
|
||||
{i18n._(t`Failed to deny this approval node.`)}
|
||||
<ErrorDetail error={denyError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
WorkflowApprovalActionButtons.propTypes = {
|
||||
workflowApproval: PropTypes.shape({}).isRequired,
|
||||
icon: PropTypes.bool,
|
||||
onSuccessfulAction: PropTypes.func,
|
||||
};
|
||||
|
||||
WorkflowApprovalActionButtons.defaultProps = {
|
||||
icon: true,
|
||||
onSuccessfulAction: () => {},
|
||||
};
|
||||
|
||||
export default withI18n()(WorkflowApprovalActionButtons);
|
||||
@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { WorkflowApprovalsAPI } from '../../../api';
|
||||
import WorkflowApprovalActionButtons from './WorkflowApprovalActionButtons';
|
||||
import workflowApproval from '../data.workflowApproval.json';
|
||||
|
||||
jest.mock('../../../api/models/WorkflowApprovals');
|
||||
|
||||
describe('<WorkflowApprovalActionButtons />', () => {
|
||||
let wrapper;
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('initially renders succesfully with icons', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowApprovalActionButtons workflowApproval={workflowApproval} />
|
||||
);
|
||||
expect(wrapper.find('CheckIcon').length).toBe(1);
|
||||
expect(wrapper.find('CloseIcon').length).toBe(1);
|
||||
expect(wrapper.find('Button[children="Approve"]').length).toBe(0);
|
||||
expect(wrapper.find('Button[children="Deny"]').length).toBe(0);
|
||||
});
|
||||
test('initially renders succesfully without icons', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowApprovalActionButtons
|
||||
workflowApproval={workflowApproval}
|
||||
icon={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('CheckIcon').length).toBe(0);
|
||||
expect(wrapper.find('CloseIcon').length).toBe(0);
|
||||
expect(wrapper.find('Button[children="Approve"]').length).toBe(1);
|
||||
expect(wrapper.find('Button[children="Deny"]').length).toBe(1);
|
||||
});
|
||||
test('approving makes correct call with correct param', async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowApprovalActionButtons workflowApproval={workflowApproval} />
|
||||
);
|
||||
await act(async () => wrapper.find('CheckIcon').simulate('click'));
|
||||
expect(WorkflowApprovalsAPI.approve).toHaveBeenCalledWith(
|
||||
workflowApproval.id
|
||||
);
|
||||
});
|
||||
test('denying makes correct call with correct param', async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowApprovalActionButtons workflowApproval={workflowApproval} />
|
||||
);
|
||||
await act(async () => wrapper.find('CloseIcon').simulate('click'));
|
||||
expect(WorkflowApprovalsAPI.deny).toHaveBeenCalledWith(workflowApproval.id);
|
||||
});
|
||||
test('approval error shown', async () => {
|
||||
WorkflowApprovalsAPI.approve.mockRejectedValueOnce(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'post',
|
||||
url: '/api/v2/workflow_approvals/approve',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
status: 403,
|
||||
},
|
||||
})
|
||||
);
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowApprovalActionButtons workflowApproval={workflowApproval} />
|
||||
);
|
||||
expect(wrapper.find('AlertModal').length).toBe(0);
|
||||
await act(async () => wrapper.find('CheckIcon').simulate('click'));
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AlertModal').length).toBe(1);
|
||||
});
|
||||
test('denial error shown', async () => {
|
||||
WorkflowApprovalsAPI.deny.mockRejectedValueOnce(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'post',
|
||||
url: '/api/v2/workflow_approvals/deny',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
status: 403,
|
||||
},
|
||||
})
|
||||
);
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowApprovalActionButtons workflowApproval={workflowApproval} />
|
||||
);
|
||||
expect(wrapper.find('AlertModal').length).toBe(0);
|
||||
await act(async () => wrapper.find('CloseIcon').simulate('click'));
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AlertModal').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Label, Tooltip } from '@patternfly/react-core';
|
||||
import { InfoCircleIcon } from '@patternfly/react-icons';
|
||||
import { WorkflowApproval } from '../../../types';
|
||||
import { formatDateString } from '../../../util/dates';
|
||||
|
||||
function WorkflowApprovalStatus({ workflowApproval, i18n }) {
|
||||
if (workflowApproval.status === 'pending') {
|
||||
return workflowApproval.approval_expiration
|
||||
? i18n._(
|
||||
t`Expires on ${formatDateString(
|
||||
workflowApproval.approval_expiration
|
||||
)}`
|
||||
)
|
||||
: i18n._(t`Never expires`);
|
||||
}
|
||||
|
||||
if (workflowApproval.timed_out) {
|
||||
return <Label color="red">{i18n._(t`Timed out`)}</Label>;
|
||||
}
|
||||
|
||||
if (workflowApproval.canceled_on) {
|
||||
return <Label color="red">{i18n._(t`Canceled`)}</Label>;
|
||||
}
|
||||
|
||||
if (workflowApproval.status === 'failed' && workflowApproval.failed) {
|
||||
return (
|
||||
<Tooltip
|
||||
content={i18n._(
|
||||
t`Denied by ${
|
||||
workflowApproval.summary_fields.approved_or_denied_by.username
|
||||
} - ${formatDateString(workflowApproval.finished)}`
|
||||
)}
|
||||
position="top"
|
||||
>
|
||||
<Label color="red" icon={<InfoCircleIcon />}>
|
||||
{i18n._(t`Denied`)}
|
||||
</Label>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (workflowApproval.status === 'successful') {
|
||||
return (
|
||||
<Tooltip
|
||||
content={i18n._(
|
||||
t`Approved by ${
|
||||
workflowApproval.summary_fields.approved_or_denied_by.username
|
||||
} - ${formatDateString(workflowApproval.finished)}`
|
||||
)}
|
||||
position="top"
|
||||
>
|
||||
<Label color="green" icon={<InfoCircleIcon />}>
|
||||
{i18n._(t`Approved`)}
|
||||
</Label>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
WorkflowApprovalStatus.defaultProps = {
|
||||
workflowApproval: WorkflowApproval.isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(WorkflowApprovalStatus);
|
||||
@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { formatDateString } from '../../../util/dates';
|
||||
import WorkflowApprovalStatus from './WorkflowApprovalStatus';
|
||||
import workflowApproval from '../data.workflowApproval.json';
|
||||
|
||||
describe('<WorkflowApprovalStatus />', () => {
|
||||
let wrapper;
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('shows no expiration when approval status is pending and no approval_expiration', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowApprovalStatus workflowApproval={workflowApproval} />
|
||||
);
|
||||
expect(wrapper.text()).toBe('Never expires');
|
||||
});
|
||||
test('shows expiration date/time when approval status is pending and approval_expiration present', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowApprovalStatus
|
||||
workflowApproval={{
|
||||
...workflowApproval,
|
||||
approval_expiration: '2020-10-10T17:13:12.067947Z',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.text()).toBe(
|
||||
`Expires on ${formatDateString('2020-10-10T17:13:12.067947Z')}`
|
||||
);
|
||||
});
|
||||
test('shows when an approval has timed out', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowApprovalStatus
|
||||
workflowApproval={{
|
||||
...workflowApproval,
|
||||
status: 'failed',
|
||||
timed_out: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('Label').text()).toBe('Timed out');
|
||||
});
|
||||
test('shows when an approval has canceled', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowApprovalStatus
|
||||
workflowApproval={{
|
||||
...workflowApproval,
|
||||
status: 'canceled',
|
||||
canceled_on: '2020-10-10T17:13:12.067947Z',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('Label').text()).toBe('Canceled');
|
||||
});
|
||||
test('shows when an approval has approved', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowApprovalStatus
|
||||
workflowApproval={{
|
||||
...workflowApproval,
|
||||
summary_fields: {
|
||||
...workflowApproval.summary_fields,
|
||||
approved_or_denied_by: {
|
||||
id: 1,
|
||||
username: 'Foobar',
|
||||
},
|
||||
},
|
||||
status: 'successful',
|
||||
finished: '2020-10-10T17:13:12.067947Z',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('Label').text()).toBe('Approved');
|
||||
});
|
||||
test('shows when an approval has denied', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowApprovalStatus
|
||||
workflowApproval={{
|
||||
...workflowApproval,
|
||||
summary_fields: {
|
||||
...workflowApproval.summary_fields,
|
||||
approved_or_denied_by: {
|
||||
id: 1,
|
||||
username: 'Foobar',
|
||||
},
|
||||
},
|
||||
status: 'failed',
|
||||
finished: '2020-10-10T17:13:12.067947Z',
|
||||
failed: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('Label').text()).toBe('Denied');
|
||||
});
|
||||
});
|
||||
@ -391,3 +391,19 @@ export const NotificationTemplate = shape({
|
||||
organization: Organization,
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkflowApproval = shape({
|
||||
id: number.isRequired,
|
||||
name: string.isRequired,
|
||||
description: string,
|
||||
url: string.isRequired,
|
||||
failed: bool,
|
||||
started: string,
|
||||
finished: string,
|
||||
canceled_on: string,
|
||||
elapsed: number,
|
||||
job_explanation: string,
|
||||
can_approve_or_deny: bool,
|
||||
approval_expiration: string,
|
||||
timed_out: bool,
|
||||
});
|
||||
|
||||
@ -6,10 +6,16 @@ import { getLanguage } from './language';
|
||||
const prependZeros = value => value.toString().padStart(2, 0);
|
||||
|
||||
export function formatDateString(dateString, lang = getLanguage(navigator)) {
|
||||
if (dateString === null) {
|
||||
return null;
|
||||
}
|
||||
return new Date(dateString).toLocaleString(lang);
|
||||
}
|
||||
|
||||
export function formatDateStringUTC(dateString, lang = getLanguage(navigator)) {
|
||||
if (dateString === null) {
|
||||
return null;
|
||||
}
|
||||
return new Date(dateString).toLocaleString(lang, { timeZone: 'UTC' });
|
||||
}
|
||||
|
||||
|
||||
@ -21,9 +21,14 @@ const i18n = {
|
||||
describe('formatDateString', () => {
|
||||
test('it returns the expected value', () => {
|
||||
const lang = 'en-US';
|
||||
expect(formatDateString(null, lang)).toEqual(null);
|
||||
expect(formatDateString('', lang)).toEqual('Invalid Date');
|
||||
expect(formatDateString({}, lang)).toEqual('Invalid Date');
|
||||
expect(formatDateString(undefined, lang)).toEqual('Invalid Date');
|
||||
expect(formatDateString('foobar', lang)).toEqual('Invalid Date');
|
||||
expect(formatDateString('2018-011-31T01:14:52.969227Z', lang)).toEqual(
|
||||
'Invalid Date'
|
||||
);
|
||||
expect(formatDateString('2018-01-31T01:14:52.969227Z', lang)).toEqual(
|
||||
'1/31/2018, 1:14:52 AM'
|
||||
);
|
||||
@ -33,9 +38,14 @@ describe('formatDateString', () => {
|
||||
describe('formatDateStringUTC', () => {
|
||||
test('it returns the expected value', () => {
|
||||
const lang = 'en-US';
|
||||
expect(formatDateStringUTC(null, lang)).toEqual(null);
|
||||
expect(formatDateStringUTC('', lang)).toEqual('Invalid Date');
|
||||
expect(formatDateStringUTC({}, lang)).toEqual('Invalid Date');
|
||||
expect(formatDateStringUTC(undefined, lang)).toEqual('Invalid Date');
|
||||
expect(formatDateStringUTC('foobar', lang)).toEqual('Invalid Date');
|
||||
expect(formatDateStringUTC('2018-011-31T01:14:52.969227Z', lang)).toEqual(
|
||||
'Invalid Date'
|
||||
);
|
||||
expect(formatDateStringUTC('2018-01-31T01:14:52.969227Z', lang)).toEqual(
|
||||
'1/31/2018, 1:14:52 AM'
|
||||
);
|
||||
|
||||
@ -3,12 +3,18 @@ import { useState, useEffect, useRef } from 'react';
|
||||
export default function useThrottle(value, limit) {
|
||||
const [throttledValue, setThrottledValue] = useState(value);
|
||||
const lastRan = useRef(Date.now());
|
||||
const initialValue = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== initialValue.current) {
|
||||
setThrottledValue(value);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const handler = setTimeout(() => {
|
||||
if (Date.now() - lastRan.current >= limit) {
|
||||
setThrottledValue(value);
|
||||
lastRan.current = Date.now();
|
||||
setThrottledValue(value);
|
||||
}
|
||||
}, limit - (Date.now() - lastRan.current));
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user