mirror of
https://github.com/ansible/awx.git
synced 2026-02-16 02:30:01 -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:
@@ -32,6 +32,7 @@ import Tokens from './models/Tokens';
|
|||||||
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
|
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
|
||||||
import UnifiedJobs from './models/UnifiedJobs';
|
import UnifiedJobs from './models/UnifiedJobs';
|
||||||
import Users from './models/Users';
|
import Users from './models/Users';
|
||||||
|
import WorkflowApprovals from './models/WorkflowApprovals';
|
||||||
import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates';
|
import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates';
|
||||||
import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
|
import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
|
||||||
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
|
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
|
||||||
@@ -71,6 +72,7 @@ const TokensAPI = new Tokens();
|
|||||||
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
|
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
|
||||||
const UnifiedJobsAPI = new UnifiedJobs();
|
const UnifiedJobsAPI = new UnifiedJobs();
|
||||||
const UsersAPI = new Users();
|
const UsersAPI = new Users();
|
||||||
|
const WorkflowApprovalsAPI = new WorkflowApprovals();
|
||||||
const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates();
|
const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates();
|
||||||
const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes();
|
const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes();
|
||||||
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
|
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
|
||||||
@@ -111,6 +113,7 @@ export {
|
|||||||
UnifiedJobTemplatesAPI,
|
UnifiedJobTemplatesAPI,
|
||||||
UnifiedJobsAPI,
|
UnifiedJobsAPI,
|
||||||
UsersAPI,
|
UsersAPI,
|
||||||
|
WorkflowApprovalsAPI,
|
||||||
WorkflowApprovalTemplatesAPI,
|
WorkflowApprovalTemplatesAPI,
|
||||||
WorkflowJobTemplateNodesAPI,
|
WorkflowJobTemplateNodesAPI,
|
||||||
WorkflowJobTemplatesAPI,
|
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 Teams from './screens/Team';
|
||||||
import Templates from './screens/Template';
|
import Templates from './screens/Template';
|
||||||
import Users from './screens/User';
|
import Users from './screens/User';
|
||||||
|
import WorkflowApprovals from './screens/WorkflowApproval';
|
||||||
|
|
||||||
// Ideally, this should just be a regular object that we export, but we
|
// 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
|
// need the i18n. When lingui3 arrives, we will be able to import i18n
|
||||||
@@ -126,6 +127,11 @@ function getRouteConfig(i18n) {
|
|||||||
path: '/applications',
|
path: '/applications',
|
||||||
screen: Applications,
|
screen: Applications,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: i18n._(t`Workflow Approvals`),
|
||||||
|
path: '/workflow_approvals',
|
||||||
|
screen: WorkflowApprovals,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ function NotificationTemplatesList({ i18n }) {
|
|||||||
key="delete"
|
key="delete"
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
itemsToDelete={selected}
|
itemsToDelete={selected}
|
||||||
pluralizedItemName="Organizations"
|
pluralizedItemName={i18n._(t`Notification Templates`)}
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -164,7 +164,7 @@ function NotificationTemplatesList({ i18n }) {
|
|||||||
title={i18n._(t`Error!`)}
|
title={i18n._(t`Error!`)}
|
||||||
onClose={clearDeletionError}
|
onClose={clearDeletionError}
|
||||||
>
|
>
|
||||||
{i18n._(t`Failed to delete one or more organizations.`)}
|
{i18n._(t`Failed to delete one or more notification template.`)}
|
||||||
<ErrorDetail error={deletionError} />
|
<ErrorDetail error={deletionError} />
|
||||||
</AlertModal>
|
</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,
|
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);
|
const prependZeros = value => value.toString().padStart(2, 0);
|
||||||
|
|
||||||
export function formatDateString(dateString, lang = getLanguage(navigator)) {
|
export function formatDateString(dateString, lang = getLanguage(navigator)) {
|
||||||
|
if (dateString === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return new Date(dateString).toLocaleString(lang);
|
return new Date(dateString).toLocaleString(lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDateStringUTC(dateString, lang = getLanguage(navigator)) {
|
export function formatDateStringUTC(dateString, lang = getLanguage(navigator)) {
|
||||||
|
if (dateString === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return new Date(dateString).toLocaleString(lang, { timeZone: 'UTC' });
|
return new Date(dateString).toLocaleString(lang, { timeZone: 'UTC' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,14 @@ const i18n = {
|
|||||||
describe('formatDateString', () => {
|
describe('formatDateString', () => {
|
||||||
test('it returns the expected value', () => {
|
test('it returns the expected value', () => {
|
||||||
const lang = 'en-US';
|
const lang = 'en-US';
|
||||||
|
expect(formatDateString(null, lang)).toEqual(null);
|
||||||
expect(formatDateString('', lang)).toEqual('Invalid Date');
|
expect(formatDateString('', lang)).toEqual('Invalid Date');
|
||||||
expect(formatDateString({}, lang)).toEqual('Invalid Date');
|
expect(formatDateString({}, lang)).toEqual('Invalid Date');
|
||||||
expect(formatDateString(undefined, 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(
|
expect(formatDateString('2018-01-31T01:14:52.969227Z', lang)).toEqual(
|
||||||
'1/31/2018, 1:14:52 AM'
|
'1/31/2018, 1:14:52 AM'
|
||||||
);
|
);
|
||||||
@@ -33,9 +38,14 @@ describe('formatDateString', () => {
|
|||||||
describe('formatDateStringUTC', () => {
|
describe('formatDateStringUTC', () => {
|
||||||
test('it returns the expected value', () => {
|
test('it returns the expected value', () => {
|
||||||
const lang = 'en-US';
|
const lang = 'en-US';
|
||||||
|
expect(formatDateStringUTC(null, lang)).toEqual(null);
|
||||||
expect(formatDateStringUTC('', lang)).toEqual('Invalid Date');
|
expect(formatDateStringUTC('', lang)).toEqual('Invalid Date');
|
||||||
expect(formatDateStringUTC({}, lang)).toEqual('Invalid Date');
|
expect(formatDateStringUTC({}, lang)).toEqual('Invalid Date');
|
||||||
expect(formatDateStringUTC(undefined, 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(
|
expect(formatDateStringUTC('2018-01-31T01:14:52.969227Z', lang)).toEqual(
|
||||||
'1/31/2018, 1:14:52 AM'
|
'1/31/2018, 1:14:52 AM'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
export default function useThrottle(value, limit) {
|
export default function useThrottle(value, limit) {
|
||||||
const [throttledValue, setThrottledValue] = useState(value);
|
const [throttledValue, setThrottledValue] = useState(value);
|
||||||
const lastRan = useRef(Date.now());
|
const lastRan = useRef(Date.now());
|
||||||
|
const initialValue = useRef(value);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (value !== initialValue.current) {
|
||||||
|
setThrottledValue(value);
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
const handler = setTimeout(() => {
|
const handler = setTimeout(() => {
|
||||||
if (Date.now() - lastRan.current >= limit) {
|
if (Date.now() - lastRan.current >= limit) {
|
||||||
setThrottledValue(value);
|
|
||||||
lastRan.current = Date.now();
|
lastRan.current = Date.now();
|
||||||
|
setThrottledValue(value);
|
||||||
}
|
}
|
||||||
}, limit - (Date.now() - lastRan.current));
|
}, limit - (Date.now() - lastRan.current));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user