Adds /#/workflow_approvals list and details and allows users to approve or deny workflow approvals from these interfaces

This commit is contained in:
mabashian 2020-10-12 09:36:22 -04:00
parent b338da40c5
commit ee7f73623f
28 changed files with 2049 additions and 3 deletions

View File

@ -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,

View 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;

View File

@ -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,
},
],
},
{

View File

@ -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>
</>

View 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);

View File

@ -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);
});
});

View File

@ -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);

View File

@ -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
);
});
});

View File

@ -0,0 +1,3 @@
import WorkflowApprovalDetail from './WorkflowApprovalDetail';
export default WorkflowApprovalDetail;

View File

@ -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);

View File

@ -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!');
});
});

View File

@ -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);

View File

@ -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();
});
});

View File

@ -0,0 +1,4 @@
import WorkflowApprovalList from './WorkflowApprovalList';
export default WorkflowApprovalList;
export { default as WorkflowApprovalListItem } from './WorkflowApprovalListItem';

View File

@ -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;
}

View File

@ -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 dont 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);
});
});

View File

@ -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);

View File

@ -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();
});
});

View File

@ -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
}

View File

@ -0,0 +1 @@
export { default } from './WorkflowApprovals';

View File

@ -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);

View File

@ -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);
});
});

View File

@ -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);

View File

@ -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');
});
});

View File

@ -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,
});

View File

@ -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' });
}

View File

@ -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'
);

View File

@ -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));